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

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

[CActiveRecord::scopes() でわかる、SQL上級者のためのO/Rマッピング](http://qiita.com/tanakahisateru/items/9038960fac64a63fc6df)

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

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

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

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

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

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

```php
Message::findAll();
```

```sql
SELECT * FROM `message`
 WHERE `trashed`=:qp0
```

## クエリへのメソッド追加

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

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

```php
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());
    }
}
```

```php
Message::find()->newest(5)->all();
```

```sql
SELECT * FROM `message`
 ORDER BY `created_at` DESC LIMIT 5"
```

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

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

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

## 名前付き+デフォルト

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

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

    public function newest($limit)
    {
		// ...
    }
}
```

```php
Message::find()->newest(5)->all();
```

```sql
SELECT * FROM `message`
 WHERE `trashed`=:qp0
 ORDER BY `created_at` DESC LIMIT 5
```


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

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

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

```sql
SELECT * FROM `message`
 WHERE `read`=:qp0
```

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

```php
Message::find()->andWhere(['read' => false])->all();
```

```sql
SELECT * FROM `message`
 WHERE (`trashed`=:qp0) AND (`read`=:qp1)
```

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

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

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

参考: [ActiveRecordのJOINにテーブルエイリアスを使う](http://qiita.com/tanakahisateru/items/b447f3ccf42214e12470)

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

```php
Message::find()
    ->from(['m' => Message::tableName()])
    ->andWhere(['m.read' => false]);
// init() の段階で trashed は from を意識していない
```

```sql
SELECT * FROM `message` `m`
 WHERE (`trashed`=:qp0) AND (`m`.`read`=:qp1)
```

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

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

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

```php
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]);
    }
}
```

```php
MessageAsM::find()
    ->andWhere(['m.read' => false])
    ->newest(5)
    ->all();
```

```sql
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](http://www.yiiframework.com/doc-2.0/guide-db-active-record.html#scopes)