Help us understand the problem. What is going on with this article?

Yii 2 ActiveRecord の痒いところ

More than 3 years have passed since last update.

Active Record は、Yii 1.1 から 2.0 へのアップグレードに伴って非常に大きな変更を受けました。私が特に重要だと思うのは、次の二点です。

  1. メイン・モデルとリレーショナル・モデルが必ず別のクエリで取得されるようになったこと。
  2. 生の SQL から Active Record までの変移が非常にリニアになったこと。

私はこれらの変更が気に入っていて、特に、前者については、別の記事 ( 演習 - HAS_MANY リレーションによる検索 (Yii 2 版) ) でも、それの利点について述べたつもりです。

しかし、一つ、ちょっと気になる所があります。それは、テーブル・エイリアスを設定・取得する標準的な方法が無い ということです (2015年11月、Yii 2.0.6 現在)。

具体的な例を挙げて説明しましょう。

リレーションの例

「項目」というモデルがその「登録者」と「更新者」を持っている、というのはよくあることです。そのリレーションをどう書くか。Gii のモデル・ジェネレータに任せると、次のようなコードを吐き出します。

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getCreatedBy()
    {
        return $this->hasOne(User::className(), ['id' => 'created_by']);
    }

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getUpdatedBy()
    {
        return $this->hasOne(User::className(), ['id' => 'updated_by']);
    }

すなわち、ItemUser に対して二つの異なるリレーションを持ちます。一つは Item has one createdBy であり、もう一つは Item has one updatedBy です。

エイリアスが無くても困らないの?

ここで、「同じテーブルを見ているのに、エイリアス を付けて区別する必要は無いの?」 というのは、まことにもっともな疑問でしょう。

さらに進んで「あ、そうか、リレーション名がデフォルトのエイリアスになるんだ」と早合点する人もいるかも知れません。

しかし、リレーションの定義にテーブル・エイリアスは付きません。なぜなら、その必要がない からです。

リレーショナル・モデルの取得に JOIN は使わない

これは、既に他の記事 (Yii2にみるイーガーローディング戦略) でも説明されていることですが、重要なところなので、あらためて繰り返します。

Item を そのリレーションである createdBy および updatedBy と一緒に取得するためには、with というメソッドを使って、次のようなコードを書きます。

$items = Item::find()
    ->with(["createdBy", "updatedBy"])
    ->all();

この ActiveRecord のコードが最終的にどのような SQL に翻訳されて実行されるのでしょうか。

Yii 1.x では、次のような SQL が生成されました。

SELECT * FROM `item` as `t`
    LEFT JOIN `user` AS `c1` ON `c1`.`id` = `t`.`created_by`
    LEFT JOIN `user` AS `c2` ON `c2`.`id` = `t`.`updated_by`   

教科書に出てきてもおかしくないような SQL で、まあ、普通はこう書くとしたものでしょう。

ところが、 Yii 2.0 では、次のような SQL が生成されて実行されます。

SELECT * FROM `item`;
SELECT * FROM `user` WHERE `id` in IN ('1', '2', '3', '4', '6', '8');
SELECT * FROM `user` WHERE `id` in IN ('2', '3');

三つの SELECT 文は、それぞれ、1) メイン・モデルである Item、2) リレーショナル・モデルである createdBy、そして、3) 同じくリレーショナル・モデルである updatedBy だけを取得します。IN ( ... ) の中には、最初の SELECT の結果から収集された created_by または updated_by の値が入ります。

「何でそんなことするの? 効率が悪いやろ? 一つの SQL で済むものを、何でわざわざ三つに分けるの?」と言う人もいるでしょう。いや、必ず、います。全体のデータ転送量まで含めて、よーく考えてみると、必ずしもこれが非効率的だとは言えない筈なのですが、一部の人たちの目には、このような SQL は許し難い愚行であると見えるようです。

ともかくも、これが、この文章の最初で述べた「メイン・モデルとリレーショナル・モデルが必ず別のクエリで取得されるようになった」ということの中身です。

今は、そのことの是非を議論することは控えます。ただ、このようにしてリレーショナル・モデルを取得する場合は、JOIN が不要 になること、従って、また、テーブル・エイリアスも不要 になることを確認してください。

メイン・モデル取得時にリレーションを参照したいときは?

しかし、リレーショナル・モデルではなく、メイン・モデルを取得する際に、リレーションのテーブルを参照したい場合はあります。例えば、「登録者」の名前で「項目」を探したいような場合です。

SELECT * FROM `item`
    LEFT JOIN `user` ON `user.`id` = `item`.`created_by`
    WHERE `user`.`name` LIKE :name;

これを ActiveRecord ではどう書いたらよいのか?

これに対して、Yii 2.0 のごく初期においては、「すなおに JOIN すればよろしい」というのが開発チームのポリシーでした。すなわち、

$items = Item::find()
    ->leftJoin('user', ['user.id' => 'item.created_by'])
    ->where('like', 'user.name', $name)
    ->with('createdBy')
    ->all();

と書けばよろしい、と。

え? createdBy リレーションの定義は JOIN には使われないの?

うん、使わない。ノー・プロブレム。

joinWith : リレーション定義を使ってリレーションを参照する

さすがにそれでは納得しないユーザーが多かったため、現在では、joinWith というメソッドが利用に供されています。これを使うと、リレーションの定義に従ってテーブルを JOIN することが可能になります。

$items = Item::find()
    ->joinWith('createdBy')
    ->where('like', 'user.name', $name)
    ->all();

一見、便利に見える joinWith ですが、上記のコードには、いくつか問題が見え隠れしています。

(そろそろ、背中がむず痒くなってきた、、、)

joinWith の落し穴 1: ミス・リーディングなメソッド仕様

joinWith は、メイン・モデルの取得時にリレーショナル・テーブルを JOIN するとともに、デフォルトでは、リレーショナル・モデルの取得をも実行します。すなわち、with を兼ねています。リレーショナル・モデルの取得を抑止するためには、

    ->joinWith('createdBy', false)

のように、第二のパラメータに明示的に false を指定しなければなりません。

このメソッドの仕様は設計の失敗であったと私は考えています。これによって「メイン・モデルとリレーショナル・モデルが必ず別のクエリで取得される」という原則が見えにくくなってしまいました。

このミス・リーディングなメソッド仕様によって土壺に落ちる初心者は後を絶たないであろうと予測されます。

joinWith の落し穴 2: テーブル名はどこから?

もう一つの問題点が、JOIN されるテーブルの名前です。

    ->where('like', 'user.name', $name)

当たり前のように 'user.name' と書いていますが、この 'user' というテーブル名、どこから来ているんですかね?

    ->where('like', User::tableName() . 'name', $name)

と書けば、ちょっとマシになった気がするかも知れませんが、それは気のせいです。問題が「User というモデルがどこから来ているのか」に変っただけで、答えが無いのは同じです。

joinWith の落し穴 3: テーブル・エイリアスは?

更に問題になるのは、テーブル名にエイリアスを使う必要がある場合に、スマートな方法が無い、ということです。

例えば、「登録者が '木原' であり、更新者が '吉田' である項目を列挙せよ」という場合です。

「すなおに JOIN する」方式で行くなら、次のように書きます。

$items = Item::find()
    ->leftJoin('user c', ['c.id' => 'item.created_by'])
    ->leftJoin('user u', ['u.id' => 'item.updated_by'])
    ->where('like', 'c.name', '木原')
    ->andWhere('like', 'u.name', '吉田')
    ->with(['createdBy', 'updatedBy'])
    ->all();

生の SQL がスケスケで、ちょっと恥ずかしいような気はしますが、曖昧なところは一つも無く、意図されているところは明確に伝わってくるでしょう。

これと同じことを joinWith を使って書くには、どうすれば良いのでしょう。

$items = Item::find()
    ->joinWith(['createdBy', 'updatedBy'])
    ...

では、テーブル名が衝突するので、駄目です。何とかして、エイリアスを指定する必要があります。

次のように書くのが、現状ではもっとも穏当な方法でしょうか。

$items = Item::find()
    ->joinWith([
        'createdBy' => function($query) {
            $query->from(['c' => User::tableName()]);
         },
        'updatedBy' => function($query) {
            $query->from(['u' => User::tableName()]);
         }
    ])
    ->where('like', 'c.name', '木原')
    ->andWhere('like', 'u.name', '吉田')
    ->all();

めんどくせえ、スッキリせんなぁ、と感じる人もあるでしょう。

あまりお勧めは出来ませんが、テーブル・エイリアスの指定をリレーションの定義に忍ばせておくという手はあります。

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getCreatedBy()
    {
        return $this->hasOne(User::className(), ['id' => 'created_by'])
            ->from(['c' => User::tableName()]);
    }

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getUpdatedBy()
    {
        return $this->hasOne(User::className(), ['id' => 'updated_by'])
            ->from(['u' => User::tableName()]);
    }
$items = Item::find()
    ->joinWith(['createdBy', 'updatedBy'])
    ->where('like', 'c.name', '木原')
    ->andWhere('like', 'u.name', '吉田')
    ->all();

ただし、JOIN するリレーションの数が増えたり入れ子になったりすると、破綻する可能性は出てきます。また、そもそも、何で 'c' というエイリアスなの、どこからそれが来るの、丸暗記するの、という問題は残ります。見た目にだけでもスッキリしたい向きには、こういう手もありますよ、ということです。

上記二つの解法のいずれにおいても、テーブル名やテーブル・エイリアスをリレーション定義から取得しているのではない、という根本的な問題は手つかずのままです。だいたい、リレーションの定義にテーブル・エイリアスが必要だなんて、最初は考えていなかった訳ですから。

今後どうなるのか

2015年11月、Yii 2.0.6 現在では、このテーブル・エイリアスの問題は、開発者の一人である Carsten Brandt (cebe) による以下のプル・リクエストに基づいて解決されることになるのであろうと予測されます。

#10253 Advanced table alias handling for Query and ActiveQuery

Yii 2.0.7 での採用が予定されているこの機能拡張が実施された後は、次のように書くことが可能になるようです。

$items = Item::find()
    ->joinWith(['createdBy c', 'updatedBy u'])
    ->where('like', 'c.name', '木原')
    ->andWhere('like', 'u.name', '吉田')
    ->all();

まあ、これならスッキリするかな。

しかし、ネストされたリレーションとか、大丈夫でしょうか。また、何か、ややこしい事になりそうな気もします。

痒いところをボリボリ掻いて、ちょっとだけスッキリしたところで、おしまいです。

softark
山の中の半農半プログラマ。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした