複合主キーモデルで `find_signed` が `ArgumentError` を発生させるバグを修正
複合主キーを持つモデルで find_signed を呼び出すと ArgumentError が発生するバグが修正されました。1行の変更により、find_signed が find_signed! や find と一貫した動作をするようになります。
背景
find_signed は複合主キー(CPK)モデルに対して ArgumentError を発生させていました。マジックサインインリンク、パスワードリセット、メール確認などで複合主キーモデルの find_signed を使用しているケースで問題が生じていました。
再現コードは以下のとおりです:
class Cpk::Order < ActiveRecord::Base
self.primary_key = [:shop_id, :id]
end
order = Cpk::Order.first
token = order.signed_id
Cpk::Order.find_signed!(token)
# => #<Cpk::Order id: 75294940, shop_id: 37647470, ...>
Cpk::Order.find_signed(token)
# => ArgumentError: Expected corresponding value for ["shop_id", "id"] to be an Array
find_signed! は正常に動作するにもかかわらず、find_signed だけが失敗するという非対称な挙動が問題でした。
根本原因は signed_id.rb 内の find_by primary_key => id という呼び出しにあります。複合主キーの場合、primary_key は ["shop_id", "id"] という配列、id も [37647470, 75294940] という配列になります。predicate_builder.rb は配列のキーを「複数レコードの検索」と解釈するため、値が配列の配列([[shop_id, id], [shop_id, id], ...])であることを期待します。値が配列でない要素を見つけると ArgumentError を発生させます。一方、find_signed! が使用する find(id) は find_one を経由し、finder_methods.rb で primary_key と id を zip で結合するため、この問題を回避できていました。
なお、同種のバグは2024年9月に find_by_token_for に対して 8617a7c(@fatkodima 氏)で修正されています。token_for.rb では find_by(model.primary_key => id) が find_by(model.primary_key => [id]) に変更されましたが、同一のパターンを持つ signed_id.rb はその時点で更新されませんでした。
技術的な変更
activerecord/lib/active_record/signed_id.rb の1行変更により、id を配列で包むことで predicate builder が単一の複合キータプルとして解釈できるようにしました。
変更前:
if id = signed_id_verifier.verified(signed_id, purpose: combine_signed_id_purposes(purpose), **options)
find_by primary_key => id
end
変更後:
if id = signed_id_verifier.verified(signed_id, purpose: combine_signed_id_purposes(purpose), **options)
find_by(primary_key => [id])
end
[id] と包むことで、predicate builder は「1つの複合キータプルにマッチするレコードを検索」と解釈します。単一主キーの場合は primary_key が文字列、id がスカラー値なので、[id] は単一要素の配列となり従来と同じ動作をします。
テストは activerecord/test/cases/signed_id_test.rb に3つ追加されています:
- CPKモデルに対する
find_signedの往復テスト - リレーションスコープ付きCPKモデルでの
find_signed(ポジティブ・ネガティブスコープの両方) - CPKモデルに対する
find_signed!の往復テスト(リグレッションガード)
設計判断
最終的にマージされたコードでは、過去の類似修正(token_for.rb の 8617a7c)と同様に find_by(primary_key => [id]) という配列でラップする方式 が採用されました。
PR本文では primary_key.zip(id).to_h というアプローチも検討されており、このアプローチは { shop_id: 37647470, id: 75294940 } という明示的な条件ハッシュを生成し、finder_methods.rb の find_one と同じパターンで可読性が高い点が利点として挙げられています。PR本文では zip アプローチが推奨されていますが、最終的なコードでは [id] でラップする方式が採用されています。どちらのアプローチも最終的に同じSQLクエリを生成するため、動作上の違いはありません。
find ではなく find_by を維持している点も重要な判断です。find に切り替えると SignedId::RelationMethods#find_signed によるリレーションスコープ(例: Cpk::Order.where(...).find_signed(token))が機能しなくなります。find_by を使うことで、スコープ付きのクエリが引き続き正しく動作します。
まとめ
本PRは、token_for.rb で2024年9月に適用された同種の修正が signed_id.rb に漏れていた問題を解消するものです。1行の変更で find_signed の動作が find_signed! や find と一貫するようになり、複合主キーモデルを使用したサインインリンクやパスワードリセット機能が正しく動作するようになります。