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,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ripper"
5
+ require "tmpdir"
6
+
7
+ module FactoryHoist
8
+ module FastBuild
9
+ FALLBACK = Object.new.freeze
10
+ @compiled = {}
11
+ @generation = 0
12
+ @pending = {}
13
+ @mutex = Mutex.new
14
+
15
+ class << self
16
+ def call(name, traits, overrides)
17
+ return FALLBACK unless traits.empty?
18
+
19
+ key = name.is_a?(Symbol) ? name : name.to_sym
20
+ cached = @compiled[key]
21
+ evaluator = cached ? cached[:evaluator] : compile(key)
22
+ if evaluator && !overrides.empty?
23
+ override_names = overrides.keys.map(&:to_sym)
24
+ aliases = evaluator.factory_ir[:attributes].any? do |attribute|
25
+ override_names.any? { |override| attribute.name != override && attribute.alias_for?(override) }
26
+ end
27
+ return FALLBACK if aliases
28
+ end
29
+ evaluator ? evaluator.new(overrides).build : FALLBACK
30
+ end
31
+
32
+ def compile(name)
33
+ factory = ::FactoryBot::Internal.factory_by_name(name)
34
+ cached = @compiled[name.to_sym]
35
+ return cached[:evaluator] if cached && cached[:factory].equal?(factory)
36
+
37
+ @mutex.synchronize do
38
+ cached = @compiled[name.to_sym]
39
+ return cached[:evaluator] if cached && cached[:factory].equal?(factory)
40
+
41
+ ir = build_ir(factory)
42
+ return unless ir
43
+
44
+ install_reload_hook
45
+ @generation += 1
46
+ safe_name = name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 80]
47
+ token = "#{safe_name}_#{Process.pid}_#{factory.object_id}_#{@generation}"
48
+ @pending[token] = ir
49
+ path = source_path(token)
50
+ ir[:source_path] = path
51
+ FileUtils.mkdir_p(File.dirname(path))
52
+ File.write(path, generated_source(token, ir))
53
+ require path
54
+ @compiled.fetch(name.to_sym).fetch(:evaluator)
55
+ end
56
+ rescue KeyError, NameError
57
+ nil
58
+ end
59
+
60
+ def install(token, &builder)
61
+ ir = @pending.delete(token)
62
+ evaluator = Class.new(Evaluator)
63
+ ir[:attributes].each do |attribute|
64
+ raw_name = :"__factory_hoist_#{attribute.name}"
65
+ evaluator.define_method(raw_name, &attribute.to_proc)
66
+ end
67
+ evaluator.class_eval(&builder)
68
+ evaluator.factory_ir = ir
69
+ @compiled[ir[:name]] = {
70
+ factory: ir[:factory], evaluator: evaluator, source_path: ir[:source_path]
71
+ }
72
+ end
73
+
74
+ def reset!
75
+ @mutex.synchronize do
76
+ @compiled.clear
77
+ @pending.clear
78
+ end
79
+ end
80
+
81
+ def reload!
82
+ @mutex.synchronize do
83
+ @compiled.each_value { |entry| entry[:evaluator].factory_ir.delete(:klass) }
84
+ end
85
+ end
86
+
87
+ def source_path(token)
88
+ File.join(Dir.tmpdir, "factory_hoist", "#{token}.rb")
89
+ end
90
+
91
+ def compiled_source(name)
92
+ @compiled.dig(name.to_sym, :source_path)
93
+ end
94
+
95
+ private
96
+
97
+ def install_reload_hook
98
+ return if @reload_hook_installed
99
+ return unless defined?(::ActiveSupport::Reloader)
100
+
101
+ ::ActiveSupport::Reloader.to_prepare { reload! }
102
+ @reload_hook_installed = true
103
+ end
104
+
105
+ def generated_source(token, ir)
106
+ assignments = ir[:assigned].map { |name| " object.#{name} = #{name}" }.join("\n")
107
+ known = (ir[:assigned] | ir[:ignored]).map(&:inspect).join(", ")
108
+ readers = ir[:attributes].map do |attribute|
109
+ name = attribute.name
110
+ <<~RUBY
111
+ def #{name}
112
+ return @cache[:#{name}] if @cache.key?(:#{name})
113
+ @cache[:#{name}] = __factory_hoist_#{name}
114
+ end
115
+ RUBY
116
+ end.join("\n")
117
+ <<~RUBY
118
+ FactoryHoist::FastBuild.install(#{token.dump}) do
119
+ #{readers}
120
+ def build
121
+ object = @instance = build_class.new
122
+ #{assignments}
123
+ unless @overrides.empty?
124
+ @overrides.each do |name, value|
125
+ object.public_send(:"\#{name}=", value) unless [#{known}].include?(name)
126
+ end
127
+ end
128
+ object
129
+ end
130
+ end
131
+ RUBY
132
+ end
133
+
134
+ def build_ir(factory)
135
+ factory.compile
136
+ definition = factory.definition
137
+ return unless definition.callbacks.empty? && definition.constructor.nil?
138
+
139
+ attributes = factory.send(:attributes).to_a
140
+ klass = factory.build_class
141
+ assigned = attributes.reject(&:ignored).map(&:name)
142
+ names = attributes.map(&:name)
143
+ reserved = Evaluator.instance_methods(true) | Evaluator.private_instance_methods(true) | [:build]
144
+ required_arguments = klass.instance_method(:initialize).parameters.any? do |type, _name|
145
+ %i[req keyreq].include?(type)
146
+ end
147
+ invalid_name = names.any? do |name|
148
+ !name.match?(/\A[a-z_]\w*\z/) || reserved.include?(name) || Ripper.lex(name.to_s).first[1] == :on_kw
149
+ end
150
+ return if required_arguments || !klass.name || invalid_name
151
+
152
+ {
153
+ name: factory.name,
154
+ factory: factory,
155
+ class_name: klass.name,
156
+ klass: klass,
157
+ attributes: attributes,
158
+ assigned: assigned,
159
+ ignored: attributes.select(&:ignored).map(&:name)
160
+ }
161
+ end
162
+ end
163
+
164
+ class Evaluator
165
+ class << self
166
+ attr_accessor :factory_ir
167
+ end
168
+
169
+ def initialize(overrides)
170
+ @overrides = overrides.empty? ? overrides : overrides.transform_keys(&:to_sym)
171
+ @cache = @overrides.empty? ? {} : @overrides.dup
172
+ end
173
+
174
+ attr_reader :instance
175
+
176
+ def association(name, *traits_and_overrides)
177
+ overrides = traits_and_overrides.last.is_a?(Hash) ? traits_and_overrides.pop.dup : {}
178
+ strategy = overrides.delete(:strategy)
179
+ strategy ||= ::FactoryBot.use_parent_strategy ? :build : :create
180
+ if %i[build create].include?(strategy)
181
+ return FactoryHoist.public_send(strategy, name, *traits_and_overrides, **overrides)
182
+ end
183
+
184
+ ::FactoryBot.public_send(strategy, name, *traits_and_overrides, **overrides)
185
+ end
186
+
187
+ def method_missing(name, ...)
188
+ return @instance.send(name, ...) if @instance.respond_to?(name)
189
+ return ::FactoryBot::SyntaxRunner.new.send(name, ...) if ::FactoryBot::SyntaxRunner.new.respond_to?(name)
190
+
191
+ super
192
+ end
193
+
194
+ def respond_to_missing?(name, include_private = false)
195
+ @instance.respond_to?(name) || ::FactoryBot::SyntaxRunner.new.respond_to?(name) || super
196
+ end
197
+
198
+ private
199
+
200
+ def build_class
201
+ ir = self.class.factory_ir
202
+ return ir[:klass] if ir[:klass]
203
+
204
+ parts = ir[:class_name].split("::").reject(&:empty?)
205
+ return ir[:klass] = Object.const_get(parts.first, false) if parts.one?
206
+
207
+ ir[:klass] = parts.inject(Object) { |scope, constant| scope.const_get(constant, false) }
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ module MinitestDSL
5
+ def hoist(name, factory = name, *traits, **attributes, &block)
6
+ name = name.to_sym
7
+ definitions = if instance_variable_defined?(:@factory_hoist_definitions)
8
+ @factory_hoist_definitions
9
+ else
10
+ @factory_hoist_definitions = {}
11
+ end
12
+ raise DuplicateHoistError, "hoist(:#{name}) is already declared in this class" if definitions.key?(name)
13
+
14
+ definition = Definition.new(name, factory, traits.freeze, attributes.freeze, block, to_s)
15
+ definitions[name] = definition
16
+ define_method(name) { factory_hoist_local_values.fetch(name) }
17
+ end
18
+
19
+ def factory_hoist_definitions
20
+ inherited = superclass.respond_to?(:factory_hoist_definitions) ? superclass.factory_hoist_definitions : {}
21
+ inherited.merge(instance_variable_get(:@factory_hoist_definitions) || {})
22
+ end
23
+ end
24
+
25
+ class MinitestValues
26
+ def initialize(test)
27
+ @test = test
28
+ @values = {}
29
+ end
30
+
31
+ def fetch(name)
32
+ FactoryHoist.stats.increment(:references)
33
+ return @values[name] if @values.key?(name)
34
+
35
+ definition = @test.class.factory_hoist_definitions.fetch(name)
36
+ FactoryHoist.stats.record_reference("#{definition.node_path} #{name}")
37
+ FactoryHoist.stats.increment(:deoptimizations)
38
+ @values[name] = definition.materialize(self)
39
+ end
40
+
41
+ alias_method :[], :fetch
42
+
43
+ def __factory_hoist_evaluate__(&block)
44
+ @test.instance_exec(&block)
45
+ end
46
+
47
+ def method_missing(name, *args, **kwargs, &block)
48
+ if args.empty? && kwargs.empty? && @test.class.factory_hoist_definitions.key?(name)
49
+ return fetch(name)
50
+ end
51
+ return @test.__send__(name, *args, **kwargs, &block) if @test.respond_to?(name, true)
52
+
53
+ super
54
+ end
55
+
56
+ def respond_to_missing?(name, include_private = false)
57
+ @test.class.factory_hoist_definitions.key?(name) || @test.respond_to?(name, true) || super
58
+ end
59
+ end
60
+
61
+ module MinitestInstance
62
+ private
63
+
64
+ def factory_hoist_local_values
65
+ @factory_hoist_local_values ||= MinitestValues.new(self)
66
+ end
67
+ end
68
+
69
+ def self.install_minitest!
70
+ return unless defined?(::Minitest::Test)
71
+ return if ::Minitest::Test.singleton_class < MinitestDSL
72
+
73
+ ::Minitest::Test.extend(MinitestDSL)
74
+ ::Minitest::Test.include(MinitestInstance)
75
+ end
76
+ end
77
+
78
+ FactoryHoist.install_minitest!
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ module ParallelDatabase
5
+ module_function
6
+
7
+ def clone(source:, target:, adapter:)
8
+ case adapter.respond_to?(:to_sym) && adapter.to_sym
9
+ when :postgresql then clone_postgresql(source, target)
10
+ when :sqlite then clone_sqlite(source, target)
11
+ else raise ArgumentError, "parallel database cloning is unsupported for #{adapter}"
12
+ end
13
+ end
14
+
15
+ def clone_sqlite(source, target)
16
+ require "sqlite3"
17
+
18
+ created = false
19
+ complete = false
20
+ mode = File.stat(source).mode & 0o777
21
+ File.open(target, File::WRONLY | File::CREAT | File::EXCL, 0o600).close
22
+ created = true
23
+ source_database = SQLite3::Database.new(source, readonly: true)
24
+ target_database = SQLite3::Database.new(target)
25
+ backup = SQLite3::Backup.new(target_database, "main", source_database, "main")
26
+ status = backup.step(-1)
27
+ unless status == SQLite3::Constants::ErrorCode::DONE
28
+ raise Error, "SQLite backup failed with status #{status}"
29
+ end
30
+
31
+ File.chmod(mode, target)
32
+ complete = true
33
+ target
34
+ rescue Errno::EEXIST
35
+ raise Error, "target database already exists: #{target}"
36
+ ensure
37
+ backup&.finish
38
+ target_database&.close
39
+ source_database&.close
40
+ File.unlink(target) if created && !complete && File.exist?(target)
41
+ end
42
+ private_class_method :clone_sqlite
43
+
44
+ def clone_postgresql(source_url, target)
45
+ require "pg"
46
+ valid_target = target.is_a?(String) && target.bytesize <= 63 && target.match?(/\A[a-zA-Z0-9_]+\z/)
47
+ raise ArgumentError, "invalid target database name" unless valid_target
48
+
49
+ source_connection = PG.connect(source_url)
50
+ parameters = source_connection.conninfo_hash.transform_keys(&:to_sym)
51
+ .reject { |_key, value| value.nil? || value.empty? }
52
+ source = parameters[:dbname]
53
+ source_connection.close
54
+ raise ArgumentError, "source URL must include a database" unless source
55
+
56
+ admin = PG.connect(parameters.merge(dbname: "postgres"))
57
+ admin.exec_params("SELECT pg_advisory_lock(hashtext($1))", ["factory_hoist_database_clone"])
58
+ ensure_cloneable!(admin, source, target)
59
+ admin.exec("CREATE DATABASE #{admin.quote_ident(target)} TEMPLATE #{admin.quote_ident(source)}")
60
+ target
61
+ ensure
62
+ source_connection&.close unless source_connection&.finished?
63
+ if admin && !admin.finished?
64
+ begin
65
+ admin.exec_params("SELECT pg_advisory_unlock(hashtext($1))", ["factory_hoist_database_clone"])
66
+ rescue PG::Error
67
+ nil
68
+ ensure
69
+ admin.close
70
+ end
71
+ end
72
+ end
73
+ private_class_method :clone_postgresql
74
+
75
+ def ensure_cloneable!(connection, source, target)
76
+ exists = connection.exec_params("SELECT 1 FROM pg_database WHERE datname = $1", [target]).ntuples.positive?
77
+ raise Error, "target database already exists: #{target}" if exists
78
+
79
+ connections = connection.exec_params(
80
+ "SELECT count(*) FROM pg_stat_activity WHERE datname = $1",
81
+ [source]
82
+ ).getvalue(0, 0).to_i
83
+ raise Error, "source database has #{connections} active connection(s): #{source}" if connections.positive?
84
+ end
85
+ private_class_method :ensure_cloneable!
86
+ end
87
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FactoryHoist
4
+ class PCG32
5
+ MULTIPLIER = 6_364_136_223_846_793_005
6
+ MASK_64 = (1 << 64) - 1
7
+
8
+ def initialize(seed, stream = 1)
9
+ @state = 0
10
+ @increment = ((stream << 1) | 1) & MASK_64
11
+ next_uint32
12
+ @state = (@state + seed) & MASK_64
13
+ next_uint32
14
+ end
15
+
16
+ def rand(limit = nil)
17
+ return next_uint32.fdiv(1 << 32) unless limit
18
+ return integer(limit) if limit.is_a?(Integer)
19
+ return range(limit) if limit.is_a?(Range)
20
+ return rand * limit if limit.is_a?(Numeric) && limit.positive?
21
+
22
+ raise ArgumentError, "unsupported random limit: #{limit.inspect}"
23
+ end
24
+
25
+ def bytes(length)
26
+ Array.new(length) { rand(256) }.pack("C*")
27
+ end
28
+
29
+ private
30
+
31
+ def integer(limit)
32
+ raise ArgumentError, "random limit must be positive: #{limit}" unless limit.positive?
33
+ return 0 if limit == 1
34
+
35
+ bits = (limit - 1).bit_length
36
+ words = (bits + 31) / 32
37
+ mask = (1 << bits) - 1
38
+ loop do
39
+ value = 0
40
+ words.times { value = (value << 32) | next_uint32 }
41
+ value &= mask
42
+ return value if value < limit
43
+ end
44
+ end
45
+
46
+ def range(value)
47
+ first = value.begin
48
+ last = value.end
49
+ if first.is_a?(Integer) && last.is_a?(Integer)
50
+ size = last - first + (value.exclude_end? ? 0 : 1)
51
+ return first + integer(size)
52
+ end
53
+ if defined?(::Date) && first.instance_of?(::Date) && last.instance_of?(::Date)
54
+ size = (last - first).to_i + (value.exclude_end? ? 0 : 1)
55
+ return first + integer(size)
56
+ end
57
+ return first if first == last && !value.exclude_end?
58
+
59
+ size = last - first
60
+ raise ArgumentError, "non-numeric range distance: #{value.inspect}" unless size.is_a?(Numeric)
61
+ raise ArgumentError, "empty random range: #{value.inspect}" unless size.positive?
62
+
63
+ first + (rand * size)
64
+ rescue NoMethodError
65
+ raise ArgumentError, "unsupported random range: #{value.inspect}"
66
+ end
67
+
68
+ def next_uint32
69
+ old_state = @state
70
+ @state = (old_state * MULTIPLIER + @increment) & MASK_64
71
+ xor_shifted = (((old_state >> 18) ^ old_state) >> 27) & 0xffffffff
72
+ rotation = old_state >> 59
73
+ ((xor_shifted >> rotation) | (xor_shifted << ((-rotation) & 31))) & 0xffffffff
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "compatibility"
4
+ require_relative "scheduler"
5
+
6
+ module FactoryHoist
7
+ module RSpecDSL
8
+ def hoist(name, factory = name, *traits, **attributes, &block)
9
+ name = name.to_sym
10
+ definitions = if instance_variable_defined?(:@factory_hoist_definitions)
11
+ @factory_hoist_definitions
12
+ else
13
+ @factory_hoist_definitions = {}
14
+ end
15
+ raise DuplicateHoistError, "hoist(:#{name}) is already declared in this group" if definitions.key?(name)
16
+
17
+ node_path = metadata[:full_description]
18
+ definition = Definition.new(name, factory, traits.freeze, attributes.freeze, block, node_path)
19
+ definitions[name] = definition
20
+ define_method(name) do
21
+ Runtime.current.fetch(self, name, definition, Scheduler.definitions_for(self.class))
22
+ end
23
+ end
24
+ end
25
+
26
+ ::RSpec::Core::ExampleGroup.extend(RSpecDSL)
27
+ ::RSpec.configure do |config|
28
+ config.before(:suite) do
29
+ Compatibility.warn_for_database_cleaner
30
+ Scheduler.install!
31
+ end
32
+ config.around(:each) do |example|
33
+ local = !Scheduler.definitions_for(example.example_group).empty?
34
+ Runtime.current.around_example(example, local: local)
35
+ end
36
+ end
37
+ end