Skip to content

Commit 6661ac7

Browse files
yahondaclaude
andcommitted
Support enforced: option on foreign keys
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>
1 parent ac83019 commit 6661ac7

5 files changed

Lines changed: 204 additions & 1 deletion

File tree

lib/active_record/connection_adapters/oracle_enhanced/schema_creation.rb

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,28 @@ def add_column_options!(sql, options)
8585
end
8686
end
8787

88+
# `enforced:` and `validate:` map to Oracle's 4-state constraint model.
89+
# Both defaults are `true`; the options are independent.
90+
#
91+
# | enforced | validate | Oracle state | DML on the child table |
92+
# |----------|----------|-------------------|-------------------------------|
93+
# | true | true | ENABLE VALIDATE | enforced, existing data valid |
94+
# | true | false | ENABLE NOVALIDATE | enforced for new DML only |
95+
# | false | true | DISABLE VALIDATE | blocked (ORA-25128) |
96+
# | false | false | DISABLE NOVALIDATE| not enforced (~ PG NOT ENFORCED) |
97+
#
98+
# Oracle's bare `DISABLE` clause defaults to `NOVALIDATE`, so when
99+
# `enforced: false` and `validate: true` (the Rails default), `VALIDATE`
100+
# is emitted explicitly to preserve the user's intent.
88101
def visit_ForeignKeyDefinition(o)
89102
super.dup.tap do |sql|
90103
sql << " DEFERRABLE INITIALLY #{o.deferrable.to_s.upcase}" if o.deferrable
91-
sql << " NOVALIDATE" unless o.validate?
104+
sql << " DISABLE" unless o.enforced?
105+
if !o.validate?
106+
sql << " NOVALIDATE"
107+
elsif !o.enforced?
108+
sql << " VALIDATE"
109+
end
92110
end
93111
end
94112

lib/active_record/connection_adapters/oracle_enhanced/schema_statements.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,7 @@ def foreign_keys(table_name) # :nodoc:
753753
,c.deferrable
754754
,c.deferred
755755
,c.validated
756+
,c.status
756757
FROM all_constraints c, all_cons_columns cc,
757758
all_constraints r, all_cons_columns rc
758759
WHERE c.owner = SYS_CONTEXT('userenv', 'current_schema')
@@ -776,6 +777,7 @@ def foreign_keys(table_name) # :nodoc:
776777
}
777778
options[:on_delete] = extract_foreign_key_action(row["delete_rule"])
778779
options[:deferrable] = extract_foreign_key_deferrable(row["deferrable"], row["deferred"])
780+
options[:enforced] = false if row["status"] == "DISABLED"
779781
options[:validate] = false if row["validated"] == "NOT VALIDATED"
780782
ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(oracle_downcase(table_name), oracle_downcase(row["to_table"]), options)
781783
end
@@ -821,6 +823,20 @@ def validate_foreign_key(from_table, to_table = nil, **options) # :nodoc:
821823
validate_constraint(from_table, fk_name_to_validate)
822824
end
823825

826+
# Accepted options: +:enforced+ (the only mutable axis) plus the identifying
827+
# keys +:column+, +:name+, +:to_table+ passed through to +foreign_key_for!+.
828+
# ALTER ... MODIFY CONSTRAINT name DISABLE/ENABLE follows Oracle's defaults:
829+
# bare DISABLE leaves the constraint at DISABLE NOVALIDATE, and bare ENABLE
830+
# at ENABLE VALIDATE; introspection reflects those side effects on +:validate+.
831+
def change_foreign_key(from_table, to_table = nil, **options) # :nodoc:
832+
unless options.key?(:enforced)
833+
raise ArgumentError, "change_foreign_key requires at least one option (e.g. enforced:)"
834+
end
835+
enforced = options[:enforced]
836+
fk_name = foreign_key_for!(from_table, to_table: to_table, **options.except(:enforced)).name
837+
execute "ALTER TABLE #{quote_table_name(from_table)} MODIFY CONSTRAINT #{quote_column_name(fk_name)} #{enforced ? 'ENABLE' : 'DISABLE'}"
838+
end
839+
824840
# Returns an array of unique constraints for the given table.
825841
# The unique constraints are represented as UniqueConstraintDefinition objects.
826842
def unique_constraints(table_name) # :nodoc:

lib/active_record/connection_adapters/oracle_enhanced_adapter.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,10 @@ def supports_validate_constraints?
527527
true
528528
end
529529

530+
def supports_enforced_foreign_keys?
531+
true
532+
end
533+
530534
def supports_expression_index?
531535
true
532536
end

spec/active_record/connection_adapters/oracle_enhanced/schema_dumper_spec.rb

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,57 @@ def drop_test_posts_table
388388
expect(fk.options[:validate]).to be(false)
389389
end
390390

391+
it "dumps enforced: false for DISABLEd foreign keys" do
392+
schema_define do
393+
add_foreign_key :test_comments, :test_posts, enforced: false
394+
end
395+
output = dump_table_schema "test_comments"
396+
expect(output).to match(/add_foreign_key "test_comments", "test_posts".*enforced: false/)
397+
end
398+
399+
it "round-trips enforced: false alone (DISABLE VALIDATE) through dump and load" do
400+
schema_define do
401+
add_foreign_key :test_comments, :test_posts, enforced: false
402+
end
403+
404+
dumped = dump_table_schema "test_comments"
405+
expect(dumped).to match(/add_foreign_key "test_comments", "test_posts".*enforced: false/)
406+
expect(dumped).not_to match(/validate: /)
407+
408+
schema_define do
409+
remove_foreign_key :test_comments, :test_posts, if_exists: true
410+
end
411+
412+
body = dumped[/ActiveRecord::Schema\[.+?\]\.define\(version: \d+\) do\n(.+)\nend\s*\z/m, 1]
413+
schema_define { instance_eval(body) }
414+
415+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
416+
expect(fk).not_to be_nil
417+
expect(fk.options[:enforced]).to be(false)
418+
expect(fk.options.key?(:validate)).to be(false)
419+
end
420+
421+
it "round-trips enforced: false, validate: false (DISABLE NOVALIDATE) through dump and load" do
422+
schema_define do
423+
add_foreign_key :test_comments, :test_posts, enforced: false, validate: false
424+
end
425+
426+
dumped = dump_table_schema "test_comments"
427+
expect(dumped).to match(/add_foreign_key "test_comments", "test_posts".*validate: false.*enforced: false/)
428+
429+
schema_define do
430+
remove_foreign_key :test_comments, :test_posts, if_exists: true
431+
end
432+
433+
body = dumped[/ActiveRecord::Schema\[.+?\]\.define\(version: \d+\) do\n(.+)\nend\s*\z/m, 1]
434+
schema_define { instance_eval(body) }
435+
436+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
437+
expect(fk).not_to be_nil
438+
expect(fk.options[:enforced]).to be(false)
439+
expect(fk.options[:validate]).to be(false)
440+
end
441+
391442
it "should not include foreign keys on ignored table regexes in schema dump" do
392443
schema_define do
393444
add_foreign_key :test_comments, :test_posts

spec/active_record/connection_adapters/oracle_enhanced/schema_statements_spec.rb

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1950,6 +1950,120 @@ class ::TestComment < ActiveRecord::Base
19501950
expect(fk).not_to be_nil
19511951
expect(fk.options.key?(:validate)).to be(false)
19521952
end
1953+
1954+
it "creates DISABLE VALIDATE when enforced: false is given (validate defaults to true)" do
1955+
schema_define do
1956+
add_foreign_key :test_comments, :test_posts, enforced: false
1957+
end
1958+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
1959+
expect(fk.options[:enforced]).to be(false)
1960+
expect(fk.options.key?(:validate)).to be(false)
1961+
expect do
1962+
TestComment.create(body: "test", test_post_id: 1)
1963+
end.to raise_error(/ORA-25128/)
1964+
end
1965+
1966+
it "creates DISABLE NOVALIDATE when both enforced: false and validate: false are given (closest to PG NOT ENFORCED)" do
1967+
schema_define do
1968+
add_foreign_key :test_comments, :test_posts, enforced: false, validate: false
1969+
end
1970+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
1971+
expect(fk.options[:enforced]).to be(false)
1972+
expect(fk.options[:validate]).to be(false)
1973+
expect do
1974+
TestComment.create(body: "test", test_post_id: 1)
1975+
end.not_to raise_error
1976+
end
1977+
1978+
it "leaves both :enforced and :validate absent when the foreign key is ENABLE VALIDATE" do
1979+
schema_define do
1980+
add_foreign_key :test_comments, :test_posts
1981+
end
1982+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
1983+
expect(fk.options.key?(:enforced)).to be(false)
1984+
expect(fk.options.key?(:validate)).to be(false)
1985+
end
1986+
1987+
it "enables a DISABLEd foreign key via change_foreign_key" do
1988+
schema_define do
1989+
add_foreign_key :test_comments, :test_posts, enforced: false, validate: false
1990+
change_foreign_key :test_comments, :test_posts, enforced: true
1991+
end
1992+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
1993+
expect(fk.options.key?(:enforced)).to be(false)
1994+
expect(fk.options.key?(:validate)).to be(false)
1995+
expect do
1996+
TestComment.create(body: "test", test_post_id: 1)
1997+
end.to raise_error(/ORA-02291/)
1998+
end
1999+
2000+
it "disables an ENFORCED foreign key via change_foreign_key" do
2001+
schema_define do
2002+
add_foreign_key :test_comments, :test_posts
2003+
change_foreign_key :test_comments, :test_posts, enforced: false
2004+
end
2005+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
2006+
expect(fk.options[:enforced]).to be(false)
2007+
expect(fk.options[:validate]).to be(false)
2008+
expect do
2009+
TestComment.create(body: "test", test_post_id: 1)
2010+
end.not_to raise_error
2011+
end
2012+
2013+
it "raises ArgumentError when change_foreign_key is called without :enforced" do
2014+
schema_define do
2015+
add_foreign_key :test_comments, :test_posts
2016+
end
2017+
expect do
2018+
ActiveRecord::Base.lease_connection.change_foreign_key :test_comments, :test_posts
2019+
end.to raise_error(ArgumentError, /change_foreign_key requires at least one option/)
2020+
end
2021+
2022+
it "toggles enforced via change_foreign_key identified by name:" do
2023+
schema_define do
2024+
add_foreign_key :test_comments, :test_posts, name: "comments_posts_fk"
2025+
change_foreign_key :test_comments, name: "comments_posts_fk", enforced: false
2026+
end
2027+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
2028+
expect(fk.options[:enforced]).to be(false)
2029+
end
2030+
2031+
it "honors enforced: false on add_reference foreign_key option hash" do
2032+
schema_define do
2033+
drop_table :test_comments, if_exists: true
2034+
create_table :test_comments, force: true do |t|
2035+
t.string :body, limit: 4000
2036+
end
2037+
add_reference :test_comments, :test_post, foreign_key: { enforced: false, validate: false }
2038+
end
2039+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
2040+
expect(fk.options[:enforced]).to be(false)
2041+
expect(fk.options[:validate]).to be(false)
2042+
end
2043+
2044+
it "honors enforced: false on inline t.foreign_key inside create_table" do
2045+
schema_define do
2046+
drop_table :test_comments, if_exists: true
2047+
create_table :test_comments, force: true do |t|
2048+
t.string :body, limit: 4000
2049+
t.references :test_post
2050+
t.foreign_key :test_posts, enforced: false, validate: false
2051+
end
2052+
end
2053+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
2054+
expect(fk.options[:enforced]).to be(false)
2055+
expect(fk.options[:validate]).to be(false)
2056+
end
2057+
2058+
it "round-trips enforced: false combined with deferrable: :deferred" do
2059+
schema_define do
2060+
add_foreign_key :test_comments, :test_posts, enforced: false, validate: false, deferrable: :deferred
2061+
end
2062+
fk = ActiveRecord::Base.lease_connection.foreign_keys(:test_comments).first
2063+
expect(fk.options[:enforced]).to be(false)
2064+
expect(fk.options[:validate]).to be(false)
2065+
expect(fk.options[:deferrable]).to eq(:deferred)
2066+
end
19532067
end
19542068

19552069
describe "check constraints" do

0 commit comments

Comments
 (0)