Skip to content

Support enforced: option on foreign keys#2793

Merged
yahonda merged 1 commit into
rsim:masterfrom
yahonda:enforced-foreign-key
May 20, 2026
Merged

Support enforced: option on foreign keys#2793
yahonda merged 1 commit into
rsim:masterfrom
yahonda:enforced-foreign-key

Conversation

@yahonda

@yahonda yahonda commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Mirrors rails/rails#57377 (merged commit 6cb88eae), which adds an enforced: option to add_foreign_key (and a change_foreign_key helper) for PostgreSQL 18.4+ NOT ENFORCED constraints. The Ruby option name enforced: follows the [NOT] ENFORCED constraint characteristic introduced in SQL:2011 and carried forward into SQL:2016 (see References below); Oracle does not implement the [NOT] ENFORCED keyword. Instead, oracle-enhanced exposes Oracle's pre-existing 4-state constraint model via the independent enforced: and validate: Ruby options.

The 4-state matrix

enforced: validate: Oracle state DML on child table
true (default) true (default) ENABLE VALIDATE enforced, existing data valid
true (default) false ENABLE NOVALIDATE enforced for new DML only
false true (default) DISABLE VALIDATE blocked (ORA-25128) — read-only
false false DISABLE NOVALIDATE not enforced (≈ PG NOT ENFORCED)
# enforced + validate, both defaults: ENABLE VALIDATE
add_foreign_key :articles, :authors

# Skip existing-data check, still enforce new DML
add_foreign_key :articles, :authors, validate: false

# DISABLE VALIDATE - constraint disabled, data marked validated, DML blocked
add_foreign_key :articles, :authors, enforced: false

# Cross-DB compatible "not enforced" semantics (mirrors PG NOT ENFORCED)
add_foreign_key :articles, :authors, enforced: false, validate: false

# Toggle enforced state on an existing FK
change_foreign_key :articles, :authors, enforced: true

The Ruby-API defaults are kept independent (validate: defaults to true regardless of enforced:) so all 4 states are reachable from idiomatic Rails code. The framework does not "protect" the user from DISABLE VALIDATE because Oracle itself allows it — the matrix above is reproduced as a comment above visit_ForeignKeyDefinition so this is discoverable in source.

Changes

  • supports_enforced_foreign_keys? returns true unconditionally — Oracle's DISABLE/ENABLE constraint state has been available across every Oracle release this gem supports.
  • visit_ForeignKeyDefinition appends DISABLE when enforced: false. When validate: is true (the Rails default) and enforced: false, VALIDATE is also emitted explicitly because Oracle's bare DISABLE clause defaults to NOVALIDATE — without the explicit keyword we couldn't reach DISABLE VALIDATE. The full 4-state matrix is documented in a comment above the method.
  • foreign_keys introspection selects c.status from all_constraints and maps 'DISABLED' to options[:enforced] = false. c.validated is mapped independently to options[:validate] = false — the two flags are not coupled. The dumped output preserves both, so every Oracle state round-trips.
  • change_foreign_key issues ALTER TABLE ... MODIFY CONSTRAINT ... ENABLE|DISABLE via the existing foreign_key_for! lookup helper. MODIFY ... DISABLE resets the validated state to NOT VALIDATED, so disabling an enforced FK reports both enforced: false and validate: false.

Schema dumping relies on the abstract ActiveRecord::ConnectionAdapters::SchemaDumper#foreign_keys, which since rails/rails#57377 emits enforced: false whenever foreign_key.enforced? is false (where enforced? is options.fetch(:enforced, true) on ForeignKeyDefinition). No adapter-level dumper override is needed.

Tests

  • 5 specs in schema_statements_spec.rb exercising all 4 states + both change_foreign_key directions. ORA-25128 (DML blocked under DISABLE VALIDATE) is asserted, not avoided.
  • 3 specs in schema_dumper_spec.rb covering enforced: false emission and full dump-and-load round-trips for both DISABLE VALIDATE and DISABLE NOVALIDATE.

269 examples pass against FREEPDB1 (Oracle 23ai).

References

[NOT] ENFORCED (the Rails option name)

  • Upstream Rails PR (merged): Add enforced: option for foreign keys on PostgreSQL 18.4+ rails/rails#57377 — merged commit rails/rails@6cb88ea
  • pgsql-hackers thread (accessible primary reference, since the SQL standard itself is behind ISO's paywall): https://postgrespro.com/list/thread-id/1770540 — Simon Riggs's 2010 proposal cites SQL:2011 §4.17.2: "Table constraints are either enforced or not enforced. Domain constraints and assertions are always enforced."
  • SQL:2016 (ISO/IEC 9075-2:2016) carries the same syntax forward. Relevant subclauses:
    • §4.17 — definition of enforced / not enforced integrity constraints
    • §11.6 <table constraint definition><constraint characteristics> syntactic slot
    • §11.8 <referential constraint definition> — accepts <constraint characteristics> (this is the FK path)
    • §11.9 <check constraint definition> — accepts <constraint characteristics>
    • Annex F — feature taxonomy, including F815 Not enforced check constraints
    • Grammar: <constraint enforcement> ::= ENFORCED | NOT ENFORCED
  • PostgreSQL feature table src/backend/catalog/sql_features.txt references F815; the entry was updated for FK support in PostgreSQL commit b663b9436e.

ENABLE / DISABLE / VALIDATE / NOVALIDATE (the Oracle-side translation)

  • Oracle 8i SQL Reference, Release 3 (8.1.7) — constraint_clause: https://docs.oracle.com/cd/A87860_01/doc/server.817/a85397/state14a.htm — documents the constraint_state clause (ENABLE / DISABLE, VALIDATE / NOVALIDATE) as applicable to foreign-key (referential) constraints. The four-state model and its semantic definitions ("ENABLE VALIDATE … guarantees that all data is and will continue to be valid"; "DISABLE NOVALIDATE signifies that Oracle makes no effort to maintain the constraint") are already present in 8i and have been carried forward to current releases unchanged. The DML-blocking behavior of DISABLE VALIDATE (ORA-25128 — "No insert/update/delete on table with constraint (…) disabled and validated") is part of the same long-standing semantics.

@yahonda yahonda force-pushed the enforced-foreign-key branch 2 times, most recently from c45491b to d73fae2 Compare May 14, 2026 23:47
@yahonda

yahonda commented May 16, 2026

Copy link
Copy Markdown
Collaborator Author

rails/rails#57377 has been opened to Rails.

@yahonda yahonda marked this pull request as ready for review May 19, 2026 23:21
@yahonda yahonda force-pushed the enforced-foreign-key branch from 894c5df to 748a42a Compare May 19, 2026 23:26
@yahonda

yahonda commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

rails/rails#57377 has been merged.

Mirrors rails/rails#57377 (commit 6cb88eae), which adds an `enforced:`
option to `add_foreign_key` (and a `change_foreign_key` helper) for
PostgreSQL 18.4+ NOT ENFORCED constraints. The Ruby option name follows
SQL:2011's [NOT] ENFORCED constraint characteristic (carried into
SQL:2016). Oracle does not implement the [NOT] ENFORCED keyword;
instead, the adapter exposes Oracle's pre-existing 4-state constraint
model via the independent `enforced:` and `validate:` Ruby options.

  enforced | validate | Oracle state
  ---------|----------|----------------------------------------
  true     | true     | ENABLE VALIDATE
  true     | false    | ENABLE NOVALIDATE
  false    | true     | DISABLE VALIDATE   (blocks DML, ORA-25128)
  false    | false    | DISABLE NOVALIDATE (~ PG NOT ENFORCED)

Both Ruby-API defaults are `true` and the options are independent.
Users wanting PostgreSQL-NOT-ENFORCED semantics on Oracle should pass
both `enforced: false, validate: false`; passing only `enforced: false`
yields DISABLE VALIDATE (intentional, but blocks DML). The matrix is
also reproduced in source above visit_ForeignKeyDefinition.

- supports_enforced_foreign_keys? returns true unconditionally
- visit_ForeignKeyDefinition emits ` DISABLE` (before any ` VALIDATE` /
  ` NOVALIDATE`) and emits explicit ` VALIDATE` when DISABLE is set and
  validate is true, because Oracle's bare DISABLE defaults to NOVALIDATE
- foreign_keys introspection selects c.status from all_constraints;
  c.status -> options[:enforced]=false, c.validated -> options[:validate]
  =false, reported independently
- change_foreign_key issues ALTER TABLE ... MODIFY CONSTRAINT ...
  ENABLE/DISABLE via foreign_key_for! lookup; raises ArgumentError when
  :enforced is omitted (mirrors PG, prevents a no-op call from silently
  disabling the FK). Docstring records the accepted opts and the bare
  DISABLE/ENABLE side effect on Oracle's VALIDATED state.

Schema dumping relies on the abstract SchemaDumper#foreign_keys, which
since rails/rails#57377 emits `enforced:` based on
ForeignKeyDefinition#enforced? (options.fetch(:enforced, true)); no
adapter-level dumper override is needed.

References:
- pgsql-hackers (SQL:2011 4.17.2 quote; accessible primary reference
  since the ISO standard itself is paywalled):
  https://postgrespro.com/list/thread-id/1770540
- Oracle 8i SQL Reference Release 3 (8.1.7) constraint_clause -
  documents ENABLE/DISABLE and VALIDATE/NOVALIDATE on foreign-key
  constraints; the DML-blocking semantics of DISABLE VALIDATE
  (ORA-25128) are part of the same long-standing model:
  https://docs.oracle.com/cd/A87860_01/doc/server.817/a85397/state14a.htm

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant