LoginSignup
7
7

More than 5 years have passed since last update.

Yii2のスコープはどこへ行ったのか

Posted at

Yii1 の ActiveRecord には scopes という 機能 がありました。SQL に名前を付けて簡単に呼び出せたり、はじめからデフォルトの WHERE が適用されていたりするものです。

CActiveRecord::scopes() でわかる、SQL上級者のためのO/Rマッピング

Yii2 では scopes() メソッドが除去されました。同じ機能はどのように提供されるのでしょう。

デフォルトスコープの代わりに

このカスタマイズのもっとも重要なポイントは ActiveRecord::find() です。これはあらゆるクエリにおける単一点で、 findAll()findOne() も内部的にここを通ります。

デフォルトで適用したい条件を与える簡単な方法は、この find() を少しオーバーライドすることです。

基本の戻り値を奪って、条件付きの ActiveQuery にして返せばよいというわけです。たとえば、「ゴミ箱に送ったメッセージは出さない」という条件がデフォルトで備わっている場合はこうです。

class Message extends \yii\db\ActiveRecord
{
    /**
     * @return \yii\db\ActiveQuery
     */
    public static function find()
    {
        // ゴミ箱にあるものは出さない
        return parent::find()->where(['trashed' => false]);
    }
}
Message::findAll();
SELECT * FROM `message`
 WHERE `trashed`=:qp0

クエリへのメソッド追加

最新5件のメッセージを得たいとき、クライアントコード側でどのカラムが日時なのかを知る必要がないようにしたいですね。 Message::find()->newest(5)->all() のように呼び出せる newest() メソッドを追加することにします。

newest() メソッドはすでに ActiveRecord の管理下から外れて、ActiveQuery のメソッド呼び出しになっています。ということは、ActiveQuery のほうにメソッドを増やさなければなりません。

class MessageQuery extends \yii\db\ActiveQuery
{
    public function newest($limit)
    {
        return $this
            ->orderBy(['created_at' => SORT_DESC])
            ->limit($limit);
    }
}

class Message extends \yii\db\ActiveRecord
{
    /**
     * @return MessageQuery  ← ここ重要
     */
    public static function find()
    {
        return new MessageQuery(static::className());
    }
}
Message::find()->newest(5)->all();
SELECT * FROM `message`
 ORDER BY `created_at` DESC LIMIT 5"

Yii2 では、クエリを修飾する拡張メソッドを実装するのは、 ActiveRecord::scopes() の配列ではなく、ActiveRecord とペアになる *Query クラスです。

実在するメソッドなので、クラスのdocコメントに @method アノテーションを追加しなくても、IDE による補完が正しく行われます。オーバーライドした find() のdocコメントが適切に書いてあれば、マジックメソッドのせいで解析が途切れることはありません。

Message::find()->n //ここで newest と引数が補完候補に

名前付き+デフォルト

デフォルトと名前付きをいちどにやりたい場合、せっかくクエリを切り出したのに記述箇所が分散するのは残念なので、 ActiveQuery を派生したクラスのほうで init() のプロセスに入れてしまいましょう。

class MessageQuery extends \yii\db\ActiveQuery
{
    public function init()
    {
        parent::init();
        $this->where(['trashed' => false]);
    }

    public function newest($limit)
    {
        // ...
    }
}
Message::find()->newest(5)->all();
SELECT * FROM `message`
 WHERE `trashed`=:qp0
 ORDER BY `created_at` DESC LIMIT 5

注意点1 意図しないリセットを避ける

デフォルトで ->where() 等が付いているのは便利ですが、同じ文脈でさらに条件を積み重ねたいときは、それをクリアしてしまわないように気を付けなければなりません。

// where() は条件をリセットしてしまう
Message::find()->where(['read' => false])->all();
SELECT * FROM `message`
 WHERE `read`=:qp0

デフォルト条件をリセットされないように、 andWhere() などで始めるべきです。

Message::find()->andWhere(['read' => false])->all();
SELECT * FROM `message`
 WHERE (`trashed`=:qp0) AND (`read`=:qp1)

andWhere() は既存の条件がなければ where() と同じようにふるまうので、いきなり呼んでも大丈夫です。前にどんな仕様追加があるかわからないときは積極的に andWhere()orWhere() を使うようにし、仕様追加があってもすべて自己責任でクエリを再構築する場合のみ where() を使うのがよいでしょう。

注意点2 テーブルエイリアスとの併用

カスタムクエリとテーブルエイリアスを同時に指定しないといけない場合、少々厄介なことが起きるかもしれません。

参考: ActiveRecordのJOINにテーブルエイリアスを使う

なにも気にせずにやってみると...

Message::find()
    ->from(['m' => Message::tableName()])
    ->andWhere(['m.read' => false]);
// init() の段階で trashed は from を意識していない
SELECT * FROM `message` `m`
 WHERE (`trashed`=:qp0) AND (`m`.`read`=:qp1)

from() 以前にデフォルトで付与された条件の trashed のテーブル名がありません。クライアントコードで勝手にエイリアスをつけているので MessageQuery は当然そんな事情は知らないのです。

この状況に対する一般的な解決策は、現在提供されていません。

あくまで一例ですが、たとえば、 m エイリアスを付けたクエリ専用に、サフィックス ...AsM を持つ仮想的な ActiveRecord/ActiveQuery クラスでこのように拡張するといった方法が考えられます。

class MessageAsM extends Message
{
    public static function instantiate($row)
    {
        // レコードを実体化するときは Message クラスを使う
        return new Message();
    }

    public static function find()
    {
        return new MessageQueryAsM(static::className());
    }
}

class MessageQueryAsM extends MessageQuery
{
    public function init()
    {
        parent::init();
        // where リセット
        $this
            ->from(['m' => MessageAsM::tableName()])
            ->where(['m.trashed' => false]);
    }

    public function newest($limit)
    {
        // orderBy リセット
        return parent::newest($limit)
            ->orderBy(['m.created_at' => SORT_DESC]);
    }
}
MessageAsM::find()
    ->andWhere(['m.read' => false])
    ->newest(5)
    ->all();
SELECT * FROM `message` `m`
 WHERE (`m`.`trashed`=:qp0) AND (`m`.`read`=:qp1)
 ORDER BY `m`.`created_at` DESC LIMIT 5

実体化されるクラスは instantiate() メソッドで決定されるので、MessageAsM からのクエリでも、その結果は Message のインスタンスになります。

Message::findAsM() メソッドを作って使い分け... というのはうまくいきません。なぜなら、Message::findOne() などは必ず元の find() を使うからです。find() を使うメソッドをすべてオーバーライドして使い分けさせないといけないのでは、本末転倒です。

2.1 ではもうちょっと楽な方法になればいいなと思います。けど、困ってもだいたい継承で解決できて、フレームワークの中身にパッチを当てなくて済むのは Yii のいいところですね。

まとめ

Yii 1 の scopes は配列で書かれた実装のないメソッドでしたが、同じものが Yii2 では、通常のクエリ構築を言語機能の継承とオーバーライドで実現した、特別な機能ではないものになりました。余計なものが挟まらないのでパフォーマンスも上がり、IDEやデバッガで処理手順も追いやすくなりました。

たしかに、Yii 2 の方法では、簡単な使い方でも ActiveRecord が 2 つのクラスに分割されてしまって難しくなるのではないかという懸念もあります。しかし、クラスが増えても今は名前空間があるので恐れることはありません。

その証拠に、Gii の生成する CRUD も検索専用には ...Search という、基本モデルの派生クラスを作るようになりました。Yii 2 では、クラスが増えてでも、1 クラス内に意味が混在するような実装を避けたほうがいい、という方針を取るのが良いようです。

最後の継承での解決例のように、基本の ActiveRecord はシンプルに、それを個々の機能の文脈(フロントエンドよりバックエンドのほうができることが多いなど)に応じて継承して使うと、共通するモデルの仕様が干渉し合わない、うまい設計になるんじゃないでしょうか。

参考

Yii Framework 2.0 API Documentation / Active Record / Scopes

7
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
7
7