From c27556a6330128b097dfdeb286e342aa1c2e3810 Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Wed, 6 May 2026 12:57:41 -0700 Subject: [PATCH] Verify foreign keys after mount when configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror Rails' fixture-loading FK check: when ActiveRecord.verify_foreign_keys_for_fixtures is true (default since Rails 8.0 load_defaults), call connection.check_all_foreign_keys_valid! after the batch executes. PG and SQLite raise on violations; MySQL is a no-op. Wrap any StatementInvalid in a FixtureKit::Error with a hint that the cache may be stale relative to the schema or fixture definitions — most likely trigger is a developer regenerating fixtures against a different schema than the one currently loaded. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/fixture_kit/coders/active_record_coder.rb | 15 +++++ spec/unit/coders/active_record_coder_spec.rb | 65 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/lib/fixture_kit/coders/active_record_coder.rb b/lib/fixture_kit/coders/active_record_coder.rb index 8df921c..ec76257 100644 --- a/lib/fixture_kit/coders/active_record_coder.rb +++ b/lib/fixture_kit/coders/active_record_coder.rb @@ -41,6 +41,8 @@ def mount(data) # This should be revisited when Rails 8.2 makes it public. connection.__send__(:execute_batch, statements, "FixtureKit Insert") end + + verify_foreign_keys!(connection) end end end @@ -93,6 +95,19 @@ def build_insert_sql(table_name, columns, rows, connection) "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}" end + def verify_foreign_keys!(connection) + return unless ActiveRecord.verify_foreign_keys_for_fixtures + + begin + connection.check_all_foreign_keys_valid! + rescue ActiveRecord::StatementInvalid => e + raise FixtureKit::Error, + "Foreign key violations found in cached fixture data. The cache may be " \ + "stale relative to your current schema or fixture definitions. " \ + "Original error:\n\n#{e.message}" + end + end + def models_by_pool(data) seen = Set.new diff --git a/spec/unit/coders/active_record_coder_spec.rb b/spec/unit/coders/active_record_coder_spec.rb index 753d3e2..f7619c4 100644 --- a/spec/unit/coders/active_record_coder_spec.rb +++ b/spec/unit/coders/active_record_coder_spec.rb @@ -146,6 +146,71 @@ def exercise_user_write_operations(suffix) coder.mount(records) end + + context "when ActiveRecord.verify_foreign_keys_for_fixtures is true" do + around do |example| + previous = ActiveRecord.verify_foreign_keys_for_fixtures + ActiveRecord.verify_foreign_keys_for_fixtures = true + example.run + ensure + ActiveRecord.verify_foreign_keys_for_fixtures = previous + end + + it "calls check_all_foreign_keys_valid! after the batch executes" do + records = { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" } + fake_connection = stub_fake_connection + stub_pool(User, fake_connection) + + coder.mount(records) + + expect(fake_connection).to have_received(:check_all_foreign_keys_valid!).once + end + + it "wraps an FK violation in a FixtureKit::Error with a hint about stale cache" do + records = { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" } + fake_connection = stub_fake_connection + allow(fake_connection).to receive(:check_all_foreign_keys_valid!) + .and_raise(ActiveRecord::StatementInvalid, "Foreign key violations found: orders") + stub_pool(User, fake_connection) + + expect { coder.mount(records) }.to raise_error(FixtureKit::Error, /cached fixture data.*stale.*Foreign key violations found: orders/m) + end + end + + context "when ActiveRecord.verify_foreign_keys_for_fixtures is false" do + around do |example| + previous = ActiveRecord.verify_foreign_keys_for_fixtures + ActiveRecord.verify_foreign_keys_for_fixtures = false + example.run + ensure + ActiveRecord.verify_foreign_keys_for_fixtures = previous + end + + it "does not call check_all_foreign_keys_valid!" do + records = { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" } + fake_connection = stub_fake_connection + stub_pool(User, fake_connection) + + coder.mount(records) + + expect(fake_connection).not_to have_received(:check_all_foreign_keys_valid!) + end + end + + def stub_fake_connection + fake_connection = double("connection") + allow(fake_connection).to receive(:disable_referential_integrity).and_yield + allow(fake_connection).to receive(:execute_batch) + allow(fake_connection).to receive(:quote_table_name) { |name| %("#{name}") } + allow(fake_connection).to receive(:check_all_foreign_keys_valid!) + fake_connection + end + + def stub_pool(model, connection) + pool = double("pool-for-#{model.name}") + allow(pool).to receive(:with_connection).and_yield(connection) + allow(model).to receive(:connection_pool).and_return(pool) + end end describe "sql.active_record payload name format assumptions" do