foundries 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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ # Blueprint is the base class for individual factory wrappers within a Foundry.
5
+ #
6
+ # Each Blueprint wraps one or more factory_bot factories and knows how to:
7
+ # - Create records using factory_bot
8
+ # - Track created records in a collection
9
+ # - Navigate parent-child relationships
10
+ # - Find existing records before creating duplicates
11
+ #
12
+ # Subclass Blueprint and use the class-level DSL to declare behavior:
13
+ #
14
+ # class UserBlueprint < Foundries::Blueprint
15
+ # handles :user, :admin
16
+ # factory :user
17
+ # collection :users
18
+ # parent :none
19
+ # permitted_attrs %i[name email]
20
+ # end
21
+ #
22
+ class Blueprint
23
+ include FactoryBot::Syntax::Methods
24
+
25
+ attr_reader :foundry
26
+
27
+ class << self
28
+ def handles(*methods)
29
+ @handled_methods ||= []
30
+ @handled_methods.concat(methods)
31
+ end
32
+
33
+ def handled_methods
34
+ @handled_methods || []
35
+ end
36
+
37
+ # Declare which factory_bot factory this blueprint uses.
38
+ # If not set, inferred from the class name.
39
+ def factory(name = nil)
40
+ if name
41
+ @factory_name = name
42
+ else
43
+ @factory_name || inferred_factory_name
44
+ end
45
+ end
46
+
47
+ def factory_name
48
+ factory
49
+ end
50
+
51
+ # Declare the collection name for tracking created records.
52
+ # This also defines `#collection` and `#record_class` instance methods.
53
+ def collection(method_name = nil)
54
+ return @collection_name unless method_name
55
+
56
+ @collection_name = method_name
57
+
58
+ define_method(:collection) do
59
+ foundry.send(:"#{method_name}_collection")
60
+ end
61
+
62
+ define_method(:record_class) do
63
+ method_name.to_s.classify.constantize
64
+ end
65
+ end
66
+
67
+ attr_reader :collection_name
68
+
69
+ # Declare the foreign key used to link to the parent.
70
+ def parent_key(key = nil)
71
+ return @parent_key_name unless key
72
+
73
+ @parent_key_name = key
74
+ define_method(:parent_key) { key }
75
+ end
76
+
77
+ # Declare how to find the parent record from `current` state.
78
+ #
79
+ # parent :none - no parent relationship
80
+ # parent :self - self-referential (e.g. nested categories)
81
+ # parent :competency - reads current.competency
82
+ #
83
+ def parent(method_name = nil)
84
+ return @parent_method unless method_name
85
+
86
+ @parent_method = method_name
87
+
88
+ if method_name == :none
89
+ define_method(:same_parent?) { |_| true }
90
+ elsif method_name == :self
91
+ define_method(:same_parent?) { |_| true }
92
+ define_method(:parent) do
93
+ current.send(current_accessor)
94
+ end
95
+ else
96
+ define_method(:parent) do
97
+ current.send(method_name)
98
+ end
99
+
100
+ define_singleton_method(:parent_accessor) do
101
+ method_name
102
+ end
103
+ end
104
+ end
105
+
106
+ # Declare which attributes are allowed through to factory_bot.
107
+ def permitted_attrs(attr_list)
108
+ define_method(:permitted_attrs) do |attrs|
109
+ keys = attr_list.dup
110
+ keys << parent_key if parent_key
111
+ attrs.slice(*keys)
112
+ end
113
+ end
114
+
115
+ # Declare nested attributes (for accepts_nested_attributes_for).
116
+ def nested_attrs(hash)
117
+ nested_object_name, attr_names = hash.shift
118
+
119
+ define_method(:nested_attrs) do |attrs|
120
+ attrs_to_nest = attrs.slice(*attr_names)
121
+ key = :"#{nested_object_name}_attributes"
122
+ {key => attrs_to_nest}
123
+ end
124
+ end
125
+
126
+ # Load state from an existing object back into a foundry.
127
+ def load_state_from(object, foundry)
128
+ return unless respond_to?(:parent_accessor)
129
+
130
+ parent_object = object.send(parent_accessor)
131
+ foundry.load_existing_objects(parent_object)
132
+ end
133
+
134
+ private
135
+
136
+ def inferred_factory_name
137
+ name&.demodulize&.delete_suffix("Blueprint")&.underscore&.to_sym
138
+ end
139
+ end
140
+
141
+ def self.new(...)
142
+ instance = super
143
+ foundry = instance.foundry
144
+ recorder = foundry&.instance_variable_get(:@_similarity_recorder)
145
+ instance._wrap_for_similarity_recording!(recorder) if recorder
146
+ instance
147
+ end
148
+
149
+ def initialize(foundry)
150
+ @foundry = foundry
151
+ @attrs = {}
152
+ end
153
+
154
+ def _wrap_for_similarity_recording!(recorder)
155
+ methods_to_wrap = self.class.public_instance_methods(false).select do |m|
156
+ self.class.instance_method(m).parameters.any? { |type, _| type == :block }
157
+ end
158
+
159
+ methods_to_wrap.each do |method_name|
160
+ original = method(method_name)
161
+ define_singleton_method(method_name) do |*args, **kwargs, &block|
162
+ recorder.record(method_name.to_s, has_block: !block.nil?) do
163
+ original.call(*args, **kwargs, &block)
164
+ end
165
+ end
166
+ end
167
+ end
168
+
169
+ delegate :current=, :current, :update_current, :execute_and_restore_state,
170
+ to: :foundry
171
+
172
+ def assume_trait?(val)
173
+ val.is_a?(Symbol) || val.is_a?(Array)
174
+ end
175
+
176
+ def inspect
177
+ self.class.name
178
+ end
179
+
180
+ def parent_key
181
+ nil
182
+ end
183
+
184
+ def parent
185
+ nil
186
+ end
187
+
188
+ # Saves current state, yields, then restores state.
189
+ # Use this when entering a nested block to scope context.
190
+ def update_state_for_block(object, &block)
191
+ execute_and_restore_state do
192
+ update_current(object)
193
+ current.resource = object
194
+ instance_exec(&block)
195
+ end
196
+ end
197
+
198
+ # Find a record in the collection by name, falling back to the database.
199
+ def find(name, col_name: "name")
200
+ raise "#find called with nil :name, for col_name: #{col_name}." unless name
201
+
202
+ found_record = collection.detect do |object|
203
+ object.send(col_name).casecmp?(name) && same_parent?(object)
204
+ end
205
+
206
+ found_record ||
207
+ record_class.find_by(col_name => name)&.tap { |rec| collection << rec }
208
+ end
209
+
210
+ # Find a record in the collection by arbitrary criteria, falling back to the database.
211
+ def find_by(criteria = {})
212
+ found_record = collection.detect do |object|
213
+ criteria.all? { |attr, value| object.send(attr) == value }
214
+ end
215
+
216
+ return found_record if found_record
217
+
218
+ record_class.find_by(criteria)&.tap { |record| collection << record }
219
+ end
220
+
221
+ def same_parent?(object)
222
+ return true unless parent
223
+
224
+ object.send(parent_key) == parent_id
225
+ end
226
+
227
+ def parent_id
228
+ parent&.id
229
+ end
230
+
231
+ def current_accessor
232
+ self.class.name.demodulize.underscore.delete_suffix("_blueprint")
233
+ end
234
+
235
+ def find_or_create_object
236
+ send(:"#{mode}_object")
237
+ end
238
+
239
+ def mode
240
+ current.resource.nil? ? :find : :create
241
+ end
242
+
243
+ def reset_attrs_and_type
244
+ @type = nil
245
+ reset_attrs
246
+ end
247
+
248
+ def reset_attrs
249
+ @attrs = {}
250
+ end
251
+
252
+ # Delegate unknown methods to the foundry so that all blueprint methods
253
+ # are available in nested blocks. Also supports dynamic find_<klass>_by.
254
+ def method_missing(name, *args, **kwargs, &block)
255
+ if (match = missing_find_by_request?(name))
256
+ klass_name = match.named_captures["klass"]
257
+ return collection_find_by(klass_name, args)
258
+ end
259
+
260
+ if foundry.respond_to?(name)
261
+ return foundry.send(name, *args, **kwargs, &block)
262
+ end
263
+
264
+ super
265
+ end
266
+
267
+ def respond_to_missing?(name, include_private = false)
268
+ missing_find_by_request?(name) || foundry.respond_to?(name) || super
269
+ end
270
+
271
+ private
272
+
273
+ def missing_find_by_request?(method_name)
274
+ method_name.match(/^find_(?<klass>.*)_by$/)
275
+ end
276
+
277
+ def collection_find_by(klass_name, args)
278
+ attrs = args.first
279
+ target_collection_name = "#{klass_name.pluralize}_collection"
280
+ objects = foundry.send(target_collection_name)
281
+ objects.detect do |object|
282
+ attrs.all? { |(attr, value)| object.send(attr) == value }
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ module Similarity
5
+ module Comparator
6
+ def self.compare(new_key, new_tree, registry)
7
+ warnings = []
8
+
9
+ registry.each do |existing_key, existing_tree|
10
+ next if existing_key == new_key
11
+ pair_key = [new_key, existing_key].sort
12
+
13
+ if new_tree == existing_tree
14
+ warnings << {
15
+ pair: pair_key,
16
+ message: "[Foundries] Preset :#{preset_name(new_key)} and " \
17
+ ":#{preset_name(existing_key)} have identical structure " \
18
+ "(#{display_tree(new_tree)})"
19
+ }
20
+ elsif existing_tree.contains?(new_tree)
21
+ warnings << {
22
+ pair: pair_key,
23
+ message: "[Foundries] Preset :#{preset_name(new_key)} is " \
24
+ "structurally contained within :#{preset_name(existing_key)}"
25
+ }
26
+ elsif new_tree.contains?(existing_tree)
27
+ warnings << {
28
+ pair: pair_key,
29
+ message: "[Foundries] Preset :#{preset_name(existing_key)} is " \
30
+ "structurally contained within :#{preset_name(new_key)}"
31
+ }
32
+ end
33
+ end
34
+
35
+ warnings
36
+ end
37
+
38
+ def self.preset_name(key)
39
+ key.to_s.split(".").last
40
+ end
41
+
42
+ def self.display_tree(tree)
43
+ tree.children.map(&:to_s).join(", ")
44
+ end
45
+
46
+ private_class_method :preset_name, :display_tree
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "structure_tree"
4
+
5
+ module Foundries
6
+ module Similarity
7
+ class Recorder
8
+ def initialize
9
+ @root = StructureTree.root(children: [])
10
+ @stack = [@root]
11
+ end
12
+
13
+ def record(method_name, has_block:)
14
+ node = StructureTree.new(method_name)
15
+ current_parent.children << node
16
+ if has_block
17
+ @stack.push(node)
18
+ yield
19
+ @stack.pop
20
+ elsif block_given?
21
+ yield
22
+ end
23
+ end
24
+
25
+ def normalized_tree
26
+ @root.normalize
27
+ end
28
+
29
+ private
30
+
31
+ def current_parent
32
+ @stack.last
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ module Similarity
5
+ class StructureTree
6
+ attr_reader :name, :children
7
+
8
+ def initialize(name, children: [])
9
+ @name = name.to_s
10
+ @children = children
11
+ end
12
+
13
+ def self.root(children:)
14
+ new("__root__", children: children)
15
+ end
16
+
17
+ def normalize
18
+ normalized_children = children.map(&:normalize)
19
+ collapsed = normalized_children.flat_map { |child| child.collapse_into(name) }
20
+ deduped = collapsed
21
+ .group_by(&:name)
22
+ .map { |_name, group| group.max_by(&:descendant_count) }
23
+ .sort_by(&:name)
24
+ self.class.new(name, children: deduped)
25
+ end
26
+
27
+ def collapse_into(parent_name)
28
+ if name == parent_name && !children.empty?
29
+ children
30
+ else
31
+ [self]
32
+ end
33
+ end
34
+
35
+ def descendant_count
36
+ children.sum { |c| 1 + c.descendant_count }
37
+ end
38
+
39
+ def ==(other)
40
+ other.is_a?(self.class) &&
41
+ name == other.name &&
42
+ children == other.children
43
+ end
44
+
45
+ alias_method :eql?, :==
46
+
47
+ def hash
48
+ [name, children].hash
49
+ end
50
+
51
+ def contains?(other)
52
+ return true if self == other
53
+
54
+ embeds?(other) || children.any? { |child| child.contains?(other) }
55
+ end
56
+
57
+ def embeds?(other)
58
+ return false unless name == other.name
59
+
60
+ other.children.all? do |other_child|
61
+ children.any? { |child| child.embeds?(other_child) }
62
+ end
63
+ end
64
+
65
+ def to_s
66
+ if children.empty?
67
+ name
68
+ else
69
+ "#{name} > [#{children.map(&:to_s).join(", ")}]"
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "similarity/structure_tree"
4
+ require_relative "similarity/recorder"
5
+ require_relative "similarity/comparator"
6
+
7
+ module Foundries
8
+ module Similarity
9
+ class << self
10
+ attr_writer :enabled
11
+
12
+ def enabled?
13
+ return @enabled unless @enabled.nil?
14
+ ENV["FOUNDRIES_SIMILARITY"] == "1"
15
+ end
16
+
17
+ def registry
18
+ @registry ||= {}
19
+ end
20
+
21
+ def warned_pairs
22
+ @warned_pairs ||= Set.new
23
+ end
24
+
25
+ def reset!
26
+ @registry = {}
27
+ @warned_pairs = Set.new
28
+ @enabled = nil
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ module Snapshot
5
+ module Adapter
6
+ def self.for(connection)
7
+ case connection.adapter_name
8
+ when /postgresql/i
9
+ Adapters::PostgresAdapter.new(connection)
10
+ when /sqlite/i
11
+ Adapters::SqliteAdapter.new(connection)
12
+ else
13
+ raise "Unsupported adapter: #{connection.adapter_name}"
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ module Snapshot
5
+ module Adapters
6
+ class PostgresAdapter
7
+ def initialize(connection)
8
+ @connection = connection
9
+ end
10
+
11
+ def table_names
12
+ @connection.tables - %w[schema_migrations ar_internal_metadata]
13
+ end
14
+
15
+ def empty?(table_name)
16
+ @connection.select_value("SELECT NOT EXISTS (SELECT 1 FROM #{quoted(table_name)})")
17
+ end
18
+
19
+ def capture(table_name, io)
20
+ raw = @connection.raw_connection
21
+ raw.copy_data("COPY #{quoted(table_name)} TO STDOUT") do
22
+ while (row = raw.get_copy_data)
23
+ io.write(row)
24
+ end
25
+ end
26
+ end
27
+
28
+ def restore(table_name, io)
29
+ raw = @connection.raw_connection
30
+ raw.copy_data("COPY #{quoted(table_name)} FROM STDIN") do
31
+ io.each_line do |line|
32
+ raw.put_copy_data(line)
33
+ end
34
+ end
35
+ end
36
+
37
+ def disable_referential_integrity
38
+ @connection.execute("SET session_replication_role = replica")
39
+ yield
40
+ ensure
41
+ @connection.execute("SET session_replication_role = DEFAULT")
42
+ end
43
+
44
+ def reset_sequence(table_name)
45
+ pk = @connection.primary_key(table_name)
46
+ return unless pk
47
+
48
+ # Check for sequence first — UUID PKs have no sequence
49
+ seq = @connection.select_value(
50
+ "SELECT pg_get_serial_sequence(#{@connection.quote(table_name)}, #{@connection.quote(pk)})"
51
+ )
52
+ return unless seq
53
+
54
+ max_id = @connection.select_value(
55
+ "SELECT COALESCE(MAX(#{@connection.quote_column_name(pk)}), 0) FROM #{quoted(table_name)}"
56
+ )
57
+ @connection.execute("SELECT setval(#{@connection.quote(seq)}, #{max_id})")
58
+ end
59
+
60
+ private
61
+
62
+ def quoted(table_name)
63
+ @connection.quote_table_name(table_name)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ module Snapshot
5
+ module Adapters
6
+ class SqliteAdapter
7
+ def initialize(connection)
8
+ @connection = connection
9
+ end
10
+
11
+ def table_names
12
+ @connection.tables - %w[schema_migrations ar_internal_metadata]
13
+ end
14
+
15
+ def empty?(table_name)
16
+ @connection.select_value("SELECT COUNT(*) FROM #{quoted(table_name)}") == 0
17
+ end
18
+
19
+ def capture(table_name, io)
20
+ rows = @connection.select_all("SELECT * FROM #{quoted(table_name)}")
21
+ rows.each do |row|
22
+ values = row.values.map { |v| @connection.quote(v) }.join(", ")
23
+ columns = row.keys.map { |k| @connection.quote_column_name(k) }.join(", ")
24
+ io.puts "INSERT INTO #{quoted(table_name)} (#{columns}) VALUES (#{values});"
25
+ end
26
+ end
27
+
28
+ def restore(table_name, io)
29
+ io.each_line do |line|
30
+ @connection.execute(line.strip) unless line.strip.empty?
31
+ end
32
+ end
33
+
34
+ def disable_referential_integrity
35
+ @connection.execute("PRAGMA defer_foreign_keys = ON")
36
+ yield
37
+ ensure
38
+ @connection.execute("PRAGMA defer_foreign_keys = OFF")
39
+ end
40
+
41
+ def reset_sequence(table_name)
42
+ # SQLite auto-increments handle this naturally
43
+ end
44
+
45
+ private
46
+
47
+ def quoted(table_name)
48
+ @connection.quote_table_name(table_name)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foundries
4
+ module Snapshot
5
+ class Fingerprint
6
+ def initialize(connection, source_paths: [])
7
+ @connection = connection
8
+ @source_paths = Array(source_paths)
9
+ end
10
+
11
+ def current
12
+ digest = Digest::MD5.new
13
+ digest.update(schema_version)
14
+ @source_paths.sort.each do |path|
15
+ digest.update(File.read(path)) if File.exist?(path)
16
+ end
17
+ digest.hexdigest
18
+ end
19
+
20
+ private
21
+
22
+ def schema_version
23
+ @connection.select_value(
24
+ "SELECT MAX(version) FROM schema_migrations"
25
+ ).to_s
26
+ end
27
+ end
28
+ end
29
+ end