演習 - HAS_MANY リレーションによる検索 (Yii 2 版)

この記事では、Yii 2 の ActiveRecord を使って HAS_MANY リレーションによる検索を実行する実務的なテクニックを説明します。

**Yii 1.1** では、CActiveRecord や CActiveDataProvider を使っているときに、HAS_MANY のリレーションによる検索をうまく書くことが出来ず、途方に暮れてしまう場合がありました。それに対しての解決方法を示そうと試みたのが、[「演習 - HAS_MANY による検索 (Yii 1.1 版)」](http://qiita.com/softark/items/27dd182aca3624873d73) という記事でした。

さて、それでは、新しくなった **Yii 2** では、HAS_MANY のリレーションによる検索はどんな感じになるのか、というのが、この記事のテーマです。

## リレーション
二つのエンティティが **1:N** の関係になる場合があります。というか、普通のリレーショナルデータベースを使う限りにおいては、この関係が唯一可能な関係で、**1:1** の関係は 1:N の特殊形、**N:N** の関係は複数の 1:N の組合せとして考えることが出来ます。

で、この 1:N のありふれた関係を Yii の ORM である ActiveRecord でも、**HAS_ONE** および **HAS_MANY** のリレーションとしてサポートしています。1:N の N の側から見れば HAS_ONE であり、1 の側から見れば HAS_MANY ということですな。

> Yii 1.1 の BELONGS_TO が、Yii 2 では HAS_ONE と呼ばれるようになりました。

では、最初に、次のような関係の二つのオブジェクトを仮設しましょう。

## 例 : 投稿者と記事

投稿者(Author) と 記事(Post) の関係を考えて下さい。An Author has many Posts であり、A Post has one Author であるという関係ですね。

```php:Author.php
<?php
/**
 * 投稿者のモデル
 * @property integer $id
 * @property string $name 投稿者名
...
 */
class Author extends \yii\db\ActiveRecord
{
    ...
    /**
     * @return \yii\db\ActiveQuery
     */
    public function getPosts()
    {
        return $this->hasMany(Post::className(), ['author_id' => 'id']);
    }
    ...
```

```php:Post.php
<?php
/**
 * 記事のモデル
 * @property integer $id
 * @property integer $author_id 投稿者 id への FK
 * @property string $title タイトル
...
 */
class Post extends \yii\db\ActiveRecord
{
    ...
    /**
     * @return \yii\db\ActiveQuery
     */
    public function getAuthor()
    {
        return $this->hasOne(Author::className(), ['id' => 'author_id']);
    }
    ...
```

まあ、こんな感じかな。

リレーションは **\yii\db\ActiveQuery** を返すゲッターメソッドとして定義されます。

で、この例を使って、よくある課題を解決する方法を考えてみよう、という訳です。

## 課題 1

**ある言葉をタイトルに含む記事を列挙せよ**

まずは肩慣らし。ある言葉をタイトルに含む記事を取得してみましょう。

```php
<?php
// タイトルに検索語を含む投稿をすべて取得
$posts = Post::find()
    ->where(['like', 'title', $searchword])
    ->all();
// 表示
foreach($posts as $post) {
    echo "Title = {$post->title}\n";
}
```

あ、簡単すぎたか。というか、そもそも、リレーションを使っていない。それにしても、Yii 2 だと簡潔に書けるようになったなぁ。

ま、いいか。はい、次。

## 課題 2

**ある言葉をタイトルに含む記事を投稿者名とともに列挙せよ**

今度は、投稿者名も一緒に取得しなさい、と。

```php
<?php
// タイトルに検索語を含む投稿を投稿者名とともにすべて取得
$posts = Post::find()
    ->where(['like', 'title', $searchword])
    ->with('author')
    ->all();
// 表示
foreach($posts as $post) {
    echo "Title = {$post->title}\n";
    echo "Author = {$post->author->name}\n";
}
```

これも簡単でしたね。

あれ？「そんなことしなくても、課題 1 のコードに `$post->author->name` を echo する文を加えたら良いだけじゃないの」と言った人、誰ですか？

そう、あなたは正しい。Post モデルには author というリレーションが定義されているので、**$post->author** とやりさえすれば、関連する Author を勝手に取ってきてくれます。これ、便利ですね。いわゆる **レイジーローディング** という奴です。

しかし レイジーローディング だと、この場合、Post の配列を取得するのに一回、それぞれの Author を取得するのに Post の個数分、合計 1+N 個のクエリが実行されることになります。

それでは効率が悪いだろう、ということで、ここでは **yii\db\ActiveQuery::with()** を使って、Post と Author を一緒に取得しています。これだと、実行されるクエリはたった二回で済みます。つまり、メインモデルを取得した直後に、リレーションモデルを一気にまとめて取得してくれる、これがいわゆる **イーガーローディング** です。

ちなみに、どんなときでも イーガーローディング の方が効率的で望ましいかというと、そういう訳でもないので、要注意です。

## 課題 2-B

**ある言葉を名前に含む投稿者の記事を投稿者名とともに列挙せよ**

ここでちょっと道草を食って、課題 2 のバリエーションをやってみます。今度は、メインモデルではなく、リレーションモデルの属性で検索します。

```php
<?php
// 名前に検索語を含む投稿者の投稿を投稿者名とともにすべて取得
$posts = Post::find()
    ->joinWith('author')
    ->where(['like', 'author.name', $searchword])
    ->all();
// 表示
foreach($posts as $post) {
    echo "Title = {$post->title}\n";
    echo "Author = {$post->author->name}\n";
}
```

リレーションモデルの属性によって検索する必要があるために、**yii\db\ActiveQuery::joinWith()** を使って、`author` テーブルを**結合**しています。注意すべきは、`where` のパラメータにおいて、カラム名をテーブル名で修飾して曖昧さをなくす必要がある場合もある、ということです。

注意すべきことが、もう一つあります。それは、`joinWith` を使ってテーブルを結合しても、イーガーローディングで実行されるクエリが一回だけになったりはしない、ということです。**Yii 2 では、メインモデルの取得と、リレーションモデルの取得は、必ず、別のクエリによって行われます**。

従って、課題 1 において、効率を求めて `with` を `joinWith` に変更するような小細工を弄しても、かえって逆効果になりますよ。

**`joinWith` でテーブルを結合するのは、クエリの効率を上げるためではなく、リレーションモデルの属性によってメインモデルを検索する必要があるからです。**

なお、`joinWith` を使うと既定ではイーガーローディングになりますが、オプションの `$eagerLoading` パラメータを `false` に指定すれば、リレーションモデルの取得を抑制することが出来ます。

「そんな事より、おまえ、HAS_MANY はどうなった？」とか言って怒っている人、誰ですか？

そう、あなたはこれ以上ないぐらいに正しい。これは、まだ、リレーションと言っても簡単な方の HAS_ONE の話でした。済みません。じゃ、いよいよ本題。

## 課題 3

**ある言葉をタイトルに含む記事を書いた投稿者を列挙せよ**

記事じゃなく、投稿者の方を取得しなさい、ということです。

```php
<?php
// タイトルに検索語を含む投稿の投稿者をすべて取得
$authors = Author::find()
    ->joinWith('posts', false)  // リレーションは取得しない
    ->where(['like', 'post.title', $searchword])
    ->all();
// 表示
foreach($authors as $author) {
    echo "Author = {$author->name}\n";
}
```

検索するのに必要なので `posts` リレーションを `joinWith` していますが、投稿は取得する必要がないので、`$eagerLoading` を `false` にしています。

## 課題 4

**ある言葉をタイトルに含む記事を書いた投稿者をその全ての投稿とともに列挙せよ**

```php
<?php
// タイトルに検索語を含む投稿の投稿者を、全ての投稿とともに、すべて取得
$authors = Author::find()
    ->joinWith('posts')  // リレーションをイーガーロードする
    ->where(['like', 'post.title', $searchword])
    ->all();
// 表示
foreach($authors as $author) {
    echo "Author = {$author->name}\n";
    foreach($author->posts as $post) {
        echo "Title = {$post->title}\n";
    }
}
```

何度も言いますが、**メインモデルのためのクエリとリレーションモデルのためのクエリは別々に実行される**、という事を忘れないでください。`where` において投稿のタイトルで絞り込んでいますが、それは、メインモデルの読み出しにのみ関わる条件です。リレーションモデルは、リレーションの定義に従って (すなわち、タイトルがどのようなものであれ)、全て読み出されます。

では、ちょっとひねって、これはどうでしょう。

## 課題 4-B

**ある言葉をタイトルに含む記事を書いた投稿者を当該記事とともに列挙せよ**

実は、こっちの方が悩ましい。

一つのやり方は、こうです。

```php
<?php
// タイトルに検索語を含む投稿の投稿者をすべて取得
$authors = Author::find()
    ->joinWith('posts', false) // 投稿は取得しない
    ->where(['like', 'post.title', $searchword])
    ->all();
// 表示
foreach($authors as $author) {
    echo "Author = {$author->name}\n";
    // タイトルに検索語を含む投稿をすべて取得
    $posts = $author->getPosts()->where(['like', 'post.title', $searchword])->all();
    foreach($posts as $post) {
        echo "Title = {$post->title}\n";
    }
}
```

メインモデルの検索に必要なので `posts` リレーションを `joinWith` していますが、`$eagerLoading` を `false` にして、投稿を取得しないようにしています。そして、個々の投稿者について、その場で条件を指定する形のレイジーローディングをして、投稿を取得しています。

リレーションモデルの取得にその場で条件を指定することは、次のように、イーガーローディングでも可能です。

```php
<?php
// タイトルに検索語を含む投稿の投稿者をすべて取得
$authors = Author::find()
    ->joinWith('posts', false)  // こっちはメインモデル検索用
    ->where(['like', 'post.title', $searchword])
    ->with([    // こっちはリレーション取得用
        'posts' => function($query) use ($searchword) {
            $query->where(['like', 'post.title', $searchword]);
        },
    ])
    ->all();
// 表示
foreach($authors as $author) {
    echo "Author = {$author->name}\n";
    foreach($posts as $post) {
        echo "Title = {$post->title}\n";
    }
}
```

メインモデル検索用のリレーション定義とリレーションモデル取得用のリレーション定義が同じ訳ですから、innerJoinWith を使って、次のようにまとめる方がすっきりしますね。

```php
<?php
// タイトルに検索語を含む投稿の投稿者を当該投稿とともにすべて取得
$authors = Author::find()
    ->innerJoinWith([
        'posts' => function($query) use ($searchword) {
            $query->where(['like', 'post.title', $searchword]);
        },
    ])
    ->all();
// 表示
foreach($authors as $author) {
    echo "Author = {$author->name}\n";
    foreach($posts as $post) {
        echo "Title = {$post->title}\n";
    }
}
```

うん、すっきりした。

では、次。これはちょっと難しいかも。

## 課題 5

**ある言葉をタイトルに含む記事を書いた投稿者をその全記事とともに名前順で5人まで表示せよ**

まあ、とりあえず、課題 4 のコードに、ORDER と LIMIT を追加してみます。

### とりあえずの解答

```php
<?php
$authors = Author::find()
    ->joinWith('posts')
    ->where(['like', 'post.title', $searchword])
    ->orderBy(['author.name' => SORT_ASC])
    ->limit(5)
    ->all();
// 表示
foreach($authors as $author) {
    echo "Author = {$author->name}\n";
    foreach($author->posts as $post) {
        echo "Title = {$post->title}\n";
    }
}
```

しかし、表示された結果を見ると、おかしなことになっているのに気付くでしょう。 例えば、こんな出力結果になりますよね。

```
[検索語 = foo]
投稿者 = ぴよ
	記事 = foo の効用
	記事 = 私は foo が好きだ
	記事 = 私は bar も好きだ
投稿者 = ふが
	記事 = foo って何よ
	記事 = bar って何よ
投稿者 = ほげ
	記事 = foo は使うな
	記事 = foo の代りに hoge だ
	記事 = bar は使うな
	記事 = bar の代りに piyo だ
[以上]
```

投稿者を5人取得したいのに、3人しか取得できていません。

これはどうしてかと言うと、メインモデルを取得するために使われるクエリが次のような SQL である事が原因となっています。

```sql
SELECT * from author
  LEFT JOIN post on author.id = post.author_id
  WHERE post.title LIKE '%foo%'
  ORDER BY author.name
  LIMIT 5
```

つまり、`LIMIT 5` が適用されるのは、author テーブルと post テーブルが結合された結果としての仮想的なテーブルの行に対してです。結果として得られた LIMIT を上限とする行から、重複を排除して投稿者を拾っていくと、指定した数より少なくなる可能性は大いにあります。何しろ、An author **has many** posts ですから。

Yii 1.1 の場合は、MySQL 専用でしたが、**グループ化** というトリックを使いました。これが Yii 2 でも使えるようです。

### 解答修正案その1

```php
<?php
$authors = Author::find()
    ->joinWith('posts')
    ->where(['like', 'post.title', $searchword])
    ->groupBy('author.id')
    ->orderBy(['author.name' => SORT_ASC])
    ->limit(5)
    ->all();
```

投稿者の ID でグループ化して重複を省いてやるわけですね。

しかし、そんなことするんだったら、**DISTINCT** で取ってきたらどうよ、その方が綺麗で互換性も高いでしょうに、という意見もあるでしょう。いや、ごもっとも。さっそくやってみましょう。

### 解答修正案その2

```php
<?php
$authors = Author::find()
    ->distinct()
    ->joinWith('posts')
    ->where(['like', 'post.title', $searchword])
    ->orderBy(['author.name' => SORT_ASC])
    ->limit(5)
    ->all();
```

あらま、出来ちゃった。

拍子抜けしつつ、では、最後の課題。

## 課題 6

**ある言葉をタイトルに含む記事を書いた投稿者を該当する記事とともに名前順で5人まで表示せよ**

これも、もう簡単ですね。リレーションの読み出しに、もう一度同じフィルタをかけてやれば良いだけの事です。

### 解答例

```php
<?php
$authors = Author::find()
    ->distinct()
    ->joinWith([
        'posts' => function($query) use ($searchword) {
            $query->where(['like', 'post.title', $searchword]);
        },
    ])
    ->orderBy(['author.name' => SORT_ASC])
    ->limit(5)
    ->all();
```

ちょろいな。 (&copy; @crifff)

## まとめ

と言うわけで、たいしたクライマックスも無く、竜頭蛇尾に終るのであります。

概して言うと、Yii 2 のデータベース周りは大幅な改変を受けて、Yii 1.1 に比べると、ものすごくスッキリとしたものに進化しました。使いやすいですよ。

### 参考

公式ガイドのデータベース関連のところを参照して下さい。

- [Yii 2.0 決定版ガイド](https://github.com/yiisoft/yii2/blob/master/docs/guide-ja/README.md)
  - [クエリビルダとクエリ](https://github.com/yiisoft/yii2/blob/master/docs/guide-ja/db-query-builder.md)
  - [アクティブレコード](https://github.com/yiisoft/yii2/blob/master/docs/guide-ja/db-active-record.md)

それなりに難しい所ではあるので、一通り理解した後でも、ときどき読み返してみるのがお奨めです。