Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@

### Fixed

- Resolve loaded Active Record models by their configured table names, preserving
model-backed attribute serialization, and raise deterministically when unrelated
models ambiguously own one table
([#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)).
Expand Down
1 change: 1 addition & 0 deletions lib/fixture_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
13 changes: 13 additions & 0 deletions lib/fixture_builder/ambiguous_model_error.rb
Original file line number Diff line number Diff line change
@@ -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
68 changes: 49 additions & 19 deletions lib/fixture_builder/builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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
Expand Down Expand Up @@ -64,7 +65,6 @@ def write_data_to_files

def clean_out_old_data
delete_tables
delete_yml_files
end

def delete_tables
Expand All @@ -86,6 +86,14 @@ def say(*messages)
end
# standard:enable Rails/Output

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_empty_fixtures_for_all_tables
tables.each do |table_name|
write_fixture_file({}, table_name)
Expand All @@ -94,15 +102,11 @@ def dump_empty_fixtures_for_all_tables

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
Expand All @@ -114,7 +118,6 @@ 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) }
Expand All @@ -132,24 +135,51 @@ def dump_tables
say "Built #{fixtures.to_sentence}"
end

private

def resolve_models_by_table
tables.each_with_object({}) do |table_name, models_by_table|
models_by_table[table_name] = resolve_model(table_name)
end
end

def resolve_model(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

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?(ActiveRecord::Base.connection_pool)

primary_keys = Array(model.primary_key).compact
primary_keys.any? && primary_keys.all? { |key| model.columns_hash.key?(key) }
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
42 changes: 31 additions & 11 deletions test/fixture_builder_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -188,21 +188,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
Expand Down Expand Up @@ -253,6 +264,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
Expand Down
Loading