foundries 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf089585b858ec4b4fb09964e7b8e17d1c2bcc3dcc1236a5a1414afb49810c11
4
- data.tar.gz: 9919d0537dd62b84c9e3b49ccfce1de19778998203aca7e5bff36292e790fb8c
3
+ metadata.gz: c8245b09fe2227ef4a25d5751c30f4a94a1d2d80519ef5fb97b8ba2b9dc527ef
4
+ data.tar.gz: 24162154115e9f413117fb88d71aa0cbc19374cbbf9b6fa059f23e36535511e5
5
5
  SHA512:
6
- metadata.gz: 8a1a3e44f94106f6a4b8ac0702c9ffff6d55e54d57cd4c7e19d8c34eb8ee74cebadadf35267a49269c5e638d2fae9421de3e47af9577377b9d42f9fc21cdd38d
7
- data.tar.gz: f3045a5705fd20d79d0ac77c29b4f104f6c6d09b690e39bb94d93c3f78df8458f883da25ec5efe68aced414d11cb1b879db6b03c205befa308dbf9f4aba1bf75
6
+ metadata.gz: f0b22d20d6edc795ef1c7a2f3403b1a7e5cbbbc0d3ca4bd19fc82c25992bcea6d460175df202d8cd0465dd328a8bc189672171423ed283aa7879390450261318
7
+ data.tar.gz: 1ece80e18cdc964013db346ec665e2919f146bda411ddaf4254abb4c714c712a4a692cb30241a051da98029c164fbbc77b1e4fc44c2dac0cadda396c0fbccadf
data/CHANGELOG.md CHANGED
@@ -5,22 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
- ## [0.1.1] - 2026-03-06
8
+ ## [0.1.3] - 2026-03-13
9
9
 
10
10
  ### Added
11
11
 
12
- - Blueprint base class with collection tracking, parent scoping, and factory_bot integration (0002336)
13
- - Snapshot caching system with Postgres and SQLite adapters for test suite acceleration (0002336)
14
- - Source file content hashing in snapshot fingerprint for automatic cache invalidation (cfb348d)
12
+ - ancestor DSL and ancestors_for for path-based hierarchy building (7027e06)
15
13
 
16
- ### Changed
14
+ ## [0.1.2] - 2026-03-12
17
15
 
18
- - Snapshot caching is opt-in via ARMATURE_CACHE=1 environment variable (0002336)
19
- - Similarity detection only warns on identical structure, not containment (a5bdf25)
16
+ ### Added
20
17
 
21
- ### Fixed
18
+ - aliases DSL on Base for shorthand method names (377632f)
19
+ - lookup_order DSL on Blueprint for ancestor traversal (377632f)
20
+ - find_or_create pattern on Blueprint (377632f)
21
+ - Tests for inherited registries, parent-scoped find, collection_find_by kwargs, find_or_create, ascending_find, and parent_present? (2d92945)
22
22
 
23
- - Parentless blueprints no longer raise NameError when find calls same_parent? (cfb348d)
24
- - Snapshot-cached presets no longer produce false similarity warnings (a5bdf25)
23
+ ### Changed
25
24
 
26
- ## [0.1.0] - 2026-02-25
25
+ - Collections initialize before blueprints (377632f)
26
+ - Fix find parent scoping and collection_find_by kwargs handling (3ebfc3e)
@@ -59,6 +59,18 @@ module Foundries
59
59
  blueprint_registry.keys.filter_map { |klass| klass.collection_name&.to_s }
60
60
  end
61
61
 
62
+ # Declare shorthand aliases for blueprint methods.
63
+ #
64
+ # aliases member: :enrollment, hva_mod: :skilled_mod
65
+ #
66
+ # Resolves aliases during method_missing before blueprint
67
+ # delegation.
68
+ def aliases(mapping = nil)
69
+ return @aliases || {} unless mapping
70
+
71
+ @aliases = (@aliases || {}).merge(mapping)
72
+ end
73
+
62
74
  # Methods delegated from this foundry to its blueprint instances.
63
75
  def delegations
64
76
  blueprint_registry.select { |_, methods| methods.any? }
@@ -121,16 +133,17 @@ module Foundries
121
133
 
122
134
  def inherited(subclass)
123
135
  super
124
- # Ensure subclasses get their own registries
125
- subclass.instance_variable_set(:@blueprint_registry, {})
126
- subclass.instance_variable_set(:@extra_collections, [])
136
+ # Copy parent registries so subclasses inherit blueprints
137
+ subclass.instance_variable_set(:@blueprint_registry, blueprint_registry.dup)
138
+ subclass.instance_variable_set(:@extra_collections, extra_collections.dup)
139
+ subclass.instance_variable_set(:@aliases, aliases.dup)
127
140
  end
128
141
  end
129
142
 
130
143
  def initialize(&block)
131
144
  @_similarity_recorder = self.class._active_similarity_recorder
132
- instantiate_blueprints
133
145
  initialize_collections
146
+ instantiate_blueprints
134
147
  @current = OpenStruct.new(resource: self)
135
148
  setup
136
149
  instance_exec(&block) if block
@@ -187,6 +200,31 @@ module Foundries
187
200
  end
188
201
  alias_method :update_current, :load_state
189
202
 
203
+ # Build a hierarchy from a path string or array.
204
+ # Finds the blueprint that handles `type` and calls
205
+ # `ancestors` on it to recursively create the chain.
206
+ #
207
+ # ancestors_for :task, "org/block/template/phase/mod/event" do
208
+ # task "my_task"
209
+ # end
210
+ #
211
+ def ancestors_for(type, path = nil, path_arr: nil, &block)
212
+ path_arr ||= path.split("/")
213
+ blueprint_for(type).ancestors(path_arr, &block)
214
+ end
215
+
216
+ # Find the blueprint instance that handles a given method.
217
+ def blueprint_for(method_name)
218
+ resolved = self.class.aliases[method_name] || method_name
219
+ self.class.delegations.each do |klass, methods|
220
+ if methods.include?(resolved)
221
+ ivar = :"@#{ivar_name_for(klass)}"
222
+ return instance_variable_get(ivar)
223
+ end
224
+ end
225
+ raise "No blueprint handles :#{method_name}"
226
+ end
227
+
190
228
  private
191
229
 
192
230
  # Override in subclasses for post-initialize hooks (e.g. pending phase rules).
@@ -213,6 +251,13 @@ module Foundries
213
251
  end
214
252
  end
215
253
  end
254
+
255
+ # Set up alias methods that delegate to existing blueprint methods
256
+ self.class.aliases.each do |alias_name, target_name|
257
+ define_singleton_method(alias_name) do |*args, **kwargs, &block|
258
+ send(target_name, *args, **kwargs, &block)
259
+ end
260
+ end
216
261
  end
217
262
 
218
263
  def initialize_collections
@@ -103,6 +103,36 @@ module Foundries
103
103
  end
104
104
  end
105
105
 
106
+ # Declare ancestor traversal order for ascending_find.
107
+ #
108
+ # lookup_order %i[evented_mod phase cohort]
109
+ #
110
+ # When no parent is present, ascending_find walks these
111
+ # ancestor types on `current`, checking collection_name
112
+ # on each.
113
+ def lookup_order(ancestors = nil)
114
+ return @lookup_order || [] unless ancestors
115
+
116
+ @lookup_order = ancestors
117
+ end
118
+
119
+ # Declare the ancestor type for path-based hierarchy
120
+ # creation via `ancestors_for`.
121
+ #
122
+ # ancestor :event
123
+ #
124
+ # Generates an `ancestors(path, &block)` method that
125
+ # pops the last path segment and either:
126
+ # - calls `foundry.send(type, name, &block)` if the
127
+ # path is empty (terminal)
128
+ # - calls `foundry.ancestors_for(type, ...)` to
129
+ # continue recursion otherwise
130
+ def ancestor(type = nil)
131
+ return @ancestor_type unless type
132
+
133
+ @ancestor_type = type
134
+ end
135
+
106
136
  # Declare which attributes are allowed through to factory_bot.
107
137
  def permitted_attrs(attr_list)
108
138
  define_method(:permitted_attrs) do |attrs|
@@ -195,6 +225,67 @@ module Foundries
195
225
  end
196
226
  end
197
227
 
228
+ # Walk a path array to build ancestor hierarchy, then
229
+ # yield the block in the innermost context. Requires
230
+ # the `ancestor` class DSL to be declared.
231
+ #
232
+ # ancestors(["org", "block", "template"], &block)
233
+ #
234
+ def ancestors(path, &block)
235
+ type = self.class.ancestor
236
+ raise "No ancestor declared for #{self.class}" unless type
237
+
238
+ name = path.pop
239
+ if path.empty?
240
+ foundry.send(type, name, &block)
241
+ else
242
+ foundry.ancestors_for(type, path_arr: path) do
243
+ foundry.send(type, name, &block)
244
+ end
245
+ end
246
+ end
247
+
248
+ # Find or create: when no parent is present, walks ancestors
249
+ # via lookup_order; otherwise finds from parent or creates.
250
+ def find_or_create(name, attrs = {})
251
+ return ascending_find(name) unless parent_present?
252
+
253
+ find_from_parent(name) || create_object(name, attrs)
254
+ end
255
+
256
+ # Walk ancestor types declared in lookup_order, checking
257
+ # collection_name on each ancestor found in current state.
258
+ # Falls back to collection find.
259
+ def ascending_find(name)
260
+ object = nil
261
+ self.class.lookup_order.each do |ancestor_type|
262
+ ancestor = current.send(ancestor_type)
263
+ next unless ancestor
264
+
265
+ col = self.class.collection_name
266
+ object = ancestor.send(col).find_by(name:)
267
+ break if object
268
+ end
269
+
270
+ object || find(name)
271
+ end
272
+
273
+ # Whether a parent is available in the current context.
274
+ def parent_present?
275
+ parent_method = self.class.parent
276
+ return true if parent_method.in?(%i[self none])
277
+
278
+ parent
279
+ end
280
+
281
+ # Find from the parent's association, falling back to
282
+ # collection find.
283
+ def find_from_parent(name, col_name: "name")
284
+ col = self.class.collection_name
285
+ parent.send(col).find_by(col_name => name) ||
286
+ find(name, col_name:)
287
+ end
288
+
198
289
  # Find a record in the collection by name, falling back to the database.
199
290
  def find(name, col_name: "name")
200
291
  raise "#find called with nil :name, for col_name: #{col_name}." unless name
@@ -202,9 +293,13 @@ module Foundries
202
293
  found_record = collection.detect do |object|
203
294
  object.send(col_name).casecmp?(name) && same_parent?(object)
204
295
  end
296
+ return found_record if found_record
205
297
 
206
- found_record ||
207
- record_class.find_by(col_name => name)&.tap { |rec| collection << rec }
298
+ scope = record_class.where(col_name => name)
299
+ if parent_key && parent_id
300
+ scope = scope.where(parent_key => parent_id)
301
+ end
302
+ scope.first&.tap { |rec| collection << rec }
208
303
  end
209
304
 
210
305
  # Find a record in the collection by arbitrary criteria, falling back to the database.
@@ -254,7 +349,8 @@ module Foundries
254
349
  def method_missing(name, *args, **kwargs, &block)
255
350
  if (match = missing_find_by_request?(name))
256
351
  klass_name = match.named_captures["klass"]
257
- return collection_find_by(klass_name, args)
352
+ attrs = kwargs.any? ? kwargs : args.first
353
+ return collection_find_by(klass_name, attrs)
258
354
  end
259
355
 
260
356
  if foundry.respond_to?(name)
@@ -274,8 +370,7 @@ module Foundries
274
370
  method_name.match(/^find_(?<klass>.*)_by$/)
275
371
  end
276
372
 
277
- def collection_find_by(klass_name, args)
278
- attrs = args.first
373
+ def collection_find_by(klass_name, attrs)
279
374
  target_collection_name = "#{klass_name.pluralize}_collection"
280
375
  objects = foundry.send(target_collection_name)
281
376
  objects.detect do |object|
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Foundries
4
- VERSION = "0.1.1"
5
- RELEASE_DATE = "2026-03-06"
4
+ VERSION = "0.1.3"
5
+ RELEASE_DATE = "2026-03-13"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foundries
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Dowd