From 2de7c3fb9693ee472c66525e386aba902c4de35c Mon Sep 17 00:00:00 2001 From: Grant Hutchins Date: Mon, 7 Sep 2026 19:44:02 -0500 Subject: [PATCH] Generate model-aware fixtures --- CHANGELOG.md | 7 + README.md | 61 +- .../2026-08-29-maintainer-backlog-triage.md | 42 +- fixture_builder.gemspec | 1 + lib/fixture_builder.rb | 1 + lib/fixture_builder/ambiguous_model_error.rb | 13 + lib/fixture_builder/builder.rb | 48 +- lib/fixture_builder/model_resolver.rb | 39 ++ test/builder/generated_columns_test.rb | 35 ++ .../generated_fixture_ownership_test.rb | 7 +- test/builder/metadata_test.rb | 151 +++++ test/builder/raw_sql_test.rb | 62 ++ test/builder/serialization_test.rb | 84 +++ test/builder/table_name_test.rb | 54 ++ .../generated_file_marker_test.rb | 5 +- test/configuration/manifest_test.rb | 239 +++++++ test/configuration_test.rb | 97 +++ test/fixture_builder_test.rb | 589 +----------------- test/fixtures_path_test.rb | 32 + .../alphabetical_definition_order_test.rb | 26 + ...erse_alphabetical_definition_order_test.rb | 26 + .../ambiguous_model_error_test.rb | 28 + test/model_resolver/selection_test.rb | 196 ++++++ test/model_resolver/sti_test.rb | 52 ++ test/model_resolver/table_name_test.rb | 73 +++ .../model_resolver_ambiguity_behavior.rb | 41 ++ test/support/test_database.rb | 5 +- test/test_helper.rb | 83 ++- 28 files changed, 1483 insertions(+), 614 deletions(-) create mode 100644 lib/fixture_builder/ambiguous_model_error.rb create mode 100644 lib/fixture_builder/model_resolver.rb create mode 100644 test/builder/generated_columns_test.rb create mode 100644 test/builder/metadata_test.rb create mode 100644 test/builder/raw_sql_test.rb create mode 100644 test/builder/serialization_test.rb create mode 100644 test/builder/table_name_test.rb create mode 100644 test/configuration/manifest_test.rb create mode 100644 test/configuration_test.rb create mode 100644 test/fixtures_path_test.rb create mode 100644 test/model_resolver/ambiguity/alphabetical_definition_order_test.rb create mode 100644 test/model_resolver/ambiguity/reverse_alphabetical_definition_order_test.rb create mode 100644 test/model_resolver/ambiguous_model_error_test.rb create mode 100644 test/model_resolver/selection_test.rb create mode 100644 test/model_resolver/sti_test.rb create mode 100644 test/model_resolver/table_name_test.rb create mode 100644 test/support/model_resolver_ambiguity_behavior.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 98aba01..e123878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/README.md b/README.md index 35db6e9..b9179c1 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. @@ -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 =================== diff --git a/doc/plans/2026-08-29-maintainer-backlog-triage.md b/doc/plans/2026-08-29-maintainer-backlog-triage.md index ef57c3b..57c5be2 100644 --- a/doc/plans/2026-08-29-maintainer-backlog-triage.md +++ b/doc/plans/2026-08-29-maintainer-backlog-triage.md @@ -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 --- @@ -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 @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/fixture_builder.gemspec b/fixture_builder.gemspec index 964a46d..af47a71 100644 --- a/fixture_builder.gemspec +++ b/fixture_builder.gemspec @@ -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 diff --git a/lib/fixture_builder.rb b/lib/fixture_builder.rb index 152306b..87ce29b 100644 --- a/lib/fixture_builder.rb +++ b/lib/fixture_builder.rb @@ -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" 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 6d3c315..539955d 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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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? diff --git a/lib/fixture_builder/model_resolver.rb b/lib/fixture_builder/model_resolver.rb new file mode 100644 index 0000000..e66a814 --- /dev/null +++ b/lib/fixture_builder/model_resolver.rb @@ -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 diff --git a/test/builder/generated_columns_test.rb b/test/builder/generated_columns_test.rb new file mode 100644 index 0000000..4d038d4 --- /dev/null +++ b/test/builder/generated_columns_test.rb @@ -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 diff --git a/test/builder/generated_fixture_ownership_test.rb b/test/builder/generated_fixture_ownership_test.rb index c5f1ab2..5f6b262 100644 --- a/test/builder/generated_fixture_ownership_test.rb +++ b/test/builder/generated_fixture_ownership_test.rb @@ -14,8 +14,11 @@ class GeneratedFixtureOwnershipTest < Test::Unit::TestCase def setup @directory = Dir.mktmpdir("fixture-builder-ownership") - ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") - ActiveRecord::Base.connection.create_table(:ownership_records) { |table| table.string :name } + # with_table captures this adapter when the suite loads, so reset the + # in-memory database without replacing its connection pool. + connection = ActiveRecord::Base.connection + connection.tables.each { |table| connection.drop_table(table) } + connection.create_table(:ownership_records) { |table| table.string :name } OwnershipRecord.reset_column_information end diff --git a/test/builder/metadata_test.rb b/test/builder/metadata_test.rb new file mode 100644 index 0000000..1ca2c53 --- /dev/null +++ b/test/builder/metadata_test.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +module ModelMetadataNamespace +end + +module BuilderTests + class MetadataTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_model :Creature do + table do |table| + table.string :name, null: false + end + end + + with_model :MythicalCreature do + table do |table| + table.string :name, null: false + table.string :type + end + end + + with_model :Dragon, superclass: :MythicalCreature do + table(false) + end + + with_table :model_metadata_namespaced_creatures do |table| + table.string :name, null: false + end + + with_model :"ModelMetadataNamespace::Chimera" do + table + + model do + self.table_name = "model_metadata_namespaced_creatures" + end + end + + with_table :model_metadata_raw_tables do |table| + table.string :name + end + + def test_model_backed_files_describe_their_model_and_load_without_a_class_map + table_name = Creature.table_name + assert_equal "Creature", Creature.name + assert_not_equal Creature.name, table_name.classify + + generate_for(table_name) { Creature.create!(name: "$LABEL") } + + fixture = YAML.safe_load_file(fixture_path("#{table_name}.yml")) + assert_equal({"model_class" => Creature.name}, fixture.fetch("_fixture")) + records = fixture.except("_fixture") + assert_equal "$LABEL", records.values.first.fetch("name") + + Creature.delete_all + create_fixtures(table_name) + assert_equal records.keys.first, Creature.find_by!(name: records.keys.first).name + assert_nil Creature.find_by(name: "_fixture") + end + + def test_namespaced_model_round_trips_through_generated_native_metadata + table_name = "model_metadata_namespaced_creatures" + model = ModelMetadataNamespace::Chimera + + generate_for(table_name) { model.create!(name: "Namespaced creature") } + + generated_fixture_path = fixture_path("#{table_name}.yml") + fixture = YAML.safe_load_file(generated_fixture_path) + assert_equal "ModelMetadataNamespace::Chimera", model.name + assert_equal table_name, model.table_name + assert_not_include "/", table_name + assert File.exist?(generated_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") + end + + def test_empty_model_and_raw_tables_follow_write_empty_files + model_table = Creature.table_name + raw_table = "model_metadata_raw_tables" + + [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" => Creature.name}}, + YAML.safe_load_file(fixture_path("#{model_table}.yml"))) + assert_equal({}, YAML.safe_load_file(fixture_path("#{raw_table}.yml"))) + else + assert_false File.exist?(fixture_path("#{model_table}.yml")) + assert_false File.exist?(fixture_path("#{raw_table}.yml")) + end + end + end + + def test_reserved_fixture_label_raises_for_model_and_raw_tables + model_table = Creature.table_name + error = assert_raise(ArgumentError) do + generate_for(model_table) { Creature.create!(name: "_fixture") } + end + assert_match(/#{model_table}.*_fixture/, error.message) + + raw_table = "model_metadata_raw_tables" + 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) + end + + def test_sti_files_describe_the_root_model_and_load_sibling_records + table_name = MythicalCreature.table_name + generate_for(table_name) do + MythicalCreature.create!(name: "Base") + Dragon.create!(name: "Sibling") + end + + fixture = YAML.safe_load_file(fixture_path("#{table_name}.yml")) + assert_equal MythicalCreature.name, fixture.dig("_fixture", "model_class") + + MythicalCreature.delete_all + create_fixtures(table_name) + assert_equal [MythicalCreature.name, Dragon.name], + MythicalCreature.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 +end diff --git a/test/builder/raw_sql_test.rb b/test/builder/raw_sql_test.rb new file mode 100644 index 0000000..d77da04 --- /dev/null +++ b/test/builder/raw_sql_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +module BuilderTests + class RawSqlTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_table :generated_column_records do |table| + table.string :name, null: false + table.virtual :name_length, type: :integer, as: "length(name)", stored: true + end + + def test_generated_columns_are_excluded_for_raw_query_tables + table_name = "generated_column_records" + force_fixture_generation + quoted_table_name = ActiveRecord::Base.connection.quote_table_name(table_name) + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = ActiveRecord::Base.connection.tables - [table_name] + fbuilder.factory do + ActiveRecord::Base.connection.execute( + "INSERT INTO #{quoted_table_name} (name) VALUES ('Merlin')" + ) + end + end + + generated_fixture = YAML.safe_load_file(fixture_path("#{table_name}.yml")) + assert_equal "Merlin", generated_fixture.dig("merlin", "name") + assert_not_include generated_fixture.fetch("merlin"), "name_length" + + ActiveRecord::Base.connection.delete("DELETE FROM #{quoted_table_name}") + create_fixtures(table_name) + assert_equal 6, + ActiveRecord::Base.connection.select_value("SELECT name_length FROM #{quoted_table_name}") + end + + def test_raw_query_select_aliases_are_preserved + table_name = "generated_column_records" + force_fixture_generation + quoted_table_name = ActiveRecord::Base.connection.quote_table_name(table_name) + capture_output do + 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 #{quoted_table_name} (name) VALUES ('Merlin')" + ) + end + end + end + + generated_fixture = YAML.safe_load_file(fixture_path("#{table_name}.yml")) + record = generated_fixture.fetch("merlin") + assert_equal "Merlin", record["name"] + assert_equal "MERLIN", record["shouted_name"] + assert_not_include record, "name_length" + end + end +end diff --git a/test/builder/serialization_test.rb b/test/builder/serialization_test.rb new file mode 100644 index 0000000..deaed3a --- /dev/null +++ b/test/builder/serialization_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +# standard:disable Rails/ApplicationRecord +module BuilderTests + class SerializationTest < Test::Unit::TestCase + include TestDatabase + prepend IsolatedFixtureFilesystem + + def setup + super + create_and_blow_away_old_db + end + + def test_serialization + force_fixture_generation + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory do + @enty = MagicalCreature.create(name: "Enty", species: "ent", + powers: %w[shading rooting seeding]) + end + end + generated_fixture = YAML.load(File.open(fixture_path("#{MagicalCreature.table_name}.yml"))) + assert_equal "---\n- shading\n- rooting\n- seeding\n", generated_fixture["enty"]["powers"] + end + + def test_dates_are_iso_formatted_without_mutating_global_date_formats + force_fixture_generation + + default_date_format_exists = Date::DATE_FORMATS.key?(:default) + default_date_format = Date::DATE_FORMATS[:default] + custom_date_format = "%m/%d/%Y" + Date::DATE_FORMATS[:default] = custom_date_format + + begin + date_format_during_generation = nil + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.name_model_with MagicalCreature do |_record, index| + date_format_during_generation = Date::DATE_FORMATS[:default] + "creature_#{index}" + end + fbuilder.factory do + MagicalCreature.create!(name: "Ariel", species: "mermaid", born_on: Date.new(1990, 1, 2)) + end + end + + fixture_contents = File.read(fixture_path("#{MagicalCreature.table_name}.yml")) + assert_includes fixture_contents, "born_on: '1990-01-02'\n" + assert_equal custom_date_format, date_format_during_generation + assert_equal custom_date_format, Date::DATE_FORMATS[:default] + ensure + if default_date_format_exists + Date::DATE_FORMATS[:default] = default_date_format + else + Date::DATE_FORMATS.delete(:default) + end + end + + if default_date_format_exists + assert_equal default_date_format, Date::DATE_FORMATS[:default] + else + assert_not_include Date::DATE_FORMATS, :default + end + end + + def test_do_not_include_virtual_attributes + force_fixture_generation + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory do + MagicalCreature.create(name: "Uni", species: "unicorn", powers: %w[rainbows flying]) + end + end + generated_fixture = YAML.load(File.open(fixture_path("#{MagicalCreature.table_name}.yml"))) + assert !generated_fixture["uni"].key?("virtual") + end + end +end +# standard:enable Rails/ApplicationRecord diff --git a/test/builder/table_name_test.rb b/test/builder/table_name_test.rb new file mode 100644 index 0000000..1cc3445 --- /dev/null +++ b/test/builder/table_name_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +module BuilderTests + class TableNameTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_model :MappedCreature do + table do |table| + table.string :name, null: false + table.virtual :name_length, type: :integer, as: "length(name)", stored: true + end + end + + with_table :mapped_creatures do |table| + table.string :unrelated + table.virtual :name, type: :string, as: "upper(unrelated)", stored: true + end + + def test_model_and_conventional_decoy_tables_exclude_their_own_generated_columns + model_table_name = MappedCreature.table_name + decoy_table_name = MappedCreature.name.tableize + + assert_equal "mapped_creatures", decoy_table_name + assert_not_equal model_table_name, decoy_table_name + + connection = ActiveRecord::Base.connection + assert_equal %w[id name name_length], connection.columns(model_table_name).map(&:name).sort + assert_equal %w[id name unrelated], connection.columns(decoy_table_name).map(&:name).sort + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check = [] + fbuilder.skip_tables = connection.tables - [model_table_name, decoy_table_name] + fbuilder.factory do + MappedCreature.create!(name: "Nimue") + connection.execute("INSERT INTO #{connection.quote_table_name(decoy_table_name)} (unrelated) VALUES ('Morgana')") + end + end + + model_fixture = YAML.safe_load_file(fixture_path("#{model_table_name}.yml")) + assert_equal "MappedCreature", model_fixture.dig("_fixture", "model_class") + model_record = model_fixture.fetch("nimue") + assert_equal "Nimue", model_record["name"] + assert_not_include model_record, "name_length" + + decoy_fixture = YAML.safe_load_file(fixture_path("#{decoy_table_name}.yml")) + assert_not_include decoy_fixture, "_fixture" + decoy_record = decoy_fixture.fetch("#{decoy_table_name}_001") + assert_equal "Morgana", decoy_record["unrelated"] + assert_not_include decoy_record, "name" + end + end +end diff --git a/test/configuration/generated_file_marker_test.rb b/test/configuration/generated_file_marker_test.rb index c852e69..a57cc20 100644 --- a/test/configuration/generated_file_marker_test.rb +++ b/test/configuration/generated_file_marker_test.rb @@ -19,8 +19,9 @@ def setup @source = File.join(@directory, "source.rb") FileUtils.mkdir_p(@fixtures) File.write(@source, "source\n") - ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") - ActiveRecord::Base.connection.create_table(:marker_migration_records) { |table| table.string :name } + connection = ActiveRecord::Base.connection + connection.tables.each { |table| connection.drop_table(table) } + connection.create_table(:marker_migration_records) { |table| table.string :name } MarkerMigrationRecord.reset_column_information end diff --git a/test/configuration/manifest_test.rb b/test/configuration/manifest_test.rb new file mode 100644 index 0000000..842cf50 --- /dev/null +++ b/test/configuration/manifest_test.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +# standard:disable Rails/ApplicationRecord +module ConfigurationTests + class ManifestTest < Test::Unit::TestCase + include TestDatabase + prepend IsolatedFixtureFilesystem + + def setup + super + create_and_blow_away_old_db + end + + def test_malformed_manifest_raises_without_running_factory + manifest_path = fixture_builder_file + File.write(manifest_path, "---\ninvalid: [\n") + factory_called = false + + assert_raise(Psych::SyntaxError) do + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory { factory_called = true } + end + end + + assert_false factory_called + end + + def test_skips_rebuild_for_valid_empty_fixture_snapshot + force_fixture_generation + FileUtils.rm_f(Dir[fixture_path("*.yml")]) + builds = 0 + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.write_empty_files = false + fbuilder.factory { builds += 1 } + end + + manifest_path = fixture_builder_file + assert_empty YAML.safe_load_file(manifest_path).fetch("fixtures") + reset_fixture_builder_configuration + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.write_empty_files = false + fbuilder.factory { builds += 1 } + end + + assert_equal 1, builds + end + + def test_rebuilding_due_to_differing_file_hashes + force_fixture_generation_due_to_differing_file_hashes + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory do + @enty = MagicalCreature.create(name: "Enty", species: "ent", + powers: %w[shading rooting seeding]) + end + end + generated_fixture = YAML.load(File.open(fixture_path("#{MagicalCreature.table_name}.yml"))) + assert_equal "---\n- shading\n- rooting\n- seeding\n", generated_fixture["enty"]["powers"] + end + + def test_rebuilds_when_generated_fixture_hashes_differ + force_fixture_generation + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory do + @enty = MagicalCreature.create(name: "Enty", species: "ent", + powers: %w[shading rooting seeding]) + end + end + + reset_fixture_builder_configuration + fixture_path = fixture_path("#{MagicalCreature.table_name}.yml") + generated_fixture = YAML.load_file(fixture_path) + generated_fixture["enty"]["retired_column"] = "bogus" + File.write(fixture_path, generated_fixture.to_yaml) + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory do + @enty = MagicalCreature.create(name: "Enty", species: "ent", + powers: %w[shading rooting seeding]) + end + end + + regenerated_fixture = YAML.load_file(fixture_path) + assert_false regenerated_fixture["enty"].key?("retired_column") + assert_equal "Enty", regenerated_fixture["enty"]["name"] + assert_equal "ent", regenerated_fixture["enty"]["species"] + end + + def test_fresh_manifest_returns_without_acquiring_lock + force_fixture_generation + builds = 0 + lock_path = nil + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + lock_path = fbuilder.lock_path + fbuilder.factory do + builds += 1 + @enty = MagicalCreature.create(name: "Enty", species: "ent") + end + end + + assert_path_exist lock_path + FileUtils.rm(lock_path) + reset_fixture_builder_configuration + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory { builds += 1 } + end + + assert_equal 1, builds + assert_path_not_exist lock_path + end + + def test_raising_after_build_invalidates_manifest_and_retries + force_fixture_generation + builds = 0 + factory = proc do + builds += 1 + @enty = MagicalCreature.create(name: "Enty", species: "ent") + end + + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory(&factory) + end + + manifest_path = fixture_builder_file + fixture_path = fixture_path("#{MagicalCreature.table_name}.yml") + generated_fixture = YAML.safe_load_file(fixture_path) + generated_fixture["enty"]["retired_column"] = "bogus" + File.write(fixture_path, generated_fixture.to_yaml) + reset_fixture_builder_configuration + + assert_raise(RuntimeError) do + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.after_build = proc { raise "after build failure" } + fbuilder.factory(&factory) + end + end + + assert_false File.exist?(manifest_path) + + reset_fixture_builder_configuration + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory(&factory) + end + + assert_equal 3, builds + assert_equal 1, YAML.safe_load_file(manifest_path)["version"] + end + + def test_sha256_manifest_digests_when_deprecated_use_sha1_digests_is_enabled + force_fixture_generation_due_to_differing_file_hashes + + source_path = Pathname.new(__FILE__) + FixtureBuilder.configure do |fbuilder| + _output, warning = capture_output do + fbuilder.use_sha1_digests = true + end + + assert_true fbuilder.use_sha1_digests + assert_match( + /use_sha1_digests is deprecated and will be removed in FixtureBuilder 0.7; it is ignored because SHA-256 is always used/, + warning + ) + + fbuilder.files_to_check = [source_path] + fbuilder.factory do + @enty = MagicalCreature.create(name: "Enty", species: "ent", + powers: %w[shading rooting seeding]) + end + + manifest = YAML.safe_load_file(fixture_builder_file) + fixture_path = fixture_path("#{MagicalCreature.table_name}.yml") + assert_equal 1, manifest["version"] + assert_equal Digest::SHA256.file(source_path).hexdigest, + manifest.fetch("sources").fetch(source_path.to_s) + assert_equal Digest::SHA256.file(fixture_path).hexdigest, + manifest.fetch("fixtures").fetch(File.basename(fixture_path)) + + first_modified_time = File.mtime(fixture_path) + fbuilder.factory do + end + second_modified_time = File.mtime(fixture_path) + assert_equal first_modified_time, second_modified_time + end + end + + data( + "empty document" => "", + "false" => "false\\n", + "scalar" => "scalar\\n", + "flat manifest" => {"source.rb" => "old digest"}.to_yaml, + "unsupported future version" => {"version" => 2, "sources" => {}, "fixtures" => {}}.to_yaml, + "invalid current shape" => {"version" => 1, "sources" => {}, "fixtures" => {}, 1 => "invalid"}.to_yaml + ) + def test_rebuilds_parsed_invalid_manifest(payload) + assert_manifest_rebuilds(payload) + end + + private + + def assert_manifest_rebuilds(payload) + force_fixture_generation + builds = 0 + factory = proc do + builds += 1 + @enty = MagicalCreature.create(name: "Enty", species: "ent") + end + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory(&factory) + end + File.write(fixture_builder_file, payload) + reset_fixture_builder_configuration + FixtureBuilder.configure do |fbuilder| + fbuilder.files_to_check += Dir[test_path("*.rb")] + fbuilder.factory(&factory) + end + assert_equal 2, builds + end + end +end +# standard:enable Rails/ApplicationRecord diff --git a/test/configuration_test.rb b/test/configuration_test.rb new file mode 100644 index 0000000..a7a8b9b --- /dev/null +++ b/test/configuration_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: false + +require_relative "test_helper" + +class ConfigurationTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + class Model + def self.table_name + "models" + end + end + + def test_name_with + hash = {"email" => "bob@example.com"} + FixtureBuilder.configure do |config| + config.name_model_with ConfigurationTest::Model do |record_hash, index| + [record_hash["email"].split("@").first, index].join("_") + end + end + assert_equal "bob_001", + FixtureBuilder.configuration.send(:record_name, hash, ConfigurationTest::Model.table_name) + 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 + + _output, warning = capture_output do + error = assert_raise(ArgumentError) do + configuration.public_send("#{attribute}=", sql) + end + + assert_equal( + "Positional %s table placeholders are no longer supported; use %
s or %{table}. " \ + "See https://docs.ruby-lang.org/en/3.3/format_specifications_rdoc.html" \ + "#label-Reference+by+Name.", + error.message + ) + end + + assert_empty warning + end + end + + def test_sql_setters_warn_on_every_assignment_and_retain_named_table_formats + ["%
s", "%{table}"].each do |table_format| + { + select_sql: "SELECT * FROM #{table_format}", + delete_sql: "DELETE FROM #{table_format}" + }.each do |attribute, sql| + configuration = FixtureBuilder::Configuration.new + + _output, warning = capture_output do + configuration.public_send("#{attribute}=", sql) + end + + assert_include( + warning, + "#{attribute}= is deprecated and planned for removal in FixtureBuilder 0.7. " \ + "If you are actively using this feature, please share your use case at " \ + "https://github.com/rdy/fixture_builder/issues/94 so we can consider the best way " \ + "to continue to support it." + ) + assert_equal sql, configuration.public_send(attribute) + end + end + end + + def test_configuration_constructor_accepts_deprecated_use_sha1_digests_option + _output, warning = capture_output do + configuration = FixtureBuilder::Configuration.new(use_sha1_digests: true) + + assert_true configuration.use_sha1_digests + end + + assert_match( + /use_sha1_digests is deprecated and will be removed in FixtureBuilder 0.7; it is ignored because SHA-256 is always used/, + warning + ) + end + + def test_fixtures_dir + assert_equal fixture_directory, FixtureBuilder.configuration.send(:fixtures_dir).to_s + end + + def test_lock_path_tracks_fixture_builder_file + configuration = FixtureBuilder::Configuration.new + configuration.fixture_builder_file = "tmp/first-fixture-builder.yml" + assert_equal "#{File.expand_path(configuration.fixture_builder_file)}.lock", + configuration.lock_path + + configuration.fixture_builder_file = "tmp/second-fixture-builder.yml" + assert_equal "#{File.expand_path(configuration.fixture_builder_file)}.lock", + configuration.lock_path + end +end diff --git a/test/fixture_builder_test.rb b/test/fixture_builder_test.rb index 957f02e..ab974c7 100644 --- a/test/fixture_builder_test.rb +++ b/test/fixture_builder_test.rb @@ -1,243 +1,15 @@ # frozen_string_literal: false -require File.expand_path(File.join(File.dirname(__FILE__), "test_helper")) - -class FixtureBuilderTestModel - def self.table_name - "models" - end -end +require_relative "test_helper" +# standard:disable Rails/ApplicationRecord class FixtureBuilderTest < Test::Unit::TestCase include TestDatabase + prepend IsolatedFixtureFilesystem - def teardown - FixtureBuilder.instance_variable_set(:@configuration, nil) - end - - def test_name_with - hash = {"email" => "bob@example.com"} - FixtureBuilder.configure do |config| - config.name_model_with FixtureBuilderTestModel do |record_hash, index| - [record_hash["email"].split("@").first, index].join("_") - end - end - assert_equal "bob_001", - FixtureBuilder.configuration.send(:record_name, hash, FixtureBuilderTestModel.table_name) - end - - def test_ivar_naming - create_and_blow_away_old_db - force_fixture_generation - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory do - @king_of_gnomes = MagicalCreature.create(name: "robert", species: "gnome") - end - end - generated_fixture = YAML.load(File.open(test_path("fixtures/magical_creatures.yml"))) - assert_equal "king_of_gnomes", generated_fixture.keys.first - end - - def test_serialization - create_and_blow_away_old_db - force_fixture_generation - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory do - @enty = MagicalCreature.create(name: "Enty", species: "ent", - powers: %w[shading rooting seeding]) - end - end - generated_fixture = YAML.load(File.open(test_path("fixtures/magical_creatures.yml"))) - assert_equal "---\n- shading\n- rooting\n- seeding\n", generated_fixture["enty"]["powers"] - end - - def test_dates_are_iso_formatted_without_mutating_global_date_formats - create_and_blow_away_old_db - force_fixture_generation - - default_date_format_exists = Date::DATE_FORMATS.key?(:default) - default_date_format = Date::DATE_FORMATS[:default] - custom_date_format = "%m/%d/%Y" - Date::DATE_FORMATS[:default] = custom_date_format - - begin - date_format_during_generation = nil - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.name_model_with MagicalCreature do |_record, index| - date_format_during_generation = Date::DATE_FORMATS[:default] - "creature_#{index}" - end - fbuilder.factory do - MagicalCreature.create!(name: "Ariel", species: "mermaid", born_on: Date.new(1990, 1, 2)) - end - end - - fixture_contents = File.read(test_path("fixtures/magical_creatures.yml")) - assert_includes fixture_contents, "born_on: '1990-01-02'\n" - assert_equal custom_date_format, date_format_during_generation - assert_equal custom_date_format, Date::DATE_FORMATS[:default] - ensure - if default_date_format_exists - Date::DATE_FORMATS[:default] = default_date_format - else - Date::DATE_FORMATS.delete(:default) - end - end - - if default_date_format_exists - assert_equal default_date_format, Date::DATE_FORMATS[:default] - else - assert_not_include Date::DATE_FORMATS, :default - end - end - - def test_do_not_include_virtual_attributes - create_and_blow_away_old_db - force_fixture_generation - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory do - MagicalCreature.create(name: "Uni", species: "unicorn", powers: %w[rainbows flying]) - end - end - generated_fixture = YAML.load(File.open(test_path("fixtures/magical_creatures.yml"))) - assert !generated_fixture["uni"].key?("virtual") - end - - def test_generated_columns_are_excluded_for_model_backed_tables - create_and_blow_away_old_db - 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(test_path("fixtures/#{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 - - def test_generated_columns_are_excluded_for_raw_query_tables - create_and_blow_away_old_db - force_fixture_generation - - table_name = GENERATED_COLUMN_RECORDS_TABLE - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check = [] - fbuilder.skip_tables = ActiveRecord::Base.connection.tables - [table_name] - fbuilder.factory do - ActiveRecord::Base.connection.execute( - "INSERT INTO #{table_name} (name) VALUES ('Merlin')" - ) - end - end - - generated_fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) - assert_equal "Merlin", generated_fixture.dig("merlin", "name") - assert_not_include generated_fixture.fetch("merlin"), "name_length" - - ActiveRecord::Base.connection.delete("DELETE FROM #{table_name}") - create_fixtures(table_name) - assert_equal 6, - ActiveRecord::Base.connection.select_value("SELECT name_length FROM #{table_name}") - end - - def test_raw_query_select_aliases_are_preserved + def setup + super create_and_blow_away_old_db - force_fixture_generation - - table_name = GENERATED_COLUMN_RECORDS_TABLE - capture_output do - 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 - end - - generated_fixture = YAML.safe_load_file(test_path("fixtures/#{table_name}.yml")) - record = generated_fixture.fetch("merlin") - assert_equal "Merlin", record["name"] - # A custom `select_sql` is executed as written, so its alias reaches the - # snapshot. Only the database-generated column, which cannot be inserted, - # is removed. The alias is deliberately not a column of the table, so this - # fixture is not expected to load. - assert_equal "MERLIN", record["shouted_name"] - assert_not_include record, "name_length" - end - - def test_generated_columns_come_from_the_model_table_name - create_and_blow_away_old_db - force_fixture_generation - - table_name = RELOCATED_CREATURES_TABLE - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check = [] - fbuilder.skip_tables = ActiveRecord::Base.connection.tables - [table_name] - fbuilder.factory { RelocatedCreature.create!(name: "Nimue") } - 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" - end - - def test_custom_json_attribute_type_round_trips_through_fixtures - create_and_blow_away_old_db - force_fixture_generation - wizard_data = WizardData.new( - level: 99, - title: "The Grey", - allies: %w[Frodo Aragorn] - ) - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory do - MagicalCreature.create!( - name: "Gandalf", - species: "wizard", - wizard_data: wizard_data - ) - end - end - - generated_fixture = YAML.safe_load_file(test_path("fixtures/magical_creatures.yml")) - assert_equal( - {"level" => 99, "title" => "The Grey", "allies" => %w[Frodo Aragorn]}, - generated_fixture.dig("gandalf", "wizard_data") - ) - - MagicalCreature.delete_all - ActiveRecord::FixtureSet.create_fixtures( - test_path("fixtures"), - MagicalCreature.table_name - ) - - assert_equal wizard_data, MagicalCreature.find_by!(name: "Gandalf").wizard_data end def test_configure @@ -253,64 +25,6 @@ def test_deprecator_has_fixture_builder_metadata assert_equal "FixtureBuilder", FixtureBuilder.deprecator.gem_name 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 - - _output, warning = capture_output do - error = assert_raise(ArgumentError) do - configuration.public_send("#{attribute}=", sql) - end - - assert_equal( - "Positional %s table placeholders are no longer supported; use %
s or %{table}. " \ - "See https://docs.ruby-lang.org/en/3.3/format_specifications_rdoc.html" \ - "#label-Reference+by+Name.", - error.message - ) - end - - assert_empty warning - end - end - - def test_sql_setters_warn_on_every_assignment_and_retain_named_table_formats - ["%
s", "%{table}"].each do |table_format| - { - select_sql: "SELECT * FROM #{table_format}", - delete_sql: "DELETE FROM #{table_format}" - }.each do |attribute, sql| - configuration = FixtureBuilder::Configuration.new - - _output, warning = capture_output do - configuration.public_send("#{attribute}=", sql) - end - - assert_include( - warning, - "#{attribute}= is deprecated and planned for removal in FixtureBuilder 0.7. " \ - "If you are actively using this feature, please share your use case at " \ - "https://github.com/rdy/fixture_builder/issues/94 so we can consider the best way " \ - "to continue to support it." - ) - assert_equal sql, configuration.public_send(attribute) - end - end - end - - def test_configuration_constructor_accepts_deprecated_use_sha1_digests_option - _output, warning = capture_output do - configuration = FixtureBuilder::Configuration.new(use_sha1_digests: true) - - assert_true configuration.use_sha1_digests - end - - assert_match( - /use_sha1_digests is deprecated and will be removed in FixtureBuilder 0.7; it is ignored because SHA-256 is always used/, - warning - ) - end - def test_configuration_accepts_deprecated_use_sha1_digests_option_when_memoized configuration = FixtureBuilder.configuration @@ -350,290 +64,51 @@ def test_configure_rejects_unknown_options end end - def test_absolute_rails_fixtures_path_uses_database_tasks_fixtures_path - original_fixtures_path = ActiveRecord::Tasks::DatabaseTasks.fixtures_path - authoritative_path = test_path("authoritative_fixtures") - ActiveRecord::Tasks::DatabaseTasks.fixtures_path = authoritative_path - - assert_same authoritative_path, - FixtureBuilder::FixturesPath.absolute_rails_fixtures_path - ensure - ActiveRecord::Tasks::DatabaseTasks.fixtures_path = original_fixtures_path - end - - def test_absolute_rails_fixtures_path_propagates_database_tasks_errors - database_tasks = ActiveRecord::Tasks::DatabaseTasks - original_method = database_tasks.method(:fixtures_path) - error_class = Class.new(StandardError) - database_tasks.define_singleton_method(:fixtures_path) { raise error_class } - - assert_raise(error_class) do - FixtureBuilder::FixturesPath.absolute_rails_fixtures_path - end - ensure - database_tasks.singleton_class.remove_method(:fixtures_path) - database_tasks.define_singleton_method(:fixtures_path, original_method) - end - - def test_fixtures_dir - assert_match(%r{test/fixtures$}, FixtureBuilder.configuration.send(:fixtures_dir).to_s) - end - - def test_rebuilding_due_to_differing_file_hashes - create_and_blow_away_old_db - force_fixture_generation_due_to_differing_file_hashes - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory do - @enty = MagicalCreature.create(name: "Enty", species: "ent", - powers: %w[shading rooting seeding]) - end - end - generated_fixture = YAML.load(File.open(test_path("fixtures/magical_creatures.yml"))) - assert_equal "---\n- shading\n- rooting\n- seeding\n", generated_fixture["enty"]["powers"] - end - - def test_rebuilds_when_generated_fixture_hashes_differ - create_and_blow_away_old_db - force_fixture_generation - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory do - @enty = MagicalCreature.create(name: "Enty", species: "ent", - powers: %w[shading rooting seeding]) - end - end - - FixtureBuilder.instance_variable_set(:@configuration, nil) - fixture_path = test_path("fixtures/magical_creatures.yml") - generated_fixture = YAML.load_file(fixture_path) - generated_fixture["enty"]["retired_column"] = "bogus" - File.write(fixture_path, generated_fixture.to_yaml) - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory do - @enty = MagicalCreature.create(name: "Enty", species: "ent", - powers: %w[shading rooting seeding]) - end - end - - regenerated_fixture = YAML.load_file(fixture_path) - assert_false regenerated_fixture["enty"].key?("retired_column") - assert_equal "Enty", regenerated_fixture["enty"]["name"] - assert_equal "ent", regenerated_fixture["enty"]["species"] - end - - data( - "empty document" => "", - "false" => "false\n", - "scalar" => "scalar\n", - "flat manifest" => {"source.rb" => "old digest"}.to_yaml, - "unsupported future version" => {"version" => 2, "sources" => {}, "fixtures" => {}}.to_yaml, - "invalid current shape" => { - "version" => 1, - "sources" => {}, - "fixtures" => {}, - 1 => "invalid" - }.to_yaml - ) - def test_rebuilds_parsed_invalid_manifest(payload) - assert_manifest_rebuilds(payload) - end - - def test_malformed_manifest_raises_without_running_factory - create_and_blow_away_old_db - manifest_path = File.expand_path("../tmp/fixture_builder.yml", __dir__) - File.write(manifest_path, "---\ninvalid: [\n") - factory_called = false - - assert_raise(Psych::SyntaxError) do - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory { factory_called = true } - end - end - - assert_false factory_called - end - - def test_lock_path_tracks_fixture_builder_file - configuration = FixtureBuilder::Configuration.new - configuration.fixture_builder_file = "tmp/first-fixture-builder.yml" - assert_equal "#{File.expand_path(configuration.fixture_builder_file)}.lock", - configuration.lock_path - - configuration.fixture_builder_file = "tmp/second-fixture-builder.yml" - assert_equal "#{File.expand_path(configuration.fixture_builder_file)}.lock", - configuration.lock_path - end - - def test_fresh_manifest_returns_without_acquiring_lock - create_and_blow_away_old_db + def test_ivar_naming force_fixture_generation - builds = 0 - lock_path = nil FixtureBuilder.configure do |fbuilder| fbuilder.files_to_check += Dir[test_path("*.rb")] - lock_path = fbuilder.lock_path fbuilder.factory do - builds += 1 - @enty = MagicalCreature.create(name: "Enty", species: "ent") + @king_of_gnomes = MagicalCreature.create(name: "robert", species: "gnome") end end - - assert_path_exist lock_path - FileUtils.rm(lock_path) - FixtureBuilder.instance_variable_set(:@configuration, nil) - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory { builds += 1 } - end - - assert_equal 1, builds - assert_path_not_exist lock_path - end - - def test_skips_rebuild_for_valid_empty_fixture_snapshot - create_and_blow_away_old_db - force_fixture_generation - fixture_snapshot = Dir[test_path("fixtures/*.yml")].to_h do |filename| - [filename, File.binread(filename)] - end - FileUtils.rm_f(fixture_snapshot.keys) - builds = 0 - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.write_empty_files = false - fbuilder.factory { builds += 1 } - end - - manifest_path = File.expand_path("../tmp/fixture_builder.yml", __dir__) - assert_empty YAML.safe_load_file(manifest_path).fetch("fixtures") - FixtureBuilder.instance_variable_set(:@configuration, nil) - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.write_empty_files = false - fbuilder.factory { builds += 1 } - end - - assert_equal 1, builds - ensure - current_fixtures = Dir[test_path("fixtures/*.yml")] - FileUtils.rm_f(current_fixtures - fixture_snapshot.keys) if fixture_snapshot - fixture_snapshot&.each { |filename, contents| File.binwrite(filename, contents) } + generated_fixture = YAML.load(File.open(fixture_path("#{MagicalCreature.table_name}.yml"))) + assert_equal "king_of_gnomes", generated_fixture.except("_fixture").keys.first end - def test_raising_after_build_invalidates_manifest_and_retries - create_and_blow_away_old_db + def test_custom_json_attribute_type_round_trips_through_fixtures force_fixture_generation - builds = 0 - factory = proc do - builds += 1 - @enty = MagicalCreature.create(name: "Enty", species: "ent") - end - - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory(&factory) - end - - manifest_path = Rails.root.join("tmp/fixture_builder.yml") - fixture_path = test_path("fixtures/magical_creatures.yml") - generated_fixture = YAML.safe_load_file(fixture_path) - generated_fixture["enty"]["retired_column"] = "bogus" - File.write(fixture_path, generated_fixture.to_yaml) - create_and_blow_away_old_db - FixtureBuilder.instance_variable_set(:@configuration, nil) - - assert_raise(RuntimeError) do - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.after_build = proc { raise "after build failure" } - fbuilder.factory(&factory) - end - end - - assert_false File.exist?(manifest_path) + wizard_data = WizardData.new( + level: 99, + title: "The Grey", + allies: %w[Frodo Aragorn] + ) - FixtureBuilder.instance_variable_set(:@configuration, nil) FixtureBuilder.configure do |fbuilder| fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory(&factory) - end - - assert_equal 3, builds - assert_equal 1, YAML.safe_load_file(manifest_path)["version"] - end - - def test_sha256_manifest_digests_when_deprecated_use_sha1_digests_is_enabled - create_and_blow_away_old_db - force_fixture_generation_due_to_differing_file_hashes - - source_path = Pathname.new(test_path("fixture_builder_test.rb")) - FixtureBuilder.configure do |fbuilder| - _output, warning = capture_output do - fbuilder.use_sha1_digests = true - end - - assert_true fbuilder.use_sha1_digests - assert_match( - /use_sha1_digests is deprecated and will be removed in FixtureBuilder 0.7; it is ignored because SHA-256 is always used/, - warning - ) - - fbuilder.files_to_check = [source_path] - fbuilder.factory do - @enty = MagicalCreature.create(name: "Enty", species: "ent", - powers: %w[shading rooting seeding]) - end - - manifest = YAML.safe_load_file(File.expand_path("../tmp/fixture_builder.yml", __dir__)) - fixture_path = test_path("fixtures/magical_creatures.yml") - assert_equal 1, manifest["version"] - assert_equal Digest::SHA256.file(source_path).hexdigest, - manifest.fetch("sources").fetch(source_path.to_s) - assert_equal Digest::SHA256.file(fixture_path).hexdigest, - manifest.fetch("fixtures").fetch(File.basename(fixture_path)) - - first_modified_time = File.mtime(fixture_path) fbuilder.factory do + MagicalCreature.create!( + name: "Gandalf", + species: "wizard", + wizard_data: wizard_data + ) end - second_modified_time = File.mtime(fixture_path) - assert_equal first_modified_time, second_modified_time - end - end - - private - - def assert_manifest_rebuilds(payload) - create_and_blow_away_old_db - force_fixture_generation - builds = 0 - factory = proc do - builds += 1 - @enty = MagicalCreature.create(name: "Enty", species: "ent") end - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory(&factory) - end + generated_fixture = YAML.safe_load_file(fixture_path("#{MagicalCreature.table_name}.yml")) + assert_equal( + {"level" => 99, "title" => "The Grey", "allies" => %w[Frodo Aragorn]}, + generated_fixture.dig("gandalf", "wizard_data") + ) - manifest_path = File.expand_path("../tmp/fixture_builder.yml", __dir__) - File.write(manifest_path, payload) - FixtureBuilder.instance_variable_set(:@configuration, nil) - FixtureBuilder.configure do |fbuilder| - fbuilder.files_to_check += Dir[test_path("*.rb")] - fbuilder.factory(&factory) - end + MagicalCreature.delete_all + ActiveRecord::FixtureSet.create_fixtures( + fixture_directory, + MagicalCreature.table_name + ) - assert_equal 2, builds + assert_equal wizard_data, MagicalCreature.find_by!(name: "Gandalf").wizard_data end end +# standard:enable Rails/ApplicationRecord diff --git a/test/fixtures_path_test.rb b/test/fixtures_path_test.rb new file mode 100644 index 0000000..8784ec8 --- /dev/null +++ b/test/fixtures_path_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: false + +require_relative "test_helper" + +class FixturesPathTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + def test_absolute_rails_fixtures_path_uses_database_tasks_fixtures_path + original_fixtures_path = ActiveRecord::Tasks::DatabaseTasks.fixtures_path + authoritative_path = test_path("authoritative_fixtures") + ActiveRecord::Tasks::DatabaseTasks.fixtures_path = authoritative_path + + assert_same authoritative_path, + FixtureBuilder::FixturesPath.absolute_rails_fixtures_path + ensure + ActiveRecord::Tasks::DatabaseTasks.fixtures_path = original_fixtures_path + end + + def test_absolute_rails_fixtures_path_propagates_database_tasks_errors + database_tasks = ActiveRecord::Tasks::DatabaseTasks + original_method = database_tasks.method(:fixtures_path) + error_class = Class.new(StandardError) + database_tasks.define_singleton_method(:fixtures_path) { raise error_class } + + assert_raise(error_class) do + FixtureBuilder::FixturesPath.absolute_rails_fixtures_path + end + ensure + database_tasks.singleton_class.remove_method(:fixtures_path) + database_tasks.define_singleton_method(:fixtures_path, original_method) + end +end diff --git a/test/model_resolver/ambiguity/alphabetical_definition_order_test.rb b/test/model_resolver/ambiguity/alphabetical_definition_order_test.rb new file mode 100644 index 0000000..e807827 --- /dev/null +++ b/test/model_resolver/ambiguity/alphabetical_definition_order_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false + +require_relative "../../test_helper" +require_relative "../../support/model_resolver_ambiguity_behavior" + +# standard:disable Rails/ApplicationRecord +module ModelResolverTests + module Ambiguity + class AlphabeticalDefinitionOrderTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_model :Alpha do + table { |table| table.string :name } + end + + with_model :Beta do + table { |table| table.string :name } + end + + # Both definition orders must reject ambiguous models, report their names + # alphabetically, and leave the existing fixture untouched. + include ModelResolverTests::Ambiguity::Behavior + end + end +end +# standard:enable Rails/ApplicationRecord diff --git a/test/model_resolver/ambiguity/reverse_alphabetical_definition_order_test.rb b/test/model_resolver/ambiguity/reverse_alphabetical_definition_order_test.rb new file mode 100644 index 0000000..a67d1cf --- /dev/null +++ b/test/model_resolver/ambiguity/reverse_alphabetical_definition_order_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false + +require_relative "../../test_helper" +require_relative "../../support/model_resolver_ambiguity_behavior" + +# standard:disable Rails/ApplicationRecord +module ModelResolverTests + module Ambiguity + class ReverseAlphabeticalDefinitionOrderTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_model :Beta do + table { |table| table.string :name } + end + + with_model :Alpha do + table { |table| table.string :name } + end + + # Both definition orders must reject ambiguous models, report their names + # alphabetically, and leave the existing fixture untouched. + include ModelResolverTests::Ambiguity::Behavior + end + end +end +# standard:enable Rails/ApplicationRecord diff --git a/test/model_resolver/ambiguous_model_error_test.rb b/test/model_resolver/ambiguous_model_error_test.rb new file mode 100644 index 0000000..565962c --- /dev/null +++ b/test/model_resolver/ambiguous_model_error_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative "../test_helper" + +module ModelResolverTests + class ErrorTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + def test_ambiguous_model_error_exposes_its_table_name_and_models + magical_creature = named_model("MagicalCreature") + generated_creature = named_model("GeneratedCreature") + error = FixtureBuilder::AmbiguousModelError.new("creatures", [magical_creature, generated_creature]) + + assert_equal "creatures", error.table_name + assert_equal [generated_creature, magical_creature], error.models + assert_equal( + "Multiple models match table creatures: GeneratedCreature, MagicalCreature", + error.message + ) + end + + private + + def named_model(name) + Class.new.tap { |model| model.define_singleton_method(:name) { name } } + end + end +end diff --git a/test/model_resolver/selection_test.rb b/test/model_resolver/selection_test.rb new file mode 100644 index 0000000..09524ec --- /dev/null +++ b/test/model_resolver/selection_test.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +# Regression tests for model resolution by configured table name (#109). +# standard:disable Rails/ApplicationRecord +module ModelResolverTests + class SelectionTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_table :fixture_builder_autoloaded_models do |table| + table.json :wizard_data + end + + with_table :legendary_creatures do |table| + table.string :name + end + + with_model :LegendaryCreature do + table + + model do + self.abstract_class = true + end + end + + with_model :Phoenix, + superclass: :LegendaryCreature do + table + + model do + self.table_name = "legendary_creatures" + end + end + + with_model :Unicorn, + superclass: :LegendaryCreature do + table + + model do + self.table_name = "legendary_creatures" + end + end + + with_table :separate_pools do |table| + table.string :name + end + + with_model :SeparatePool do + table + + model do + establish_connection(adapter: "sqlite3", database: ":memory:") + self.table_name = "separate_pools" + end + end + + with_table :schema_errors do |table| + table.string :name + end + + with_model :SchemaError do + table + + model do + self.table_name = "schema_errors" + end + end + + with_table :fixture_builder_id_less_models, id: false do |table| + table.string :name + end + + def teardown + SeparatePool.connection_pool.disconnect! + super + end + + def test_conventionally_named_autoloaded_model_uses_model_backed_serialization + table_name = "fixture_builder_autoloaded_models" + + 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 + + model = Object.const_get(:FixtureBuilderAutoloadedModel) + assert_equal table_name, model.table_name + assert_equal :json, model.columns_hash.fetch("wizard_data").type + + fixture = YAML.safe_load_file(fixture_path("#{table_name}.yml")) + assert_equal({"model_class" => model.name}, fixture.fetch("_fixture")) + assert_equal({"level" => 99}, fixture.except("_fixture").values.first["wizard_data"]) + + model.delete_all + create_fixtures(table_name) + assert_equal WizardData.new(level: 99, title: nil, allies: nil), model.first!.wizard_data + end + end + + def test_concrete_siblings_under_an_abstract_ancestor_remain_ambiguous + table_name = "legendary_creatures" + 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[Phoenix Unicorn], + error.models.map(&:name) + end + + def test_separate_pool_model_with_the_same_table_name_is_ignored + table_name = "separate_pools" + 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(fixture_path("#{table_name}.yml")) + assert_equal "Base pool row", fixture.except("_fixture").values.first["name"] + end + + def test_candidate_schema_errors_propagate + table_name = "schema_errors" + error_class = Class.new(StandardError) + SchemaError.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 + + def test_id_less_model_table_uses_raw_sql_and_preserves_select_aliases + table_name = "fixture_builder_id_less_models" + 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(fixture_path("#{table_name}.yml")) + assert_not_include fixture, "_fixture" + assert_equal "Merlin", fixture.except("_fixture").values.first["name"] + assert_equal "MERLIN", fixture.except("_fixture").values.first["shouted_name"] + 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 + + # Autoload behavior inherently requires a file-backed constant rather than + # with_model's eagerly installed constant; with_table still owns its schema. + 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 + end +end +# standard:enable Rails/ApplicationRecord diff --git a/test/model_resolver/sti_test.rb b/test/model_resolver/sti_test.rb new file mode 100644 index 0000000..1eebc29 --- /dev/null +++ b/test/model_resolver/sti_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +# standard:disable Rails/ApplicationRecord +module ModelResolverTests + class StiTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_model :Creature do + table do |table| + table.string :name + table.string :type + end + end + + with_model :Dragon, superclass: :Creature do + table(false) + end + + def test_sti_models_dump_all_subtype_rows_through_the_base_model + table_name = Creature.table_name + assert_equal Creature, resolve_model(table_name) + force_fixture_generation + + build_fixtures_for(table_name) do + Creature.create!(name: "Base creature") + Dragon.create!(name: "Subclass creature") + end + + fixture = YAML.safe_load_file(fixture_path("#{table_name}.yml")) + assert_equal %w[Base\ creature Subclass\ creature], fixture.except("_fixture").values.pluck("name") + assert_nil fixture.except("_fixture").values.first["type"] + assert_equal Dragon.name, fixture.except("_fixture").values.last["type"] + 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 +end +# standard:enable Rails/ApplicationRecord diff --git a/test/model_resolver/table_name_test.rb b/test/model_resolver/table_name_test.rb new file mode 100644 index 0000000..e6c225d --- /dev/null +++ b/test/model_resolver/table_name_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: false + +require_relative "../test_helper" + +# standard:disable Rails/ApplicationRecord +module ModelResolverTests + class TableNameTest < Test::Unit::TestCase + prepend IsolatedFixtureFilesystem + + with_model :ArchivedCreature 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 :RawCreature 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 = ArchivedCreature.table_name + raw_table = RawCreature.table_name + assert_equal ArchivedCreature, resolve_model(archive_table) + assert_nil resolve_model(raw_table) + + force_fixture_generation + build_fixtures_for(archive_table, raw_table) do + ArchivedCreature.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(fixture_path("#{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(fixture_path("#{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 + + 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 +end +# standard:enable Rails/ApplicationRecord diff --git a/test/support/model_resolver_ambiguity_behavior.rb b/test/support/model_resolver_ambiguity_behavior.rb new file mode 100644 index 0000000..a56faf7 --- /dev/null +++ b/test/support/model_resolver_ambiguity_behavior.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: false + +module ModelResolverTests + module Ambiguity + module Behavior + def test_unrelated_models_raise_before_replacing_the_fixture + table_name = Alpha.table_name + Beta.table_name = table_name + Beta.reset_column_information + fixture_path = fixture_path("#{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[Alpha Beta], error.models.map(&:name) + assert_equal( + "Multiple models match table #{table_name}: Alpha, Beta", + error.message + ) + assert_equal original_fixture, File.binread(fixture_path) + 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 + end +end diff --git a/test/support/test_database.rb b/test/support/test_database.rb index 70f247e..c5b0eab 100644 --- a/test/support/test_database.rb +++ b/test/support/test_database.rb @@ -38,8 +38,10 @@ def self.included(base) def create_and_blow_away_old_db ActiveRecord::Base.configurations = {"test" => CONFIGURATION} - ActiveRecord::Base.establish_connection(:test) connection = ActiveRecord::Base.connection + connection.tables.each { |table| connection.drop_table(table) } + connection.schema_cache.clear! + ActiveRecord::FixtureSet.reset_cache connection.create_table(:magical_creatures, force: true) do |t| t.column :name, :string t.column :species, :string @@ -73,7 +75,6 @@ def create_and_blow_away_old_db t.string :name, null: false end - GeneratedCreature.reset_column_information RelocatedCreature.reset_column_information end diff --git a/test/test_helper.rb b/test/test_helper.rb index c79eabe..ee3f13c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,8 @@ require "rubygems" require "bundler/setup" +require "fileutils" +require "tmpdir" require "test/unit" class Rails @@ -22,6 +24,52 @@ def test_path(glob) require "active_record" require "active_record/fixtures" +module IsolatedFixtureFilesystem + def setup + @original_fixtures_path = ActiveRecord::Tasks::DatabaseTasks.fixtures_path + @temporary_fixture_root = Dir.mktmpdir("fixture_builder_test") + @fixture_directory = File.join(@temporary_fixture_root, "fixtures") + FileUtils.mkdir_p(@fixture_directory) + ActiveRecord::Tasks::DatabaseTasks.fixtures_path = @fixture_directory + reset_fixture_builder_configuration + super + rescue + clean_up_isolated_fixture_filesystem + raise + end + + def teardown + super + ensure + clean_up_isolated_fixture_filesystem + end + + private + + attr_reader :fixture_directory + + def fixture_path(path = "") + File.join(fixture_directory, path) + end + + def fixture_builder_file + File.join(@temporary_fixture_root, "fixture_builder.yml") + end + + def reset_fixture_builder_configuration + FixtureBuilder.instance_variable_set(:@configuration, nil) + FixtureBuilder.configuration.fixture_directory = fixture_directory + FixtureBuilder.configuration.fixture_builder_file = fixture_builder_file + end + + def clean_up_isolated_fixture_filesystem + FixtureBuilder.instance_variable_set(:@configuration, nil) + ensure + ActiveRecord::Tasks::DatabaseTasks.fixtures_path = @original_fixtures_path if defined?(@original_fixtures_path) + FileUtils.remove_entry(@temporary_fixture_root) if defined?(@temporary_fixture_root) && File.exist?(@temporary_fixture_root) + end +end + def create_fixtures(*table_names, &block) fixture_set = ActiveRecord::FixtureSet @@ -34,16 +82,20 @@ def create_fixtures(*table_names, &block) # rewritten YAML is reparsed; Rails 8.0 and 8.1 use the original load path. if fixture_set.respond_to?(:without_parsing_cache) fixture_set.without_parsing_cache do - fixture_set.create_fixtures(test_path("fixtures"), table_names, {}, &block) + fixture_set.create_fixtures(fixture_directory, table_names, {}, &block) end else - fixture_set.create_fixtures(test_path("fixtures"), table_names, {}, &block) + fixture_set.create_fixtures(fixture_directory, table_names, {}, &block) end end require "sqlite3" require "fixture_builder" require_relative "support/test_database" +ActiveRecord::Base.configurations = {"test" => {"adapter" => "sqlite3", "database" => ":memory:"}} +ActiveRecord::Base.establish_connection(:test) + +require "with_model/test_unit" class WizardData attr_reader :level, :title, :allies @@ -96,12 +148,6 @@ def wizard_data(attributes) end # standard:disable Rails/ApplicationRecord -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. class RelocatedCreature < ActiveRecord::Base self.table_name = "creature_archive" end @@ -109,20 +155,29 @@ class RelocatedCreature < ActiveRecord::Base class MagicalCreature < ActiveRecord::Base validates_presence_of :name, :species serialize :powers, type: Array - default_scope -> { where(deleted: false) } - attribute :virtual, ActiveRecord::Type::Integer.new attribute :wizard_data, WizardDataType.new end # standard:enable Rails/ApplicationRecord def force_fixture_generation - FileUtils.rm_f(File.expand_path("../tmp/fixture_builder.yml", __dir__)) + FileUtils.rm_f(current_fixture_builder_file) + reset_fixture_builder_configuration if isolated_fixture_filesystem? end def force_fixture_generation_due_to_differing_file_hashes - path = File.expand_path("../tmp/fixture_builder.yml", __dir__) - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, "blah blah blah") + FileUtils.mkdir_p(File.dirname(current_fixture_builder_file)) + File.write(current_fixture_builder_file, "blah blah blah") + reset_fixture_builder_configuration if isolated_fixture_filesystem? +end + +def current_fixture_builder_file + return fixture_builder_file if isolated_fixture_filesystem? + + File.expand_path("../tmp/fixture_builder.yml", __dir__) +end + +def isolated_fixture_filesystem? + respond_to?(:fixture_builder_file, true) end