PostgreSQL 18.4+向け外部キーの `enforced:` オプションを追加
Active Recordの add_foreign_key と change_foreign_key に enforced: オプションが追加され、PostgreSQL 18.4以降で外部キー制約の参照整合性チェックをスーパーユーザー権限なしに制御できるようになりました。
背景
PostgreSQL 17以前では、外部キーチェックを一時的に無効化するには DISABLE TRIGGER ALL を発行する必要があり、スーパーユーザー権限が必要でした。さらにトリガーを再有効化しても既存の不正データは再検証されないため、データの整合性が静かに崩れるリスクがありました。
PostgreSQL 18.0でこの問題を解決する NOT ENFORCED オプションが外部キー制約に導入されました。テーブルオーナーが権限なしに切り替えでき、ENFORCED に戻す際は全行の再検証が行われます。ただし、PostgreSQL 18.0〜18.3には「NOT ENFORCED 外部キーで DEFERRABLE 属性が失われる」バグが存在しており、Railsが依存する遅延制約の仕組みが壊れる問題がありました。このバグはPostgreSQL 18.4で修正されたため(「Fix loss of deferrability of foreign-key triggers」)、Active Recordはバージョン18.4以降を要件としています。
なお、以前にスーパーユーザー権限不要の手段として SET CONSTRAINTS ALL DEFERRED を利用するアプローチが試みられましたが、NO ACTION 以外の参照アクションは DEFERRABLE 宣言があっても遅延できないため、2812694 でリバートされた経緯があります。
技術的な変更
enforced: オプションはスキーマ定義・SQL生成・スキーマダンプ・バージョンチェックの4層にわたって実装されています。
ForeignKeyDefinitionへのメソッド追加
schema_definitions.rb に enforced? メソッドが追加されました。options[:enforced] が未指定の場合は true をデフォルト返す実装で、既存の外部キーへの影響はありません。
def enforced?
options.fetch(:enforced, true)
end
SQL生成
PostgreSQLの visit_ForeignKeyDefinition に NOT ENFORCED 句の追記が加わりました。enforced? が false のときのみ付加されるため、既存のDDL出力は変わりません。
def visit_ForeignKeyDefinition(o)
super.dup.tap do |sql|
sql << " DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}" if o.deferrable
sql << " NOT ENFORCED" unless o.enforced?
end
end
バージョンチェックと change_foreign_key の追加
postgresql_adapter.rb に supports_enforced_foreign_keys? が実装され、database_version >= 18_00_04 を要件とします。add_foreign_key はこのチェックを呼び出し、要件未満のバージョンでは ArgumentError を発生させます。また、抽象アダプターに change_foreign_key のデフォルト実装(NotImplementedError)が追加され、PostgreSQLアダプターがオーバーライドする形で ALTER TABLE ... ALTER CONSTRAINT ... ENFORCED / NOT ENFORCED を発行します。
def supports_enforced_foreign_keys?
database_version >= 18_00_04
end
foreign_keys メソッドとスキーマダンプ
foreign_keys の内部クエリは supports_enforced_foreign_keys? が真のとき c.conenforced AS enforced カラムを動的に追加します。スキーマダンパーは enforced? が false のときのみ enforced: false を出力します。PostgreSQLは NOT ENFORCED 制約を内部的に NOT VALID として扱うため、ダンプには enforced: false と validate: false の両方が出力されます。
parts << "enforced: #{foreign_key.enforced?.inspect}" unless foreign_key.enforced?
設計判断
バージョン条件を supports_enforced_foreign_keys? メソッドに集約することで、将来の disable_referential_integrity 対応(フォローアップPRで予定)でも同じ判定ロジックを再利用できる設計になっています。
change_foreign_key を抽象アダプターに NotImplementedError として追加したことも重要な判断です。これにより、他のアダプターへの将来の拡張に向けたインターフェース定義が確保されつつ、現時点ではPostgreSQLアダプターのみが実装を持ちます。ドキュメントには「マイグレーションコマンドではなくランタイムヘルパー」として明記されており、可逆マイグレーションとして登録されていないことが意図的に示されています。
NOT ENFORCED 制約がPostgreSQLによって内部的に NOT VALID として扱われる点は、Active Record側での追加ハンドリングを必要とせず、validate: false として自然に表現されます。スキーマダンプが両フラグを出力することで、ダンプから復元した際も同じ状態が再現されます。
まとめ
本PRは、PostgreSQL 18.4のバグ修正を前提に、スーパーユーザー権限なしで外部キーの参照整合性チェックを制御する手段をActive Recordに組み込んだ変更です。バージョンチェック・SQL生成・スキーマダンプを一貫して拡張することで、enforced: false の外部キーをRailsのマイグレーション体系の中で安全に扱えるようになりました。