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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a0ecd2813e2ccbe57b1889f98748c0590d208e83005b32fb0302004b8c31b708
4
+ data.tar.gz: 82dd0fe2b8f3fee39e3eb6821fb2fd3e2c222cebd599c4d6a0cd898b3af780f6
5
+ SHA512:
6
+ metadata.gz: 8585abf3336113d8ba508a67b9b83c207f7e3de3deeeb251f34cd1ba2449e9f62928e73b8e0f350205438f4284f0e07df59ac15456305fb8088d66febff6d738
7
+ data.tar.gz: 02db8e30a432fddd54df80e32aa801bea689660bd252746ab3b79bc3a04e6bccf9cc75eed9c0977641ff37be25bce1237325bed770c457ca78225ec073d692bb
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
+ and this project adheres to [Semantic Versioning](http://semver.org/).
7
+
8
+ ## [0.1.0] - 2026-02-25
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 SOFware LLC
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,249 @@
1
+ # Foundries
2
+
3
+ Declarative trees of related records using factory_bot.
4
+
5
+ Foundries composes factory_bot factories into **blueprints** that know how to create, find, and relate records. You register blueprints with a **base** class, then build entire object graphs with a nested DSL:
6
+
7
+ ```ruby
8
+ TestFoundry.new do
9
+ team "Engineering" do
10
+ user "Alice"
11
+ admin "Bob"
12
+
13
+ project "API" do
14
+ task "Auth", priority: "high"
15
+ task "Caching"
16
+ end
17
+ end
18
+ end
19
+ ```
20
+
21
+ Each method call creates a record (or finds an existing one), and nesting establishes parent-child context automatically. No manual foreign key wiring.
22
+
23
+ ## Installation
24
+
25
+ ```ruby
26
+ gem "foundries"
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### Blueprints
32
+
33
+ A blueprint wraps a single factory_bot factory and declares how it participates in the tree:
34
+
35
+ ```ruby
36
+ class TeamBlueprint < Foundries::Blueprint
37
+ handles :team
38
+ factory :team
39
+ collection :teams
40
+ parent :none
41
+ permitted_attrs %i[name]
42
+
43
+ def team(name, attrs = {}, &block)
44
+ @attrs = attrs.merge(name: name)
45
+ object = find(name) || create_object
46
+ update_state_for_block(object, &block) if block
47
+ object
48
+ ensure
49
+ reset_attrs
50
+ end
51
+
52
+ private
53
+
54
+ def create_object
55
+ create(:team, attrs).tap { |record| collection << record }
56
+ end
57
+
58
+ def attrs
59
+ permitted_attrs @attrs
60
+ end
61
+ end
62
+ ```
63
+
64
+ #### Blueprint DSL
65
+
66
+ | Method | Purpose |
67
+ |--------|---------|
68
+ | `handles :method_name` | Methods this blueprint exposes on the foundry |
69
+ | `factory :name` | Which factory_bot factory to use (inferred from class name if omitted) |
70
+ | `collection :name` | Collection name for tracking created records |
71
+ | `parent :name` | How to find the parent record (`:none`, `:self`, or a method on `current`) |
72
+ | `parent_key :foreign_key` | Foreign key column linking to the parent |
73
+ | `permitted_attrs %i[...]` | Attributes allowed through to factory_bot |
74
+ | `nested_attrs key => [...]` | For `accepts_nested_attributes_for` |
75
+
76
+ #### Finding records
77
+
78
+ Blueprints automatically prevent duplicates. `find(name)` checks the in-memory collection first, then falls back to the database. `find_by(criteria)` works with arbitrary attributes.
79
+
80
+ #### Parent context
81
+
82
+ When a block is passed to a blueprint method, `update_state_for_block` saves the current context, sets the new record as `current.resource`, executes the block, then restores the previous context. Child blueprints read their parent from `current`:
83
+
84
+ ```ruby
85
+ class UserBlueprint < Foundries::Blueprint
86
+ handles :user
87
+ parent :team # reads current.team
88
+ parent_key :team_id # sets team_id on created records
89
+ # ...
90
+ end
91
+ ```
92
+
93
+ ### Base (the foundry)
94
+
95
+ Register blueprints and optional extra collections:
96
+
97
+ ```ruby
98
+ class TestFoundry < Foundries::Base
99
+ blueprint TeamBlueprint
100
+ blueprint UserBlueprint
101
+ blueprint ProjectBlueprint
102
+ blueprint TaskBlueprint
103
+
104
+ collection :tags # extra collection not from a blueprint
105
+ end
106
+ ```
107
+
108
+ The base class:
109
+
110
+ - Instantiates each blueprint and delegates its `handles` methods
111
+ - Initializes a `Set` for each collection (e.g. `teams_collection`)
112
+ - Tracks `current` state so nested blocks know their parent context
113
+ - Deduplicates records via each blueprint's `find` logic
114
+
115
+ ### Presets
116
+
117
+ Presets are named class methods that build a preconfigured foundry:
118
+
119
+ ```ruby
120
+ class TestFoundry < Foundries::Base
121
+ # ...
122
+
123
+ preset :dev_team do
124
+ team "Engineering" do
125
+ user "Alice"
126
+ project "Main" do
127
+ task "Setup"
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ # In a test:
134
+ let(:foundry) { TestFoundry.dev_team }
135
+ ```
136
+
137
+ ### Reopening
138
+
139
+ Add more records to an existing foundry:
140
+
141
+ ```ruby
142
+ foundry = TestFoundry.dev_team
143
+ foundry.reopen do
144
+ team "Design" do
145
+ user "Carol"
146
+ end
147
+ end
148
+ ```
149
+
150
+ ### Building from existing objects
151
+
152
+ Start from records already in the database:
153
+
154
+ ```ruby
155
+ foundry = TestFoundry.new
156
+ foundry.from(existing_team) do
157
+ user "New hire"
158
+ end
159
+ ```
160
+
161
+ ### Lifecycle hooks
162
+
163
+ Override `setup` and `teardown` in your base subclass for pre/post processing:
164
+
165
+ ```ruby
166
+ class TestFoundry < Foundries::Base
167
+ private
168
+
169
+ def setup
170
+ @pending_rules = []
171
+ end
172
+
173
+ def teardown
174
+ process_pending_rules
175
+ end
176
+ end
177
+ ```
178
+
179
+ ## Snapshot Caching
180
+
181
+ When using ActiveRecord, Foundries can snapshot preset data to disk and restore it instead of re-running factories. This is useful for speeding up test suites where the same preset is called many times.
182
+
183
+ Enable with an environment variable:
184
+
185
+ ```
186
+ FOUNDRIES_CACHE=1 bundle exec rspec
187
+ ```
188
+
189
+ Or configure directly:
190
+
191
+ ```ruby
192
+ Foundries::Snapshot.enabled = true
193
+ Foundries::Snapshot.storage_path = "tmp/foundries" # default
194
+ Foundries::Snapshot.source_paths = [
195
+ "lib/blueprints/**/*.rb",
196
+ "lib/test_foundry.rb"
197
+ ]
198
+ ```
199
+
200
+ Snapshots are invalidated automatically when the schema version changes or when source files listed in `source_paths` are modified. Data is captured using database-native copy operations (PostgreSQL `COPY`, SQLite `INSERT`) and restored with referential integrity checks temporarily disabled.
201
+
202
+ ## Similarity Detection
203
+
204
+ Foundries can detect when presets have overlapping structure, highlighting consolidation opportunities. When enabled, it records the normalized blueprint call tree of each preset and compares against previously seen presets.
205
+
206
+ Enable with an environment variable:
207
+
208
+ ```
209
+ FOUNDRIES_SIMILARITY=1 bundle exec rspec
210
+ ```
211
+
212
+ Or configure directly:
213
+
214
+ ```ruby
215
+ Foundries::Similarity.enabled = true
216
+ ```
217
+
218
+ When two presets share identical structure or one is structurally contained within another, a warning is printed to stderr:
219
+
220
+ ```
221
+ [Foundries] Preset :basic and :extended have identical structure (team > [project > [task], user])
222
+ [Foundries] Preset :simple is structurally contained within :complex
223
+ ```
224
+
225
+ Each unique pair is warned once per process. The detection normalizes trees by deduplicating sibling nodes (keeping the richest subtree), collapsing pass-through chains, and sorting alphabetically. This means presets that build the same *shape* of data are detected regardless of the specific names or attribute values used.
226
+
227
+ ## Requirements
228
+
229
+ - Ruby >= 4.0
230
+ - factory_bot >= 6.0
231
+ - ActiveRecord (optional, for snapshot caching)
232
+
233
+ ## License
234
+
235
+ MIT
236
+
237
+ ## Development
238
+
239
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake` to run the tests.
240
+
241
+ To install this gem onto your local machine, run bundle exec rake install.
242
+
243
+ This project is managed with [Reissue](https://github.com/SOFware/reissue).
244
+
245
+ Releases are automated via the [shared release workflow](https://github.com/SOFware/reissue/blob/main/.github/workflows/SHARED_WORKFLOW_README.md). Trigger a release by running the "Release gem to RubyGems.org" workflow from the Actions tab.
246
+
247
+ ## Contributing
248
+
249
+ Bug reports and pull requests are welcome on GitHub at https://github.com/SOFware/foundries.
data/Rakefile ADDED
@@ -0,0 +1,17 @@
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
+ require "standard/rake"
9
+
10
+ task default: %i[spec standard]
11
+
12
+ require "reissue/gem"
13
+
14
+ Reissue::Task.create :reissue do |task|
15
+ task.version_file = "lib/foundries/version.rb"
16
+ task.fragment = :git
17
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+
5
+ module Foundries
6
+ # Base is the orchestrator that composes multiple Blueprints into a single
7
+ # declarative builder for trees of related records.
8
+ #
9
+ # Subclass Base and declare which blueprints it uses:
10
+ #
11
+ # class MyFoundry < Foundries::Base
12
+ # blueprint UserBlueprint
13
+ # blueprint ProjectBlueprint
14
+ #
15
+ # # Optional: additional collections beyond what blueprints declare
16
+ # collection :tags
17
+ # end
18
+ #
19
+ # MyFoundry.new do
20
+ # user "Alice" do
21
+ # project "Widget" do
22
+ # # ...
23
+ # end
24
+ # end
25
+ # end
26
+ #
27
+ class Base
28
+ include FactoryBot::Syntax::Methods
29
+
30
+ class << self
31
+ attr_accessor :_active_similarity_recorder
32
+
33
+ # Register a blueprint class with this foundry.
34
+ def blueprint(klass)
35
+ blueprint_registry[klass] = klass.handled_methods
36
+ end
37
+
38
+ # All registered blueprint classes and their handled methods.
39
+ def blueprint_registry
40
+ @blueprint_registry ||= {}
41
+ end
42
+
43
+ # Declare additional collection names beyond those from blueprints.
44
+ def collection(*names)
45
+ extra_collections.concat(names.map(&:to_s))
46
+ end
47
+
48
+ def extra_collections
49
+ @extra_collections ||= []
50
+ end
51
+
52
+ # All collection accessor names (e.g. "users_collection").
53
+ def collection_accessors
54
+ (blueprint_collection_names + extra_collections).map { |name| "#{name}_collection" }
55
+ end
56
+
57
+ # Collection names derived from blueprint declarations.
58
+ def blueprint_collection_names
59
+ blueprint_registry.keys.filter_map { |klass| klass.collection_name&.to_s }
60
+ end
61
+
62
+ # Methods delegated from this foundry to its blueprint instances.
63
+ def delegations
64
+ blueprint_registry.select { |_, methods| methods.any? }
65
+ end
66
+
67
+ # Define presets — named class methods that build a preconfigured foundry.
68
+ #
69
+ # class MyFoundry < Foundries::Base
70
+ # preset :full_team do
71
+ # user "Alice"
72
+ # user "Bob"
73
+ # end
74
+ # end
75
+ #
76
+ # MyFoundry.full_team # => configured foundry instance
77
+ #
78
+ def preset(name, &block)
79
+ define_singleton_method(name) do
80
+ with_similarity_recording(name, Similarity.enabled?) do
81
+ if defined?(Foundries::Snapshot) && Foundries::Snapshot.enabled?
82
+ store = Foundries::Snapshot::Store.new(name)
83
+
84
+ if store.cached?
85
+ store.restore
86
+ next new # hollow — no block, data already in DB
87
+ end
88
+
89
+ store.record_empty_tables
90
+ foundry = new(&block)
91
+ store.capture
92
+ foundry
93
+ else
94
+ new(&block)
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ def with_similarity_recording(preset_name, recording)
101
+ if recording
102
+ self._active_similarity_recorder = Similarity::Recorder.new
103
+ foundry = yield
104
+ tree = _active_similarity_recorder.normalized_tree
105
+ self._active_similarity_recorder = nil
106
+
107
+ key = "#{name}.#{preset_name}"
108
+ warnings = Similarity::Comparator.compare(key, tree, Similarity.registry)
109
+ warnings.each do |w|
110
+ next unless Similarity.warned_pairs.add?(w[:pair])
111
+ warn w[:message]
112
+ end
113
+ Similarity.registry[key] = tree
114
+ foundry
115
+ else
116
+ yield
117
+ end
118
+ end
119
+
120
+ def inherited(subclass)
121
+ super
122
+ # Ensure subclasses get their own registries
123
+ subclass.instance_variable_set(:@blueprint_registry, {})
124
+ subclass.instance_variable_set(:@extra_collections, [])
125
+ end
126
+ end
127
+
128
+ def initialize(&block)
129
+ @_similarity_recorder = self.class._active_similarity_recorder
130
+ instantiate_blueprints
131
+ initialize_collections
132
+ @current = OpenStruct.new(resource: self)
133
+ setup
134
+ instance_exec(&block) if block
135
+ teardown
136
+ @current.resource = nil
137
+ end
138
+
139
+ attr_accessor :current
140
+
141
+ # Reopen the foundry to add more records.
142
+ def reopen(&block)
143
+ @current = OpenStruct.new(resource: self)
144
+ instance_exec(&block) if block
145
+ teardown
146
+ @current.resource = nil
147
+ self
148
+ end
149
+
150
+ # Build within the context of existing objects.
151
+ def from(objects, &block)
152
+ execute_and_restore_state do
153
+ load_existing_objects(objects)
154
+ instance_exec(&block) if block
155
+ teardown
156
+ end
157
+ end
158
+
159
+ def load_existing_objects(objects)
160
+ return if objects.nil? || (objects.respond_to?(:empty?) && objects.empty?)
161
+
162
+ Array(objects).each do |object|
163
+ load_state(object)
164
+
165
+ klass_name = object.class.name
166
+ blueprint_class = find_blueprint_class_for(klass_name)
167
+ next unless blueprint_class
168
+
169
+ blueprint_class.load_state_from(object, self)
170
+ end
171
+ end
172
+
173
+ def execute_and_restore_state
174
+ initial_state = @current.dup
175
+ yield.tap { @current = initial_state }
176
+ end
177
+
178
+ def load_state(object)
179
+ klass_name = object.class.name.underscore.tr("/", "_")
180
+ current.send(:"#{klass_name}=", object)
181
+ collection_name = "#{klass_name.pluralize}_collection"
182
+ return unless respond_to?(collection_name)
183
+
184
+ send(collection_name) << object
185
+ end
186
+ alias_method :update_current, :load_state
187
+
188
+ private
189
+
190
+ # Override in subclasses for post-initialize hooks (e.g. pending phase rules).
191
+ def setup
192
+ end
193
+
194
+ # Override in subclasses for post-block hooks (e.g. processing pending items).
195
+ def teardown
196
+ end
197
+
198
+ def instantiate_blueprints
199
+ self.class.blueprint_registry.each_key do |klass|
200
+ ivar = :"@#{ivar_name_for(klass)}"
201
+ instance_variable_set(ivar, klass.new(self))
202
+ end
203
+
204
+ # Set up delegation from foundry methods to blueprint instances
205
+ self.class.delegations.each do |klass, methods|
206
+ ivar = :"@#{ivar_name_for(klass)}"
207
+ blueprint_instance = instance_variable_get(ivar)
208
+ methods.each do |method_name|
209
+ define_singleton_method(method_name) do |*args, **kwargs, &block|
210
+ blueprint_instance.send(method_name, *args, **kwargs, &block)
211
+ end
212
+ end
213
+ end
214
+ end
215
+
216
+ def initialize_collections
217
+ self.class.collection_accessors.each do |col|
218
+ instance_variable_set(:"@#{col}", Set.new)
219
+ # Define accessor if not already defined
220
+ define_singleton_method(col) { instance_variable_get(:"@#{col}") }
221
+ define_singleton_method(:"#{col}=") { |val| instance_variable_set(:"@#{col}", val) }
222
+ end
223
+ end
224
+
225
+ def ivar_name_for(klass)
226
+ klass.name.demodulize.underscore
227
+ end
228
+
229
+ def find_blueprint_class_for(model_class_name)
230
+ self.class.blueprint_registry.keys.detect do |klass|
231
+ klass.name.demodulize.delete_suffix("Blueprint") == model_class_name
232
+ end
233
+ end
234
+ end
235
+ end