6
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

YiiAdvent Calendar 2012

Day 15

Yiiのアクティブレコードについてひとこと言っておく 2

Posted at

ひとこと言っておくのパート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_girlINSERT INTO boy_meets_girl (boy_id, girl_id) VALUES (12, 34); とすれば、id=12のboyとid=34のgirlにリンクができ、この調子で、どれとどれの組み合わせでも、いくつでもリンクできるというのがそれ。ロマンスの神様はリア充なのです。

これ、SELECTの場合は relations() で指定できるMANY_MANYだけで問題ないんです。中間テーブル用のモデルもとくに要らない。

Boy.php
<?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のインスタンスのリストが取れます。

もちろん女子も男子といっぱい知り合えます。

Girl.php
<?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 という列がないと困ります。これ、スキーマ変更ですよね。めんどくさいなぁ...

さらに、そんなモデルクラスができてしまうと、厳密にはこういう関係になっちゃいますね。

Boy.php
<?php
class Boy extends CActiveRecord
{
	public function relations()
	{
		return array(
			'girlRelations' => array(boy::HAS_MANY, 'BoyMeetsGirl', 'boy_id'),
		);
	}
Girl.php
<?php
class Girl extends CActiveRecord
{
	public function relations()
	{
		return array(
			'boyRelations' => array(boy::HAS_MANY, 'BoyMeetsGirl', 'girl_id'),
		);
	}
BoyMeetsGirl.php
<?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を発行。

Boy.php
<?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がなければまあ、エラーが出ずにどんどん重複していくんで、それもいろいろと人間関係的にまずいのですが。

えーと…

Boy.php
<?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の実装も必要ですね。やれやれ、何回同じテーブル名/列名を書かなきゃいけないのかと。

というわけでビヘイビアを作りました。言いたいことはすべてこのコードにあります。

ManyManySupport.php

Yiiの CActiveRecord には、リレーションのメタデータをオブジェクトで取得する方法があります。MANY_MANYの定義もそれで調べることができるのです。

これで relations() で行った定義から、中間テーブル名、自分のIDの列名、相手のIDの列名をそれぞれ出したら、もうあとのコードはパターンにハメられます。

使い方は簡単。このビヘイビアを components あたりに置いて、MANY_MANYがあるモデルで常に使うように指定します。

<?php
	public function behaviors()
	{
		return array(
			'manyManySupport'=>array(
				'class' => 'ManyManySupport',
			),
		);
	}

あとは拡張された各引数に、リレーション定義の登録名と、リンクしたい相手のインスタンスを渡せばOK。

Boy.php
<?php
class Boy extends CActiveRecord
{
	public function meet($girl)
	{
		$boy->bindManyMany('girls', $girl);
	}

	public function breakUpWith($girl)
	{
		$boy->unbindManyMany('girls', $girl);
	}

次に作るもので使おうと思って作ったので、まだ甘いかもしれないけど、少なくともこのエントリで書いた面倒なコードを一般化できているという点だけでも、役に立つとは思います。

もし仕様が気に入らなかったら、適当に特殊化して使ってください。

ええ、もちろん、車輪を再発明してるんじゃないかという危惧はありますw

6
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?