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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
60 changes: 54 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 %<table>s
* delete_sql: DELETE FROM %<table>s
```
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 %<table>s
delete_sql: DELETE FROM %<table>s
```

FixtureBuilder omits database-generated columns from snapshots because Rails
fixtures cannot write them.
Expand Down Expand Up @@ -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
===================

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
57 changes: 31 additions & 26 deletions lib/fixture_builder/builder.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# frozen_string_literal: true

require "fixture_builder/model_resolver"

module FixtureBuilder
class Builder
include Delegations::Namer
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
38 changes: 38 additions & 0 deletions lib/fixture_builder/model_resolver.rb
Original file line number Diff line number Diff line change
@@ -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
45 changes: 33 additions & 12 deletions test/fixture_builder_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading