`reset_counters` が文字列IDを受け取ると0にリセットされるバグを修正
reset_counters に文字列型のIDを渡すと、カウンターが正しい値ではなく0にリセットされるバグが修正されました。主キーの型キャストを適切に行うことで、文字列IDでも整数IDと同様に正確なカウントが設定されるようになります。
背景
reset_counters は、counter_cacheの値をDBの実際の関連レコード数に合わせて再計算・更新するメソッドです。しかし #56122 および #57185 で報告されたように、@topic.id.to_s のように文字列型のIDを渡すと、カウンターが正しい値ではなく0にリセットされる問題がありました。
この問題は、reset_counters 内部でIDをクエリ条件に使用する際、渡された文字列IDが主キーの型(整数)に一致しないため、一致するレコードが見つからず結果として0が設定される挙動から生じていました。Topic.reset_counters(@topic.to_param, "replies") のように、ルーティングから取得したパラメータ(文字列)をそのまま渡すユースケースで再現します。
技術的な変更
修正の核心は、IDリストを構築した後に type_for_attribute で取得した主キーの型情報を使い、各IDを適切な型にキャストする処理の追加です。
変更前:
def reset_counters(id, *counters, touch: nil)
ids = if composite_primary_key?
if id.first.is_a?(Array)
id
else
[id]
end
else
Array(id)
end
変更後:
def reset_counters(id, *counters, touch: nil)
if composite_primary_key?
ids = id.first.is_a?(Array) ? id : [id]
types = primary_key.map { |column| type_for_attribute(column) }
ids = ids.map do |id_value|
id_value.map.with_index { |id, i| types[i].cast(id) }
end
else
ids = Array(id)
type = type_for_attribute(primary_key)
ids = ids.map { |id| type.cast(id) }
end
通常の主キーの場合、Array(id) でIDの配列を作成した後、type_for_attribute(primary_key) で主キーの型オブジェクトを取得し、type.cast(id) で各IDを適切な型に変換します。複合主キー(CPK) の場合は、各カラムに対応する型オブジェクトを配列で取得し、map.with_index を使ってそれぞれのカラムの型でキャストします。
テストは2つ追加されています。通常の主キーモデル向けに Topic.reset_counters(@topic.id.to_s, :replies) が正しく動作することを検証するテスト、およびCPKモデル向けに Cpk::Order.reset_counters(string_id, :books) を検証するテストです。後者のテストのためにフィクスチャ (cpk_orders.yml, cpk_books.yml) も追加されています。
設計判断
IDの型キャストをメソッド入口で一元化するアプローチが採用されました。
type_for_attribute はActiveRecordが既存のスキーマ情報から型オブジェクトを取得するAPIであり、外部ライブラリへの依存なくモデルの主キー型を正確に取得できます。cast はActiveRecordの型システムが提供する変換メソッドであり、"1" を 1 に変換するといった標準的な型強制を担います。これにより、文字列・整数どちらで渡されても後続の処理が型一致した値を受け取ることが保証されます。
また、この変更はCPKと非CPKの両ブランチで対称的に型キャストを適用しており、将来的な主キー型の変更に対しても一貫した動作を提供します。
まとめ
本PRは、reset_counters の引数として渡されるIDを主キーの型情報でキャストするという、局所的かつ低リスクな修正によって問題を解消しました。to_param やURLパラメータ経由で文字列IDが渡される実際のユースケースに対応しており、通常の主キーと複合主キーの両方で安全に動作するようになっています。