Composite Primary Key 実装をポリモーフィズムでリファクタリング

rails/rails

Composite Primary Key の分散ロジックを統一的な ActiveRecord::Key に置き換えることで、条件分岐を削減し可読性と拡張性を向上させました。これにより、既存のシングルキーコードはそのまま動作し、複合キーへの対応は新クラスに委譲されます。

背景

従来の Composite Primary Key 実装は、composite_primary_key? や配列チェックといった条件分岐がコードベース各所に散在しており、保守コストが高くなっていました。特に belongs_tocollection_associationrelation など主要コンポーネントで同様のロジックが繰り返され、バグ修正や機能追加時に抜け漏れが発生しやすい構造でした。この PR は、そうした散在ロジックを ポリモーフィズム に集約することを目的としています。

技術的な変更

ActiveRecord::Key クラスが新規追加され、シングルキー、複合キー、キー無しの 3 種類をサブクラスで表現します。Key.for(name) が引数の型に応じて適切なインスタンスを生成し、composite?where_hash などの共通インターフェースを提供します。\n\n```ruby:activerecord/lib/active_record/key.rb
module ActiveRecord
class Key
def self.for(name)
case name
when Array then Composite.new(name)
when nil, false then None.new
else Single.new(name)
end
end
# ...省略...
end
end


`ActiveRecord` 名前空間に `autoload :Key` が追加され、必要時に遅延ロードされるようになりました。\n\n```diff
@@ -57,6 +57,7 @@ module ActiveRecord
   autoload :Inheritance
   autoload :Integration
   autoload :InternalMetadata
+  autoload :Key
   autoload :LogSubscriber
   autoload :Marshalling
   autoload :Migration

primary_key= メソッドは従来の配列判定ロジックを削除し、ActiveRecord::Key.for(value) でキー定義オブジェクトを生成します。@primary_key にはキー名だけを保持し、@primary_key_definition が実際のカラム情報を保持するため、CompositePrimaryKey モジュールのインクルードはキーが複合である場合に限定されます。\n\n```diff
@@ -128,14 +132,11 @@ def primary_key=(value)
- @primary_key = if value.is_a?(Array)
- include CompositePrimaryKey
- @primary_key = value.map { |v| -v.to_s }.freeze
- elsif value
- -value.to_s
- end
+ @primary_key_definition = ActiveRecord::Key.for(value)
+ @primary_key = @primary_key_definition.name
+
+ include CompositePrimaryKey if @primary_key_definition.composite?


`ids_writer` や `include?` など集合操作を行う箇所は、`ActiveRecord::Key.for` で取得したキーオブジェクトの `cast`、`value_of`、`where_hash` メソッドを利用するよう統一されました。これにより、単一キーと複合キーで分岐するコードが一行に収まります。\n\n```diff
@@ -60,29 +60,18 @@ def ids_writer(ids)
-        primary_key = reflection.association_primary_key
+        primary_key = ActiveRecord::Key.for(reflection.association_primary_key)
         ids = Array(ids).compact_blank
+        ids.map! { |id| primary_key.cast(id, klass) }
@@ -268,8 +257,7 @@ def include?(record)
-          record_id = klass.composite_primary_key? ? klass.primary_key.zip(record.id).to_h : record.id
-          scope.exists?(record_id)
+          scope.exists?(klass.primary_key_definition.where_hash(record.id))

Relation 系の更新系メソッド(update_alldelete_alldestroy)でも主キー取得を model.primary_key_definition に置き換え、arel_columns が自動で単一・複合を判別して Arel カラムを生成します。\n\n```diff
@@ -632,11 +632,7 @@ def update_all(updates)
- key = if model.composite_primary_key?
- primary_key.map { |pk| table[pk] }
- else
- table[primary_key]
- end
+ key = model.primary_key_definition.arel_columns(table)


`PredicateBuilder` 系のクエリ生成ロジックも同様にキーオブジェクトへ委譲し、`where_clauses` が複合キーの場合に複数条件を組み立てます。これにより、配列判定や手動マッピングが不要となり、コード量が大幅に削減されました。\n\n```diff
@@ -9,14 +9,11 @@ def initialize(reflection, value)
-        if reflection.join_foreign_key.is_a?(Array)
-          id_list = ids
-          id_list = id_list.pluck(primary_key) if id_list.is_a?(Relation)
+        key = ActiveRecord::Key.for(reflection.join_foreign_key)
+        id_list = ids
+        id_list = id_list.pluck(primary_key) if key.composite? && id_list.is_a?(Relation)
@@ -9,14 +9,11 @@ def initialize(reflection, value)
-        if reflection.join_foreign_key.is_a?(Array)
-          id_list = ids
-          id_list = id_list.pluck(primary_key) if id_list.is_a?(Relation)
+        key = ActiveRecord::Key.for(reflection.join_foreign_key)
+        id_list = ids
+        id_list = id_list.pluck(primary_key) if key.composite? && id_list.is_a?(Relation)
@@ -9,14 +9,11 @@ def initialize(reflection, value)
-        if reflection.join_foreign_key.is_a?(Array)
-          id_list = ids
-          id_list = id_list.pluck(primary_key) if id_list.is_a?(Relation)
+        key = ActiveRecord::Key.for(reflection.join_foreign_key)
+        id_list = ids
+        id_list = id_list.pluck(primary_key) if key.composite? && id_list.is_a?(Relation)

テストスイートにも ActiveRecord::Key の振る舞いを検証する key_test.rb が追加され、シングル、複合、無キーの各ケースで期待通りに動作することが自動的に保証されます。\n\n```ruby:activerecord/test/cases/key_test.rb
class KeyTest < ActiveRecord::TestCase
def test_simple_key_shape
pk = Key.for("id")
assert_not_predicate pk, :composite?
assert_predicate pk, :present?
assert_equal "id", pk.name
end
# ... 省略 ...
end


## 設計判断

**ポリモーフィズムによる抽象化** を選択した最大の理由は、条件分岐を一箇所に集約できる点です。従来は `composite_primary_key?` や `Array` 判定を多数のファイルに記述していたため、変更漏れが生じやすく保守性が低下していました。`ActiveRecord::Key` が `Single`/`Composite`/`None` の三態を提供することで、呼び出し元は「キーが何型か」を意識せずに共通インターフェースだけを利用できます。

互換性は **既存のシングルキーコード** に影響を与えません。`primary_key=` がキーオブジェクトを生成しつつ、`@primary_key` には元の文字列(または `nil`)を保持するため、外部 API のシグネチャは変わらず、マイグレーションや既存アプリケーションはそのまま動作します。複合キーが必要な場合だけ `CompositePrimaryKey` モジュールがインクルードされるため、不要な機能がロードされるリスクも低減しています。

## まとめ

今回のリファクタリングは、Composite Primary Key の分散条件ロジックを **ActiveRecord::Key** に統一し、コードの可読性・保守性・拡張性を大幅に向上させました。キーの型に応じた処理はキーオブジェクトに委譲され、既存の挙動は保持されたまま新しい抽象化が導入されたため、将来的なキー関連機能追加もシンプルに実装できる基盤が整いました。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
86c655e5

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

記事はリード文、背景、技術的変更、設計判断、まとめの順に構成されており、3部構成が明確です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライトは正しい形式 (```ruby:filepath) で使用され、GitHubリンクも PR 番号を正しくリンク化しています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

内容は Rails コア開発者向けの高度な技術情報で、初心者向けの過度な説明はありません。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションは総論→各論→結論の流れになっており、段落はトピックセンテンスで始まり、1段落1トピックが保たれています。

Diff内容との照合 ⚠ WARNING

コードブロックとDiff内容の一致

ほとんどのコードブロックは提供された Diff と一致していますが、`activerecord/lib/active_record/key.rb` の掲載が冒頭部分のみで全容が省略されている点があります。抜粋自体は正確ですが、Diff 全体を提示していないため警告とします。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

使用されている用語は PR 内容と一致しており、誤用は見られません。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

技術的説明は Diff と PR の記述に基づき正確で、根拠のない主張はありません。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

全ての主張は PR のタイトル・説明・Diff に裏付けられており、憶測や不確かな情報は含まれていません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR 番号やファイルパス、メソッド名などの固有名詞は正しく記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事タイトルは PR の内容を正確に日本語で表現しており、一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事に LTS やリリース日程など PR では触れられていない外部知識は含まれていません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現の歪曲はなく、PR の記述と整合しています。