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