diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2baea8b..4367266 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,68 @@ jobs: - name: Run tests run: bundle exec rspec + test_db: + runs-on: ubuntu-latest + name: test (${{ matrix.appraisal }}, ${{ matrix.database }}) + + strategy: + fail-fast: false + matrix: + ruby: ["4.0"] + appraisal: ["rails_8.1"] + database: ["postgresql", "mysql2"] + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -h localhost" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + BUNDLE_GEMFILE: gemfiles/${{ matrix.appraisal }}.gemfile + FIXTURE_KIT_INTEGRATION_FRAMEWORK: rspec + FIXTURE_KIT_DB: ${{ matrix.database }} + FIXTURE_KIT_DB_HOST: ${{ matrix.database == 'mysql2' && '127.0.0.1' || 'localhost' }} + FIXTURE_KIT_DB_USERNAME: ${{ matrix.database == 'mysql2' && 'root' || 'postgres' }} + FIXTURE_KIT_DB_PASSWORD: ${{ matrix.database == 'mysql2' && 'root' || 'postgres' }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Install dummy app dependencies + working-directory: spec/dummy + env: + BUNDLE_GEMFILE: "" + run: bundle install + + - name: Run tests + run: bundle exec rspec + lint: runs-on: ubuntu-latest diff --git a/Appraisals b/Appraisals index 0067581..e07b3fb 100644 --- a/Appraisals +++ b/Appraisals @@ -2,10 +2,26 @@ appraise "rails-8.0" do gem "activesupport", "~> 8.0.0" gem "activerecord", "~> 8.0.0" gem "railties", "~> 8.0.0" + + group :postgres do + gem "pg" + end + + group :mysql do + gem "mysql2" + end end appraise "rails-8.1" do gem "activesupport", "~> 8.1.0" gem "activerecord", "~> 8.1.0" gem "railties", "~> 8.1.0" + + group :postgres do + gem "pg" + end + + group :mysql do + gem "mysql2" + end end diff --git a/Gemfile b/Gemfile index be173b2..87f6250 100644 --- a/Gemfile +++ b/Gemfile @@ -3,3 +3,11 @@ source "https://rubygems.org" gemspec + +group :postgres do + gem "pg" +end + +group :mysql do + gem "mysql2" +end diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile index 621a692..40fd720 100644 --- a/gemfiles/rails_8.0.gemfile +++ b/gemfiles/rails_8.0.gemfile @@ -6,4 +6,12 @@ gem "activesupport", "~> 8.0.0" gem "activerecord", "~> 8.0.0" gem "railties", "~> 8.0.0" +group :postgres do + gem "pg" +end + +group :mysql do + gem "mysql2" +end + gemspec path: "../" diff --git a/gemfiles/rails_8.0.gemfile.lock b/gemfiles/rails_8.0.gemfile.lock index 1d5f4a1..b4c26a4 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -74,6 +74,8 @@ GEM nokogiri (>= 1.12.0) minitest (6.0.1) prism (~> 1.5) + mysql2 (0.5.7) + bigdecimal nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.1-aarch64-linux-musl) @@ -90,6 +92,13 @@ GEM racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -181,6 +190,8 @@ DEPENDENCIES appraisal fixture_kit! irb + mysql2 + pg railties (~> 8.0.0) rake rspec-rails @@ -212,6 +223,7 @@ CHECKSUMS logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + mysql2 (0.5.7) sha256=ba09ede515a0ae8a7192040a1b778c0fb0f025fa5877e9be895cd325fa5e9d7b nokogiri (1.19.1-aarch64-linux-gnu) sha256=cfdb0eafd9a554a88f12ebcc688d2b9005f9fce42b00b970e3dc199587b27f32 nokogiri (1.19.1-aarch64-linux-musl) sha256=1e2150ab43c3b373aba76cd1190af7b9e92103564063e48c474f7600923620b5 nokogiri (1.19.1-arm-linux-gnu) sha256=0a39ed59abe3bf279fab9dd4c6db6fe8af01af0608f6e1f08b8ffa4e5d407fa3 @@ -220,6 +232,13 @@ CHECKSUMS nokogiri (1.19.1-x86_64-darwin) sha256=7093896778cc03efb74b85f915a775862730e887f2e58d6921e3fa3d981e68bf nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a nokogiri (1.19.1-x86_64-linux-musl) sha256=4267f38ad4fc7e52a2e7ee28ed494e8f9d8eb4f4b3320901d55981c7b995fc23 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile index b394a09..4cb9159 100644 --- a/gemfiles/rails_8.1.gemfile +++ b/gemfiles/rails_8.1.gemfile @@ -6,4 +6,12 @@ gem "activesupport", "~> 8.1.0" gem "activerecord", "~> 8.1.0" gem "railties", "~> 8.1.0" +group :postgres do + gem "pg" +end + +group :mysql do + gem "mysql2" +end + gemspec path: "../" diff --git a/gemfiles/rails_8.1.gemfile.lock b/gemfiles/rails_8.1.gemfile.lock index d6d959c..5b684f2 100644 --- a/gemfiles/rails_8.1.gemfile.lock +++ b/gemfiles/rails_8.1.gemfile.lock @@ -75,6 +75,8 @@ GEM nokogiri (>= 1.12.0) minitest (6.0.1) prism (~> 1.5) + mysql2 (0.5.7) + bigdecimal nokogiri (1.19.1-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.1-aarch64-linux-musl) @@ -91,6 +93,13 @@ GEM racc (~> 1.4) nokogiri (1.19.1-x86_64-linux-musl) racc (~> 1.4) + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -182,6 +191,8 @@ DEPENDENCIES appraisal fixture_kit! irb + mysql2 + pg railties (~> 8.1.0) rake rspec-rails @@ -214,6 +225,7 @@ CHECKSUMS logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + mysql2 (0.5.7) sha256=ba09ede515a0ae8a7192040a1b778c0fb0f025fa5877e9be895cd325fa5e9d7b nokogiri (1.19.1-aarch64-linux-gnu) sha256=cfdb0eafd9a554a88f12ebcc688d2b9005f9fce42b00b970e3dc199587b27f32 nokogiri (1.19.1-aarch64-linux-musl) sha256=1e2150ab43c3b373aba76cd1190af7b9e92103564063e48c474f7600923620b5 nokogiri (1.19.1-arm-linux-gnu) sha256=0a39ed59abe3bf279fab9dd4c6db6fe8af01af0608f6e1f08b8ffa4e5d407fa3 @@ -222,6 +234,13 @@ CHECKSUMS nokogiri (1.19.1-x86_64-darwin) sha256=7093896778cc03efb74b85f915a775862730e887f2e58d6921e3fa3d981e68bf nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a nokogiri (1.19.1-x86_64-linux-musl) sha256=4267f38ad4fc7e52a2e7ee28ed494e8f9d8eb4f4b3320901d55981c7b995fc23 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 diff --git a/lib/fixture_kit/coders/active_record_coder.rb b/lib/fixture_kit/coders/active_record_coder.rb index d06e7f8..f1ee5d0 100644 --- a/lib/fixture_kit/coders/active_record_coder.rb +++ b/lib/fixture_kit/coders/active_record_coder.rb @@ -15,7 +15,10 @@ def generate(parent_data: nil, &block) model_name = name[NAME_PATTERN, :model_name] next unless model_name - captured_models.add(ActiveSupport::Inflector.constantize(model_name)) + klass = ActiveSupport::Inflector.safe_constantize(model_name) + next unless klass.is_a?(Class) && klass < ActiveRecord::Base + + captured_models.add(klass) end ActiveSupport::Notifications.subscribed(subscriber, EVENT, monotonic: true, &block) @@ -27,11 +30,25 @@ def generate(parent_data: nil, &block) end def mount(data) - statements_by_connection(data).each do |connection, statements| - connection.disable_referential_integrity do - # execute_batch is private in current supported Rails versions. - # This should be revisited when Rails 8.2 makes it public. - connection.__send__(:execute_batch, statements, "FixtureKit Load") + models_by_pool(data).each do |pool, models| + pool.with_connection do |connection| + statements = models.flat_map do |model| + [build_delete_sql(connection, model.table_name), data[model]].compact + end + + connection.disable_referential_integrity do + # execute_batch is private in current supported Rails versions. + # This should be revisited when Rails 8.2 makes it public. + connection.__send__(:execute_batch, statements, "FixtureKit Insert") + end + + verify_foreign_keys!(connection) + + # Replayed INSERTs use explicit PKs, which Postgres sequences do not + # observe. Re-sync the sequence so subsequent Model.create calls don't + # collide with an id we just inserted. No-op on adapters whose PK + # generators advance from explicit-id INSERTs (MySQL, SQLite). + reset_primary_key_sequences(connection, models.map(&:table_name)) end end end @@ -51,24 +68,30 @@ def base_table_model(model) def generate_statements(models) models.each_with_object({}) do |model, statements| - columns = model.column_names + columns = insertable_columns(model) + column_names = columns.map(&:name) rows = [] model.unscoped.order(:id).find_each do |record| - row_values = columns.map do |col| + row_values = column_names.map do |col| value = record.read_attribute_before_type_cast(col) model.connection.quote(value) end rows << "(#{row_values.join(", ")})" end - sql = rows.empty? ? nil : build_insert_sql(model.table_name, columns, rows, model.connection) + sql = rows.empty? ? nil : build_insert_sql(model.table_name, column_names, rows, model.connection) statements[model] = sql end end - def build_delete_sql(model) - "DELETE FROM #{model.quoted_table_name}" + def insertable_columns(model) + supports_virtual = model.connection.supports_virtual_columns? + model.columns.reject { |c| supports_virtual && c.virtual? } + end + + def build_delete_sql(connection, table_name) + "DELETE FROM #{connection.quote_table_name(table_name)}" end def build_insert_sql(table_name, columns, rows, connection) @@ -78,19 +101,38 @@ def build_insert_sql(table_name, columns, rows, connection) "INSERT INTO #{quoted_table} (#{quoted_columns.join(", ")}) VALUES #{rows.join(", ")}" end - def statements_by_connection(records) - deleted_tables = Set.new + def verify_foreign_keys!(connection) + return unless ActiveRecord.verify_foreign_keys_for_fixtures - records.each_with_object({}) do |(model, sql), grouped| - connection = model.connection - grouped[connection] ||= [] + 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 - table_key = [connection, model.table_name] - if deleted_tables.add?(table_key) - grouped[connection] << build_delete_sql(model) - end + def reset_primary_key_sequences(connection, tables) + # Rails main (>= 8.2) batches the reset in one round-trip per connection. + # Older versions fall back to one query per table. + if connection.respond_to?(:reset_column_sequences!) + connection.reset_column_sequences!(tables.map { |t| [t] }) + elsif connection.respond_to?(:reset_pk_sequence!) + tables.each { |t| connection.reset_pk_sequence!(t) } + end + end + + def models_by_pool(data) + seen = Set.new + + data.each_with_object({}) do |(model, _), grouped| + pool = model.connection_pool + next unless seen.add?([pool, model.table_name]) - grouped[connection] << sql if sql + grouped[pool] ||= [] + grouped[pool] << model end end end diff --git a/spec/dummy/Gemfile b/spec/dummy/Gemfile index 85a9cf8..3688cf9 100644 --- a/spec/dummy/Gemfile +++ b/spec/dummy/Gemfile @@ -3,3 +3,11 @@ source "https://rubygems.org" gemspec path: "../.." + +group :postgres do + gem "pg" +end + +group :mysql do + gem "mysql2" +end diff --git a/spec/dummy/Gemfile.lock b/spec/dummy/Gemfile.lock index 82b2f45..8206416 100644 --- a/spec/dummy/Gemfile.lock +++ b/spec/dummy/Gemfile.lock @@ -76,6 +76,8 @@ GEM minitest (6.0.2) drb (~> 2.0) prism (~> 1.5) + mysql2 (0.5.7) + bigdecimal nokogiri (1.19.2-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.19.2-aarch64-linux-musl) @@ -92,6 +94,13 @@ GEM racc (~> 1.4) nokogiri (1.19.2-x86_64-linux-musl) racc (~> 1.4) + pg (1.6.3) + pg (1.6.3-aarch64-linux) + pg (1.6.3-aarch64-linux-musl) + pg (1.6.3-arm64-darwin) + pg (1.6.3-x86_64-darwin) + pg (1.6.3-x86_64-linux) + pg (1.6.3-x86_64-linux-musl) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -181,6 +190,8 @@ DEPENDENCIES appraisal fixture_kit! irb + mysql2 + pg railties rake rspec-rails @@ -213,6 +224,7 @@ CHECKSUMS logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.1) sha256=d436c73dbd0c1147b16c4a41db097942d217303e1f7728704b37e4df9f6d2e04 minitest (6.0.2) sha256=db6e57956f6ecc6134683b4c87467d6dd792323c7f0eea7b93f66bd284adbc3d + mysql2 (0.5.7) sha256=ba09ede515a0ae8a7192040a1b778c0fb0f025fa5877e9be895cd325fa5e9d7b nokogiri (1.19.2-aarch64-linux-gnu) sha256=c34d5c8208025587554608e98fd88ab125b29c80f9352b821964e9a5d5cfbd19 nokogiri (1.19.2-aarch64-linux-musl) sha256=7f6b4b0202d507326841a4f790294bf75098aef50c7173443812e3ac5cb06515 nokogiri (1.19.2-arm-linux-gnu) sha256=b7fa1139016f3dc850bda1260988f0d749934a939d04ef2da13bec060d7d5081 @@ -221,6 +233,13 @@ CHECKSUMS nokogiri (1.19.2-x86_64-darwin) sha256=7d9af11fda72dfaa2961d8c4d5380ca0b51bc389dc5f8d4b859b9644f195e7a4 nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f nokogiri (1.19.2-x86_64-linux-musl) sha256=93128448e61a9383a30baef041bf1f5817e22f297a1d400521e90294445069a8 + pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 + pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea + pg (1.6.3-aarch64-linux-musl) sha256=06a75f4ea04b05140146f2a10550b8e0d9f006a79cdaf8b5b130cde40e3ecc2c + pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f + pg (1.6.3-x86_64-darwin) sha256=ee2e04a17c0627225054ffeb43e31a95be9d7e93abda2737ea3ce4a62f2729d6 + pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d + pg (1.6.3-x86_64-linux-musl) sha256=9c9c90d98c72f78eb04c0f55e9618fe55d1512128e411035fe229ff427864009 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 diff --git a/spec/dummy/app/models/computed_widget.rb b/spec/dummy/app/models/computed_widget.rb new file mode 100644 index 0000000..b09e76b --- /dev/null +++ b/spec/dummy/app/models/computed_widget.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +class ComputedWidget < ApplicationRecord +end diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml index 86e56f2..938774e 100644 --- a/spec/dummy/config/database.yml +++ b/spec/dummy/config/database.yml @@ -1,11 +1,61 @@ +<% +adapter = ENV.fetch("FIXTURE_KIT_DB", "sqlite3") +host = ENV.fetch("FIXTURE_KIT_DB_HOST") { adapter == "mysql2" ? "127.0.0.1" : "localhost" } +username = ENV.fetch("FIXTURE_KIT_DB_USERNAME") do + case adapter + when "postgresql" then ENV.fetch("USER", "postgres") + when "mysql2" then "root" + end +end +password = ENV["FIXTURE_KIT_DB_PASSWORD"] +%> + test: primary: + <% case adapter + when "postgresql" %> + adapter: postgresql + database: fixture_kit_test_primary + host: <%= host %> + username: <%= username %> + password: <%= password %> + encoding: unicode + pool: 5 + <% when "mysql2" %> + adapter: mysql2 + database: fixture_kit_test_primary + host: <%= host %> + username: <%= username %> + password: <%= password %> + encoding: utf8mb4 + pool: 5 + <% else %> adapter: sqlite3 database: tmp/primary_test.sqlite3 pool: 5 timeout: 5000 + <% end %> analytics: + <% case adapter + when "postgresql" %> + adapter: postgresql + database: fixture_kit_test_analytics + host: <%= host %> + username: <%= username %> + password: <%= password %> + encoding: unicode + pool: 5 + <% when "mysql2" %> + adapter: mysql2 + database: fixture_kit_test_analytics + host: <%= host %> + username: <%= username %> + password: <%= password %> + encoding: utf8mb4 + pool: 5 + <% else %> adapter: sqlite3 database: tmp/analytics_test.sqlite3 pool: 5 timeout: 5000 + <% end %> diff --git a/spec/integration/pk_sequence_repro_spec.rb b/spec/integration/pk_sequence_repro_spec.rb new file mode 100644 index 0000000..f57aa36 --- /dev/null +++ b/spec/integration/pk_sequence_repro_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Reproduces issue #51: when a cached fixture is mounted onto a database whose +# PK generator has not seen the inserted ids (e.g. a parallel test worker with +# its own DB copy), a subsequent Model.create can collide with one of the +# explicit ids the cache replayed. Postgres exhibits this; MySQL/SQLite advance +# their counters from explicit-id inserts and stay safe. +RSpec.describe "Primary key sequence after fixture mount" do + fixture do + User.create!(name: "Alice PK Repro", email: "alice-pk-repro@example.com") + User.create!(name: "Bob PK Repro", email: "bob-pk-repro@example.com") + end + + after do + User.connection.disable_referential_integrity do + User.connection.execute("DELETE FROM #{User.quoted_table_name}") + end + end + + it "lets a new record be created without colliding with replayed explicit ids" do + # Simulate a parallel-worker scenario: empty table, PK generator at its + # initial value. The fixture's auto-mount already populated the table for + # us, so wipe and reset before re-mounting. + wipe_and_reset_pk!(User) + + declaration = self.class.metadata[FixtureKit::RSpec::DECLARATION_METADATA_KEY] + declaration.mount + + expect { + User.create!(name: "Charlie PK Repro", email: "charlie-pk-repro@example.com") + }.not_to raise_error + end + + def wipe_and_reset_pk!(model) + connection = model.connection + connection.disable_referential_integrity do + connection.execute("DELETE FROM #{model.quoted_table_name}") + end + + case connection.adapter_name.to_s.downcase + when "postgresql" + sequence = connection.pk_and_sequence_for(model.table_name)&.last + connection.execute("ALTER SEQUENCE #{sequence} RESTART WITH 1") if sequence + when "mysql", "mysql2", "trilogy" + # MySQL advances AUTO_INCREMENT on explicit-id INSERTs, so the counter + # already keeps up with the cached ids. ALTER TABLE ... AUTO_INCREMENT + # implicitly commits, which would break the surrounding transactional + # fixture, so we leave it alone. The test should still pass on MySQL. + when "sqlite" + if connection.data_source_exists?("sqlite_sequence") + connection.execute("DELETE FROM sqlite_sequence WHERE name = #{connection.quote(model.table_name)}") + end + else + raise "Unsupported adapter for PK reset: #{connection.adapter_name.inspect}" + end + end +end diff --git a/spec/integration/virtual_columns_spec.rb b/spec/integration/virtual_columns_spec.rb new file mode 100644 index 0000000..a28666a --- /dev/null +++ b/spec/integration/virtual_columns_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Models with generated/virtual columns can't accept INSERTs into those +# columns. The coder must filter them out when building cached statements. +RSpec.describe "Fixture round-trip with virtual columns" do + fixture do + ComputedWidget.create!(name: "alpha", quantity: 1) + ComputedWidget.create!(name: "beta", quantity: 2) + end + + after { ComputedWidget.delete_all } + + it "loads records with their generated column values" do + widgets = ComputedWidget.order(:id).to_a + + expect(widgets.map(&:name)).to eq(["alpha", "beta"]) + expect(widgets.map(&:name_upper)).to eq(["ALPHA", "BETA"]) + end +end diff --git a/spec/support/dummy_rails_helper.rb b/spec/support/dummy_rails_helper.rb index 8c5f7c0..088d6d4 100644 --- a/spec/support/dummy_rails_helper.rb +++ b/spec/support/dummy_rails_helper.rb @@ -25,20 +25,68 @@ def clear_fixture_cache(fixture_name = nil) end end +class TestDatabaseBootstrap < ActiveRecord::Base + self.abstract_class = true +end + +# Ensure non-sqlite test databases exist before connecting (sqlite files auto-create). +# Uses a separate connection class so the main ActiveRecord::Base connection +# is established cleanly later from its own configuration. +def ensure_test_databases_exist! + return if ENV.fetch("FIXTURE_KIT_DB", "sqlite3") == "sqlite3" + + ActiveRecord::Base.configurations.configs_for(env_name: "test").each do |db_config| + create_database_if_missing(db_config) + end +end + +def create_database_if_missing(db_config) + config = db_config.configuration_hash.dup + database = config[:database] + + case config[:adapter] + when "postgresql" + config = config.merge(database: "postgres") + when "mysql2" + config = config.except(:database) + else + return + end + + bootstrap = TestDatabaseBootstrap + bootstrap.remove_connection if bootstrap.connected? + bootstrap.establish_connection(config) + quoted = bootstrap.connection.quote_column_name(database) + + case db_config.adapter + when "postgresql" + bootstrap.connection.execute("CREATE DATABASE #{quoted}") + when "mysql2" + bootstrap.connection.execute("CREATE DATABASE IF NOT EXISTS #{quoted}") + end +rescue ActiveRecord::StatementInvalid => e + raise unless e.message.include?("already exists") +ensure + TestDatabaseBootstrap.remove_connection if TestDatabaseBootstrap.connected? +end + # Create schema for both databases def setup_databases + ensure_test_databases_exist! + ActiveRecord::Base.connection.disable_referential_integrity do - ActiveRecord::Base.connection.drop_table(:comments, if_exists: true) - ActiveRecord::Base.connection.drop_table(:tasks, if_exists: true) - ActiveRecord::Base.connection.drop_table(:projects, if_exists: true) - ActiveRecord::Base.connection.drop_table(:users, if_exists: true) - ActiveRecord::Base.connection.drop_table(:vehicles, if_exists: true) - ActiveRecord::Base.connection.drop_table(:gadgets, if_exists: true) + ActiveRecord::Base.connection.drop_table(:comments, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:tasks, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:projects, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:users, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:vehicles, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:gadgets, if_exists: true, force: :cascade) + ActiveRecord::Base.connection.drop_table(:computed_widgets, if_exists: true, force: :cascade) end AnalyticsRecord.connection.disable_referential_integrity do - AnalyticsRecord.connection.drop_table(:activity_logs, if_exists: true) - AnalyticsRecord.connection.drop_table(:time_entries, if_exists: true) + AnalyticsRecord.connection.drop_table(:activity_logs, if_exists: true, force: :cascade) + AnalyticsRecord.connection.drop_table(:time_entries, if_exists: true, force: :cascade) end # Primary database schema @@ -88,6 +136,13 @@ def setup_databases t.timestamps end + ActiveRecord::Base.connection.create_table :computed_widgets, force: true do |t| + t.string :name, null: false + t.integer :quantity, null: false, default: 0 + t.virtual :name_upper, type: :string, as: "UPPER(name)", stored: true + t.timestamps + end + # Analytics database schema AnalyticsRecord.connection.create_table :activity_logs, force: true do |t| t.integer :external_user_id, null: false diff --git a/spec/unit/coders/active_record_coder_spec.rb b/spec/unit/coders/active_record_coder_spec.rb index 78099b2..5dc75a3 100644 --- a/spec/unit/coders/active_record_coder_spec.rb +++ b/spec/unit/coders/active_record_coder_spec.rb @@ -20,14 +20,16 @@ def exercise_user_write_operations(suffix) { name: "Bulk Insert 2 #{suffix}", email: "bulk-insert-2-#{suffix}@example.com" } ]) + upsert_options = upsert_all_options(User) + User.upsert_all([ { id: user.id, name: "Upsert #{suffix}", email: "create-#{suffix}@example.com" } - ], unique_by: :id) + ], **upsert_options) User.upsert_all([ { id: user.id, name: "Bulk Upsert Existing #{suffix}", email: "create-#{suffix}@example.com" }, { id: user.id + 10_000_000, name: "Bulk Upsert New #{suffix}", email: "bulk-upsert-#{suffix}@example.com" } - ], unique_by: :id) + ], **upsert_options) doomed = User.create!(name: "Delete #{suffix}", email: "delete-#{suffix}@example.com") User.where(id: doomed.id).delete_all @@ -36,6 +38,13 @@ def exercise_user_write_operations(suffix) destroyed.destroy! end + # MySQL's upsert uses ON DUPLICATE KEY UPDATE and rejects :unique_by; + # Postgres requires it to target the conflict. + def upsert_all_options(model) + return {} if model.connection.adapter_name.to_s.downcase.start_with?("mysql", "trilogy") + { unique_by: :id } + end + describe "#generate" do it "captures user model writes for all supported write operation types" do suffix = SecureRandom.hex(6) @@ -140,11 +149,121 @@ def exercise_user_write_operations(suffix) ] expect(primary_connection).to receive(:disable_referential_integrity).once.and_yield - expect(primary_connection).to receive(:execute_batch).with(primary_statements, "FixtureKit Load").once + expect(primary_connection).to receive(:execute_batch).with(primary_statements, "FixtureKit Insert").once expect(analytics_connection).to receive(:disable_referential_integrity).once.and_yield - expect(analytics_connection).to receive(:execute_batch).with(analytics_statements, "FixtureKit Load").once + expect(analytics_connection).to receive(:execute_batch).with(analytics_statements, "FixtureKit Insert").once + + coder.mount(records) + end + + it "uses the batched reset_column_sequences! when the adapter exposes it" do + records = { + User => "INSERT INTO users (id, name) VALUES (1, 'Alice')", + Project => "INSERT INTO projects (id, name, owner_id) VALUES (1, 'Website', 1)" + } + + fake_connection = stub_fake_connection + allow(fake_connection).to receive(:respond_to?).with(:reset_column_sequences!).and_return(true) + allow(fake_connection).to receive(:reset_column_sequences!) + stub_shared_pool([User, Project], fake_connection) + + coder.mount(records) + + expect(fake_connection).to have_received(:reset_column_sequences!) + .with([[User.table_name], [Project.table_name]]).once + end + + it "falls back to per-table reset_pk_sequence! when reset_column_sequences! is unavailable" do + records = { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" } + + fake_connection = stub_fake_connection + allow(fake_connection).to receive(:respond_to?).with(:reset_pk_sequence!).and_return(true) + allow(fake_connection).to receive(:reset_pk_sequence!) + stub_pool(User, fake_connection) coder.mount(records) + + expect(fake_connection).to have_received(:reset_pk_sequence!).with(User.table_name).once + end + + it "skips PK sequence reset on adapters that expose neither method" do + records = { User => "INSERT INTO users (id, name) VALUES (1, 'Alice')" } + + fake_connection = stub_fake_connection + stub_pool(User, fake_connection) + + expect { coder.mount(records) }.not_to raise_error + 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(:respond_to?).with(:reset_column_sequences!).and_return(false) + allow(fake_connection).to receive(:respond_to?).with(:reset_pk_sequence!).and_return(false) + allow(fake_connection).to receive(:check_all_foreign_keys_valid!) + fake_connection + end + + def stub_pool(model, connection) + stub_shared_pool([model], connection) + end + + def stub_shared_pool(models, connection) + pool = double("pool-for-#{models.map(&:name).join('-')}") + allow(pool).to receive(:with_connection).and_yield(connection) + models.each { |m| allow(m).to receive(:connection_pool).and_return(pool) } end end diff --git a/spec/unit/fixture_cache_spec.rb b/spec/unit/fixture_cache_spec.rb index 4f7d8f5..8d4a1bf 100644 --- a/spec/unit/fixture_cache_spec.rb +++ b/spec/unit/fixture_cache_spec.rb @@ -356,9 +356,9 @@ def identifier_for(identifier) ] expect(primary_connection).to receive(:disable_referential_integrity).once.and_yield - expect(primary_connection).to receive(:execute_batch).with(primary_statements, "FixtureKit Load").once + expect(primary_connection).to receive(:execute_batch).with(primary_statements, "FixtureKit Insert").once expect(analytics_connection).to receive(:disable_referential_integrity).once.and_yield - expect(analytics_connection).to receive(:execute_batch).with(analytics_statements, "FixtureKit Load").once + expect(analytics_connection).to receive(:execute_batch).with(analytics_statements, "FixtureKit Insert").once expect(cache.load).to eq(:repository) end