Re: 中間テーブルのcreated、modifiedについて

イントロダクション

CakePHPのhasAndBelongsToMany(以下habtmと呼ぶ)機構は、期待する振る舞いをしないことがあります。
CakePHPはそのマニュアルで、habtmが複雑になったときについてこう言及しています:
http://book.cakephp.org/ja/view/1034/Saving-Related-Model-Data-HABTM

また、join table (結合の情報を記述するテーブル) に (関係の作成時刻や、メタデータなどの) フィールドを付け加えたい場合にも言及しておきましょう。HABTM の join table に付け加える事もできますが、もっと簡単な方法もあります。
2つのモデルの HABTM 関係は、本当は、hasMany と belongTo による3つのモデルの関係を省略して表したものです。

このように、hasManyとbelongsToに分解することを推奨されます。
しかし、モデルとフォームが密接に関わりあってるため、これをすると、habtmの為に用意された機構が使えなくなります

  • data[HabtmModel][HabtmModel]という構造
  • FormHelper::input('HabtmModel'); といった書式
  • 自動的なリンクテーブルの更新(新しい関係性のみが保存される)

これらにより、分解したはいいがどうも使いづらい、どうすればいいんだ、という意見が沸いてくるのは当然です。
しかし、分解したからといって、アプリケーション側でその機構を作れないわけではありません

本来のhabtmと同じような振る舞い

<?php
class UsersController extends AppController {
	public function add(){
		if (!empty($this->data)) {
			if (is_array($groups = $this->data['GroupsUser']['GroupsUser'])) {
				foreach ($groups as $group_id) {
					// 整形
					$data = array(); //省略。
				}
			}
			$this->User->saveAll($data);
			// do something
		}
	}
}

FormHelperの挙動を詳しく追いかけると、望むような形式で返してくれることは、残念ながらありません。
一つの方法として、このように整形することを考え付くでしょう。
しかし、明らかにfatコントローラです。edit()の時もコピペになること間違いなしです。
これらのことは、モデル側で吸収すべきです。


さて、ごちゃごちゃ言っても始まらないので、自分なりの一つの答えを提示します。もちろん、これが唯一の正解ではないということをご理解ください。
以下、
User => Team <= Group
User:

  • id
  • name

Gourp:

  • id
  • name

Tema:

  • id
  • user_id
  • group_id

とします。

コントローラは、bakeしたものから、次のコードをadd, editの末尾に付加するだけです。

<?php $this->set('groups', $this->User->Team->Group->find('list')); ?>

Groupモデル、TeamモデルもBakeしたもので良いです。

Userモデル:(基本はやはりBake)
<?php
class User extends AppModel {
	var $name = 'User';
	var $displayField = 'name';
	//The Associations below have been created with all possible keys, those that are not needed can be removed

	var $hasMany = array(
		'Team',
	);

	function afterSave($created) {
		$groups = @(array)$this->data[$this->alias]['group_id'];
		if (!empty($groups)) {
			$old = $created ? array() : $this->Team->find('list', array('fields' => array('Team.group_id')));

			$user_id = $this->id;
			foreach (array_diff($groups, $old) as $group_id) {
				$this->Team->Group->id = $group_id;
				$this->Team->create(compact('user_id', 'group_id'));

				if (!$this->Team->Group->exists() || !$this->Team->save()) {
					return false;
				}
			}
			if (!$created) {
				$this->Team->deleteAll(array('Team.group_id' => array_diff($old, $groups)));
			}
		} elseif (!$created) {
			$this->Team->deleteAll(array('Team.user_id' => $this->id));
		}
	}

	function afterFind($results, $primary = false) {
		if ($primary) {
			foreach ($results as $index => $result) {
				$results[$index] = $this->normalizeResult($result);
			}
		}
		return $results;
	}

	// edit()の為の整形
	function normalizeResult($result) {
		if (!empty($result['Team'][0]['group_id'])) {
			$result[$this->alias]['group_id'] = Set::extract('/Team/group_id', $result);
		}
		return $result;
	}
}
add.ctp(edit.ctp)
<div class="users form">
<?php echo $this->Form->create('User');?>
	<fieldset>
 		<legend><?php __('Add User'); ?></legend>
	<?php
		echo $this->Form->input('name');
		echo $this->Form->input('group_id', array('multiple' => 'multiple')); // これを付け加えただけ
	?>
	</fieldset>
<?php echo $this->Form->end(__('Submit', true));?>
</div>
<?php // アクションリストは省略 ?>

基本はafterSave()とafterFind()で対応ということです。
おっと、これを見て以下のような疑問をもったら鋭いです:

  1. 一々こういうふうに書かなきゃいけないの?だるいな・・・
  2. Groupモデルも同じようにしたいんだけど・・・
  3. afterSave()で失敗したらfalse返してるけど、意味無いよね?トランザクションどうするの?


まず1. と2. に関してです。CakePHPにはBehaviorという恐ろしく便利なものがありますよね。
あとは言わなくてもわかると思います :)
3. に関しては、return falseの代わりにExceptionを投げましょう。
PHP4の場合は、Model::onError()をうまく使えば制御はできます。

HABTM Add & Delete Behavior

http://bakery.cakephp.org/articles/bparise/2007/05/09/add-delete-habtm-behavior
以上の解説のように、本来のhabtmの挙動とはまた違った挙動にしたい(例えば、既存のものはそのままで、新しい関係を追加(or削除)だけしたい場合も、ビヘイビアにまとめるのがいいでしょう。
上記のものは分解することなく実現していますが、概念だけは押さえておきましょう。

最後に

この問題は、自分がCakePHPを使ったはじめてのアプリケーションでぶち当たったものです。
とてつもなく悪あがきをした挙句、解決のコードもまったく美しくなく、コントローラで整形を繰り返し、また、モデルのコールバックすらも膨れ上がったものになっていまいました。
修正のチケットも投げました。賛同者もいましたが、テストコードも無い、良くなかったコミットは結局マージされることなく、間もなく1.3は新機能の取り込みを止めました。
フォーラムでは、@kanonjiさんに深く質問していただきました。
この記事は、それへの回答でもありますが、過去の自分への回答でもあります。