0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

belongs_to/has_oneのキークリア処理はなぜPK重複で壊れたか

0
Posted at

はじめに

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 nil is 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 :authorauthor_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.rbnullify_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_keybelongs_to側のpreserve_owner_pkと同じ発想(他にクリアできるカラムがあるときだけ保護する)。ただし書き込み先が違う。belongs_toownercomment.post = nilならcomment自身)を書き換えるのに対し、has_onerecord(関連先だった古いレコード、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
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?