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 @@ -49,6 +49,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
61 changes: 55 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
```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 %<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 @@ -262,6 +270,47 @@ manifest, so the next build has no manifest-based migration suppression. Marker
migration does not remove historical unmarked duplicates; inspect or move those
manually.

### 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, but it primes conventional model autoloading
before considering already-loaded 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
42 changes: 33 additions & 9 deletions doc/plans/2026-08-29-maintainer-backlog-triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
plan: Maintainer backlog triage
status: active
created: "2026-08-29"
last_updated: "2026-09-03"
last_updated: "2026-09-07"
owner: Grant Hutchins
scope: Open issues and pull requests in rdy/fixture_builder
---
Expand Down Expand Up @@ -189,6 +189,28 @@ the direction of #94.
The model-independent extraction idea may inform #49, but it does not justify
reviving the proposed public hook surface.

## Current replacement stack

### Model-aware fixture generation (PR 1)

In progress from current `master`: use loaded Active Record models by their
configured table names, emit Rails-native `_fixture.model_class` metadata for
model-backed YAML, and preserve raw SQL fallback for tables without an eligible
model. The implementation keeps Rails fixture loading authoritative: explicit
fixture-class mappings take precedence and unresolved metadata may use
conventional inference. Its regression coverage is organized by final ownership in
`test/fixture_builder_test.rb`, `test/configuration_test.rb`,
`test/configuration/manifest_test.rb`, `test/fixtures_path_test.rb`,
`test/builder/`, and `test/model_resolver/`.

### Ephemeral test-model migration (PR 2)

Planned as the dependent follow-up: migrate remaining ordinary fixture-builder
tests to models whose schemas are declared by their owning test cases. Retain
manual setup only for raw SQL, constant/autoload timing, alternate pools, schema
errors, namespaced table-name boundaries, and STI. Keep the metadata-aware and
metadata-free legacy fixture inputs as separate compatibility paths.

## Execution order

### Phase 1: Backlog cleanup
Expand All @@ -215,7 +237,7 @@ reviving the proposed public hook surface.
- **Implementation:** Remove the `Date::DATE_FORMATS` mutation from
`lib/fixture_builder/builder.rb`.
- **Tests:** Add focused ISO-date and global-state coverage to
`test/fixture_builder_test.rb`.
`test/builder/serialization_test.rb`.
- **Completion gate:** The focused test file and `bin/rake` pass without a date
deprecation warning, then #69 closes through the implementation pull request.

Expand Down Expand Up @@ -304,8 +326,9 @@ PostgreSQL reproduction.
- **Implementation:** Replace the unconditional `order(:id)` behavior in
`lib/fixture_builder/builder.rb` with declared-primary-key ordering and a
deterministic fallback for keyless tables.
- **Tests:** Extend `test/fixture_builder_test.rb` and its schema/models with
custom-primary-key and keyless-table cases. Prove two generations are stable.
- **Tests:** Extend `test/builder/raw_sql_test.rb` and its owning ephemeral
schemas with custom-primary-key and keyless-table cases. Prove two generations
are stable.
- **Completion gate:** The regression fails before the implementation, then the
focused test file and `bin/rake` pass without weakening deterministic output.

Expand All @@ -315,9 +338,10 @@ PostgreSQL reproduction.
`lib/fixture_builder/`, expose it through
`lib/fixture_builder/configuration.rb`, and update builder path handling and
manifest traversal.
- **Tests:** Extend `test/fixture_builder_test.rb` with a namespaced model, JSON
data, nested fixture output, recursive cleanup, and manifest invalidation.
Add isolated value-object tests if its validation has meaningful branches.
- **Tests:** Extend `test/builder/metadata_test.rb` and
`test/model_resolver/table_name_test.rb` with a namespaced model, JSON data,
nested fixture output, recursive cleanup, and manifest invalidation. Add
isolated value-object tests if its validation has meaningful branches.
- **Completion gate:** The end-to-end regression fails first; then focused tests
and `bin/rake` pass. Update `README.md` and `CHANGELOG.md` in the same pull
request.
Expand All @@ -332,8 +356,8 @@ primitive makes a small dependency stack clearer.
- [ ] If no required use case remains, replace their implementation with Arel
in `lib/fixture_builder/builder.rb` and remove the setters from
`lib/fixture_builder/configuration.rb`.
- [ ] Update `test/fixture_builder_test.rb`, `README.md`, and `CHANGELOG.md` for
every removed public API.
- [ ] Update `test/configuration_test.rb`, `test/builder/`, `README.md`, and
`CHANGELOG.md` for every removed public API.
- [ ] Run `bin/rake`, the stable Ruby/Rails matrix, and the required GitHub
Actions workflow before releasing 0.7.

Expand Down
1 change: 1 addition & 0 deletions fixture_builder.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ Gem::Specification.new do |s|
s.add_development_dependency "rake"
s.add_development_dependency "sqlite3"
s.add_development_dependency "test-unit"
s.add_development_dependency "with_model"
end
1 change: 1 addition & 0 deletions lib/fixture_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
require "fixture_builder/configuration"
require "fixture_builder/namer"
require "fixture_builder/fixture_file"
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
48 changes: 26 additions & 22 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 @@ -17,6 +19,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,9 +61,7 @@ def names_from_ivars!
end

def write_data_to_files
emitted_files = write_empty_files ? dump_empty_fixtures_for_all_tables : []
emitted_files |= dump_tables
remove_stale_fixture_files(emitted_files)
remove_stale_fixture_files(dump_tables)
end

def clean_out_old_data
Expand All @@ -82,24 +83,13 @@ def say(*messages)
end
# standard:enable Rails/Output

def dump_empty_fixtures_for_all_tables
tables.map do |table_name|
write_fixture_file({}, table_name)
File.basename(fixture_file(table_name))
end
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 @@ -111,16 +101,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 @@ -130,12 +125,21 @@ def dump_tables
fixtures
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?

Expand Down
39 changes: 39 additions & 0 deletions lib/fixture_builder/model_resolver.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

module FixtureBuilder
class ModelResolver
def initialize(connection_pool:)
@connection_pool = connection_pool
end

def resolve(table_name)
# Prime conventional autoloading before considering already-loaded models.
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
35 changes: 35 additions & 0 deletions test/builder/generated_columns_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: false

require_relative "../test_helper"

module BuilderTests
class GeneratedColumnsTest < Test::Unit::TestCase
prepend IsolatedFixtureFilesystem

with_model :GeneratedCreature do
table do |table|
table.string :name, null: false
table.virtual :name_length, type: :integer, as: "length(name)", stored: true
end
end

def test_generated_columns_are_excluded_for_model_backed_tables
force_fixture_generation

table_name = GeneratedCreature.table_name
FixtureBuilder.configure do |fbuilder|
fbuilder.files_to_check = []
fbuilder.skip_tables = ActiveRecord::Base.connection.tables - [table_name]
fbuilder.factory { GeneratedCreature.create!(name: "Myrddin") }
end

generated_fixture = YAML.safe_load_file(fixture_path("#{table_name}.yml"))
assert_equal "Myrddin", generated_fixture.dig("myrddin", "name")
assert_not_include generated_fixture.fetch("myrddin"), "name_length"

GeneratedCreature.delete_all
create_fixtures(table_name)
assert_equal 7, GeneratedCreature.find_by!(name: "Myrddin").name_length
end
end
end
Loading