複合外部キーを持つ `belongs_to touch: true` が旧レコードをタッチしない問題を修正
複合外部キーを持つ belongs_to アソシエーションで touch: true を設定している場合、外部キーが変更されたとき旧レコードの updated_at が更新されないサイレントバグを修正しました。touch_record メソッドが複合キーの変更検出に対応したことで、単一・複合いずれの外部キーでも正しく動作するようになります。
背景
touch: true 付きの belongs_to は、レコードの保存・削除時に親レコードの updated_at を更新する仕組みです。しかし複合外部キーの場合、外部キーを変更しても 旧 親レコードのタッチが発生しないバグが存在していました。
問題の根本原因は touch_record メソッド内の changes[foreign_key] というハッシュルックアップにありました。saved_changes は "shop_id" や "order_id" のように個別のカラム名をキーとして持つのに対し、複合外部キーは ["shop_id", "order_id"] という配列として渡されていたため、ルックアップは常に nil を返していました。結果として old_foreign_id が取得できず、旧親レコードへのタッチ処理がサイレントにスキップされていました。
例えば、Book が Order 1 から Order 2 へ移動した場合、Order 2 の updated_at は更新されますが、Order 1 はタッチされないまま残るという問題です。
技術的な変更
touch_record の old_foreign_id 取得ロジックが複合キー対応に拡張されました。変更の核心は、外部キーが配列かどうかを判定し、各カラムを個別に changes ハッシュで検索する点にあります。
変更前:
def self.touch_record(o, changes, foreign_key, name, touch) # :nodoc:
old_foreign_id = changes[foreign_key] && changes[foreign_key].first
変更後:
def self.touch_record(o, changes, foreign_key, name, touch) # :nodoc:
old_foreign_id =
if foreign_key.is_a?(Array)
if foreign_key.any? { |fk| changes[fk] }
foreign_key.map do |fk|
changes[fk] ? changes[fk].first : o.read_attribute(fk)
end
end
elsif changes[foreign_key]
changes[foreign_key].first
end
複合外部キーの場合、foreign_key.any? { |fk| changes[fk] } でいずれかのカラムが変更されたかを確認します。変更があれば、各カラムについて「changes[fk] に変更前の値があればそれを、なければ read_attribute で現在値を」取得することで、旧複合キーの値を完全に復元します。これにより、複合キーの一部のみが変更されたケースも正確に処理されます。
find_by の呼び出しにも小さな修正が加わっています。
変更前:
old_record = klass.find_by(primary_key => old_foreign_id)
変更後:
old_record = klass.find_by(primary_key => [old_foreign_id])
[old_foreign_id] と配列でラップすることで、単一主キー・複合主キーのどちらでも Array(primary_key).zip(...) 的なマッピングが一貫して動作するようにしています。
テスト面では activerecord/test/models/cpk/book.rb の belongs_to :order に touch: true が追加され、cpk_orders テーブルに t.timestamps カラムが追加されました。テストケースとして以下の3シナリオが timestamp_test.rb に追加されています:
- 通常の保存時に CPK 親レコードがタッチされること
- 削除時に CPK 親レコードがタッチされること
- 複合キーの一部変更時に新旧両方の親レコードがタッチされること
設計判断
単一外部キーと複合外部キーを 分岐で処理する方式 が採用されました。既存の単一外部キーのコードパスは elsif ブランチとして残され、動作変更がありません。
複合キーの旧値復元において、o.read_attribute(fk) へのフォールバックが重要な役割を果たしています。複合キーを構成する全カラムが変更されるとは限らないため、変更されていないカラムは現在のレコードの値をそのまま旧キーの一部として使います。これにより「一部のみ変更された複合キー」というエッジケースを追加のデータ取得なしに処理しています。
find_by への [old_foreign_id] ラップは、複合主キーに対して find_by を呼ぶ際の内部的なキーマッピングの一貫性を保つための変更です。複合外部キーの場合 old_foreign_id はすでに配列になっているため、単一キーとの扱いを揃える意図があります。
まとめ
この修正は、touch_record における外部キーの変更検出を単一キーと複合キーの両方に対して正しく機能させるものです。changes ハッシュのルックアップを個別カラム単位に改め、旧複合キーの組み立てロジックを追加することで、既存の単一外部キーのコードパスに影響を与えずにサイレントバグを解消しています。