CakePHPのHABTM

前書き

久々にすんげーはまった。1つの問題で8時間越えした気がする。
正直ねーだろと思った。腹立ったから長いよー。
はてなもいちいち

<?php

って書かせないでくれー。
ちなみにCakePHPの1.2.5の話です。1.2.6と1.3のソースコードは見たけど、後述のdynamicWithはまだ残っていたような気がする。

CakePHPのモデル生成順序

俺も昨日追いかけてみて初めて知ったんだけど、CakePHPって貼ったルールによって自動的にモデルを生成してんくだよね。
例えばこういうの。複数形とか間違えてるかも。

<?php
class Company extends AppModel {
	var $hasMany = array(
		'Division' => array(
			'className' => 'Division',
			'foreignKey' => 'company_id',
			'dependent' => false,
			'conditions' => '',
			'fields' => '',
			'order' => '',
			'limit' => '',
			'offset' => '',
			'exclusive' => '',
			'finderQuery' => '',
			'counterQuery' => ''
		)
	);
}
class Division extends AppModel {
	var $hasMany = array(
		'Employee' => array(
			'className' => 'Employee',
			'foreignKey' => 'divison_id',
			'dependent' => false,
			'conditions' => '',
			'fields' => '',
			'order' => '',
			'limit' => '',
			'offset' => '',
			'exclusive' => '',
			'finderQuery' => '',
			'counterQuery' => ''
		)
	);
}
class Employee extends AppModel {
}
?>

こういうのがあった場合にController側でこういう風に書いたりする。

<?php
class CompanyController extends AppController {
	var $uses = array('Company');
}
?>

すると、controller側のloadModel関数内でまずCompanyが呼ばれて、その後Modelの__createLinksが呼ばれて、hasManyで指定されるDivisonに対して__constructLinkedModelが呼ばれて、ClassRegistryのinit処理が呼ばれて、まあクラスが登録されるわけですよ。
んで、さらにDivisionの__createLinksから(中略)Employeeが呼ばれるっちゅー仕組み。
当然モデル数が100とかあって、ルール作りまくってるとものっそい重くなるからね注意してねって感じで。

HABTM

CakePHPにはHABTMてのがあるんだけど、知らん人はまあぐぐってねと。モデル同士のN対N関係をあらわすもので、こいつにやられた。
HABTMはモデルA<--(1)--(N)-->モデルB<--(N)--(1)-->モデルCって感じの関係を表すのに使われて、まあこういう風に書くわけだ。

<?php
class Division extends AppModel {
	var $hasAndBelongsToMany = array(
		'User' => array(
			'className' => 'User',
			'joinTable' => 'divisions_users',
			'foreignKey' => 'division_id',
			'associationForeignKey' => 'user_id',
			'unique' => true,
			'conditions' => '',
			'fields' => '',
			'order' => '',
			'limit' => '',
			'offset' => '',
			'finderQuery' => '',
			'deleteQuery' => '',
			'insertQuery' => ''
		)
	);
}
class DivisionsUser extends AppModel {
	var $belongsTo = array(
		'Division' => array(
			'className' => 'Division',
			'foreignKey' => 'division_id',
			'conditions' => '',
			'fields' => '',
			'order' => ''
		),
		'User' => array(
			'className' => 'User',
			'foreignKey' => 'user_id',
			'conditions' => '',
			'fields' => '',
			'order' => ''
		)
	);
}
class User extends AppModel {
	var $hasAndBelongsToMany = array(
		'Division' => array(
			'className' => 'Division',
			'joinTable' => 'divisions_users',
			'foreignKey' => 'user_id',
			'associationForeignKey' => 'division_id',
			'unique' => true,
			'conditions' => '',
			'fields' => '',
			'order' => '',
			'limit' => '',
			'offset' => '',
			'finderQuery' => '',
			'deleteQuery' => '',
			'insertQuery' => ''
		)
	);
}
?>

こうすると、N対Nも自動で簡単にやってくれる。
素晴らしい機能だよなぁ、と思うじゃんよ。
それがこういうのがあると悪魔に変わっちゃうんだ。

<?php
class Order extends AppModel {
	var $belongsTo = array(
		'User' => array(
			'className' => 'User',
			'foreignKey' => 'user_id',
			'conditions' => '',
			'fields' => '',
			'order' => ''
		),
	);
}
?>

dynamicWith

誰が作ったのこれ?マジでやめてほしいんだけど・・。
というのが、modelクラスの__generateAssociationにあるdynamicWith機能。
先のOrderに対するコントローラでこういうのがあるとするじゃんか。

<?php
class OrdersController extends AppController {
	var $uses = array('Order', 'DivisionsUser');
}
?>

で、普通はありえないんだけど長くなりすぎるのでそういうシチュエーションだと想像してもらうとして、このOrdersコントローラはなぜかDivisionsUserを更新するとする。
この際のモデルの呼び出し順序はこう。

  • Order -> User -> Division
  • DivisionsUser

ここでの注目点は、さっきのようにhasManyではなくHABTMなのでUserからDivisionsUserが直接参照ではなく間接参照になっているので、UserからDivisionの間でDivisionsUserに対する__constructLinkedModelが呼ばれていない点。
検証するならこういうのを__constructLinkedModelに仕込めばいいと思う。

<?php
function __constructLinkedModel($assoc, $className = null) {
	if (empty($className)) {
		$className = $assoc;
	}
		
	echo $this->name.':'.$className.',';
	// 後略
?>

でも普通に考えて、そのモデルが無いとリンク生成なんて出来ないんでCakeは自動生成してる。なぜかdynamicWithという悪魔を使ってね。

<?php
//前略
class Model extends Overloadable {
//中略
	var $__associationKeys = array(
//中略
		'hasAndBelongsToMany' => array('className', 'joinTable', 'with', 'foreignKey', 'associationForeignKey', 'conditions', 'fields', 'order', 'limit', 'offset', 'unique', 'finderQuery', 'deleteQuery', 'insertQuery')
//中略
function __generateAssociation($type) {
//中略
	foreach ($this->{$type} as $assocKey => $assocData) {
		$class = $assocKey;
		$dynamicWith = false;

		foreach ($this->__associationKeys[$type] as $key) {
			if (!isset($this->{$type}[$assocKey][$key]) || $this->{$type}[$assocKey][$key] === null) {
				switch ($key) {
//中略
					case 'with':
						$data = Inflector::camelize(Inflector::singularize($this->{$type}[$assocKey]['joinTable']));
						$dynamicWith = true;
//中略
			}
		}

		if (!empty($this->{$type}[$assocKey]['with'])) {
//中略
			if (!ClassRegistry::isKeySet($joinClass) && $dynamicWith === true) {
				$this->{$joinClass} = new AppModel(array(
					'name' => $joinClass,
					'table' => $this->{$type}[$assocKey]['joinTable'],
					'ds' => $this->useDbConfig
				));
			} else {
				$this->__constructLinkedModel($joinClass, $plugin . $joinClass);
				$this->{$type}[$assocKey]['joinTable'] = $this->{$joinClass}->table;
			}
//後略
?>


順に解説していく。

class Model extends Overloadable {

クラス宣言すね。

var $__associationKeys = array(
//中略
'hasAndBelongsToMany' => array('className', 'joinTable', 'with', 'foreignKey', 'associationForeignKey', 'conditions', 'fields', 'order', 'limit', 'offset', 'unique', 'finderQuery', 'deleteQuery', 'insertQuery')

上書きしないでねって感じで宣言されてるキーすね。

function __generateAssociation($type) {
//中略
foreach ($this->{$type} as $assocKey => $assocData) {
$class = $assocKey;
$dynamicWith = false;

foreach ($this->__associationKeys[$type] as $key) {
if (!isset($this->{$type}[$assocKey][$key]) || $this->{$type}[$assocKey][$key] === null) {

__generateAssocicationっちゅー、普通は__constructLinkedModel関数の後に呼ばれる関数すね。
まあなんかアソシエーションのキーごとにループしてますね。上書きしないでほしそうな__associationKeysでループしてる点注意。

switch ($key) {
//中略
case 'with':
$data = Inflector::camelize(Inflector::singularize($this->{$type}[$assocKey]['joinTable']));
$dynamicWith = true;

キーがwithの場合にdynamicWith変数をtrueにしてますね。
まあつまり、どんなモデルでもHABTMなら100%TRUEて事です。(2010年08月30日追記)すいません、間違ってました。この少し上でisset判定とかをしているので、withキーに値がセットされていなければTRUEになる、が正しいです。

if (!empty($this->{$type}[$assocKey]['with'])) {
//中略
if (!ClassRegistry::isKeySet($joinClass) && $dynamicWith === true) {
$this->{$joinClass} = new AppModel(array(
'name' => $joinClass,
'table' => $this->{$type}[$assocKey]['joinTable'],
'ds' => $this->useDbConfig
));
} else {
$this->__constructLinkedModel($joinClass, $plugin . $joinClass);
$this->{$type}[$assocKey]['joinTable'] = $this->{$joinClass}->table;
}

クラスがまだ登録されておらず、HABTMの関係性があるモデルなら、AppModelのインスタンスとして生成してますね。
AppModelのインスタンスとして生成した時点でModelクラスのコンストラクタが呼ばれて、コンストラクタ内でClassRegistry::addObjectを呼んでるから、生成したインスタンスと名前を登録してますね。
んで、一度生成したインスタンスをClassRegistryに再生成させたりしないから*1、以後はAppModelになるわけだ。


さっきの例だと、User -> Divisionの間でこれが行われてDivisionsUserがAppModelになって、DivionsUserを取り込んだときには生成済みなんでやっぱりAppModelになってるってからくりだ。

わかる!?

すんげーわかりにくいのは100も承知なんだけど、DivisionsUserモデルを使いたいのにこれAppModelのインスタンスになっちまってんだよ。
つーことはValidationとかも一切きかんわけ。
何なんこれどういうこと?
$dynamicWith === trueをfalse === trueにした方が上手くいくんじゃないの?
(2010年08月30日追記)すいません、当時熱くなってましたが、普通にモデルに対してwithを設定すればいいです。


いや、もしかしたら純粋なリンクテーブル以外でHABTMを使わずbelongsToとhasManyでやれって事かもしれんけど、bakeでリンクテーブルに独自のデータがあろうがなかろうが自動でHABTM作るわけだし、CakePHPの指針としてはそうじゃないって事でしょ?
こういう事がありえるからusesをarray()かnullにして、自前loadModelをした方がいいとかいう議論があるみたいだけど、それってフレームワークとしてどうよって事なんじゃないの。


ほんと久しぶりにはまった。
今回Cakeを使ってみて、1.3系ではis_a使ってるから大丈夫だけどShellがget_parent_classを使っているせいでShellのスーパークラスとしてAppShell作ると駄目だとか、なんか少々フラストレーションたまること多いぜ。
コミッターが多すぎて技術レベルが統一されてないとかか?


でももしかして俺が間違ってて、このくらい追いかけるのはオープンソースに依存するプロジェクトであれば当然とかかな?
Struts2でFreeMakerのバグからファイルデスクリプタが蓄積されてくバグを追いかけた時以来の面倒くささだったけど、こういうのって実は一般的とか?
マジかよ。皆すげーな。




すまん、勢いだけで書いた。
なんか間違ってるかもしれんしCakeのコアをちゃんと読んでる人でもわかりにくいと思われる。




(2010年08月30日追記)
ということで間違ってました。理想的な解決策はトラックバックしてくれた http://d.hatena.ne.jp/cakephper/20100826/1282798441 を見てください。


ただ一点、'with'には中間テーブルの名前が入るんで、リンク先に書かれている「空文字でもいい」は本問題への解決にはなんら問題ないのですが、dynamicWithがtrueではないためAppModelは生成されず、withがemptyだと__constructLinkedModelも呼ばれず中間のモデルが作られないため、Modelを一つずつimportしているような場合中間モデルのデータが迷子になりそうです。


実はCakePHPを商用運用する場合、バッチ処理やパフォーマンスの問題でusesを使わないケースが意外に多いので、要注意です。
HABTMを使う場合、withをちゃんと指定しましょうね、という指針にするのがベストだと思います。


bakeはjoinTableを指定しながらwithを指定しないので、CakePHP自体がbakeでwithも自動生成してくれたら、こういう余計なはまりポイントが無くなってフレームワークとして親切なんじゃないかなぁと思いますが、リンクテーブルだけのモデルは作らないシステムなんかだと確かにdynamicWithで十分なので逆に迷惑なのかなと。
指針でカバーするしかない気がします。

*1:isKeySetとかでModelとClassRegstryを追いかけてみるとわかる