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.
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "factory_bot"
4
+ require "minitest"
5
+ require "monitor"
6
+ require "rspec/core"
7
+ require_relative "factory_hoist/configuration"
8
+ require_relative "factory_hoist/pcg32"
9
+ require_relative "factory_hoist/runtime"
10
+ require_relative "factory_hoist/stats"
11
+ require_relative "factory_hoist/version"
12
+
13
+ module FactoryHoist
14
+ RANDOM_MONITOR = Monitor.new
15
+
16
+ class Error < StandardError; end
17
+ class BulkWriteError < Error; end
18
+ class DuplicateHoistError < Error; end
19
+ class FactoryUnavailableError < Error; end
20
+ class MaterializationError < Error; end
21
+ class SharedDataMutationError < Error; end
22
+
23
+ class << self
24
+ attr_writer :configuration
25
+
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ def configure
31
+ yield(configuration)
32
+ end
33
+
34
+ def reset!
35
+ @configuration = Configuration.new
36
+ Thread.current[:factory_hoist_random] = nil
37
+ stats.reset!
38
+ Runtime.reset!
39
+ FastBuild.reset! if defined?(FastBuild)
40
+ end
41
+
42
+ def build(name, *traits, **attributes, &block)
43
+ result = if defined?(::Faker::Config)
44
+ with_random_source(random) { build_factory(name, traits, attributes) }
45
+ else
46
+ build_factory(name, traits, attributes)
47
+ end
48
+
49
+ result.tap { |record| block&.call(record) }
50
+ end
51
+
52
+ def create(name, *traits, **attributes, &block)
53
+ run_factory(:create, name, traits, attributes).tap { |record| block&.call(record) }
54
+ end
55
+
56
+ def build_list(name, count, *traits, **attributes, &block)
57
+ Array.new(count) do |index|
58
+ build(name, *traits, **attributes).tap { |record| block&.call(record, index) }
59
+ end
60
+ end
61
+
62
+ def create_list(name, count, *traits, **attributes, &block)
63
+ Array.new(count) do |index|
64
+ create(name, *traits, **attributes).tap { |record| block&.call(record, index) }
65
+ end
66
+ end
67
+
68
+ def unsafe_bulk_insert(name, count, *traits, **attributes)
69
+ require_relative "factory_hoist/bulk_writer"
70
+ BulkWriter.call(name, count, traits, attributes)
71
+ end
72
+
73
+ def clone_database(source:, target:, adapter:)
74
+ require_relative "factory_hoist/parallel_database"
75
+ ParallelDatabase.clone(source: source, target: target, adapter: adapter)
76
+ end
77
+
78
+ def advise(*paths, io: $stdout)
79
+ require_relative "factory_hoist/advisor"
80
+ Advisor.new(paths.empty? ? ["spec"] : paths).print(io)
81
+ end
82
+
83
+ def stats
84
+ @stats ||= Stats.new
85
+ end
86
+
87
+ def random
88
+ Thread.current[:factory_hoist_random] ||= PCG32.new(configuration.suite_seed)
89
+ end
90
+
91
+ def with_seed(seed)
92
+ with_random_source(PCG32.new(seed)) { yield }
93
+ end
94
+
95
+ private
96
+
97
+ def build_factory(name, traits, attributes)
98
+ adapter = configuration.factory_adapter
99
+ return adapter.call(:build, name, traits, attributes) if adapter
100
+
101
+ require_relative "factory_hoist/fast_build" unless defined?(::FactoryHoist::FastBuild)
102
+ result = FastBuild.call(name, traits, attributes)
103
+ return result unless result.equal?(FastBuild::FALLBACK)
104
+
105
+ call_factory_bot(:build, name, traits, attributes)
106
+ end
107
+
108
+ def with_random_source(source)
109
+ faker_config = ::Faker::Config if defined?(::Faker::Config)
110
+ if RANDOM_MONITOR.mon_owned? && Thread.current[:factory_hoist_random].equal?(source) &&
111
+ (!faker_config || faker_config.random.equal?(source))
112
+ return yield
113
+ end
114
+ return scope_random_source(source) { yield } unless faker_config
115
+
116
+ RANDOM_MONITOR.synchronize { scope_random_source(source) { yield } }
117
+ end
118
+
119
+ def scope_random_source(source)
120
+ previous = Thread.current[:factory_hoist_random]
121
+ Thread.current[:factory_hoist_random] = source
122
+ faker_config = ::Faker::Config if defined?(::Faker::Config)
123
+ previous_faker = faker_config.random if faker_config
124
+ faker_config.random = source if faker_config
125
+ yield
126
+ ensure
127
+ faker_config.random = previous_faker if faker_config
128
+ Thread.current[:factory_hoist_random] = previous
129
+ end
130
+
131
+ def run_factory(strategy, name, traits, attributes)
132
+ adapter = configuration.factory_adapter
133
+ adapter ||= default_factory_adapter
134
+ adapter.call(strategy, name, traits, attributes)
135
+ end
136
+
137
+ def default_factory_adapter
138
+ return method(:call_factory_bot) if defined?(::FactoryBot)
139
+
140
+ raise FactoryUnavailableError,
141
+ "configure FactoryHoist.configuration.factory_adapter or require factory_bot"
142
+ end
143
+
144
+ def call_factory_bot(strategy, name, traits, attributes)
145
+ ::FactoryBot.public_send(strategy, name, *traits, **attributes)
146
+ end
147
+ end
148
+ end
149
+
150
+ require_relative "factory_hoist/rspec"
151
+ require_relative "factory_hoist/minitest"
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "active_record"
6
+ require "factory_hoist"
7
+
8
+ ActiveRecord::Base.establish_connection(
9
+ ENV.fetch("DATABASE_URL", "postgresql:///postgres")
10
+ )
11
+ connection = ActiveRecord::Base.connection
12
+ connection.execute <<~SQL
13
+ CREATE TEMP TABLE factory_hoist_subxid_check (
14
+ id bigserial PRIMARY KEY,
15
+ name text NOT NULL
16
+ ) ON COMMIT PRESERVE ROWS
17
+ SQL
18
+
19
+ model = Class.new(ActiveRecord::Base) do
20
+ self.table_name = "factory_hoist_subxid_check"
21
+ end
22
+ FactoryHoist.reset!
23
+ FactoryHoist.configuration.subxid_budget = 60
24
+ FactoryHoist.configuration.factory_adapter = lambda do |_strategy, _name, _traits, attributes|
25
+ model.create!({name: "hoisted"}.merge(attributes))
26
+ end
27
+
28
+ definition = FactoryHoist::Definition.new(:record, :record, [], {}, nil, "postgres check")
29
+ session = FactoryHoist::Runtime::Session.new
30
+ group = Object.new
31
+ transaction_ids = []
32
+ subtrans_reads = lambda do
33
+ connection.select_value("SELECT blks_read FROM pg_stat_slru WHERE name = 'Subtrans'").to_i
34
+ end
35
+ before_reads = subtrans_reads.call
36
+ checkpoint = connection.select_value("SELECT next_xid::text FROM pg_control_checkpoint()")
37
+
38
+ session.enter(group, {record: definition})
39
+ 61.times do
40
+ example = Object.new
41
+ example.define_singleton_method(:run) do
42
+ connection.execute("INSERT INTO factory_hoist_subxid_check(name) VALUES ('example')")
43
+ transaction_ids << connection.select_value("SELECT txid_current_if_assigned()")
44
+ raise "backend xid is not visible" unless connection.select_value(<<~SQL)
45
+ SELECT backend_xid::text FROM pg_stat_activity WHERE pid = pg_backend_pid()
46
+ SQL
47
+ end
48
+ session.around_example(example)
49
+ end
50
+ session.leave(group)
51
+
52
+ raise "outer transaction was not rebuilt" unless transaction_ids.uniq.size == 2
53
+ raise "unexpected subtrans disk read" unless subtrans_reads.call == before_reads
54
+ raise "hoisted rows leaked" unless model.count.zero?
55
+ raise "pg_control_checkpoint unavailable" unless checkpoint
56
+
57
+ FactoryHoist.configuration.subxid_budget = 1
58
+ context_group = Object.new
59
+ session.enter(context_group, {record: definition}, materialize: false)
60
+ model.create!(name: "context setup")
61
+ session.materialize(context_group)
62
+ 2.times do
63
+ example = Object.new
64
+ example.define_singleton_method(:run) do
65
+ raise "context setup was lost during rebuild" unless model.count == 2
66
+ end
67
+ session.around_example(example)
68
+ end
69
+ session.leave(context_group)
70
+ raise "context setup leaked" unless model.count.zero?
71
+
72
+ FactoryBot.define do
73
+ factory :factory_hoist_postgres_bulk_check, class: model do
74
+ name { "bulk" }
75
+ end
76
+ end
77
+ connection.transaction(requires_new: true) do
78
+ begin
79
+ FactoryHoist.unsafe_bulk_insert(:factory_hoist_postgres_bulk_check, 2, name: nil)
80
+ rescue FactoryHoist::BulkWriteError => error
81
+ raise "bulk failure location missing" unless error.message.include?("row 0")
82
+ else
83
+ raise "invalid bulk insert unexpectedly succeeded"
84
+ end
85
+ model.create!(name: "transaction remains usable")
86
+ raise ActiveRecord::Rollback
87
+ end
88
+ raise "bulk verification leaked rows" unless model.count.zero?
89
+
90
+ FactoryHoist.configuration.factory_adapter = lambda do |_strategy, _name, _traits, attributes|
91
+ model.create!({name: "reset"}.merge(attributes))
92
+ end
93
+ reset_definition = FactoryHoist::Definition.new(:record, :record, [], {}, nil, "reset check")
94
+ FactoryHoist::Runtime.current.enter(Object.new, {record: reset_definition})
95
+ raise "reset setup failed" unless model.count == 1
96
+
97
+ FactoryHoist.reset!
98
+
99
+ raise "reset left a transaction open" if connection.transaction_open?
100
+ raise "reset leaked rows" unless model.count.zero?
101
+
102
+ puts "PostgreSQL verified: subxid budget, context state, bulk failure recovery, and reset cleanup"
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "pg"
6
+ require "factory_hoist"
7
+
8
+ suffix = "#{Process.pid}_#{Time.now.to_i}"
9
+ source = "factory_hoist_source_#{suffix}"
10
+ target = "factory_hoist_worker_#{suffix}"
11
+ begin
12
+ admin = PG.connect(dbname: "postgres")
13
+ admin.exec("CREATE DATABASE #{admin.quote_ident(source)}")
14
+ seed = PG.connect(dbname: source)
15
+ seed.exec("CREATE TABLE proof(value text NOT NULL)")
16
+ seed.exec("INSERT INTO proof VALUES ('cloned')")
17
+
18
+ begin
19
+ FactoryHoist.clone_database(
20
+ source: "postgresql:///#{source}",
21
+ target: target,
22
+ adapter: :postgresql
23
+ )
24
+ rescue FactoryHoist::Error => error
25
+ raise unless error.message.include?("active connection")
26
+ else
27
+ raise "clone accepted an active source connection"
28
+ end
29
+ seed.close
30
+
31
+ FactoryHoist.clone_database(
32
+ source: "postgresql:///#{source}",
33
+ target: target,
34
+ adapter: :postgresql
35
+ )
36
+ worker = PG.connect(dbname: target)
37
+ raise "template data missing" unless worker.exec("SELECT value FROM proof").getvalue(0, 0) == "cloned"
38
+ worker.close
39
+
40
+ begin
41
+ FactoryHoist.clone_database(
42
+ source: "postgresql:///#{source}",
43
+ target: target,
44
+ adapter: :postgresql
45
+ )
46
+ rescue FactoryHoist::Error => error
47
+ raise unless error.message.include?("already exists")
48
+ else
49
+ raise "clone overwrote an existing target"
50
+ end
51
+
52
+ puts "PostgreSQL template clone and refusal paths verified: #{source} -> #{target}"
53
+ ensure
54
+ worker&.close unless worker&.finished?
55
+ seed&.close unless seed&.finished?
56
+ if admin
57
+ admin.exec("DROP DATABASE IF EXISTS #{admin.quote_ident(target)} WITH (FORCE)") if target
58
+ admin.exec("DROP DATABASE IF EXISTS #{admin.quote_ident(source)} WITH (FORCE)") if source
59
+ admin.close
60
+ end
61
+ end
metadata ADDED
@@ -0,0 +1,130 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: factory_hoist
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: factory_bot
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.5'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '7'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '6.5'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '7'
32
+ - !ruby/object:Gem::Dependency
33
+ name: minitest
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '5'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '7'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '5'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '7'
52
+ - !ruby/object:Gem::Dependency
53
+ name: rspec-core
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '3.12'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '4'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.12'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '4'
72
+ description: Hoists FactoryBot records to RSpec group boundaries while isolating each
73
+ example.
74
+ email:
75
+ - t.yudai92@gmail.com
76
+ executables:
77
+ - factory_hoist
78
+ extensions: []
79
+ extra_rdoc_files: []
80
+ files:
81
+ - LICENSE.txt
82
+ - README.md
83
+ - Rakefile
84
+ - bench/fast_build.rb
85
+ - bench/phase0_synthetic.rb
86
+ - docs/adversarial-audit.md
87
+ - docs/phase0-synthetic.md
88
+ - exe/factory_hoist
89
+ - lib/factory_hoist.rb
90
+ - lib/factory_hoist/advisor.rb
91
+ - lib/factory_hoist/bulk_writer.rb
92
+ - lib/factory_hoist/compatibility.rb
93
+ - lib/factory_hoist/configuration.rb
94
+ - lib/factory_hoist/database_snapshot.rb
95
+ - lib/factory_hoist/deep_copy.rb
96
+ - lib/factory_hoist/definition.rb
97
+ - lib/factory_hoist/fast_build.rb
98
+ - lib/factory_hoist/minitest.rb
99
+ - lib/factory_hoist/parallel_database.rb
100
+ - lib/factory_hoist/pcg32.rb
101
+ - lib/factory_hoist/rspec.rb
102
+ - lib/factory_hoist/runtime.rb
103
+ - lib/factory_hoist/scheduler.rb
104
+ - lib/factory_hoist/stats.rb
105
+ - lib/factory_hoist/transaction.rb
106
+ - lib/factory_hoist/version.rb
107
+ - script/verify_postgresql
108
+ - script/verify_postgresql_clone
109
+ licenses:
110
+ - MIT
111
+ metadata:
112
+ rubygems_mfa_required: 'true'
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: 3.2.0
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubygems_version: 4.0.19
128
+ specification_version: 4
129
+ summary: Share factory data safely across RSpec examples
130
+ test_files: []