counter_cultureの条件付きカウンタキャッシュでは関連を参照しない

counter_cultureというカウンタキャッシュ用のgemがあります。

[magnusvk/counter_culture: Turbo-charged counter caches for your Rails app.](https://github.com/magnusvk/counter_culture)

このgemは条件付きカウンタキャッシュ機能を提供していて便利です。ただ、gemの実装の都合上、条件の中でActive Recordの関連を参照するとカウンタの計算がうまく動かないケースが避けられないことがあるので気をつけたほうがいいかもしれません。

以下、次のバージョンのライブラリを使います。

- [rails 5.2.2](https://github.com/rails/rails/tree/v5.2.2)
- [counter_culture 2.1.4](https://github.com/magnusvk/counter_culture/tree/v2.1.4)

## 発生する問題

条件付きカウンタキャッシュの条件が関連先に依存していると、そのオブジェクトを更新するたびにカウンタの値が減算されてしまいます。

```ruby
# Customerはorders_countを持つ
class Customer < ApplicationRecord
  has_many :orders
end

class Order < ApplicationRecord
  belongs_to :customer
  has_one :invoice

  counter_culture :customer, column_name: ->(model) {
    # 関連先のinvoiceがあれば、その状態を確認している
    !model.invoice&.canceled? ? :orders_count : nil
  }
end

class Invoice < ApplicationRecord
  belongs_to :order
  
  def canceled?
    # ...
  end
end

# ...

# ひとつでもカウンタキャッシュの条件がfalseになる関連を持っているとする
customer.orders.first.invoice.canceled? #=> true

# このとき、Orderのインスタンスorderを更新するたびに
# 親のcustomerのカウンタが減算されてしまう
customer.orders_count #=> 1
order.update! price: 100
customer.orders_count #=> 0
order.update! price: 100
customer.orders_count #=> -1
```

## 原因と対策

counter_cultureの内部では、カウンタ計算用にActive Record（以降ARと表記します）の [`#dup`](https://api.rubyonrails.org/classes/ActiveRecord/Core.html#method-i-dup) を利用しています。このメソッドはオブジェクトの関連はコピーしない仕様になっています。

> Note that this is a “shallow” copy as it copies the object's attributes only, not its associations.

その仕様が原因で `Order` が更新されたときに発火する [`CounterCulture::Extensions::_update_counts_after_update`](https://github.com/magnusvk/counter_culture/blob/babcffce4017e541cc810069761671c3fbb2105e/lib/counter_culture/extensions.rb#L108-L126) がつねにカウンタを減算すると誤判定してしまいます。

解決方法としては、条件付きカウンタキャッシュの条件では、そのオブジェクトの持つ属性だけを使うようにするとよいです。

```ruby
# Customerはordered_countをアトリビュートとして持つ
class Customer < ApplicationRecord
  has_many :orders
end

# Orderはcanceled_atをアトリビュートとして持つ
class Order < ApplicationRecord
  belongs_to :customer
  has_one :invoice
  
  counter_culture :customer, column_name: ->(model) {
    # 関連のinvoiceを参照しないようにした
    !model.canceled_at ? :orders_count : nil
  }
end

# ...

# orderを2件持つが、1件はカウンタキャッシュの条件がfalseになるとする
customer.orders.count #=> 2
customer.orders.first.canceled_at #=> Sun, 17 Feb 2019 00:00:00 UTC +00:00

# 何度orderを更新してもカウンタは正しい値のまま
customer.orders_count #=> 1
order.update! price: 100
customer.orders_count #=> 1
```

## 問題が発生するロジック

### counter_cultureのしくみ

gemによって、ARにクラスメソッド `counter_culture` が生えます。これを実行すると、そのクラスにカウンタ増減用のコールバックが入ります。更新時には `after_update` のコールバックとして `_update_counts_after_update` が設定されます。

https://github.com/magnusvk/counter_culture/blob/babcffce4017e541cc810069761671c3fbb2105e/lib/counter_culture/extensions.rb#L108-L126

今回のケースでは `column_name` をProcとして指定しているので、 `_update_counts_after_update` で `Order` 更新前後の該当カラム名を `counter_cache_name_was` / `counter_cache_name` として取得し、相違があれば（たとえば `nil` -> `orders_count` に変化したなら）増減算します。

#### 更新前の状態のインスタンスを取得するしくみ

`Order` 更新前のカラム名を `Counter#counter_cache_name_for` というメソッドで取得するときには、`Counter#previous_model` という更新前の属性値を持つ `Order` のインスタンスを取得するメソッドを用いています。`#previous_model` の中ではARのインスタンスを `#dup` しており、こうすると関連は `nil` で属性値だけがコピーされたインスタンスができます。

https://github.com/magnusvk/counter_culture/blob/babcffce4017e541cc810069761671c3fbb2105e/lib/counter_culture/counter.rb#L275-L285

### 更新前のカウンタキャッシュカラム名取得時の問題

上述したような、関連は `nil` で属性値だけがコピーされたインスタンスに対して、`column_name` に指定した以下の条件を評価すると、`invoice` が `nil` であることから `!model.invoice&.canceled? #=> true` となり、カラム名として `:orders_count` を得ます。

```ruby
!model.invoice&.canceled? ? :orders_count : nil
```

一方、`Order` 更新後のカラム名取得時は `#dup` していない元々のインスタンスを用いているので、関連もたどることができ、`!model.invoice&.canceled? #=> false` となって、カラム名として `nil` を得ます。

以上から、`Order` 更新前後のカラム名が異なり、`counter_cache_name != counter_cache_name_was` が成り立つので、更新前のカラム名 `:orders_count` に対応するカラムが減算されます。この流れは `Order` のインスタンスが更新されるときはつねに成り立つので、オブジェクトを更新するたびにカウンタキャッシュが減算されてしまいます。