diff --git a/CHANGELOG.md b/CHANGELOG.md index f25dd1a..99721d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,13 @@ ### Fixed +- Discover loaded Active Record models by their configured table names and write + Rails-native `_fixture.model_class` metadata for model-backed fixture files, + including namespaced models and STI roots. Raw SQL fallback and ineligible-model + files omit that metadata; unresolved metadata can fall through to Rails' + conventional inference, while unrelated loaded models for one table still raise + deterministically + ([#109](https://github.com/rdy/fixture_builder/issues/109)). - Omit database-generated columns from generated fixtures so Rails can load snapshots from tables that have them ([#100](https://github.com/rdy/fixture_builder/issues/100)). diff --git a/README.md b/README.md index e5f9d2e..e593ef9 100644 --- a/README.md +++ b/README.md @@ -107,17 +107,23 @@ When the block finishes, it dumps the state of the database into fixtures, like ```yaml # users.yml +_fixture: + model_class: User david: created_at: 2010-09-18 17:21:23.926511 Z unique_name: david id: 1 # products.yml +_fixture: + model_class: Product i_pod: name: iPod id: 1 # purchases.yml +_fixture: + model_class: Purchase purchase_001: product_id: 1 user_id: 1 @@ -155,12 +161,14 @@ There are also additional configuration options that can be changed to override By default these are set as: -* files_to_check: %w{ db/schema.rb } -* fixture_builder_file: Rails.root.join("tmp/fixture_builder.yml") -* record_name_fields: %w{ unique_name display_name name title username login } -* skip_tables: %w{ schema_migrations ar_internal_metadata } -* select_sql: SELECT * FROM %s -* delete_sql: DELETE FROM %
s +```ruby +files_to_check: %w{ db/schema.rb } +fixture_builder_file: Rails.root.join("tmp/fixture_builder.yml") +record_name_fields: %w{ unique_name display_name name title username login } +skip_tables: %w{ schema_migrations ar_internal_metadata } +select_sql: "SELECT * FROM %
s" +delete_sql: "DELETE FROM %
s" +``` FixtureBuilder omits database-generated columns from snapshots because Rails fixtures cannot write them. @@ -193,6 +201,46 @@ others wait and reuse the completed result. A failed build leaves no valid manif so a waiter or later run retries. Only the manifest is replaced atomically after successful fixture generation; the fixture set itself is not published atomically. +### Model-aware fixture files + +FixtureBuilder discovers a loaded Active Record model for each exported table, +including models whose configured table name differs from the conventional name. +A model is eligible when it is concrete, named, uses the exported table and the +same connection pool, and has usable primary keys. Independent eligible models +that share a table raise `AmbiguousModelError`. It writes Rails-native metadata +for every model-backed file, so Rails can select the model without a separate +fixture-class mapping. This also supports conventionally named models, +namespaced models, and the root model for an STI table: + +```yaml +_fixture: + model_class: Catalog::Creature +forest_dweller: + name: Forest dweller +``` + +`_fixture` and `model_class` are String YAML keys, and the value is the model's +String name. Rails treats `_fixture` as metadata rather than a record. A model +name still resolves normally when Rails loads the fixture; FixtureBuilder does +not eager-load the application to discover models. + +Rails honors an explicit `set_fixture_class` or `class_names` mapping before +file metadata. If the metadata's class name cannot be resolved, Rails can fall +through to its conventional fixture-name inference. Files for raw SQL fallback +tables and tables with ineligible models omit the metadata; that omission does +not disable Rails' conventional inference, so a raw file whose basename matches +an unrelated model can still be loaded as that model. + +Model-aware loading uses Rails' native fixture transformations. For example, +Rails can fill timestamps and primary keys, convert enums, honor STI and +associations, select the model's connection pool, and interpolate `$LABEL` in +string values. This is not a byte-for-byte promise for every special fixture +value. + +Tools that consume generated YAML as plain records must exclude the `_fixture` +metadata row. FixtureBuilder reserves `_fixture` as a record label and rejects +it during generation. + Sequence Collisions =================== @@ -241,13 +289,14 @@ Tips 1. You don't use any namespaced models 1. You keep your factory names consistent and exactly matching your model names * Modify `bin/setup` to run fixture builder and load your dev database: - ```ruby - puts "\n== Building fixtures ==" - system! 'bin/rails spec:fixture_builder:rebuild' - - puts "\n== Loading fixtures into dev database ==" - system! 'bin/rails db:fixtures:load' - ``` + + ```ruby + puts "\n== Building fixtures ==" + system! 'bin/rails spec:fixture_builder:rebuild' + + puts "\n== Loading fixtures into dev database ==" + system! 'bin/rails db:fixtures:load' + ``` More Complete Config Example ============================ diff --git a/lib/fixture_builder.rb b/lib/fixture_builder.rb index 474eb66..74c6d11 100644 --- a/lib/fixture_builder.rb +++ b/lib/fixture_builder.rb @@ -3,6 +3,7 @@ require "fixture_builder/delegations" require "fixture_builder/configuration" require "fixture_builder/namer" +require "fixture_builder/ambiguous_model_error" require "fixture_builder/builder" require "fixture_builder/fixtures_path" diff --git a/lib/fixture_builder/ambiguous_model_error.rb b/lib/fixture_builder/ambiguous_model_error.rb new file mode 100644 index 0000000..0dd55dc --- /dev/null +++ b/lib/fixture_builder/ambiguous_model_error.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module FixtureBuilder + class AmbiguousModelError < StandardError + attr_reader :table_name, :models + + def initialize(table_name, models) + @table_name = table_name + @models = models.sort_by(&:name) + super("Multiple models match table #{table_name}: #{@models.map(&:name).join(", ")}") + end + end +end diff --git a/lib/fixture_builder/builder.rb b/lib/fixture_builder/builder.rb index 63ea951..b928d12 100644 --- a/lib/fixture_builder/builder.rb +++ b/lib/fixture_builder/builder.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "fixture_builder/model_resolver" + module FixtureBuilder class Builder include Delegations::Namer @@ -16,6 +18,7 @@ def generate! clean_out_old_data create_fixture_objects names_from_ivars! + @models_by_table = resolve_models_by_table write_data_to_files after_build&.call end @@ -58,13 +61,11 @@ def names_from_ivars! def write_data_to_files delete_yml_files - dump_empty_fixtures_for_all_tables if write_empty_files dump_tables end def clean_out_old_data delete_tables - delete_yml_files end def delete_tables @@ -86,23 +87,21 @@ def say(*messages) end # standard:enable Rails/Output - def dump_empty_fixtures_for_all_tables - tables.each do |table_name| - write_fixture_file({}, table_name) - end + def write_fixture_file(fixture_data, table_name) + File.write(fixture_file(table_name), fixture_data.to_yaml) + end + + def fixture_file(table_name) + fixtures_dir("#{table_name}.yml") end def dump_tables fixtures = tables.inject([]) do |files, table_name| - table_klass = begin - table_name.classify.constantize - rescue - nil - end - rows = if table_klass && table_klass < ActiveRecord::Base - generated_names = generated_column_names(table_klass.table_name) + table_klass = @models_by_table.fetch(table_name) + generated_names = generated_column_names(table_name) + rows = if table_klass table_klass.unscoped do - table_klass.order(:id).all.collect do |obj| + table_klass.order(Array(table_klass.primary_key)).all.collect do |obj| attrs = obj.attributes_before_type_cast.slice(*table_klass.column_names) attrs.each do |attr_name, value| column_type = table_klass.columns_hash.fetch(attr_name).type @@ -114,16 +113,21 @@ def dump_tables end end else - generated_names = generated_column_names(table_name) ActiveRecord::Base.connection.select_all(format(select_sql, table: ActiveRecord::Base.connection.quote_table_name(table_name))) .map { |row| row.except(*generated_names) } end - next files if rows.empty? + next files if rows.empty? && !write_empty_files fixture_data = rows.inject({}) do |hash, record| - hash.merge(record_name(record, table_name) => record) + label = record_name(record, table_name) + if label == "_fixture" + raise ArgumentError, "Fixture table #{table_name} contains reserved record label _fixture" + end + + hash.merge(label => record) end + fixture_data = {"_fixture" => {"model_class" => table_klass.name}}.merge(fixture_data) if table_klass write_fixture_file fixture_data, table_name @@ -132,24 +136,25 @@ def dump_tables say "Built #{fixtures.to_sentence}" end + private + + def resolve_models_by_table + resolver = ModelResolver.new(connection_pool: ActiveRecord::Base.connection_pool) + tables.each_with_object({}) do |table_name, models_by_table| + models_by_table[table_name] = resolver.resolve(table_name) + end + end + # A database-generated (virtual/stored generated) column cannot be # inserted, so Rails rejects a fixture file containing it. Only those # column names are removed from the extracted rows; everything else a row # carries - including an expression a custom `select_sql` selects - is # left as it was produced. - private def generated_column_names(table_name) + def generated_column_names(table_name) connection = ActiveRecord::Base.connection return [] unless connection.supports_virtual_columns? connection.columns(table_name).select(&:virtual?).map(&:name) end - - def write_fixture_file(fixture_data, table_name) - File.write(fixture_file(table_name), fixture_data.to_yaml) - end - - def fixture_file(table_name) - fixtures_dir("#{table_name}.yml") - end end end diff --git a/lib/fixture_builder/model_resolver.rb b/lib/fixture_builder/model_resolver.rb new file mode 100644 index 0000000..1c2cfd4 --- /dev/null +++ b/lib/fixture_builder/model_resolver.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module FixtureBuilder + class ModelResolver + def initialize(connection_pool:) + @connection_pool = connection_pool + end + + def resolve(table_name) + table_name.classify.safe_constantize + candidates = ActiveRecord::Base.descendants.select do |model| + eligible_model?(model, table_name) + end + root_models = candidates.reject do |model| + candidates.any? { |candidate| candidate != model && model < candidate } + end + + return if root_models.empty? + return root_models.first if root_models.one? + + raise AmbiguousModelError.new(table_name, root_models) + end + + private + + def eligible_model?(model, table_name) + return false if model.abstract_class? + + model_name = model.name + return false unless model_name && model_name.safe_constantize.equal?(model) + return false unless model.table_name == table_name + return false unless model.connection_pool.equal?(@connection_pool) + + primary_keys = Array(model.primary_key).compact + primary_keys.any? && primary_keys.all? { |key| model.columns_hash.key?(key) } + end + end +end diff --git a/test/fixture_builder_test.rb b/test/fixture_builder_test.rb index 957f02e..ed4ba84 100644 --- a/test/fixture_builder_test.rb +++ b/test/fixture_builder_test.rb @@ -37,7 +37,8 @@ def test_ivar_naming end end generated_fixture = YAML.load(File.open(test_path("fixtures/magical_creatures.yml"))) - assert_equal "king_of_gnomes", generated_fixture.keys.first + assert_equal({"model_class" => MagicalCreature.name}, generated_fixture.fetch("_fixture")) + assert_equal "king_of_gnomes", generated_fixture.except("_fixture").keys.first end def test_serialization @@ -188,21 +189,32 @@ def test_generated_columns_come_from_the_model_table_name create_and_blow_away_old_db force_fixture_generation - table_name = RELOCATED_CREATURES_TABLE + table_names = [CREATURE_ARCHIVE_TABLE, RELOCATED_CREATURES_TABLE] + wizard_data = WizardData.new(level: 99, title: "Lady of the Lake", allies: ["Arthur"]) FixtureBuilder.configure do |fbuilder| fbuilder.files_to_check = [] - fbuilder.skip_tables = ActiveRecord::Base.connection.tables - [table_name] - fbuilder.factory { RelocatedCreature.create!(name: "Nimue") } + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - table_names + fbuilder.factory do + RelocatedCreature.create!(name: "Nimue", wizard_data: wizard_data) + ActiveRecord::Base.connection.execute( + "INSERT INTO #{RELOCATED_CREATURES_TABLE} (unrelated) VALUES ('Morgana')" + ) + end end - generated_fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) - # `name` is a plain column on the model's own table, so it must survive even - # though the iterated table of the same inferred name generates it. - assert_include generated_fixture, "nimue" - record = generated_fixture.fetch("nimue") - assert_include record, "name" - assert_equal "Nimue", record["name"] - assert_not_include record, "unrelated" + archive_fixture = YAML.safe_load_file(test_path("fixtures/#{CREATURE_ARCHIVE_TABLE}.yml")) + assert_equal( + {"level" => 99, "title" => "Lady of the Lake", "allies" => ["Arthur"]}, + archive_fixture.dig("nimue", "wizard_data") + ) + + # `RelocatedCreature` maps to `creature_archive`, not the conventionally + # inferred `relocated_creatures` table. The latter remains on the raw SQL + # path, where its database-generated `name` is excluded. + relocated_fixture = YAML.safe_load_file(test_path("fixtures/#{RELOCATED_CREATURES_TABLE}.yml")) + record = relocated_fixture.fetch("relocated_creatures_001") + assert_equal "Morgana", record["unrelated"] + assert_not_include record, "name" end def test_custom_json_attribute_type_round_trips_through_fixtures @@ -253,6 +265,15 @@ def test_deprecator_has_fixture_builder_metadata assert_equal "FixtureBuilder", FixtureBuilder.deprecator.gem_name end + def test_ambiguous_model_error_exposes_its_table_name_and_models + models = [MagicalCreature, GeneratedCreature] + error = FixtureBuilder::AmbiguousModelError.new("creatures", models) + + assert_equal "creatures", error.table_name + assert_equal [GeneratedCreature, MagicalCreature], error.models + assert_equal "Multiple models match table creatures: GeneratedCreature, MagicalCreature", error.message + end + def test_sql_setters_reject_positional_table_format_without_warning {select_sql: "SELECT * FROM %s", delete_sql: "DELETE FROM %s"}.each do |attribute, sql| configuration = FixtureBuilder::Configuration.new diff --git a/test/legacy_fixture_mode_fixture_generation_test.rb b/test/legacy_fixture_mode_fixture_generation_test.rb index 029193e..7712e82 100644 --- a/test/legacy_fixture_mode_fixture_generation_test.rb +++ b/test/legacy_fixture_mode_fixture_generation_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require File.expand_path(File.join(File.dirname(__FILE__), "test_helper")) +require "tmpdir" class LegacyFixtureModeFixtureGenerationTest < Test::Unit::TestCase include TestDatabase @@ -10,7 +11,7 @@ def setup force_fixture_generation FixtureBuilder.configure do |fbuilder| - fbuilder.legacy_fixtures = Dir[test_path("legacy_fixtures/*.yml"), test_path("other_legacy_fixture_set/*.yml")] + fbuilder.legacy_fixtures = Dir[test_path("legacy_fixtures/magical_creatures.yml"), test_path("other_legacy_fixture_set/*.yml")] fbuilder.factory do MagicalCreature.create(name: "frank", species: "unicorn") MagicalCreature.create(name: "loch ness monster", species: "sea creature", deleted: true) @@ -49,4 +50,53 @@ def test_legacy_fixtures_retain_fixture_name assert_equal "alice", @@magical_creatures["alice_the_mermaid"]["name"] assert_equal "mermaid", @@magical_creatures["alice_the_mermaid"]["species"] end + + def test_metadata_free_conventional_legacy_fixture_is_regenerated_with_native_metadata + table_name = "conventional_legacy_creatures" + model_name = "ConventionalLegacyCreature" + connection = ActiveRecord::Base.connection + # standard:disable Rails/ApplicationRecord + model = Object.const_set(model_name, Class.new(ActiveRecord::Base)) + # standard:enable Rails/ApplicationRecord + + connection.create_table(table_name, force: true) { |table| table.string :name, null: false } + + input_path = test_path("legacy_fixtures/#{table_name}.yml") + + Dir.mktmpdir("fixture-builder-legacy-convention") do |directory| + output_directory = File.join(directory, "output") + manifest_path = File.join(directory, "manifest.yml") + FileUtils.mkdir_p(output_directory) + + input_fixture = YAML.safe_load_file(input_path) + assert_not_include input_fixture, "_fixture" + + factory_records = nil + FixtureBuilder.instance_variable_set(:@configuration, nil) + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.fixture_directory = output_directory + fbuilder.fixture_builder_file = manifest_path + fbuilder.legacy_fixtures = [input_path] + fbuilder.skip_tables = connection.tables - [table_name] + fbuilder.factory { factory_records = model.order(:name).pluck(:name) } + end + + assert_equal ["Legacy creature"], factory_records + + output_path = File.join(output_directory, "#{table_name}.yml") + output_fixture = YAML.safe_load_file(output_path) + assert_equal({"model_class" => model_name}, output_fixture.fetch("_fixture")) + assert_equal "Legacy creature", output_fixture.fetch("legacy_creature").fetch("name") + + model.delete_all + ActiveRecord::FixtureSet.reset_cache + ActiveRecord::FixtureSet.create_fixtures(output_directory, table_name) + assert_equal ["Legacy creature"], model.order(:name).pluck(:name) + end + ensure + FixtureBuilder.instance_variable_set(:@configuration, nil) + connection.drop_table(table_name) if connection&.data_source_exists?(table_name) + Object.send(:remove_const, model_name) if Object.const_defined?(model_name, false) + end end diff --git a/test/legacy_fixture_mode_test.rb b/test/legacy_fixture_mode_test.rb index 543e5a3..712a314 100644 --- a/test/legacy_fixture_mode_test.rb +++ b/test/legacy_fixture_mode_test.rb @@ -16,7 +16,7 @@ def teardown def test_load_legacy_fixtures FixtureBuilder.configure do |fbuilder| - fbuilder.legacy_fixtures = Dir[test_path("legacy_fixtures/*.yml"), test_path("other_legacy_fixture_set/*.yml")] + fbuilder.legacy_fixtures = Dir[test_path("legacy_fixtures/magical_creatures.yml"), test_path("other_legacy_fixture_set/*.yml")] fbuilder.factory do end end @@ -35,7 +35,7 @@ def test_generate_new_fixtures_without_legacy def test_new_and_old_fixtures FixtureBuilder.configure do |fbuilder| - fbuilder.legacy_fixtures = Dir[test_path("legacy_fixtures/*.yml"), test_path("other_legacy_fixture_set/*.yml")] + fbuilder.legacy_fixtures = Dir[test_path("legacy_fixtures/magical_creatures.yml"), test_path("other_legacy_fixture_set/*.yml")] fbuilder.factory do MagicalCreature.create name: "Barry", species: "Party Guy" end diff --git a/test/legacy_fixtures/conventional_legacy_creatures.yml b/test/legacy_fixtures/conventional_legacy_creatures.yml new file mode 100644 index 0000000..11bafde --- /dev/null +++ b/test/legacy_fixtures/conventional_legacy_creatures.yml @@ -0,0 +1,2 @@ +legacy_creature: + name: Legacy creature diff --git a/test/legacy_fixtures/magical_creatures.yml b/test/legacy_fixtures/magical_creatures.yml index d981c56..1b6f97e 100644 --- a/test/legacy_fixtures/magical_creatures.yml +++ b/test/legacy_fixtures/magical_creatures.yml @@ -1,3 +1,6 @@ +_fixture: + model_class: MagicalCreature + bigfoot: name: bigfoot diff --git a/test/model_metadata_test.rb b/test/model_metadata_test.rb new file mode 100644 index 0000000..ad76a0c --- /dev/null +++ b/test/model_metadata_test.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class ModelMetadataTest < Test::Unit::TestCase + with_model :ModelMetadataCreature do + table do |table| + table.string :name, null: false + end + end + + with_model :ModelMetadataStiBase do + table do |table| + table.string :name, null: false + table.string :type + end + end + + with_model :ModelMetadataStiSibling, superclass: :ModelMetadataStiBase do + table(false) + end + + def teardown + FixtureBuilder.instance_variable_set(:@configuration, nil) + [ModelMetadataCreature, ModelMetadataStiBase].each do |model| + FileUtils.rm_f(test_path("fixtures/#{model.table_name}.yml")) + end + end + + def test_model_backed_files_describe_their_model_and_load_without_a_class_map + table_name = ModelMetadataCreature.table_name + assert_equal "ModelMetadataCreature", ModelMetadataCreature.name + assert_not_equal ModelMetadataCreature.name, table_name.classify + + generate_for(table_name) { ModelMetadataCreature.create!(name: "$LABEL") } + + fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) + assert_equal({"model_class" => ModelMetadataCreature.name}, fixture.fetch("_fixture")) + records = fixture.except("_fixture") + assert_equal "$LABEL", records.values.first.fetch("name") + + ModelMetadataCreature.delete_all + create_fixtures(table_name) + assert_equal records.keys.first, ModelMetadataCreature.find_by!(name: records.keys.first).name + assert_nil ModelMetadataCreature.find_by(name: "_fixture") + end + + def test_namespaced_model_round_trips_through_generated_native_metadata + namespace_name = :ModelMetadataNamespace + table_name = "model_metadata_namespaced_creatures" + connection = ActiveRecord::Base.connection + namespace = Object.const_set(namespace_name, Module.new) + # standard:disable Rails/ApplicationRecord + model = namespace.const_set(:Creature, Class.new(ActiveRecord::Base)) + # standard:enable Rails/ApplicationRecord + model.table_name = table_name + connection.create_table(table_name, force: true) { |table| table.string :name, null: false } + + generate_for(table_name) { model.create!(name: "Namespaced creature") } + + fixture_path = test_path("fixtures/#{table_name}.yml") + fixture = YAML.safe_load_file(fixture_path) + assert_equal "ModelMetadataNamespace::Creature", model.name + assert_equal table_name, model.table_name + assert_not_include "/", table_name + assert File.exist?(fixture_path) + assert_equal({"model_class" => model.name}, fixture.fetch("_fixture")) + + model.delete_all + create_fixtures(table_name) + assert_equal "Namespaced creature", model.find_by!(name: "Namespaced creature").name + assert_nil model.find_by(name: "_fixture") + ensure + connection.drop_table(table_name) if connection&.data_source_exists?(table_name) + FileUtils.rm_f(test_path("fixtures/#{table_name}.yml")) + Object.send(:remove_const, namespace_name) if Object.const_defined?(namespace_name, false) + end + + def test_empty_model_and_raw_tables_follow_write_empty_files + model_table = ModelMetadataCreature.table_name + raw_table = "model_metadata_raw_tables" + ActiveRecord::Base.connection.create_table(raw_table, force: true) { |table| table.string :name } + + [true, false].each do |write_empty_files| + generate_for(model_table, raw_table) do |fbuilder| + fbuilder.write_empty_files = write_empty_files + end + + if write_empty_files + assert_equal({"_fixture" => {"model_class" => ModelMetadataCreature.name}}, + YAML.safe_load_file(test_path("fixtures/#{model_table}.yml"))) + assert_equal({}, YAML.safe_load_file(test_path("fixtures/#{raw_table}.yml"))) + else + assert_false File.exist?(test_path("fixtures/#{model_table}.yml")) + assert_false File.exist?(test_path("fixtures/#{raw_table}.yml")) + end + end + ensure + ActiveRecord::Base.connection.drop_table(raw_table) if raw_table && ActiveRecord::Base.connection.data_source_exists?(raw_table) + FileUtils.rm_f(test_path("fixtures/#{raw_table}.yml")) if raw_table + end + + def test_reserved_fixture_label_raises_for_model_and_raw_tables + model_table = ModelMetadataCreature.table_name + error = assert_raise(ArgumentError) do + generate_for(model_table) { ModelMetadataCreature.create!(name: "_fixture") } + end + assert_match(/#{model_table}.*_fixture/, error.message) + + raw_table = "model_metadata_raw_tables" + ActiveRecord::Base.connection.create_table(raw_table, force: true) { |table| table.string :name } + error = assert_raise(ArgumentError) do + generate_for(raw_table) do + ActiveRecord::Base.connection.execute("INSERT INTO #{raw_table} (name) VALUES ('_fixture')") + end + end + assert_match(/#{raw_table}.*_fixture/, error.message) + ensure + ActiveRecord::Base.connection.drop_table(raw_table) if raw_table && ActiveRecord::Base.connection.data_source_exists?(raw_table) + FileUtils.rm_f(test_path("fixtures/#{raw_table}.yml")) if raw_table + end + + def test_sti_files_describe_the_root_model_and_load_sibling_records + table_name = ModelMetadataStiBase.table_name + generate_for(table_name) do + ModelMetadataStiBase.create!(name: "Base") + ModelMetadataStiSibling.create!(name: "Sibling") + end + + fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) + assert_equal ModelMetadataStiBase.name, fixture.dig("_fixture", "model_class") + + ModelMetadataStiBase.delete_all + create_fixtures(table_name) + assert_equal [ModelMetadataStiBase.name, ModelMetadataStiSibling.name], + ModelMetadataStiBase.order(:name).map { |record| record.class.name } + end + + private + + def generate_for(*table_names, &factory) + force_fixture_generation + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - table_names + if factory.arity == 1 + factory.call(fbuilder) + fbuilder.factory {} + else + fbuilder.factory(&factory) + end + end + end +end diff --git a/test/resolve_configured_table_name_test.rb b/test/resolve_configured_table_name_test.rb new file mode 100644 index 0000000..3736e39 --- /dev/null +++ b/test/resolve_configured_table_name_test.rb @@ -0,0 +1,396 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +# Regression tests for model resolution by configured table name (#109). +class ResolveConfiguredTableNameErrorTest < Test::Unit::TestCase + def test_ambiguous_model_error_exposes_its_table_name_and_models + models = [MagicalCreature, GeneratedCreature] + error = FixtureBuilder::AmbiguousModelError.new("creatures", models) + + assert_equal "creatures", error.table_name + assert_equal [GeneratedCreature, MagicalCreature], error.models + assert_equal( + "Multiple models match table creatures: GeneratedCreature, MagicalCreature", + error.message + ) + end +end + +# standard:disable Rails/ApplicationRecord +class ResolveConfiguredTableNameSerializationTest < Test::Unit::TestCase + with_model :FixtureBuilderScopedRelocatedCreature do + table do |table| + table.string :name, null: false + table.json :wizard_data + end + + model do + attribute :wizard_data, WizardDataType.new + end + end + + with_model :FixtureBuilderScopedRawCreature do + table(id: false) do |table| + table.string :unrelated + table.virtual :name, type: :string, as: "upper(unrelated)", stored: true + end + end + + def test_configured_table_name_uses_model_backed_custom_serialization + archive_table = FixtureBuilderScopedRelocatedCreature.table_name + raw_table = FixtureBuilderScopedRawCreature.table_name + assert_equal FixtureBuilderScopedRelocatedCreature, resolve_model(archive_table) + assert_nil resolve_model(raw_table) + + force_fixture_generation + build_fixtures_for(archive_table, raw_table) do + FixtureBuilderScopedRelocatedCreature.create!( + name: "Nimue", + wizard_data: WizardData.new(level: 99, title: "Lady of the Lake", allies: ["Arthur"]) + ) + ActiveRecord::Base.connection.execute( + "INSERT INTO #{raw_table} (unrelated) VALUES ('Morgana')" + ) + end + + archive_fixture = YAML.safe_load_file(test_path("fixtures/#{archive_table}.yml")) + assert_equal( + {"level" => 99, "title" => "Lady of the Lake", "allies" => ["Arthur"]}, + archive_fixture.dig("nimue", "wizard_data") + ) + + relocated_fixture = YAML.safe_load_file(test_path("fixtures/#{raw_table}.yml")) + assert_not_include relocated_fixture, "_fixture" + record = relocated_fixture.fetch("#{raw_table}_001") + assert_equal "Morgana", record["unrelated"] + assert_not_include record, "name" + end + + def teardown + FileUtils.rm_f(test_path("fixtures/#{FixtureBuilderScopedRelocatedCreature.table_name}.yml")) + FileUtils.rm_f(test_path("fixtures/#{FixtureBuilderScopedRawCreature.table_name}.yml")) + end + + private + + def build_fixtures_for(*table_names, &factory) + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - table_names + fbuilder.factory(&factory) + end + end + + def resolve_model(table_name) + FixtureBuilder::ModelResolver.new(connection_pool: ActiveRecord::Base.connection_pool).resolve(table_name) + end +end + +module ResolveConfiguredTableNameAmbiguityBehavior + def test_unrelated_models_raise_before_replacing_the_fixture + table_name = FixtureBuilderAmbiguousAlpha.table_name + FixtureBuilderAmbiguousZulu.table_name = table_name + FixtureBuilderAmbiguousZulu.reset_column_information + fixture_path = test_path("fixtures/#{table_name}.yml") + original_fixture = "existing fixture bytes\n" + File.binwrite(fixture_path, original_fixture) + force_fixture_generation + + error = assert_raise(FixtureBuilder::AmbiguousModelError) do + build_fixtures_for(table_name) do + ActiveRecord::Base.connection.execute("INSERT INTO #{table_name} (name) VALUES ('Merlin')") + end + end + + assert_equal table_name, error.table_name + assert_equal %w[FixtureBuilderAmbiguousAlpha FixtureBuilderAmbiguousZulu], error.models.map(&:name) + assert_equal( + "Multiple models match table #{table_name}: FixtureBuilderAmbiguousAlpha, FixtureBuilderAmbiguousZulu", + error.message + ) + assert_equal original_fixture, File.binread(fixture_path) + end + + def teardown + FileUtils.rm_f(test_path("fixtures/#{FixtureBuilderAmbiguousAlpha.table_name}.yml")) + end + + private + + def build_fixtures_for(*table_names, &factory) + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - table_names + fbuilder.factory(&factory) + end + end +end + +class ResolveConfiguredTableNameAmbiguityAlphaFirstTest < Test::Unit::TestCase + include ResolveConfiguredTableNameAmbiguityBehavior + + with_model :FixtureBuilderAmbiguousAlpha do + table { |table| table.string :name } + end + + with_model :FixtureBuilderAmbiguousZulu do + table { |table| table.string :name } + end +end + +class ResolveConfiguredTableNameAmbiguityZuluFirstTest < Test::Unit::TestCase + include ResolveConfiguredTableNameAmbiguityBehavior + + with_model :FixtureBuilderAmbiguousZulu do + table { |table| table.string :name } + end + + with_model :FixtureBuilderAmbiguousAlpha do + table { |table| table.string :name } + end +end + +class ResolveConfiguredTableNameStiTest < Test::Unit::TestCase + with_model :FixtureBuilderStiBase do + table do |table| + table.string :name + table.string :type + end + end + + with_model :FixtureBuilderStiSubclass, superclass: :FixtureBuilderStiBase do + table(false) + end + + def test_sti_models_dump_all_subtype_rows_through_the_base_model + table_name = FixtureBuilderStiBase.table_name + assert_equal FixtureBuilderStiBase, resolve_model(table_name) + force_fixture_generation + + build_fixtures_for(table_name) do + FixtureBuilderStiBase.create!(name: "Base creature") + FixtureBuilderStiSubclass.create!(name: "Subclass creature") + end + + fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) + records = fixture.except("_fixture").values + assert_equal %w[Base\ creature Subclass\ creature], records.pluck("name") + assert_nil records.first["type"] + assert_equal FixtureBuilderStiSubclass.name, records.last["type"] + end + + def teardown + FileUtils.rm_f(test_path("fixtures/#{FixtureBuilderStiBase.table_name}.yml")) + end + + private + + def build_fixtures_for(*table_names, &factory) + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - table_names + fbuilder.factory(&factory) + end + end + + def resolve_model(table_name) + FixtureBuilder::ModelResolver.new(connection_pool: ActiveRecord::Base.connection_pool).resolve(table_name) + end +end + +class ResolveConfiguredTableNameManualBoundaryTest < Test::Unit::TestCase + include TestDatabase + + def test_conventionally_named_autoloaded_model_uses_model_backed_serialization + create_and_blow_away_old_db + table_name = "fixture_builder_autoloaded_models" + + with_temporary_table(table_name, columns: {wizard_data: :json}) do + with_autoloaded_model("FixtureBuilderAutoloadedModel") do + force_fixture_generation + build_fixtures_for(table_name) do + value = ActiveRecord::Base.connection.quote({"level" => 99}.to_json) + ActiveRecord::Base.connection.execute( + "INSERT INTO #{table_name} (wizard_data) VALUES (#{value})" + ) + end + + assert_equal :json, + Object.const_get(:FixtureBuilderAutoloadedModel).columns_hash.fetch("wizard_data").type + + fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) + assert_equal({"level" => 99}, fixture.except("_fixture").values.first["wizard_data"]) + end + end + end + + def test_concrete_siblings_under_an_abstract_ancestor_remain_ambiguous + create_and_blow_away_old_db + table_name = "fixture_builder_abstract_sibling_models" + + with_temporary_table(table_name, columns: {name: :string}) do + with_abstract_sibling_models( + "FixtureBuilderAbstractAncestor", + ["FixtureBuilderAbstractAlpha", "FixtureBuilderAbstractZulu"], + table_name: table_name + ) do + force_fixture_generation + + error = assert_raise(FixtureBuilder::AmbiguousModelError) do + build_fixtures_for(table_name) do + ActiveRecord::Base.connection.execute( + "INSERT INTO #{table_name} (name) VALUES ('Merlin')" + ) + end + end + + assert_equal %w[FixtureBuilderAbstractAlpha FixtureBuilderAbstractZulu], + error.models.map(&:name) + end + end + end + + def test_separate_pool_model_with_the_same_table_name_is_ignored + create_and_blow_away_old_db + table_name = "fixture_builder_separate_pool_models" + + with_temporary_table(table_name, columns: {name: :string}) do + with_separate_pool_model("FixtureBuilderSeparatePool", table_name: table_name) do + force_fixture_generation + build_fixtures_for(table_name) do + ActiveRecord::Base.connection.execute( + "INSERT INTO #{table_name} (name) VALUES ('Base pool row')" + ) + end + + fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) + assert_equal "Base pool row", fixture.values.first["name"] + end + end + end + + def test_candidate_schema_errors_propagate + create_and_blow_away_old_db + table_name = "fixture_builder_schema_errors" + error_class = Class.new(StandardError) + + with_temporary_table(table_name, columns: {name: :string}) do + with_named_models(["FixtureBuilderSchemaError"], table_name: table_name) do |model| + model.define_singleton_method(:columns_hash) { raise error_class } + force_fixture_generation + + assert_raise(error_class) do + build_fixtures_for(table_name) do + ActiveRecord::Base.connection.execute( + "INSERT INTO #{table_name} (name) VALUES ('Merlin')" + ) + end + end + end + end + end + + def test_id_less_model_table_uses_raw_sql_and_preserves_select_aliases + create_and_blow_away_old_db + table_name = "fixture_builder_id_less_models" + + with_temporary_table(table_name, columns: {name: :string}, id: false) do + force_fixture_generation + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - [table_name] + fbuilder.select_sql = "SELECT *, upper(name) AS shouted_name FROM %
s" + fbuilder.factory do + ActiveRecord::Base.connection.execute( + "INSERT INTO #{table_name} (name) VALUES ('Merlin')" + ) + end + end + + fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) + assert_not_include fixture, "_fixture" + assert_equal "Merlin", fixture.values.first["name"] + assert_equal "MERLIN", fixture.values.first["shouted_name"] + end + end + + private + + def build_fixtures_for(*table_names, &factory) + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - table_names + fbuilder.factory(&factory) + end + end + + def with_temporary_table(table_name, columns:, id: true) + connection = ActiveRecord::Base.connection + options = {force: true} + options[:id] = false unless id + connection.create_table(table_name, **options) do |table| + columns.each { |name, type| table.column(name, type) } + end + yield connection + ensure + connection.drop_table(table_name) if connection&.data_source_exists?(table_name) + FileUtils.rm_f(test_path("fixtures/#{table_name}.yml")) + end + + def with_autoloaded_model(class_name) + path = test_path("#{class_name.underscore}.rb") + File.write(path, <<~RUBY) + Object.const_set(:#{class_name}, Class.new(ActiveRecord::Base) do + attribute :wizard_data, WizardDataType.new + end) + RUBY + Object.autoload(class_name.to_sym, path) + yield + ensure + Object.send(:remove_const, class_name) if Object.const_defined?(class_name, false) + FileUtils.rm_f(path) if path + end + + def with_named_models(class_names, table_name:) + models = class_names.map do |class_name| + Object.const_set(class_name, Class.new(ActiveRecord::Base)) + end + models.each { |model| model.table_name = table_name } + yield(*models) + ensure + remove_model_constants(models.reverse) + end + + def with_abstract_sibling_models(ancestor_name, class_names, table_name:) + models = [] + ancestor = Object.const_set(ancestor_name, Class.new(ActiveRecord::Base)) + ancestor.abstract_class = true + class_names.each do |class_name| + models << Object.const_set(class_name, Class.new(ancestor)) + end + models.each { |model| model.table_name = table_name } + yield(*models) + ensure + remove_model_constants(models.reverse + [ancestor]) + end + + def with_separate_pool_model(class_name, table_name:) + model = Object.const_set(class_name, Class.new(ActiveRecord::Base)) + model.establish_connection(adapter: "sqlite3", database: ":memory:") + model.table_name = table_name + yield model + ensure + model&.connection_pool&.disconnect! + remove_model_constants([model]) + end + + def remove_model_constants(models) + models&.each do |model| + next unless model&.name && Object.const_defined?(model.name, false) + + Object.send(:remove_const, model.name) + end + end +end +# standard:enable Rails/ApplicationRecord diff --git a/test/support/test_database.rb b/test/support/test_database.rb index 70f247e..bc32ecc 100644 --- a/test/support/test_database.rb +++ b/test/support/test_database.rb @@ -57,20 +57,17 @@ def create_and_blow_away_old_db # database-generated column. create_generated_column_table(GENERATED_COLUMN_RECORDS_TABLE) - # The table FixtureBuilder iterates (`relocated_creatures`) alongside the - # differently named table `RelocatedCreature` actually reads. - # - # The two tables expose deliberately incompatible schemas: the iterated - # table's only writable column is `unrelated` and its `name` is - # database-generated, while the model's table has a writable `name`. Reading - # generated columns from the iterated table instead of the model's table - # therefore strips `name` from the fixture. + # `RelocatedCreature` is configured for `creature_archive`, even though its + # class name conventionally maps to the distinct `relocated_creatures` table. + # These intentionally incompatible tables prove resolution follows the + # configured table name rather than inferred constant naming. connection.create_table(RELOCATED_CREATURES_TABLE, force: true) do |t| t.string :unrelated t.virtual :name, type: :string, as: "upper(unrelated)", stored: true end connection.create_table(CREATURE_ARCHIVE_TABLE, force: true) do |t| t.string :name, null: false + t.json :wizard_data end GeneratedCreature.reset_column_information diff --git a/test/test_helper.rb b/test/test_helper.rb index ccb97e0..1375ab6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -104,11 +104,11 @@ def wizard_data(attributes) class GeneratedCreature < ActiveRecord::Base end -# Inferable from the `relocated_creatures` table name, but backed by a -# differently named table, so writable column names must come from -# `table_name` rather than the table FixtureBuilder is iterating. +# Its configured table name is intentionally not inferable from this class +# name, so FixtureBuilder must resolve it from loaded model metadata. class RelocatedCreature < ActiveRecord::Base self.table_name = "creature_archive" + attribute :wizard_data, WizardDataType.new end class MagicalCreature < ActiveRecord::Base