Class: Foundries::Blueprint

Inherits:
Object
  • Object
show all
Includes:
FactoryBot::Syntax::Methods
Defined in:
lib/foundries/blueprint.rb

Overview

Blueprint is the base class for individual factory wrappers within a Foundry.

Each Blueprint wraps one or more factory_bot factories and knows how to:

  • Create records using factory_bot
  • Track created records in a collection
  • Navigate parent-child relationships
  • Find existing records before creating duplicates

Subclass Blueprint and use the class-level DSL to declare behavior:

class UserBlueprint < Foundries::Blueprint
handles :user, :admin
factory :user
collection :users
parent :none
permitted_attrs %i[name email]
end

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(foundry) ⇒ Blueprint

Returns a new instance of Blueprint.



179
180
181
182
# File 'lib/foundries/blueprint.rb', line 179

def initialize(foundry)
  @foundry = foundry
  @attrs = {}
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(name, *args, **kwargs, &block) ⇒ Object

Delegate unknown methods to the foundry so that all blueprint methods are available in nested blocks. Also supports dynamic find__by.



349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/foundries/blueprint.rb', line 349

def method_missing(name, *args, **kwargs, &block)
  if (match = missing_find_by_request?(name))
    klass_name = match.named_captures["klass"]
    attrs = kwargs.any? ? kwargs : args.first
    return collection_find_by(klass_name, attrs)
  end

  if foundry.respond_to?(name)
    return foundry.send(name, *args, **kwargs, &block)
  end

  super
end

Class Attribute Details

.collection_nameObject (readonly)

Returns the value of attribute collection_name.



67
68
69
# File 'lib/foundries/blueprint.rb', line 67

def collection_name
  @collection_name
end

Instance Attribute Details

#foundryObject (readonly)

Returns the value of attribute foundry.



25
26
27
# File 'lib/foundries/blueprint.rb', line 25

def foundry
  @foundry
end

Class Method Details

.ancestor(type = nil) ⇒ Object

Declare the ancestor type for path-based hierarchy creation via ancestors_for.

ancestor :event

Generates an ancestors(path, &block) method that pops the last path segment and either:

  • calls foundry.send(type, name, &block) if the path is empty (terminal)
  • calls foundry.ancestors_for(type, ...) to continue recursion otherwise


130
131
132
133
134
# File 'lib/foundries/blueprint.rb', line 130

def ancestor(type = nil)
  return @ancestor_type unless type

  @ancestor_type = type
end

.collection(method_name = nil) ⇒ Object

Declare the collection name for tracking created records. This also defines #collection and #record_class instance methods.



53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/foundries/blueprint.rb', line 53

def collection(method_name = nil)
  return @collection_name unless method_name

  @collection_name = method_name

  define_method(:collection) do
    foundry.send(:"#{method_name}_collection")
  end

  define_method(:record_class) do
    method_name.to_s.classify.constantize
  end
end

.factory(name = nil) ⇒ Object

Declare which factory_bot factory this blueprint uses. If not set, inferred from the class name.



39
40
41
42
43
44
45
# File 'lib/foundries/blueprint.rb', line 39

def factory(name = nil)
  if name
    @factory_name = name
  else
    @factory_name || inferred_factory_name
  end
end

.factory_nameObject



47
48
49
# File 'lib/foundries/blueprint.rb', line 47

def factory_name
  factory
end

.handled_methodsObject



33
34
35
# File 'lib/foundries/blueprint.rb', line 33

def handled_methods
  @handled_methods || []
end

.handles(*methods) ⇒ Object



28
29
30
31
# File 'lib/foundries/blueprint.rb', line 28

def handles(*methods)
  @handled_methods ||= []
  @handled_methods.concat(methods)
end

.load_state_from(object, foundry) ⇒ Object

Load state from an existing object back into a foundry.



157
158
159
160
161
162
# File 'lib/foundries/blueprint.rb', line 157

def load_state_from(object, foundry)
  return unless respond_to?(:parent_accessor)

  parent_object = object.send(parent_accessor)
  foundry.load_existing_objects(parent_object)
end

.lookup_order(ancestors = nil) ⇒ Object

Declare ancestor traversal order for ascending_find.

lookup_order %i[evented_mod phase cohort]

When no parent is present, ascending_find walks these ancestor types on current, checking collection_name on each.



113
114
115
116
117
# File 'lib/foundries/blueprint.rb', line 113

def lookup_order(ancestors = nil)
  return @lookup_order || [] unless ancestors

  @lookup_order = ancestors
end

.nested_attrs(hash) ⇒ Object

Declare nested attributes (for accepts_nested_attributes_for).



146
147
148
149
150
151
152
153
154
# File 'lib/foundries/blueprint.rb', line 146

def nested_attrs(hash)
  nested_object_name, attr_names = hash.shift

  define_method(:nested_attrs) do |attrs|
    attrs_to_nest = attrs.slice(*attr_names)
    key = :"#{nested_object_name}_attributes"
    {key => attrs_to_nest}
  end
end

.newObject



171
172
173
174
175
176
177
# File 'lib/foundries/blueprint.rb', line 171

def self.new(...)
  instance = super
  foundry = instance.foundry
  recorder = foundry&.instance_variable_get(:@_similarity_recorder)
  instance._wrap_for_similarity_recording!(recorder) if recorder
  instance
end

.parent(method_name = nil) ⇒ Object

Declare how to find the parent record from current state.

parent :none        - no parent relationship
parent :self        - self-referential (e.g. nested categories)
parent :competency  - reads current.competency


83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/foundries/blueprint.rb', line 83

def parent(method_name = nil)
  return @parent_method unless method_name

  @parent_method = method_name

  if method_name == :none
    define_method(:same_parent?) { |_| true }
  elsif method_name == :self
    define_method(:same_parent?) { |_| true }
    define_method(:parent) do
      current.send(current_accessor)
    end
  else
    define_method(:parent) do
      current.send(method_name)
    end

    define_singleton_method(:parent_accessor) do
      method_name
    end
  end
end

.parent_key(key = nil) ⇒ Object

Declare the foreign key used to link to the parent.



70
71
72
73
74
75
# File 'lib/foundries/blueprint.rb', line 70

def parent_key(key = nil)
  return @parent_key_name unless key

  @parent_key_name = key
  define_method(:parent_key) { key }
end

.permitted_attrs(attr_list) ⇒ Object

Declare which attributes are allowed through to factory_bot.



137
138
139
140
141
142
143
# File 'lib/foundries/blueprint.rb', line 137

def permitted_attrs(attr_list)
  define_method(:permitted_attrs) do |attrs|
    keys = attr_list.dup
    keys << parent_key if parent_key
    attrs.slice(*keys)
  end
end

Instance Method Details

#_wrap_for_similarity_recording!(recorder) ⇒ Object



184
185
186
187
188
189
190
191
192
193
194
195
196
197
# File 'lib/foundries/blueprint.rb', line 184

def _wrap_for_similarity_recording!(recorder)
  methods_to_wrap = self.class.public_instance_methods(false).select do |m|
    self.class.instance_method(m).parameters.any? { |type, _| type == :block }
  end

  methods_to_wrap.each do |method_name|
    original = method(method_name)
    define_singleton_method(method_name) do |*args, **kwargs, &block|
      recorder.record(method_name.to_s, has_block: !block.nil?) do
        original.call(*args, **kwargs, &block)
      end
    end
  end
end

#ancestors(path, &block) ⇒ Object

Walk a path array to build ancestor hierarchy, then yield the block in the innermost context. Requires the ancestor class DSL to be declared.

ancestors(["org", "block", "template"], &block)


234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'lib/foundries/blueprint.rb', line 234

def ancestors(path, &block)
  type = self.class.ancestor
  raise "No ancestor declared for #{self.class}" unless type

  name = path.pop
  if path.empty?
    foundry.send(type, name, &block)
  else
    foundry.ancestors_for(type, path_arr: path) do
      foundry.send(type, name, &block)
    end
  end
end

#ascending_find(name) ⇒ Object

Walk ancestor types declared in lookup_order, checking collection_name on each ancestor found in current state. Falls back to collection find.



259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/foundries/blueprint.rb', line 259

def ascending_find(name)
  object = nil
  self.class.lookup_order.each do |ancestor_type|
    ancestor = current.send(ancestor_type)
    next unless ancestor

    col = self.class.collection_name
    object = ancestor.send(col).find_by(name:)
    break if object
  end

  object || find(name)
end

#assume_trait?(val) ⇒ Boolean

Returns:

  • (Boolean)


202
203
204
# File 'lib/foundries/blueprint.rb', line 202

def assume_trait?(val)
  val.is_a?(Symbol) || val.is_a?(Array)
end

#current_accessorObject



326
327
328
# File 'lib/foundries/blueprint.rb', line 326

def current_accessor
  self.class.name.demodulize.underscore.delete_suffix("_blueprint")
end

#find(name, col_name: "name") ⇒ Object

Find a record in the collection by name, falling back to the database.



290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'lib/foundries/blueprint.rb', line 290

def find(name, col_name: "name")
  raise "#find called with nil :name, for col_name: #{col_name}." unless name

  found_record = collection.detect do |object|
    object.send(col_name).casecmp?(name) && same_parent?(object)
  end
  return found_record if found_record

  scope = record_class.where(col_name => name)
  if parent_key && parent_id
    scope = scope.where(parent_key => parent_id)
  end
  scope.first&.tap { |rec| collection << rec }
end

#find_by(criteria = {}) ⇒ Object

Find a record in the collection by arbitrary criteria, falling back to the database.



306
307
308
309
310
311
312
313
314
# File 'lib/foundries/blueprint.rb', line 306

def find_by(criteria = {})
  found_record = collection.detect do |object|
    criteria.all? { |attr, value| object.send(attr) == value }
  end

  return found_record if found_record

  record_class.find_by(criteria)&.tap { |record| collection << record }
end

#find_from_parent(name, col_name: "name") ⇒ Object

Find from the parent's association, falling back to collection find.



283
284
285
286
287
# File 'lib/foundries/blueprint.rb', line 283

def find_from_parent(name, col_name: "name")
  col = self.class.collection_name
  parent.send(col).find_by(col_name => name) ||
    find(name, col_name:)
end

#find_or_create(name, attrs = {}) ⇒ Object

Find or create: when no parent is present, walks ancestors via lookup_order; otherwise finds from parent or creates.



250
251
252
253
254
# File 'lib/foundries/blueprint.rb', line 250

def find_or_create(name, attrs = {})
  return ascending_find(name) unless parent_present?

  find_from_parent(name) || create_object(name, attrs)
end

#find_or_create_objectObject



330
331
332
# File 'lib/foundries/blueprint.rb', line 330

def find_or_create_object
  send(:"#{mode}_object")
end

#inspectObject



206
207
208
# File 'lib/foundries/blueprint.rb', line 206

def inspect
  self.class.name
end

#modeObject



334
335
336
# File 'lib/foundries/blueprint.rb', line 334

def mode
  current.resource.nil? ? :find : :create
end

#parentObject



214
215
216
# File 'lib/foundries/blueprint.rb', line 214

def parent
  nil
end

#parent_idObject



322
323
324
# File 'lib/foundries/blueprint.rb', line 322

def parent_id
  parent&.id
end

#parent_keyObject



210
211
212
# File 'lib/foundries/blueprint.rb', line 210

def parent_key
  nil
end

#parent_present?Boolean

Whether a parent is available in the current context.

Returns:

  • (Boolean)


274
275
276
277
278
279
# File 'lib/foundries/blueprint.rb', line 274

def parent_present?
  parent_method = self.class.parent
  return true if parent_method.in?(%i[self none])

  parent
end

#reset_attrsObject



343
344
345
# File 'lib/foundries/blueprint.rb', line 343

def reset_attrs
  @attrs = {}
end

#reset_attrs_and_typeObject



338
339
340
341
# File 'lib/foundries/blueprint.rb', line 338

def reset_attrs_and_type
  @type = nil
  reset_attrs
end

#respond_to_missing?(name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


363
364
365
# File 'lib/foundries/blueprint.rb', line 363

def respond_to_missing?(name, include_private = false)
  missing_find_by_request?(name) || foundry.respond_to?(name) || super
end

#same_parent?(object) ⇒ Boolean

Returns:

  • (Boolean)


316
317
318
319
320
# File 'lib/foundries/blueprint.rb', line 316

def same_parent?(object)
  return true unless parent

  object.send(parent_key) == parent_id
end

#update_state_for_block(object, &block) ⇒ Object

Saves current state, yields, then restores state. Use this when entering a nested block to scope context.



220
221
222
223
224
225
226
# File 'lib/foundries/blueprint.rb', line 220

def update_state_for_block(object, &block)
  execute_and_restore_state do
    update_current(object)
    current.resource = object
    instance_exec(&block)
  end
end