CakePHPのCounterCacheで関連件数を自動カウント

CakePHP には、紐づく別テーブルの件数が変わるとカウンター用カラムを自動的に更新してくれる、「CounterCache」というビヘイビアがあります。

ドキュメントにある利用例です。

>  たとえば、記事のリストを表示するときに、記事のコメントの数を表示することができます。 または、ユーザーを表示するときに、彼女が持っている友人/フォロワーの数を表示することもできます。 CounterCache ビヘイビアーは、これらの状況を想定しています。 
> https://book.cakephp.org/4/ja/orm/behaviors/counter-cache.html

このビヘイビアを使ってみようと思います。

# テーブルを作成

まず、テーブルを作成します。
以下のようなテーブル構成とし、記事テーブルがコメント数とタグ数のカラムを持つものとします。

* 記事：コメント = 1:N (hasMany/belongsTo)
* 記事：タグ = N:N (belongsToMany)

```sql
/* PostgreSQL */

--
-- 記事
--
CREATE TABLE articles (
  id SERIAL PRIMARY KEY,  -- ID
  title TEXT NOT NULL,    -- 記事タイトル
  body TEXT,              -- 記事本文
  name TEXT NOT NULL,     -- 記事作者氏名
  comment_count INTEGER,  -- コメント数
  tag_count INTEGER       -- タグ数
);

--
-- コメント
--
CREATE TABLE comments (
  id SERIAL PRIMARY KEY,  -- ID
  article_id INTEGER NOT NULL REFERENCES articles(id),  -- 記事ID
  body TEXT,              -- コメント本文
  name TEXT NOT NULL      -- コメント作者氏名
);

--
-- タグ
--
CREATE TABLE tags (
  id SERIAL PRIMARY KEY,   -- ID
  tag TEXT UNIQUE NOT NULL -- タグ
);

--
-- 記事 <-> タグ（中間テーブル）
--
CREATE TABLE articles_tags (
  article_id INTEGER REFERENCES articles(id),  -- 記事ID
  tag_id INTEGER REFERENCES tags(id),          -- タグID
  PRIMARY KEY (article_id, tag_id)
);
```


# 1. hasMany アソシエーションの場合

記事テーブルのコメント数カラム (`articles.comment_count`) を CounterCache で更新します。
なお、これはドキュメントの例と同じです。

## 1-1. モデルの設定

`cake bake` でコードを自動生成します。

```
$ bin/cake bake model articles
$ bin/cake bake model comments
$ bin/cake bake model tags
$ bin/cake bake model articles_tags
```

カウント対象の `CommentsTable` で、CounterCache ビヘイビアをロードします。

```php
/* src/Model/Table/CommentsTable.php（変更あり） */

public function initialize(array $config)
{
    // ... 省略 ...

    $this->belongsTo('Articles', [
        'foreignKey' => 'article_id',
        'joinType' => 'INNER',
    ]);

    // *** 以下 3行を追加 ****
    $this->addBehavior('CounterCache', [
        'Articles' => ['comment_count']
    ]);
}
```

カウンターカラムを持つ `ArticlesTable` は `cake bake` の自動生成コードのままです。

```php
/* src/Model/Table/ArticlesTable.php（変更なし） */

public function initialize(array $config)
{
    // ... 省略 ...

    $this->hasMany('Comments', [
        'foreignKey' => 'article_id',
    ]);
}
```

## 1-2. 動作確認

`save()` だけ試すのに Controller と Template を用意するのも面倒なので、ユニットテストで試してみます。

テストファイルに、以下のようなコードを追加します。

```php
/*  tests/TestCase/Model/Table/ArticlesTableTest.php */

public function testCommentCount()
{
    $this->Articles->Comments->deleteAll(['id IS NOT NULL']);
    $this->Articles->deleteAll(['id IS NOT NULL']);

    $data = [
        'title' => 'テスト記事タイトル',
        'body' => 'テスト記事本文',
        'name' => 'テスト記事作者',
        'comments' => [
            [
                'name' => 'テストコメント(1) 作者',
                'body' => 'テストコメント(1) 本文',
            ],
            [
                'name' => 'テストコメント(2) 作者',
                'body' => 'テストコメント(2) 本文',
            ],
        ],
    ];

    $entity = $this->Articles->newEntity($data);
    $result = $this->Articles->save($entity);

    $article = $this->Articles->get($result->id, [
        'contain' => ['Comments']
    ]);

    pr($article->toArray());
}
```

また、テスト用のデータは不要なので、tests/Fixture/ 以下のファイルの `init()` で `$this->records` の中身を空にしておきます。

```php
/* tests/Fixture/* */

public function init()
{
    $this->records = [];
    parent::init();
}
```

実行して `save()` 後の `get()` 結果を見てみると、「comment_count」が更新されていることがわかります。

```
$ vendor/bin/phpunit tests/TestCase/Model/Table/ArticlesTableTest.php --filter testCommentCount

Array
(
    [id] => 1
    [title] => テスト記事タイトル
    [body] => テスト記事本文
    [name] => テスト記事作者
    [comment_count] => 2
    [tag_count] => 
    [comments] => Array
        (
            [0] => Array
                (
                    [id] => 1
                    [article_id] => 1
                    [body] => テストコメント(1) 本文
                    [name] => テストコメント(1) 作者
                )
            [1] => Array
                (
                    [id] => 2
                    [article_id] => 1
                    [body] => テストコメント(2) 本文
                    [name] => テストコメント(2) 作者
                )

        )
)
```

```
=> TABLE articles;
 id |       title        |      body      |      name      | comment_count | tag_count 
----+--------------------+----------------+----------------+---------------+-----------
  1 | テスト記事タイトル | テスト記事本文 | テスト記事作者 |             2 |          
(1 row)

=> TABLE comments;
 id | article_id |          body          |          name          
----+------------+------------------------+------------------------
  1 |          1 | テストコメント(1) 本文 | テストコメント(1) 作者
  2 |          1 | テストコメント(2) 本文 | テストコメント(2) 作者
(2 rows)
```

# 2. belongsToMany アソシエーションの場合

記事テーブルのタグ数カラム (`articles.tag_count`) を CounterCache で更新します。

ドキュメントに、belongsToMany アソシエーションで使うには、という説明があるのですが、例もなく、で、結局使えるのか？と不安になります。（[StackOverflow](https://stackoverflow.com/questions/32948513/cakephp-3-countercache-with-belongstomany) でも解決したのか不明な質問が。。）

> https://book.cakephp.org/4/ja/orm/behaviors/counter-cache.html#id2
> CounterCache ビヘイビアーは、 belongsTo アソシエーションに対してのみ機能します。 たとえば、 "Comments belongsTo Articles" の場合、Article テーブルの comment_count を生成するために、 CommentsCache ビヘイビアーを CommentsTable に追加する必要があります。
>
> これを belongsToMany アソシエーションに対して機能させることは可能ですが、 アソシエーションオプションで設定されたカスタム through テーブルで CounterCache ビヘイビアーを有効にして cascadeCallbacks 設定オプションを true にする必要があります。 カスタム JOIN テーブルを設定する方法は 'through' オプションの使用 を参照してください。

結論としては、以下のとおり利用できました。

## 2-1. モデルの設定

CounterCache ビヘイビアを、中間テーブル `ArticlesTagsTable` でロードします。
（ドキュメントに、中間テーブルで、と書いていないのがわかりづらい・・・）

```php
/* src/Model/Table/ArticlesTagsTable.php（変更あり） */

public function initialize(array $config)
{
    // ... 省略 ...

    $this->addBehavior('CounterCache', [
        'Articles' => ['tag_count'],
    ]);
}
```

次に `ArticlesTable` で belongsToMany アソシエーションを指定する箇所で、中間テーブルを指定するのに「`joinTable`」の代わりに「`through`」を使い、「`cascadeCallbacks`」を `TRUE` にします。

> belongsToMany アソシエーションの配列で可能なキー
> https://book.cakephp.org/4/ja/orm/associations.html#belongstomany

```php
/* src/Model/Table/ArticlesTable.php （変更あり）*/

public function initialize(array $config)
{
    // ... 省略 ...

    $this->belongsToMany('Tags', [
        'foreignKey' => 'article_id',
        'targetForeignKey' => 'tag_id',

        // *** 以下の 1 行を削除かコメントアウト ***
        // 'joinTable' => 'articles_tags',

        // *** 以下の 2 行を追加 ***
        'through' => 'articles_tags',
        'cascadeCallbacks' => TRUE,
    ]);
}
```

なお、`TagsTable` は自動生成コードのまま変更ありません。

```php
/* src/Model/Table/TagsTable.php（変更なし） */

public function initialize(array $config)
{
    // ... 省略 ...

    $this->belongsToMany('Articles', [
        'foreignKey' => 'tag_id',
        'targetForeignKey' => 'article_id',
        'joinTable' => 'articles_tags',
    ]);
}
```

## 2-2. 動作確認

同じく、ユニットテストで試します。
テストファイルに、以下のようなコードを追加します。

```php
/*  tests/TestCase/Model/Table/ArticlesTableTest.php */

public function testTagCount()
{
    $data = [
        'title' => 'テスト記事タイトル',
        'body' => 'テスト記事本文',
        'name' => 'テスト記事作者',
        'tags' => [
            [
                'tag' => 'タグ (1)',
            ],
            [
                'tag' => 'タグ (2)',
            ],
        ],
    ];

    $entity = $this->Articles->newEntity($data);
    $result = $this->Articles->save($entity);

    $article = $this->Articles->get($result->id, [
        'contain' => ['Tags']
    ]);

    pr($article->toArray());
}
```

実行して `save()` 後の `get()` 結果を見てみると、「tag_count」が更新されていることがわかります。

```
$ vendor/bin/phpunit tests/TestCase/Model/Table/ArticlesTableTest.php --filter testTagCount

Array
(
    [id] => 1
    [title] => テスト記事タイトル
    [body] => テスト記事本文
    [name] => テスト記事作者
    [comment_count] =>
    [tag_count] => 2
    [tags] => Array
        (
            [0] => Array
                (
                    [id] => 1
                    [tag] => タグ (1)
                    [_joinData] => Array
                        (
                            [article_id] => 1
                            [tag_id] => 1
                        )
                )
            [1] => Array
                (
                    [id] => 2
                    [tag] => タグ (2)
                    [_joinData] => Array
                        (
                            [article_id] => 1
                            [tag_id] => 2
                        )

                )
        )
)
```

```
=> TABLE articles;
 id |       title        |      body      |      name      | comment_count | tag_count 
----+--------------------+----------------+----------------+---------------+-----------
  1 | テスト記事タイトル | テスト記事本文 | テスト記事作者 |               |         2
(1 row)

=> TABLE tags;
 id |   tag    
----+----------
  1 | タグ (1)
  2 | タグ (2)
(2 rows)

=> TABLE articles_tags;
 article_id | tag_id 
------------+--------
          1 |      1
          1 |      2
(2 rows)
```

# 補足

ドキュメントの最初に、以下のような注意書きがあります。

> カウンターの値は、エンティティーが保存または削除されるたびに更新されます。 updateAll() または deleteAll() を使用するか、作成した SQL を実行すると、 カウンターは更新 されません。
> https://book.cakephp.org/4/ja/orm/behaviors/counter-cache.html#id1

これは、CounterCache ビヘイビアは [内部的](https://github.com/cakephp/cakephp/blob/master/src/ORM/Behavior/CounterCacheBehavior.php) には、`save()` 時に [afterSave イベント](https://book.cakephp.org/4/ja/orm/saving-data.html#saving-entities) として、カウンターカラムを `UPDATE` していることによります。（`delete()` 時は afterDelete イベント）。

`updateAll()` や `deleteAll()`  では afterSave/afterDelete イベントが発生しないので、カウンターは更新されないことになります。
> https://book.cakephp.org/4/ja/orm/saving-data.html#Cake\ORM\Table::updateAll