仮想カラムと通常カラムの重複排除を分離し、データ損失を防止
SQLite3とPostgreSQLの Column クラスにおいて、virtual? 属性が == と hash メソッドに含まれるようになりました。これにより、GENERATED(仮想)カラムと通常カラムが誤って同一視されることがなくなり、INSERT/UPDATE時のデータ損失が防止されます。
背景
Active Recordの Deduplicable モジュールは、== と hash を使用してカラムインスタンスのレジストリを管理しています。同じ名前と型を持つカラムは重複排除の対象となり、メモリ効率化が図られています。
#56795 で報告された問題では、GENERATEDカラムが先にレジストリに登録されると、別テーブルの同名・同型の通常カラムがそのGENERATEDカラムと重複排除されてしまう事象が発生していました。GENERATEDカラムはINSERT/UPDATEから除外される性質を持つため、通常カラムが誤ってGENERATEDカラムと同一視されると、その通常カラムへの書き込みが暗黙的に無視され、データベースにNULL値が保存される深刻なデータ損失につながります。
問題の根本原因は、SQLite3::Column と PostgreSQL::Column の等価性判定が virtual? 属性を考慮していなかったことです。名前・型・その他の属性が一致すれば、GENERATEDかどうかに関わらず同一のカラムとして扱われていました。
技術的な変更
activerecord/lib/active_record/connection_adapters/sqlite3/column.rb と activerecord/lib/active_record/connection_adapters/postgresql/column.rb において、== メソッドと hash メソッドに virtual? の比較が追加されました。
SQLite3::Column の変更前:
def ==(other)
other.is_a?(Column) &&
super &&
auto_increment? == other.auto_increment?
end
def hash
Column.hash ^
super.hash ^
auto_increment?.hash ^
rowid.hash
end
SQLite3::Column の変更後:
def ==(other)
other.is_a?(Column) &&
super &&
auto_increment? == other.auto_increment? &&
virtual? == other.virtual?
end
def hash
Column.hash ^
super.hash ^
auto_increment?.hash ^
rowid.hash ^
virtual?.hash
end
PostgreSQLの Column クラスにも同様の変更が適用されています。== メソッドでは virtual? == other.virtual? の条件が追加され、hash メソッドでは virtual?.hash がXOR演算に含まれるようになりました。
両アダプタのテストファイル(activerecord/test/cases/adapters/sqlite3/virtual_column_test.rb と activerecord/test/cases/adapters/postgresql/virtual_column_test.rb)に、仮想カラムと通常カラムが重複排除されないことを検証する test_virtual_column_does_not_deduplicate_with_regular_column が追加されています。このテストでは、仮想カラムを持つテーブルのスキーマを先に読み込んでからDeduplicableレジストリに登録させ、その後に同名の通常カラムを持つテーブルを作成しています。
def test_virtual_column_does_not_deduplicate_with_regular_column
@connection.create_table :regular_columns, force: true do |t|
t.string :name
end
regular_model = Class.new(ActiveRecord::Base) { self.table_name = "regular_columns" }
# Ensure deduplication has seen the virtual column first
VirtualColumn.columns_hash
regular_col = regular_model.columns_hash["name"]
virtual_col = VirtualColumn.columns_hash["upper_name"]
assert_predicate virtual_col, :virtual?
assert_not_predicate regular_col, :virtual?
assert_not_equal regular_col, virtual_col
# The regular column must not be excluded from inserts
record = regular_model.create!(name: "hello")
record.reload
assert_equal "hello", record.name
ensure
@connection.drop_table :regular_columns, if_exists: true
end
テストは、2つのカラムが等価でないこと(assert_not_equal)と、通常カラムへの書き込みが正常に保存されること(assert_equal "hello", record.name)の両方を検証しています。
設計判断
virtual? を等価性判定に含める方式 が採用されました。
このアプローチは、カラムの重複排除ロジックそのものには手を加えず、カラムの同一性を判定する条件を厳密化する設計です。Deduplicable モジュールは引き続き == と hash に依存し、各アダプタの Column クラスが自身の特性に応じた等価性定義を提供します。
SQLite3では auto_increment? と rowid、PostgreSQLでは identity? と serial? など、アダプタ固有の属性がすでに等価性判定に含まれていました。virtual? もこれらと同様に、カラムの振る舞いを決定する重要な属性として、等価性判定の一部に組み込まれています。
hash メソッドへの追加も同様の一貫性を保っています。Rubyの Hash クラスがキーの等価性判定に eql? と hash の両方を使用するため、== で比較される属性は hash にも反映される必要があります。XOR演算(^)によって各属性のハッシュ値を組み合わせる既存のパターンを踏襲し、virtual?.hash が追加されました。
まとめ
本PRは、カラムの等価性判定に virtual? 属性を追加することで、GENERATEDカラムと通常カラムの誤った重複排除を防いでいます。== と hash の両方に一貫して virtual? を含めることで、Deduplicableレジストリが異なる generated_type を持つカラムを正しく区別し、データ損失のリスクを排除しました。