increment! と decrement! が query_constraints を考慮して更新されるよう修正
increment! と decrement! が query_constraints を持つモデルで実行された場合、従来は主キーだけで UPDATE が発行されていたが、今回の変更で制約列すべてを WHERE 句に含めるようになった。
背景
increment! と decrement! がクエリ制約付きモデルで期待通りに動作しないバグがあった。従来は self.class.update_counters(id, …) が主キー id のみでスコープを作成し、query_constraints に含まれる列は無視されていた。
この問題は、例えば query_constraints :author_name, :id と定義した TopicWithConstraints で topic.increment!(:replies_count) を呼ぶと、UPDATE topics … WHERE id = ? となり、author_name が条件に入らないため意図しないレコードが更新され得た。複合主キーモデルは id に全キーが格納されるため影響がなかったが、単一主キーかつ制約が別列を含む場合にのみ顕在化した。
技術的な変更
実装は ActiveRecord::Persistence#increment! 内でカウンタ更新に用いるクエリを組み立てる箇所を修正した。変更前は
self.class.update_counters(id, attribute => change, touch: touch)
となっていたが、変更後は
counters = { attribute => change, touch: touch }
self.class.unscoped.where!(_query_constraints_hash).update_counters(counters)
とし、_query_constraints_hash から直接 WHERE 条件を構築するようにした。これにより author_name などの制約列が UPDATE の対象になる。
decrement! は内部で increment! に負の値を渡す実装であるため、同様の修正が自動的に適用され、別途コードを追加する必要はなかった。
テストは query_constraints を持つサブクラスで increment! / decrement! が正しい SQL を生成することを検証した。追加されたテストコードは
class TopicWithConstraints < Topic
query_constraints :author_name, :id
end
topic = TopicWithConstraints.create!(title: "New", author_name: "Alice", replies_count: 0)
sql = capture_sql { topic.increment!(:replies_count) }.first
assert_match(/WHERE .*author_name/, sql)
assert_match(/WHERE .*id/, sql)
となっており、両方の制約列が WHERE 句に現れることを確認している。
設計判断
今回の修正は ActiveRecord::CounterCache.update_counters の内部実装を変更せず、increment! 側で _query_constraints_hash を直接使用する方針を取った。これにより既存の update_counters 呼び出しを利用している他の箇所に副作用を与えず、バグ修正を局所化できた。
_query_constraints_hash は制約が未設定の場合は主キーにフォールバックする仕組みなので、 query_constraints を定義していない単一主キーモデルには全く影響しない。既存コードはそのまま動作し、後方互換性が保たれた。
このアプローチは最小限の変更でバグを解消し、Rails の設定機構と整合性を保つ設計判断と言える。制約列の取り扱いロジックはモデルレベルに集約され、データベース更新の一貫性が向上した。
まとめ
increment! / decrement! が query_constraints 全体を考慮してカウンタを更新するようになり、制約付きモデルでのデータ整合性が保証された。実装は _query_constraints_hash を利用したシンプルな置換で、既存機能への影響を最小化しつつバグを根本的に修正した。