factory_hoist 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 932a7db6380072b0f2f85f0ec490d40e3e064476cfd6fedbbdd7addd6d344ab1
4
+ data.tar.gz: 7e8d769103fec0947c603a59bacc628eef8df9a5299627e371144c33f90950a9
5
+ SHA512:
6
+ metadata.gz: 6a8a0683faa318e861d01f8b8bda4f35483910712dad4f11e3ceec60de73a6bfa17ce58afadd5421e389f8f600fe2527bf18eb1345b119a26734a4eb7120e7d0
7
+ data.tar.gz: 6fda5d4647831af4cbe8781de691e24063c9e4b3ddb454102c8902557c2f38014c1c8b4b52bce380bf061e8833e6295499e274a79dfca6263122d69bc086f7d1
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # FactoryHoist
2
+
3
+ FactoryHoist creates FactoryBot records once at an RSpec example-group boundary, then gives every example an isolated in-memory copy. ActiveRecord changes are isolated with savepoints and rolled back when the group exits.
4
+
5
+ ## Installation
6
+
7
+ Add the gem to the test group:
8
+
9
+ ```ruby
10
+ group :test do
11
+ gem "factory_hoist"
12
+ end
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ `hoist` uses the declaration's name as the FactoryBot factory by default. A block returns attribute overrides and can refer to ancestor declarations.
18
+
19
+ ```ruby
20
+ RSpec.describe Order do
21
+ hoist(:company)
22
+ hoist(:user) { {company: company} }
23
+
24
+ context "paid" do
25
+ hoist(:order) { {user: user, state: :paid} }
26
+
27
+ it "can update its isolated copy" do
28
+ order.update!(state: :cancelled)
29
+ end
30
+
31
+ it "preserves relationships" do
32
+ expect(order.user).to equal(user)
33
+ end
34
+ end
35
+ end
36
+ ```
37
+
38
+ Traits and an alternate factory name are supported:
39
+
40
+ ```ruby
41
+ hoist(:admin, :user, :admin, active: true)
42
+ ```
43
+
44
+ The direct APIs delegate to FactoryBot:
45
+
46
+ ```ruby
47
+ FactoryHoist.build(:user)
48
+ FactoryHoist.create(:user)
49
+ FactoryHoist.build_list(:user, 3)
50
+ FactoryHoist.create_list(:user, 3)
51
+ ```
52
+
53
+ Simple callback-free builds are compiled to a Ruby file under the system temporary directory and bypass FactoryBot's evaluation pipeline. Traits, callbacks, required constructors, and unsupported definitions automatically fall back to FactoryBot. Run the fixed-condition benchmark with:
54
+
55
+ ```console
56
+ $ bundle exec ruby bench/fast_build.rb
57
+ ```
58
+
59
+ Bulk insertion is explicitly unsafe because it skips validations and callbacks:
60
+
61
+ ```ruby
62
+ FactoryHoist.unsafe_bulk_insert(:user, 10_000)
63
+ ```
64
+
65
+ It uses FactoryBot's `attributes_for` and ActiveRecord's `insert_all!`; it is intended for performance and seed data only.
66
+
67
+ ### Configuration
68
+
69
+ ```ruby
70
+ FactoryHoist.configure do |config|
71
+ config.subxid_budget = 60
72
+ config.suite_seed = ENV.fetch("FACTORY_HOIST_SEED", 0).to_i
73
+ config.paranoid_mode = false
74
+ end
75
+ ```
76
+
77
+ `subxid_budget` rebuilds an owned outer transaction after that many examples. Rebuilding is deferred while an active `before(:context)` hook has written database state that FactoryHoist cannot rematerialize; move that setup into `hoist` if the budget must remain strict. Set it to `0` to disable rebuilding. `suite_seed` makes `FactoryHoist.random` deterministic for a declaration path and key, including Faker-backed Fast Build attributes. `paranoid_mode` checks hoisted ActiveRecord rows before and after every example and raises if they changed.
78
+
79
+ ### Advice and statistics
80
+
81
+ ```console
82
+ $ bundle exec factory_hoist advise spec/
83
+ ```
84
+
85
+ The command runs the selected RSpec suite, then reports repeated literal factory calls, runtime references, degradation, and the 20 most expensive materializations. Runtime counters remain available through `FactoryHoist.stats.to_h`.
86
+
87
+ ## Constraints
88
+
89
+ - Minitest accepts the same DSL and safely creates once per test; cross-test hoisting is intentionally disabled because Minitest has no portable group lifecycle.
90
+ - Declarations that use example-local helpers, instance variables, `initialize_with`, or `to_create` are created inside the example transaction instead of being shared.
91
+ - `after_commit` does not fire inside the managed transaction.
92
+ - Objects that `Marshal` cannot copy silently use example-local creation and increment the degradation counter.
93
+ - DatabaseCleaner truncation is incompatible and emits a warning; use its transaction strategy.
94
+ - System specs need a shared database connection. Multi-database applications are not supported.
95
+ - Raw SQL and `update_all` are rolled back between examples, but can still create ordering effects inside one example.
96
+
97
+ ## Development
98
+
99
+ Run `bundle install`, then `bundle exec rake`. The suite includes an in-memory SQLite integration test for transaction and savepoint behavior.
100
+
101
+ With a local PostgreSQL server available, verify the subtransaction budget, PostgreSQL statistics, and bulk-insert failure recovery using a temporary table:
102
+
103
+ ```console
104
+ $ DATABASE_URL=postgresql:///postgres bundle exec ruby script/verify_postgresql
105
+ ```
106
+
107
+ Parallel workers can clone a disconnected PostgreSQL template under an advisory lock, or a SQLite database through SQLite's online backup API:
108
+
109
+ ```ruby
110
+ FactoryHoist.clone_database(
111
+ source: "postgresql:///app_test_template",
112
+ target: "app_test_1",
113
+ adapter: :postgresql
114
+ )
115
+ ```
116
+
117
+ The design-to-implementation adversarial review is recorded in [docs/adversarial-audit.md](docs/adversarial-audit.md).
118
+
119
+ ## Contributing
120
+
121
+ Bug reports and pull requests are welcome.
122
+
123
+ ## License
124
+
125
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "factory_hoist"
5
+
6
+ class BenchFastBuildUser
7
+ attr_accessor :first_name, :last_name, :email, :age, :city, :active
8
+ end
9
+
10
+ FactoryBot.define do
11
+ factory :bench_fast_build_user do
12
+ first_name { "Ada" }
13
+ last_name { "Lovelace" }
14
+ email { "#{first_name.downcase}.#{last_name.downcase}@example.test" }
15
+ age { 36 }
16
+ city { "London" }
17
+ active { true }
18
+ end
19
+ end
20
+
21
+ iterations = Integer(ENV.fetch("N", 20_000))
22
+ FactoryHoist.build(:bench_fast_build_user)
23
+
24
+ measure = lambda do |&block|
25
+ 3.times.map do
26
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ iterations.times(&block)
28
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
29
+ end.sort[1]
30
+ end
31
+
32
+ factory_bot = measure.call { FactoryBot.build(:bench_fast_build_user) }
33
+ factory_hoist = measure.call { FactoryHoist.build(:bench_fast_build_user) }
34
+ speedup = factory_bot / factory_hoist
35
+
36
+ puts format("FactoryBot %.3fs / FactoryHoist %.3fs / %.2fx", factory_bot, factory_hoist, speedup)
37
+ abort "Fast Build did not reach 5x" if speedup < 5
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "active_record"
5
+ require "factory_hoist"
6
+
7
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
8
+ ActiveRecord::Schema.verbose = false
9
+ ActiveRecord::Schema.define do
10
+ create_table(:phase0_companies) { |table| table.string :name, null: false }
11
+ create_table(:phase0_orders) do |table|
12
+ table.references :company, null: false
13
+ table.string :state, null: false
14
+ end
15
+ end
16
+
17
+ class Phase0Company < ActiveRecord::Base
18
+ end
19
+
20
+ class Phase0Order < ActiveRecord::Base
21
+ belongs_to :company, class_name: "Phase0Company"
22
+ end
23
+
24
+ FactoryBot.define do
25
+ factory :phase0_company do
26
+ name { "Acme" }
27
+ end
28
+ factory :phase0_order do
29
+ association :company, factory: :phase0_company
30
+ state { "paid" }
31
+ end
32
+ end
33
+
34
+ examples = Integer(ENV.fetch("N", 200))
35
+ connection = ActiveRecord::Base.connection
36
+ measure = lambda do |&block|
37
+ inserts = 0
38
+ subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |_name, _start, _finish, _id, payload|
39
+ inserts += 1 if payload[:sql].match?(/\AINSERT/i)
40
+ end
41
+ times = 3.times.map do
42
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43
+ block.call
44
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
45
+ end
46
+ [times.sort[1], inserts / 3]
47
+ ensure
48
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
49
+ end
50
+
51
+ baseline = measure.call do
52
+ examples.times do
53
+ connection.transaction(requires_new: true) do
54
+ FactoryBot.create(:phase0_order)
55
+ raise ActiveRecord::Rollback
56
+ end
57
+ end
58
+ end
59
+
60
+ let_it_be = measure.call do
61
+ connection.transaction(requires_new: true) do
62
+ shared = FactoryBot.create(:phase0_order)
63
+ examples.times do
64
+ connection.transaction(requires_new: true) do
65
+ Marshal.load(Marshal.dump(shared))
66
+ raise ActiveRecord::Rollback
67
+ end
68
+ end
69
+ raise ActiveRecord::Rollback
70
+ end
71
+ end
72
+
73
+ factory_hoist = measure.call do
74
+ FactoryHoist.configuration.factory_adapter = nil
75
+ FactoryHoist.configuration.subxid_budget = examples + 1
76
+ definition = FactoryHoist::Definition.new(:order, :phase0_order, [], {}, nil, "phase0")
77
+ session = FactoryHoist::Runtime::Session.new
78
+ group = Object.new
79
+ session.enter(group, {order: definition})
80
+ examples.times do
81
+ example = Object.new
82
+ example.define_singleton_method(:run) do
83
+ session.fetch(self, :order, definition, {order: definition})
84
+ end
85
+ session.around_example(example)
86
+ end
87
+ session.leave(group)
88
+ end
89
+
90
+ reduction = 1 - factory_hoist[1].fdiv(baseline[1])
91
+ report = <<~REPORT
92
+ # Synthetic Phase 0 report
93
+
94
+ | Path | Median | INSERTs |
95
+ |---|---:|---:|
96
+ | FactoryBot | #{format("%.4fs", baseline[0])} | #{baseline[1]} |
97
+ | let_it_be equivalent | #{format("%.4fs", let_it_be[0])} | #{let_it_be[1]} |
98
+ | factory_hoist | #{format("%.4fs", factory_hoist[0])} | #{factory_hoist[1]} |
99
+
100
+ Record reduction: #{format("%.1f%%", reduction * 100)}
101
+ Dynamic-argument rate: 0.0% (synthetic fixture)
102
+ Tree depth: 1; writing examples per file: #{examples}
103
+ Stop decision: undetermined without the target suite and its target time.
104
+ REPORT
105
+
106
+ puts report
107
+ File.write(ENV["REPORT"], report) if ENV["REPORT"]
108
+ raise "synthetic record reduction missed 60%" if reduction < 0.6
@@ -0,0 +1,50 @@
1
+ # Adversarial implementation audit
2
+
3
+ Audited against `.idea/factory_hoist-design.md` on 2026-09-01.
4
+
5
+ ## Verified
6
+
7
+ | Requirement | Evidence |
8
+ |---|---|
9
+ | RSpec declaration collection, selected-example reference analysis, dependency propagation, and LCA scheduling | Tests for unused, descendant-only, reverse-ordered dependencies, filtered single-example runs, and dynamic references |
10
+ | Safe fallback for dynamic inputs and unsupported factories | RSpec `let`, instance variables, custom Marshal failures, `initialize_with`, and `to_create` all deopt locally |
11
+ | Group/example savepoints, context-hook isolation, rollback, and configurable rebuild budget | SQLite tests cover hook ordering, group/example-local late connections, reset cleanup, partial failures, local rollback, and leaking-row checks after failing hooks |
12
+ | PostgreSQL subxid budget | 61 writes, one rebuild, no added `Subtrans` reads; statistics, bulk recovery, and reset cleanup verified locally and in CI |
13
+ | Lazy graph copy with relationship identity and per-example memoization | RSpec integration tests |
14
+ | Deterministic node/key seed | BLAKE2b-based seed test; Faker random source is scoped when Faker is loaded |
15
+ | PCG random source | Canonical PCG32 vector, arbitrary-size integers, numeric ranges, real Faker inputs, reset reproducibility, and thread-isolated Faker scopes |
16
+ | Failure locality | Materialization errors include the declaration node and key |
17
+ | FactoryBot build/create/list compatibility | Unit and ActiveRecord integration tests, including FactoryBot-compatible block yields |
18
+ | Fast Build | Bounded per-process paths, generated-file backtraces, reload-safe constants, association/alias semantics, generated-name safety, evaluator-name collisions, and no retry after evaluator errors; benchmark above 8x |
19
+ | Bulk Writer | ActiveRecord `insert_all!`, native type casting, row diagnosis, and PostgreSQL outer-transaction recovery after failure |
20
+ | DatabaseCleaner warning and paranoid row checks | Unit and ActiveRecord tests, including named, anonymous, composite-key, and keyless models |
21
+ | RSpec and Minitest correctness | Primary-entrypoint load-order test; RSpec sharing; Minitest deliberately deoptimizes to per-test creation |
22
+ | Packaging and CLI | Gem build, unpacked executable, and CI/full-suite coverage on the declared minimum Ruby 3.2 |
23
+ | Process-parallel database initialization | SQLite online backup covers WAL recovery and exclusive no-overwrite creation; PostgreSQL verifies active-source refusal, lock release, successful clone, and existing-target refusal |
24
+ | Phase 0 harness | Reproducible synthetic three-way benchmark in `docs/phase0-synthetic.md` |
25
+
26
+ ## Not claimable from this repository
27
+
28
+ These are empirical acceptance gates, not library code:
29
+
30
+ - G1's 60% record reduction, the 50% duplication stop condition, and the 30% dynamic-argument stop condition require the target application's full test suite.
31
+ - Comparison against mechanically applied `let_it_be` plus `build_stubbed`, representative single-file timings, RSS, and three-run suite medians require that same suite.
32
+ - MySQL behavior cannot be verified because no MySQL server is available in this environment.
33
+
34
+ ## Contradictions in the design
35
+
36
+ - G2/G4 require no migration and an unchanged suite, while §3.2 requires explicit `hoist` declarations and §4.1 requires mechanical replacement. Both cannot be true simultaneously.
37
+ - `ActiveRecord::Base.instantiate` creates an object treated as persisted, which does not preserve FactoryBot `build` semantics for a row that does not exist. Fast Build uses `new` and real model instances instead.
38
+ - Generic circular-FK `INSERT + UPDATE` is impossible when both foreign keys are immediately enforced and non-null. It requires nullable/deferred constraints or preallocated keys, none of which the API or design specifies.
39
+ - FactoryBot and database sequences are external mutable state. Node-seeding them would change their public values or ID strategy, while §6.2 requires the ID strategy to remain unchanged. The implementation node-seeds its PCG/Faker source only.
40
+
41
+ ## Deliberate safe degradation
42
+
43
+ - Minitest cross-test sharing is disabled because the design leaves its portable group tree/lifecycle unresolved. The compatibility path remains correct and reports deoptimization.
44
+ - Static reference analysis uses MRI bytecode. Dynamic, indirect, and non-MRI block declarations take the correct local fallback.
45
+ - Fast Build keeps lazy attribute readers and a compatibility-only `method_missing` for model and FactoryBot helper methods instead of rewriting arbitrary Ruby blocks. Unsupported definitions fall back, while the 5x DoD remains enforced by the benchmark.
46
+ - Phase 4 attribute-level read tracking has no defined output after A1 rejects it for correctness decisions. The advisor instead reports declaration references, unused hoists, degradation, and materialization cost.
47
+
48
+ ## Verdict
49
+
50
+ The repository is implementation-complete for the coherent, testable subset and exposes runnable checks for SQLite, PostgreSQL, database cloning, Phase 0, and Fast Build. The design as written cannot be completely satisfied because of the contradictions above. Product-level completion also remains blocked on the mandatory target-suite measurements; claiming G1/G2 or the project stop/go decision without that input would be false.
@@ -0,0 +1,19 @@
1
+ # Synthetic Phase 0 report
2
+
3
+ Run on 2026-09-01 with `N=200`, in-memory SQLite, and three-run medians.
4
+
5
+ | Path | Median | INSERTs |
6
+ |---|---:|---:|
7
+ | FactoryBot | 0.0521s | 400 |
8
+ | `let_it_be` equivalent | 0.0126s | 2 |
9
+ | factory_hoist | 0.0244s | 2 |
10
+
11
+ - Record reduction: 99.5%
12
+ - Dynamic-argument rate: 0.0% in the synthetic fixture
13
+ - Tree depth: 1
14
+ - Writing examples per file: 200
15
+ - Fast Build separately measured above 5x FactoryBot
16
+
17
+ The synthetic workload passes the 60% record-reduction gate, but factory_hoist is slower than the `let_it_be` equivalent. The design's stop decision remains undetermined because the target suite, target duration, mechanical `build_stubbed` result, depth distribution, and application code are not present.
18
+
19
+ Reproduce with `bundle exec ruby bench/phase0_synthetic.rb`.
data/exe/factory_hoist ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ unless ARGV.shift == "advise"
5
+ warn "Usage: factory_hoist advise [SPEC_PATH ...]"
6
+ exit 1
7
+ end
8
+
9
+ require "rspec/core"
10
+ require "factory_hoist"
11
+
12
+ paths = ARGV.empty? ? ["spec"] : ARGV
13
+ status = RSpec::Core::Runner.run(paths)
14
+ FactoryHoist.advise(*paths)
15
+ exit status
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ class Advisor
5
+ HOIST = /\bhoist\s*\(?\s*:(\w+)/
6
+ FACTORY_CALL = /\b(create|build|create_list|build_list)\s*\(\s*:(\w+)([^\n]*)\)/
7
+
8
+ def initialize(paths)
9
+ @paths = paths
10
+ end
11
+
12
+ def print(io)
13
+ findings = scan
14
+ if findings.empty?
15
+ io.puts "No factory hoist suggestions."
16
+ else
17
+ findings.each { |finding| io.puts finding }
18
+ end
19
+ print_runtime_stats(io)
20
+ findings
21
+ end
22
+
23
+ private
24
+
25
+ def scan
26
+ files.flat_map { |file| findings_for(file) }
27
+ end
28
+
29
+ def files
30
+ @paths.flat_map do |path|
31
+ File.directory?(path) ? Dir.glob(File.join(path, "**", "*_spec.rb")) : path
32
+ end.select { |path| File.file?(path) }.uniq.sort
33
+ end
34
+
35
+ def findings_for(file)
36
+ source = File.read(file)
37
+ unused = source.to_enum(:scan, HOIST).filter_map do
38
+ match = Regexp.last_match
39
+ name = match[1]
40
+ next if source.scan(/\b#{Regexp.escape(name)}\b/).size > 1
41
+
42
+ "#{file}:#{source[0...match.begin(0)].count("\n") + 1}: unused hoist :#{name}"
43
+ end
44
+ calls = source.scan(FACTORY_CALL).tally.filter_map do |(strategy, name, _arguments), count|
45
+ next unless count > 1
46
+
47
+ "#{file}: repeated #{strategy}(:#{name}) x#{count}; consider hoist(:#{name})"
48
+ end
49
+ unused + calls
50
+ end
51
+
52
+ def print_runtime_stats(io)
53
+ stats = FactoryHoist.stats.to_h
54
+ return if stats[:references].zero? && stats[:materializations].zero?
55
+
56
+ io.puts format(
57
+ "Runtime: %d materializations, %d references, %.1f%% degraded",
58
+ stats[:materializations], stats[:references], stats[:degradation_rate] * 100
59
+ )
60
+ stats[:materialization_costs].first(20).each do |key, seconds|
61
+ io.puts format(" %.3fs %s", seconds, key)
62
+ end
63
+ unused = stats[:materialization_costs].keys - stats[:reference_counts].keys
64
+ unused.each { |key| io.puts " unused at runtime: #{key}" }
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ module BulkWriter
5
+ module_function
6
+
7
+ def call(name, count, traits, attributes)
8
+ unless defined?(::FactoryBot::Internal)
9
+ raise FactoryUnavailableError, "unsafe_bulk_insert requires factory_bot"
10
+ end
11
+
12
+ factory = ::FactoryBot::Internal.factory_by_name(name)
13
+ rows = Array.new(count) { ::FactoryBot.attributes_for(name, *traits, **attributes) }
14
+ return [] if rows.empty?
15
+
16
+ factory.build_class.transaction(requires_new: true) do
17
+ factory.build_class.insert_all!(rows)
18
+ end
19
+ rescue StandardError => error
20
+ index = failing_row(factory&.build_class, rows || [])
21
+ location = index ? " at row #{index}" : ""
22
+ raise BulkWriteError, "#{name} bulk insert failed#{location}: #{error.message}", cause: error
23
+ end
24
+
25
+ def failing_row(model, rows)
26
+ return unless model&.respond_to?(:transaction)
27
+
28
+ failed = nil
29
+ model.transaction(requires_new: true) do
30
+ rows.each_with_index do |row, index|
31
+ model.insert_all!([row])
32
+ rescue StandardError
33
+ failed = index
34
+ raise ::ActiveRecord::Rollback
35
+ end
36
+ raise ::ActiveRecord::Rollback
37
+ end
38
+ failed
39
+ rescue StandardError
40
+ nil
41
+ end
42
+ private_class_method :failing_row
43
+ end
44
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ module Compatibility
5
+ module_function
6
+
7
+ def warn_for_database_cleaner(io = $stderr)
8
+ return unless defined?(::DatabaseCleaner)
9
+
10
+ strategy = ::DatabaseCleaner[:active_record].strategy
11
+ return unless strategy.class.name.end_with?("Truncation")
12
+
13
+ io.puts "factory_hoist: DatabaseCleaner truncation removes hoisted rows; use transaction strategy"
14
+ rescue StandardError
15
+ nil
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ class Configuration
5
+ attr_accessor :factory_adapter, :paranoid_mode, :subxid_budget, :suite_seed
6
+
7
+ def initialize
8
+ @factory_adapter = nil
9
+ @paranoid_mode = false
10
+ @subxid_budget = 60
11
+ @suite_seed = 0
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "set"
5
+
6
+ module FactoryHoist
7
+ module DatabaseSnapshot
8
+ module_function
9
+
10
+ def call(scopes)
11
+ records = records_in(scopes.flat_map { |scope| scope.values.values })
12
+ return if records.empty?
13
+
14
+ rows = records.map do |record|
15
+ model = record.class.name || "table:#{record.class.table_name}"
16
+ primary_keys = Array(record.class.primary_key)
17
+ if primary_keys.empty?
18
+ identity = record.attributes
19
+ current = [identity, record.class.unscoped.where(identity).count]
20
+ else
21
+ identity = primary_keys.zip(Array(record.id)).to_h
22
+ current = record.class.unscoped.find_by(identity)&.attributes
23
+ end
24
+ [model, record.id || identity, current]
25
+ end.sort_by { |model, id, _attributes| [model, id.to_s] }
26
+ Digest::SHA256.hexdigest(Marshal.dump(rows))
27
+ end
28
+
29
+ def records_in(objects)
30
+ return [] unless defined?(::ActiveRecord::Base)
31
+
32
+ seen = Set.new
33
+ records = []
34
+ visit = lambda do |object|
35
+ return if object.nil? || seen.include?(object.object_id)
36
+
37
+ seen << object.object_id
38
+ case object
39
+ when ::ActiveRecord::Base
40
+ records << object if object.persisted?
41
+ associations = object.instance_variable_get(:@association_cache) || {}
42
+ associations.each_value { |association| visit.call(association.target) }
43
+ when Array
44
+ object.each { |value| visit.call(value) }
45
+ when Hash
46
+ object.each_value { |value| visit.call(value) }
47
+ end
48
+ end
49
+ objects.each { |object| visit.call(object) }
50
+ records
51
+ end
52
+ private_class_method :records_in
53
+ end
54
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ module DeepCopy
5
+ module_function
6
+
7
+ def call(object)
8
+ Marshal.load(Marshal.dump(object))
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ Definition = Data.define(:name, :factory, :traits, :attributes, :block, :node_path) do
5
+ def materialize(context)
6
+ dynamic_attributes = if block
7
+ block.arity == 1 ? block.call(context) : context.__factory_hoist_evaluate__(&block)
8
+ else
9
+ {}
10
+ end
11
+ unless dynamic_attributes.is_a?(Hash)
12
+ raise ArgumentError, "hoist(:#{name}) block must return a Hash"
13
+ end
14
+
15
+ seed = Runtime.seed(node_path, name)
16
+ FactoryHoist.with_seed(seed) do
17
+ FactoryHoist.create(factory, *traits, **attributes, **dynamic_attributes)
18
+ end
19
+ rescue MaterializationError
20
+ raise
21
+ rescue StandardError => error
22
+ raise MaterializationError,
23
+ "#{node_path} hoist(:#{name}) failed: #{error.message}",
24
+ cause: error
25
+ end
26
+ end
27
+ end