ひとこと言っておくのパート2というよくわからないエントリになりました。
前回はHAS_MANYとBELONGS_TOについて書きました。もう十分言い訳はしたと思うので、今回は言い訳しないことにします。
MANY_MANYの場合
YiiにはMANY_MANYタイプの関連付けもあります。中間テーブルを使って、元と先のIDを結びつける方法です。
CREATE TABLE boy (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(128) NOT NULL
);
CREATE TABLE girl (
id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(128) NOT NULL
);
CREATE TABLE boy_meets_girl (
boy_id INTEGER NOT NULL KEY REFERENCES boy(id),
girl_id INTEGER NOT NULL KEY REFERENCES girl(id)
);
中間テーブルである boy_meets_girl
に INSERT INTO boy_meets_girl (boy_id, girl_id) VALUES (12, 34);
とすれば、id=12のboyとid=34のgirlにリンクができ、この調子で、どれとどれの組み合わせでも、いくつでもリンクできるというのがそれ。ロマンスの神様はリア充なのです。
これ、SELECTの場合は relations()
で指定できるMANY_MANYだけで問題ないんです。中間テーブル用のモデルもとくに要らない。
<?php
class Boy extends CActiveRecord
{
public function relations()
{
return array(
'girls' => array(self::MANY_MANY, 'Girl', 'boy_meets_girl(boy_id, girl_id)'),
);
}
これで、BoyのインスタンスをDBから拾ってきてgirlsプロパティを見たら、リンクされたGirlのインスタンスのリストが取れます。
もちろん女子も男子といっぱい知り合えます。
<?php
class Girl extends CActiveRecord
{
public function relations()
{
return array(
'boys' => array(self::MANY_MANY, 'Boy', 'boy_meets_girl(girl_id, boy_id)'),
);
}
boy_meets_girl(girl_id, boy_id)
と、自分と相手の指定が Boy
クラスとは逆になっているのがポイントです。
ところがこれ、CActiveRecord
でリンクを貼る、つまりINSERTを実現するのが思ったよりたいへん。
まずINSERTするには save()
メソッドが要るので、boy_meets_girl
用のモデルクラス BoyMeetsGirl
が必要です。で、ARで管理しようとすると、id
という列がないと困ります。これ、スキーマ変更ですよね。めんどくさいなぁ...
さらに、そんなモデルクラスができてしまうと、厳密にはこういう関係になっちゃいますね。
<?php
class Boy extends CActiveRecord
{
public function relations()
{
return array(
'girlRelations' => array(boy::HAS_MANY, 'BoyMeetsGirl', 'boy_id'),
);
}
<?php
class Girl extends CActiveRecord
{
public function relations()
{
return array(
'boyRelations' => array(boy::HAS_MANY, 'BoyMeetsGirl', 'girl_id'),
);
}
<?php
class BoyMeetsGirl extends CActiveRecord
{
public function relations()
{
return array(
'boy' => array(boy::BELONGS_TO, 'Boy', 'boy_id'),
'girl' => array(boy::BELONGS_TO, 'Girl', 'girl_id'),
);
}
あれ? MANY_MANY 要らんやんw
そう、データベースの世界でMANY_MANYは、HAS_MANYとBELONGS_TOを使って一般化できるのです。そして、そのほうが適切な場合が多々あります。
たとえば、ゲームのプレイヤーと所有アイテム。誰が何を持っているかという情報には、所有している個数も付けたいですよね。記事と画像の関係にも、記事中の画像表示順序があるかもしれない。BoyMeetsGirl
の場合は、好感度パラメータとかありえますね。おいどこの恋愛ゲームかという話ですが。
Giiで自動生成した場合でも、あからさまにMANY_MANY関係がありそうなテーブル設計なのに、中間テーブルを挟んでこういう、HAS_MANYとBELONGS_TOの定義を生成してくれます。
MANY_MANYの採用は、本当にその関係に付加情報が付く可能性がないのかを、よく検討してからにしましょう。
それでもMANY_MANYがいいんだ
で、よく考えた結果、でもやっぱり直接のMANY_MANY関係だけでいい場合があります。そういうときは、中間テーブル用のクラスを設けても複雑になるだけですね。シンプルでいいときはシンプルに。
では実践。
ざっくり言うと、それ用のモデルクラスは作らずに、中間テーブルに互いのIDを保存しちゃえばいいのです。Yiiらしく直接SQLを発行。
<?php
class Boy extends CActiveRecord
{
public function meetsAGirl($girl)
{
$this->commandBuilder->createInsertCommand('boy_meets_girl', array(
'boy_id'=>$this->id,
'girl_id'=>$girl->id,
))->execute();
}
さっくりINSERTできました。
でもこのメソッドには問題があって、それは、相手と自分が確実に id
を持っている必要があるのにそれをチェックしてないということ。つまり、結び付ける対象がすでにデータベースに存在しないといけない。なので、そのチェックをしないとダメですね。
さらに、すでにリンク関係があるのに同じリンクを貼ろうとしたら…
SQLのUNIQUE制約エラー(あれば)を出さないために、重複チェックは必要です。UNIQUEがなければまあ、エラーが出ずにどんどん重複していくんで、それもいろいろと人間関係的にまずいのですが。
えーと…
<?php
class Boy extends CActiveRecord
{
public function meetAGirl($girl)
{
if ($this->isNewRecord) {
throw new CDbException("Relation owner is not saved.");
}
if ($girl->isNewRecord) {
throw new CDbException("Relation girl is not saved.");
}
$criteria = $this->commandBuilder->createCriteria();
$criteria->addColumnCondition(array(
'boy_id'=>$this->id,
'girl_id'=>$girl->id,
));
$alreadyMet = $this->commandBuilder->createCountCommand(
'boy_meets_girl', $criteria
)->queryScalar() > 0 ? true : false;
if($alreadyMet) {
return;
}
$this->commandBuilder->createInsertCommand('boy_meets_girl', array(
'boy_id'=>$this->id,
'girl_id'=>$girl->id,
))->execute();
}
なんだか、いやになってきましたw
この調子で breakUpWithTheGirl
(つまり別れちゃうって意味)も必要だし、あと、Girl
から Boy
への逆向きのAPIの実装も必要ですね。やれやれ、何回同じテーブル名/列名を書かなきゃいけないのかと。
というわけでビヘイビアを作りました。言いたいことはすべてこのコードにあります。
Yiiの CActiveRecord
には、リレーションのメタデータをオブジェクトで取得する方法があります。MANY_MANYの定義もそれで調べることができるのです。
これで relations()
で行った定義から、中間テーブル名、自分のIDの列名、相手のIDの列名をそれぞれ出したら、もうあとのコードはパターンにハメられます。
使い方は簡単。このビヘイビアを components
あたりに置いて、MANY_MANYがあるモデルで常に使うように指定します。
<?php
public function behaviors()
{
return array(
'manyManySupport'=>array(
'class' => 'ManyManySupport',
),
);
}
あとは拡張された各引数に、リレーション定義の登録名と、リンクしたい相手のインスタンスを渡せばOK。
<?php
class Boy extends CActiveRecord
{
public function meet($girl)
{
$boy->bindManyMany('girls', $girl);
}
public function breakUpWith($girl)
{
$boy->unbindManyMany('girls', $girl);
}
次に作るもので使おうと思って作ったので、まだ甘いかもしれないけど、少なくともこのエントリで書いた面倒なコードを一般化できているという点だけでも、役に立つとは思います。
もし仕様が気に入らなかったら、適当に特殊化して使ってください。
ええ、もちろん、車輪を再発明してるんじゃないかという危惧はありますw