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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/bench/fast_build.rb +37 -0
- data/bench/phase0_synthetic.rb +108 -0
- data/docs/adversarial-audit.md +50 -0
- data/docs/phase0-synthetic.md +19 -0
- data/exe/factory_hoist +15 -0
- data/lib/factory_hoist/advisor.rb +67 -0
- data/lib/factory_hoist/bulk_writer.rb +44 -0
- data/lib/factory_hoist/compatibility.rb +18 -0
- data/lib/factory_hoist/configuration.rb +14 -0
- data/lib/factory_hoist/database_snapshot.rb +54 -0
- data/lib/factory_hoist/deep_copy.rb +11 -0
- data/lib/factory_hoist/definition.rb +27 -0
- data/lib/factory_hoist/fast_build.rb +211 -0
- data/lib/factory_hoist/minitest.rb +78 -0
- data/lib/factory_hoist/parallel_database.rb +87 -0
- data/lib/factory_hoist/pcg32.rb +76 -0
- data/lib/factory_hoist/rspec.rb +37 -0
- data/lib/factory_hoist/runtime.rb +326 -0
- data/lib/factory_hoist/scheduler.rb +133 -0
- data/lib/factory_hoist/stats.rb +52 -0
- data/lib/factory_hoist/transaction.rb +90 -0
- data/lib/factory_hoist/version.rb +5 -0
- data/lib/factory_hoist.rb +151 -0
- data/script/verify_postgresql +102 -0
- data/script/verify_postgresql_clone +61 -0
- metadata +130 -0
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
data/bench/fast_build.rb
ADDED
|
@@ -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,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
|