Composite Primary Key 実装をポリモーフィズムでリファクタリング
Composite Primary Key の分散ロジックを統一的な ActiveRecord::Key に置き換えることで、条件分岐を削減し可読性と拡張性を向上させました。これにより、既存のシングルキーコードはそのまま動作し、複合キーへの対応は新クラスに委譲されます。
背景
従来の Composite Primary Key 実装は、composite_primary_key? や配列チェックといった条件分岐がコードベース各所に散在しており、保守コストが高くなっていました。特に belongs_to、collection_association、relation など主要コンポーネントで同様のロジックが繰り返され、バグ修正や機能追加時に抜け漏れが発生しやすい構造でした。この 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_all、delete_all、destroy)でも主キー取得を 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** に統一し、コードの可読性・保守性・拡張性を大幅に向上させました。キーの型に応じた処理はキーオブジェクトに委譲され、既存の挙動は保持されたまま新しい抽象化が導入されたため、将来的なキー関連機能追加もシンプルに実装できる基盤が整いました。