PostgreSQLカラムの等値比較における`generated`型の不整合を修正
PostgreSQLの生成カラムにおいて、#==と#hashが異なる抽象レベルで比較していた不整合を修正しました。これにより、eql?/hashコントラクト違反によるHashやSetでのサイレントなバグが解消されます。
背景
PostgreSQL::Columnの#==と#hashが異なるフィールドを参照していたことが、この修正の根本原因です。#==はvirtual?(booleanに畳み込まれた値)を使用し、#hashは@generated(生の文字列"s"または"v")を直接参照していました。Ruby のeql?/hashコントラクトは「a == bならばa.hash == b.hash」を要求するため、この不一致はHashやSetの内部動作を壊す可能性があります。
PostgreSQL 18+では、生成カラムが格納型("s": stored)と仮想型("v": virtual)の2種類を持てるようになりました。virtual?は内部で@generated == "v"と評価されるため、generated: "s"とgenerated: "v"のカラムはどちらもvirtual? == falseとなり、#==では等しいと判定されます。一方で#hashは@generatedの生の値を使うため、異なるハッシュ値を返します。この状態でHash/SetのキーとしてColumnオブジェクトを使用すると、「等しいはずのオブジェクトが別のバケットに入る」という予測困難な挙動につながります。
なお、この根本的な不整合はコミット bdc7adce で#hashがrawなインスタンス変数を使う形にリファクタリングされた際に、#==が更新されずに生じたものです。同様のバグがSQLite3アダプターでも発見され、#56817で先行して修正されています。
技術的な変更
#==内の比較をvirtual? == other.virtual?からgenerated == other.generatedに置き換え、#hashと同じ抽象レベル(rawな@generated文字列)で比較するようにしました。
変更前:
def ==(other)
super &&
identity? == other.identity? &&
serial? == other.serial? &&
virtual? == other.virtual?
end
変更後:
def ==(other)
super &&
identity? == other.identity? &&
serial? == other.serial? &&
generated == other.generated
end
protected
attr_reader :generated
generatedメソッドはprotectedなアクセサとして定義されています。これによりother.generatedの呼び出しは同クラスのインスタンス間でのみ許可され、クラス外部からの直接アクセスは制限されます。
リグレッションテストとして、test_generated_changes_column_equalityが追加されました。generated: "s"とgenerated: "v"で構築した2つのカラムオブジェクトが、assert_not_equalとassert_not_equal ... .hashの両方を通過することを確認しています。
設計判断
virtual?(boolean)からgenerated(raw文字列)へ比較対象を統一するアプローチが採用されました。
別の修正方法として、#hash側をvirtual?を使うよう変更することも考えられます。しかし#hashを@generatedで計算することは、将来さらに多くのgenerated型が追加された場合にも自動的に対応できるため、より汎用的です。#==をgeneratedベースに合わせることで、両者の抽象レベルが一致し、将来の型追加時も修正漏れが生じにくい設計になります。
SQLite3の修正(#56817)ではvirtual_stored?の比較を追加する形をとりましたが、PostgreSQLの修正はより根本的な共通フィールドへの統一で対処しており、アダプターごとの実装の違いも見て取れます。
まとめ
#==と#hashを同一のフィールド(@generated)で比較するよう統一したことで、Rubyのeql?/hashコントラクトが正しく満たされるようになりました。PostgreSQL 18+の格納型・仮想型という新しい生成カラムの区分に対して、既存のコードが抱えていた潜在的な不整合を、最小限の変更で正確に修正した事例です。