はじめに
Rails mainにbelongs_to/has_oneのキークリア処理を修正するPRが入った(PR #58484「Fix clearing PK-overlapping association keys when assigning nil」、2026-08-18マージ)。
comment.post = nilのように関連をnilで外すとき、外部キー(FK)カラムが参照元の主キー(PK)の一部と重なっている場合、条件によっては外部キーが一切クリアされないというバグがあった。原因は意図的に入れられた保護ルールが、別のリファクタリングPRの影響で想定外のケースにまで適用されてしまったこと。本記事はこの修正を追いながら、保護ルールがどう壊れ、どう直されたかを見る。
なぜ「PKと重なるFKはクリアしない」というルールがあるのか
PR本文にその理由が書かれている。
When
nilis assigned to a singular association, we intentionally avoid clearing foreign key columns that are also part of the referencing model's primary key, assuming they're part of a common shard key or similar. (#55332)
belongs_to/has_oneで関連先をnilにすると、Railsは外部キーカラムをクリアする。しかしそのカラムが参照元モデル自身の主キーの一部でもある場合、クリアしてしまうとレコードの識別情報(シャーディングキーなど)が壊れる。そこで#55332は「主キーと重なる外部キーカラムはクリアしない」という保護ルールを入れた。
何が壊れたのか
However, that's only a reasonable assumption/behaviour if there's also another FK component that that rule still allows us to clear: keeping all parts of the FK would mean we lose the set-to-nil operation entirely.
この保護ルールが正しく機能するのは、外部キーの中に「PKと重ならない、クリアしていい別のカラム」が残っている場合だけ。もし外部キーの全カラムが主キーと重なっていたら、保護ルールを律儀に適用すると1カラムもクリアできなくなり、nil代入という操作自体が意味を失う。
This has been a broken edge case for fully-PK-overlapping FKs for a while, but the unification in #58368 made it also apply to the potentially-less-rare case where a single-column FK is part/all of the referencing record's PK.
この「全部重複してるとクリアできない」バグ自体は前からあったレアな端っこのケースだったが、#58368(単一カラムFKの扱いを複合FKと統一したリファクタリング)によって、もっとありふれたケース(単一カラムFKがPKの一部/全部であるだけ)にまで影響が広がった。
コードで追う:replace_keys
comment.post = nilを実行したときの呼び出し経路。
# belongs_to宣言から自動生成されるメソッド(builder/association.rb)
def post=(value)
association(:post).writer(value)
end
# 薄いラッパー(singular_association.rb)
def writer(record)
replace(record)
end
# 本体(belongs_to_association.rb)
def replace(record)
if record
raise_on_type_mismatch!(record)
set_inverse_instance(record)
elsif target
remove_inverse_instance(target)
end
replace_keys(record, force: true) # ← 外部キーカラムへの書き込みを担当
self.target = record
end
replaceは型チェック・逆関連の同期・メモリ上のtarget管理をまとめてやる。そのうち「外部キーカラムに実際どんな値を書き込むか」だけを担当するのがreplace_keys。
ただしwrite_attributeの中身(@attributes.write_from_user(name, value))はメモリ上の属性ストアに値を書くだけで、SQLのUPDATEは発行しない。DBへの反映は、この後comment.saveが呼ばれて初めて行われる別ステップ。「replace_keysが外部キーを更新する」というのは、あくまでRubyオブジェクトの状態を書き換える、という意味。
#58368より前:単一カラムFKには保護ロジックが無かった
def replace_keys(record, force: false)
if @foreign_key.is_a?(Array)
# 複合FKのときだけ、こういう保護ロジックがある
@foreign_key.each_with_index do |key, index|
next if record.nil? && owner_pk.include?(key)
owner.write_attribute(key, target_key_values[index])
end
else
# 単一カラムFKには保護ロジックが一切ない
owner.write_attribute(@foreign_key, target_key_value)
end
end
単一カラムFKと複合FKで完全に別の分岐になっており、保護ロジック(next if record.nil? && owner_pk.include?(key))は複合FKの分岐にしか書かれていなかった。
つまりこの時点で、複合FKの全カラムがPKと重なっているケースでは、既にこの「何もクリアされない」バグが起きていた(後述のpreserve_owner_pkが導入される前と同じロジックなので)。一方、単一カラムFKの分岐には保護ロジック自体が存在しないので、単一カラムFKでは常に無条件でクリアされており、このバグは起こりようがなかった。
#58368:分岐を統一、保護ロジックが単一カラムFKにも初めて適用される
def replace_keys(record, force: false)
...
foreign_key.each_with_index do |key, index|
next if record.nil? && owner_pk.include?(key) # 単一・複合どちらにも同じロジックが効くようになった
owner.write_attribute(key, target_key_values[index])
end
end
ActiveRecord::Keyは「単一カラムのキー」と「複合カラムのキー」を同じインターフェース(each/any?/value_ofなど)で扱えるようにするラッパークラスで、Single/Composite/Noneのいずれかになる。is_a?(Array)のような分岐をコード中に散らばらせる代わりに、値の種類ごとに中身が違うクラスを用意して、呼び出し側は分岐を意識しなくて済む。
この統一APIを介して単一/複合の扱いを1本化した結果、今まで複合FK専用だった保護ロジックが単一カラムFKにも波及した。「外部キーの全カラムがPKと重なっている」ケースで保護ロジックが全カラムをスキップしてしまい、何もクリアされないというバグ自体は、複合FKでは以前から存在していた。
しかし#58368によって、単一カラムFKでも初めて同じバグが起きるようになった。単一カラムFKがPKの一部/全部であるケースは、複合FKの完全重複より一般的なので、影響範囲は広がった。
#58484:代わりがあるときだけ保護する
def replace_keys(record, force: false)
...
# 主キーと重なるカラムを保護するのは、他にクリアできる外部キーカラムがある場合だけ
preserve_owner_pk = record.nil? && foreign_key.any? { |key| !owner_pk.include?(key) }
foreign_key.each_with_index do |key, index|
next if preserve_owner_pk && owner_pk.include?(key)
owner.write_attribute(key, target_key_values[index])
end
end
ループに入る前にpreserve_owner_pkを1回だけ計算する。「外部キーの中に、PKと重ならないカラムが1個でもあるか」を先に確認し、あれば重複カラムだけスキップして保護する。無ければ保護を諦めて、PKと重なるカラムも含めて全部クリアする。nil代入という明示的な操作を空振りさせないための判断。
4つのケースで整理する
外部キー(FK)のカラムが、主キー(PK)にどれだけ含まれているか(FK ⊆ PKの度合い。逆にPKがFKに含まれるかは今回関係ない)で場合分けすると、新旧の挙動の違いが一つの表にまとまる。
| ケース | FKがPKに含まれる度合い | 例 | #58368より前 | #58368後〜#58484前 | #58484後(現在) |
|---|---|---|---|---|---|
| ① | FKはPKに含まれない |
belongs_to :author(author_idはPKと無関係) |
正常にクリア | 正常にクリア | 正常にクリア |
| ② | FKの一部だけがPKに含まれる(複合) |
tenant_id(共有)+post_id(非共有) |
正常(tenant_id保護・post_idクリア) |
正常のまま | 正常のまま |
| ③ | FKの全部がPKに含まれる(複合) |
[author_id, book_id] ⊆ [author_id, book_id, id]
|
バグ(両方とも保護され、何もクリアされない) | バグ変わらず |
preserve_owner_pkで保護解除、直る
|
| ④ | FKの全部がPKに含まれる(単一カラム) |
payrolls.idがそのままemployeesへのFK |
保護ロジック自体が無いため無条件クリア=結果的に正常 | 統一で複合側の保護ロジックを初めて踏み、ここで新たにバグる |
preserve_owner_pkで保護解除、元の正常な挙動に戻る
|
#58484が実際に挙動を変えたのは③④だけ。①②は終始無傷。PR本文の"broken edge case for fully-PK-overlapping FKs for a while"は③(#58368とは無関係に元から壊れていた)、"the potentially-less-rare case"は④(#58368によって新たに壊れた、より一般的なケース)を指している。
②③④を実際に再現した結果。
=== ケース②:一部重複(tenant_id共有、post_id非共有) ===
before: tenant_id=1, post_id=2
after: tenant_id=1, post_id=nil
=== ケース③:全部重複・複合([author_id, book_id] ⊆ [author_id, book_id, id]) ===
before: author_id=1, book_id=2, id=3
after: author_id=nil, book_id=nil, id=3
=== ケース④:全部重複・単一カラム(tag_idが複合PK[order_id, tag_id]に含まれる) ===
before: tag_id=2
after: tag_id=nil
表の予想と全て一致した。
has_one側:belongs_toとは独立した同じ形のバグ
has_one側の修正はhas_one_association.rbのnullify_owner_attributesというメソッド。replace_keysとは別物で、belongs_toの統一(#58368)の対象にも入っていない——has_one側のコードは元々ActiveRecord::Keyを使っており、#58368とは無関係に独立して同じ種類のバグを持っていた。
# 修正前
def nullify_owner_attributes(record)
primary_key = ActiveRecord::Key.for(record.class.primary_key)
ActiveRecord::Key.for(reflection.foreign_key).each do |foreign_key_column|
record.write_attribute(foreign_key_column, nil) unless primary_key.include?(foreign_key_column)
end
record.write_attribute(reflection.type, nil) if reflection.type.present?
end
# 修正後
def nullify_owner_attributes(record)
primary_key = ActiveRecord::Key.for(record.class.primary_key)
foreign_key = ActiveRecord::Key.for(reflection.foreign_key)
preserve_primary_key = foreign_key.any? { |key| !primary_key.include?(key) }
foreign_key.each do |foreign_key_column|
next if preserve_primary_key && primary_key.include?(foreign_key_column)
record.write_attribute(foreign_key_column, nil)
end
record.write_attribute(reflection.type, nil) if reflection.type.present?
end
preserve_primary_keyはbelongs_to側のpreserve_owner_pkと同じ発想(他にクリアできるカラムがあるときだけ保護する)。ただし書き込み先が違う。belongs_toはowner(comment.post = nilならcomment自身)を書き換えるのに対し、has_oneはrecord(関連先だった古いレコード、employee.profile = nilなら古いprofile)を書き換える。has_oneでは外部キーが自分ではなく相手側のテーブルにあるため。
実際のテストで確認
PRが追加したテストのうち2つ。
class BelongsToContainedKeyChapter < Cpk::Chapter
self.primary_key = [:author_id, :book_id, :id]
belongs_to :contained_book,
class_name: "Cpk::Book",
foreign_key: [:author_id, :book_id],
primary_key: [:author_id, :id],
optional: true,
inverse_of: false
end
def test_clearing_belongs_to_nullifies_composite_foreign_key_that_is_a_subset_of_primary_key
chapter = BelongsToContainedKeyChapter.new(author_id: 1, book_id: 2)
chapter.write_attribute(:id, 3)
chapter.contained_book = nil
assert_nil chapter.author_id
assert_nil chapter.book_id
assert_equal 3, chapter.read_attribute(:id)
end
主キー[author_id, book_id, id]のうち、外部キー[author_id, book_id]は完全にその部分集合。preserve_owner_pkの条件(PKと重ならないFKカラムがあるか)を満たさないので、保護されず両方クリアされる。FKに含まれないidは無関係なので触られない。
def test_clearing_belongs_to_nullifies_foreign_key_contained_in_composite_pk
order_tag = Cpk::OrderTag.new(order_id: 1, tag_id: 2)
order_tag.tag = nil
assert_nil order_tag.tag_id
end
こちらは単一カラムFK(tag_id)が複合PK[order_id, tag_id]に完全に含まれてるケース。同じ理由でクリアされる。
まとめ
- #55332:シャーディングキー保護のため、「PKと重なる外部キーはクリアしない」という意図的なルールを導入
-
#58368:単一/複合FKのコードパスを
ActiveRecord::Keyで統一したリファクタリング。副作用として、複合FK専用だった保護ロジックが単一カラムFKにも波及し、バグの適用範囲が広がった -
#58484:「他にクリアできる非共有カラムがあるときだけ保護する」という条件を追加し、逃げ道が無いケースでは
nil代入を優先するよう修正
参考:検証に使ったコマンド
# 対象コミットの特定
git show cda4a97fa80708ab49238012cccb7f72504fc4d4 --format="%H %ad %an%n%s%n" -s
git show 3576b1b7171647af4a8881319ee31d5bdbc9dd6f --format="%H %ad %an%n%s%n" -s
# 所属PRの特定
gh api repos/rails/rails/commits/3576b1b7171647af4a8881319ee31d5bdbc9dd6f/pulls --jq '.[] | {number, title, url}'
gh pr view 58484 --repo rails/rails --json title,body,url,mergedAt,author
# 呼び出しチェーンの確認
grep -n "define_writers" -A 8 activerecord/lib/active_record/associations/builder/association.rb
grep -n "def association\b" -A 8 activerecord/lib/active_record/associations.rb
grep -n "def writer" -A 3 activerecord/lib/active_record/associations/singular_association.rb
grep -n "def replace\b" -A 20 activerecord/lib/active_record/associations/belongs_to_association.rb
grep -rn "replace_keys(" activerecord/lib/active_record/associations/
# #58368(リファクタリング元)の確認
gh pr view 58368 --repo rails/rails --json title,author,body
gh pr diff 58368 --repo rails/rails
# #58484の実際の修正diff
git show 3576b1b7171647af4a8881319ee31d5bdbc9dd6f -- activerecord/lib/active_record/associations/belongs_to_association.rb
git show 3576b1b7171647af4a8881319ee31d5bdbc9dd6f -- activerecord/test/cases/associations/belongs_to_associations_test.rb
# レビューコメントの有無確認(0件だった)
gh api repos/rails/rails/pulls/58484/comments
gh api repos/rails/rails/issues/58484/comments
# has_one側の実際のdiff
git show cda4a97fa80708ab49238012cccb7f72504fc4d4
# ActiveRecord::Keyの利用範囲・中身の確認
grep -rln "ActiveRecord::Key\.for" activerecord/lib/
cat activerecord/lib/active_record/key.rb
# has_oneのnullify_owner_attributes呼び出し元(recordの正体を確認)
grep -n "nullify_owner_attributes" activerecord/lib/active_record/associations/has_one_association.rb
# 動作確認(bundler/inlineでローカルのactiverecordソースに接続、②③④を再現)
RBENV_VERSION=3.3.8 ruby test.rb