Model::save() メソッドを丁寧にはしから読んでメモしただけですけどなかなか気持ち良い作業でした。

いつもダラーッと見てたんですけどちゃんとメモを取ったら仕組みが腑に落ちた。

と思ったら hasAndBelongsToMany モデルのみ更新のケースにおける返り値を間違って解釈してたっぽい。ので修正。そして自分自身HABTMに関しては勉強不足だったことに気付かされた。

で、新たな疑問。HABTM データの更新をしてもその結果を取得する手段が見当たらない気がするのだけど、知りたいときは関連モデルを検査するのでしょうか?それか見落としてる?

HABTMは使ったことがほとんど無いので気にしたことがなかったですが、今度使うときには調べよう。

さて、コードのコメントの見出しを順に書くとこんな感じ。pre タグの中なんでリンク張れない・・・

  1. コメントからメソッドの宣言まで
  2. オプションの準備
  3. 保存するデータをセット
  4. 保存するべきデータが存在しない場合処理を終了する
  5. 「タイムスタンプフィールドを null 値でセットした場合にはそれを更新する」ための事前処理
  6. SQLのステートメントをUPDATEにするかINSERTにするかの判断材料として $this->exists() をコール
  7. ここからタイムスタンプフィールドを保存させるかどうかのプロセスが始まる
  8. タイムスタンプフィールド自動更新の準備
  9. バリデーション
  10. データベース接続オブジェクトの取得
  11. タイムスタンプフィールドのセット
  12. ‘Mode.beforeSave’ コールバック
  13. データのプライマリキーの値が空の場合にキーを解除する
  14. データ保存のためのフィールド名と値の配列を初期化
  15. $fields, $values, $joined にデータをセット
  16. フラグ($count, $success, $created)のセットと $this->id の更新
  17. 自身のテーブルに対する UPDATE または INSERT
  18. HABTM モデルデータの更新
  19. 処理に失敗した場合に成功フラグ更新
  20. 後処理(Model.afterSaveイベント、プロパティ初期化など)
  21. 成功フラグを返して終了
// 1.コメントからメソッドの宣言まで
// モデルのデータを(もし提供されていればホワイトリストに基づきつつ)データベースへ保存する。
// デフォルトでは保存前にバリデーションが行われる。
// パラメータは3つ。どれもデフォルト値がある。第二引数と第三引数はCakePHPでよく見られる流動的な規約。

/**
 * Saves model data (based on white-list, if supplied) to the database. By
 * default, validation occurs before save.
 *
 * @param array $data Data to save.
 * @param boolean|array $validate Either a boolean, or an array.
 *   If a boolean, indicates whether or not to validate before saving.
 *   If an array, allows control of validate, callbacks, and fieldList
 * @param array $fieldList List of fields to allow to be written
 * @return mixed On success Model::$data if its not empty or true, false on failure
 * @link http://book.cakephp.org/2.0/en/models/saving-your-data.html
 */
public function save($data = null, $validate = true, $fieldList = array()) {

	// 2. オプションの準備
	// ここでは以下の変数について処理される
	// $options         - array - メソッド内で使用するオプション。引数と初期値をマージさせて作成。
	//   $options['validate']   - boolean - バリデートの是非
	//   $options['fieldList']  - array   - 保存の対象になるフィールドの一覧。
	//                                      メンバー変数 $whitelist にセットされる。
	//   $options['callbacks']  - boolean | string - コールバックの是非および指定。
	//                                      string 型で 'before'(Model.beforeSave), 'after'(Model.afterSave) を指定可能
	// $defaults        - array - 初期値。'validate' => true, 'fieldList' => array(), 'callbacks' => true
	// $this->whitelist - array - 保存の対象を限定するための配列。$options['fieldList']があればそれを、
	//                            なければメンバー変数としてセットされているものを利用する。
	//                            あとの処理でタイムスタンプフィールド(updated, modified, created)が追加される場合がある。
	// $_whitelist      - array - メンバー変数の $this->whitelist の一時退避用変数。
	// $fields          - array - 保存のフィールドリストを格納する配列。空の配列から開始して、順次必要なフィールドを追加していく。

	$defaults = array('validate' => true, 'fieldList' => array(), 'callbacks' => true);
	$_whitelist = $this->whitelist;
	$fields = array();

	if (!is_array($validate)) {
		$options = array_merge($defaults, compact('validate', 'fieldList'));
	} else {
		$options = array_merge($defaults, $validate);
	}

	if (!empty($options['fieldList'])) {
		$this->whitelist = $options['fieldList'];
		if (!empty($options['fieldList'][$this->alias]) && is_array($options['fieldList'][$this->alias])) {
			$this->whitelist = $options['fieldList'][$this->alias];
		}
	} elseif ($options['fieldList'] === null) {
		$this->whitelist = array();
	}

	// 3.保存するデータをセット
	// @see Model::set()
	// [Model::set() の挙動のポイント]
	// (1) データをエイリアスをキーとした二次配列にしてセット
	// (2) 保存するデータは $this->data へセットされるので(イベントサブジェクトが自分自身の場合に)コールバックで操作可能となる。
	// (3) データにプライマリキーが存在している場合、その値を自身の $id プロパティにセットする。
	// (4) $this->validationErrors の値をリセット(同じフィールドのみ)
	$this->set($data);

	// 4. 保存するべきデータが存在しない場合処理を終了する
	// 保存するべきデータとは、さきほどセットした $this->data の他に
	// タイムスタンプフィールド('created', 'updated', 'modified'のいずれか)も含まれる。
	// @see Model::hasField()
	if (empty($this->data) && !$this->hasField(array('created', 'updated', 'modified'))) {
		$this->whitelist = $_whitelist; // この1行は2.3.0では存在していないのでバグレポート済み。2.3.1で追加予定。
		return false;
	}

	// 5. 「タイムスタンプフィールドを null 値でセットした場合にはそれを更新する」ための事前処理
	// 'created', 'updated', 'modified' のデータがnull値の場合のみフィールドリストから解除する。
	// この処理の理由は続く[8][11]を参照のこと
	foreach (array('created', 'updated', 'modified') as $field) {
		$keyPresentAndEmpty = (
			isset($this->data[$this->alias]) &&
			array_key_exists($field, $this->data[$this->alias]) &&
			$this->data[$this->alias][$field] === null
		);
		if ($keyPresentAndEmpty) {
			unset($this->data[$this->alias][$field]);
		}
	}

	// 6. SQLのステートメントをUPDATEにするかINSERTにするかの判断材料として $this->exists() をコール
	// $exists   - boolean - 保存する対象が既に存在しているかどうか
	// @see Model::exists()
	// 事前にデータをセットした際にプライマリキーの調査を行なっていたことがここでも活かされる。
	$exists = $this->exists();

	// 7. ここからタイムスタンプフィールドを保存させるかどうかのプロセスが始まる
	// $dateFields   - array - 保存させる候補の配列。
	// 初期値として 'modified', 'updated'。そして新規保存の場合には更に 'created' が加わる
	$dateFields = array('modified', 'updated');

	if (!$exists) {
		$dateFields[] = 'created';
	}

	// 8. タイムスタンプフィールド自動更新の準備
	// $fields   - array - 保存対象になっているフィールド。
	// [11] でタイムスタンプフィールドを評価するために取得
	// ここに含まれているタイムスタンプフィールドは自動更新の対象にしない
	if (isset($this->data[$this->alias])) {
		$fields = array_keys($this->data[$this->alias]);
	}

	// 9. バリデーション
	// バリデートを行う場合($options['validate'] が真の場合)バリデートを行いエラーがある場合は処理を終了
	// @see Model::validates()
	// この結果、エラーがあれば $this->validationErrors にエラーメッセージがセットされる。
	if ($options['validate'] && !$this->validates($options)) {
		$this->whitelist = $_whitelist;
		return false;
	}

	// 10. データベース接続オブジェクトの取得
	// @see Model::getDataSource()
	$db = $this->getDataSource();

	// 11. タイムスタンプフィールドのセット
	// 保存対象に含めるかどうかを評価し、ここで具体的な値を挿入している
	foreach ($dateFields as $updateCol) {

		// 評価はまずスキーマにフィールド名が存在するか($this->hasField(...))、
		// そして既に有効なデータが存在していないか(in_array(...))を調べる
		if ($this->hasField($updateCol) && !in_array($updateCol, $fields)) {

			// 日付のフォーマッター(コールバック)の初期値。
			$default = array('formatter' => 'date');

			// フォーマッターコールバックは $this->columns[fieldName]['formatter'] で指定可能。ただしフォーマットも指定が必要。
			// 日付のフォーマットも指定可能
			// @see Model::getColumnType()
			$colType = array_merge($default, $db->columns[$this->getColumnType($updateCol)]);
			if (!array_key_exists('format', $colType)) {
				$time = strtotime('now');
			} else {
				$time = call_user_func($colType['formatter'], $colType['format']);
			}

			// 保存対象になった場合ホワイトリストがあればそれに追加
			if (!empty($this->whitelist)) {
				$this->whitelist[] = $updateCol;
			}

			// ここで初めてデータをセットする
			$this->set($updateCol, $time);
		}
	}

	// ------------------------------------------------------------------ //
	// チェック!次で Model.beforeSave のコールバックがトリガーされます。
	//           ここまではコールバックの介入できないプロセスでした。
	// ------------------------------------------------------------------ //

	// 12. 'Mode.beforeSave' コールバック
	// $options['callbacks'] が真であるか 'before' の場合にイベントをトリガー。
	if ($options['callbacks'] === true || $options['callbacks'] === 'before') {

		// コールバックへは CakeEventオブジェクト渡しではなくデータ渡し(passParams = true)。
		// @see Model::implementedEvents
		// Model::beforeSave($options) の引数にこの値がイベントオブジェクトから渡される。
		// イベントオブジェクトに設置されるプロパティは
		// CakeEvent::$subject = $this
		// CakeEvent::$data = array($options)
		//    の他に...

		$event = new CakeEvent('Model.beforeSave', $this, array($options));

		// ...CakeEvent システムの要件とは異なる
		// CakeEvent::$break = true
		// CakeEvent::$breakOn = array(false, null)
		// がある。
		// この二つは、BehaviorCollection がこのコールバックを実行する際に
		// イベントの伝播を停止させるためのフラグ $break と、
		// その停止条件 $breakOn として false または null を指定している。
		// このプロパティは ObjectCollection がコレクションしたオブジェクトをループさせるためのもので、
		// CakeEvent システム導入以前からの仕組み。という訳で歴史的な理由に基づくものと思われる。
		// @see ObjectCollection::trigger()
		list($event->break, $event->breakOn) = array(true, array(false, null));

		// イベントが途中で停止した場合には伝播を中止。
		$this->getEventManager()->dispatch($event);

		// $event->result には最後のコールバックの返り値がセットされており、ここの評価が無効の場合に処理を中止。
		// CakeEventの汎用的な規約ではコールバックの返り値がfalse以外は続行可能だが、
		// ここではそれをさらに厳しくしている。
		// 最後のコールバックの結果が void であったり "0" や空文字などを返すとここで処理が終わるので注意。
		if (!$event->result) {
			$this->whitelist = $_whitelist;
			return false;
		}
	}

	// 13. データのプライマリキーの値が空の場合にキーを解除する
	// 結果的にプライマリキーに空の値(空文字、文字列の"0"、数値の0など)を
	// 保存できない仕組みになっている
	if (empty($this->data[$this->alias][$this->primaryKey])) {
		unset($this->data[$this->alias][$this->primaryKey]);
	}

	// -------------------------------------------------------------------------------- //
	// チェック!ここまでで、 $this->data に保存すべきデータが滞り無くセットされました。
	// -------------------------------------------------------------------------------- //

	// 14. データ保存のためのフィールド名と値の配列を初期化
	// $fields - array - フィールド名
	// $values - array - 値
	// $joined - array - HABTM モデルのデータがあればセットする。
	//                   この変数の存在の有無が判定に使われるため宣言はしていない
	$fields = $values = array();

	// 15. $fields, $values, $joined にデータをセット
	// $this->data をループさせる。 $n にエイリアス、 $v にフィールド名=>値の連想配列
	foreach ($this->data as $n => $v) {
		if (isset($this->hasAndBelongsToMany[$n])) {

			// エイリアスがHABTMモデルの場合、 $joined にそのデータをコピー
			if (isset($v[$n])) {
				$v = $v[$n];
			}
			$joined[$n] = $v;
		} else {
			if ($n === $this->alias) {
				// エイリアスが自身のそれと一致した場合、

				// 空のタイムスタンプフィールドがあればデータから解除する
				foreach (array('created', 'updated', 'modified') as $field) {
					if (array_key_exists($field, $v) && empty($v[$field])) {
						unset($v[$field]);
					}
				}

				// $fields と $values にフィールド名と値をプッシュしていく
				foreach ($v as $x => $y) {
					if ($this->hasField($x) && (empty($this->whitelist) || in_array($x, $this->whitelist))) {
						list($fields[], $values[]) = array($x, $y);
					}
				}
			}
		}
	}

	// 16.フラグ($count, $success, $created)のセットと $this->id の更新
	// $count    - integer -       (このモデルのテーブルに対して)保存するデータの数。0かどうかを検査。
	// $success  - false | array - プロセスの成功フラグかつ返り値。true で初期化。
	//                             どこかの箇所で失敗した場合に false をセット。
	//                             このモデルのテーブルに対して成功した場合に $this->data をセット。
	//                             HABTM のデータのみを更新した場合は false が返る。
	//                             true が返ることはない。(←訂正)
	// $created  - boolean -       新規作成フラグ。false で初期化。INSERTに成功した場合に true をセット。
	// $this->id INSERT ステートメントを準備する場合に false をセット

	$count = count($fields);

	if (!$exists && $count > 0) {
		$this->id = false;
	}
	$success = true;
	$created = false;

	// ------------------------------------------------------------------------ //
	// チェック!データ保存の準備が完了しました。いよいよデータベースの更新です
	// ------------------------------------------------------------------------ //

	// 17.自身のテーブルに対する UPDATE または INSERT
	if ($count > 0) {
		// カウンターキャッシュ更新のための現在の値を取得して $cache にセット
		// @see Model::_prepareUpdateFields()
		$cache = $this->_prepareUpdateFields(array_combine($fields, $values));

		if (!empty($this->id)) {
			// 既存データが存在する場合 UPDATE ステートメントを実行
			// @see DboSource::update()
			// [DboSource::update() の挙動のポイント]
			// (1) 失敗した場合に Model::onError() をコール
			// @see Model::onError()
			$success = (bool)$db->update($this, $fields, $values);
		} else {
			if (empty($this->data[$this->alias][$this->primaryKey]) && $this->_isUUIDField($this->primaryKey)) {
				// プライマリキーがUUIDフィールドの場合、UUIDをセットする
				// @see Model::_isUUIDField()
				// @see String::uuid()
				if (array_key_exists($this->primaryKey, $this->data[$this->alias])) {
					$j = array_search($this->primaryKey, $fields);
					$values[$j] = String::uuid();
				} else {
					list($fields[], $values[]) = array($this->primaryKey, String::uuid());
				}
			}

			// INSERT ステートメントを実行
			// 結果を $success, $created に反映
			// @see DboSource::create()
			// [DboSource::create() の挙動のポイント]
			// (1) 成功した場合に Model::setInsertID() のコール及び、$this->id の値を更新
			// @see Model::setInsertID()
			// (2) 失敗した場合に Model::onError() をコール
			// @see Model::onError()
			if (!$db->create($this, $fields, $values)) {
				$success = false;
			} else {
				$created = true;
			}
		}

		// 処理に成功して BelogsTo モデルが存在する場合にカウンターキャッシュの更新を試みる
		// @see Model::updateCounterCache()
		if ($success && !empty($this->belongsTo)) {
			$this->updateCounterCache($cache, $created);
		}
	}

	// 18. HABTM モデルデータの更新
	// [注意]この更新の結果はこのインスタンス上ではどこにも反映されない模様→関連モデルを検査する?
	// @see Model::_saveMulti()
	if (!empty($joined) && $success === true) {
		$this->_saveMulti($joined, $this->id, $db);
	}

	// 19. 処理に成功していても自身のテーブルが更新されなかった場合は $success に false をセットする
	if ($success && $count === 0) {
		$success = false;
	}

	// 20. 後処理(Model.afterSaveイベント、プロパティ初期化など)
	if ($success && $count > 0) {
		if (!empty($this->data)) {
			if ($created) {
				// INSERTに成功した場合に $this->data のプライマリキーの値を更新
				// $this->id は DboSource::create() で更新されている
				$this->data[$this->alias][$this->primaryKey] = $this->id;
			}
		}
		if ($options['callbacks'] === true || $options['callbacks'] === 'after') {
			// Model.afterSave コールバックイベント。
			// こちらもイベント渡しではなくデータ渡し。
			// callbackMethod($created, $options) としてコールされる
			// それ以外の処理は CakeEvent の定石通り。
			$event = new CakeEvent('Model.afterSave', $this, array($created, $options));
			$this->getEventManager()->dispatch($event);
		}
		if (!empty($this->data)) {
			// このモデルのテーブルに対してデータが存在し、全体のプロセスが成功した場合に $this->data をセット
			$success = $this->data;
		}

		// プロパティ初期化
		// @see Model::_clearCache()
		$this->data = false;
		$this->_clearCache();
		$this->validationErrors = array();
	}

	// ホワイトリストの復元
	$this->whitelist = $_whitelist;

	// 21. 成功フラグを返して終了
	return $success;
}