object_forge 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd0b538c878ddc72f9041187f1612a1d779b55d7d7a4a72bb8be9c2c22d36073
4
- data.tar.gz: 0cd2ec6ea0594f13f1780eb503220d5313489657d6aa31e771d075d1ceef4c9b
3
+ metadata.gz: 9481504e1456ec667e1c0182ecef5456cb2c9094b62ba6e09da62f0c1c79a00e
4
+ data.tar.gz: d2c2fdcfd6807211ea82987c998f4d2d8c6e0b1bbc334178330f97dfd8d7df7b
5
5
  SHA512:
6
- metadata.gz: 63431e04ef6c32990268ba7545564c964a72b72b23b9469315479d696c42a4106b24dd490a80481182f04b41e91cc78614f1de177f3516bb66d965c0f71b7df9
7
- data.tar.gz: 1ed2a7e317671bb416bad5960d2bb227332ccf911d936835802470f785d98975223f3bf2070ebd7ca212b84932e773d1844bf64a718d857f8fc514c3cf2516e5
6
+ metadata.gz: 85b341cae5396f0eee11f00e6084bfbdc4561070c4657d3e7265570b48f195789c4a8763220d81f38f2dd4c31573a36064aecf346f9e6ffac8e07dbcbcf86720
7
+ data.tar.gz: dc7faa45a80ce51ac2b4f57f7a88134b8c6a44ad3889f06b896962aa6de711c7fee36b70b7952ba80e19b95d036e838c169abebc375712a76b29e21e7ff54815
data/README.md CHANGED
@@ -8,19 +8,28 @@
8
8
 
9
9
  ***
10
10
 
11
- **ObjectForge** provides a familiar way to build objects in any context with minimal assumptions about usage environment.
12
- - It is not connected to any framework and, indeed, has nothing to do with a database.
13
- - To use, just define some factories and call them wherever you need, be it in tests, console, or application code.
14
- - If you need, almost any part of the process can be easily replaced with a custom solution.
11
+ **ObjectForge** is a small factory library for Ruby objects with minimal assumptions about framework, persistence, or runtime environment.
12
+
13
+ It is designed for cases where factory-style object construction is useful, but Rails-oriented or database-oriented tooling is a poor fit. **ObjectForge** works well with plain Ruby objects, hashes, structs, and custom build flows.
14
+
15
+ The library focuses on:
16
+ - explicit configuration over hidden conventions
17
+ - support for independent registries and standalone factories
18
+ - replaceable components based on simple interfaces
19
+ - usefulness both outside of tests and inside them
20
+
21
+ If you need factory-style object generation without coupling it to Rails, ActiveRecord, or a particular application structure, **ObjectForge** might be for you.
15
22
 
16
23
  ## Table of contents
17
24
 
18
- - [Motivation (why *another* another factory gem?)](#motivation-why-another-another-factory-gem)
25
+ - [Motivation](#motivation)
19
26
  - [Installation](#installation)
20
27
  - [Usage](#usage)
28
+ - [Quick start](#quick-start)
21
29
  - [Basics](#basics)
22
- - [Separate forgeyards and forges](#separate-forgeyards-and-forges)
23
- - [Molds: customized forging](#molds-customized-forging)
30
+ - [Independent forgeyards and forges](#independent-forgeyards-and-forges)
31
+ - [Molds: configuring object construction](#molds-configuring-object-construction)
32
+ - [After-build customization](#after-build-customization)
24
33
  - [Performance tips](#performance-tips)
25
34
  - [Differences and limitations (compared to FactoryBot)](#differences-and-limitations-compared-to-factorybot)
26
35
  - [Current and planned features (roadmap)](#current-and-planned-features-roadmap)
@@ -28,22 +37,25 @@
28
37
  - [Contributing](#contributing)
29
38
  - [License](#license)
30
39
 
31
- ## Motivation (why *another* another factory gem?)
40
+ ## Motivation
41
+
42
+ Ruby already has well-known factory libraries, especially FactoryBot and Fabrication. Those tools are effective in many projects, particularly when working in Rails applications and persistence-oriented test setups.
43
+
44
+ **ObjectForge** aims at a different problem space: building objects with a factory-style workflow while making as few assumptions as possible about framework, storage, object lifecycle, or application structure.
32
45
 
33
- There are a bunch of gems that provide object generation functionality, chief among them [FactoryBot](https://github.com/thoughtbot/factory_bot) and [Fabrication](https://fabricationgem.org/).
34
- However, such gems make a lot of assumptions about why, how and what for they will be used, making them complicated and, at the same time, severely limited. Such assumptions commonly are:
35
- - assuming that every Ruby project is a Rails project;
36
- - assuming that "generating objects" equates "saving records to database";
37
- - assuming that objects are mutable and provide attribute writers;
38
- - assuming that streamlined object generation is only useful for testing;
39
- - (related to the previous point) assuming that there will never be a need to
40
- have more than one configuration of a library in the same project;
41
- - assuming that adding global methods or objects is a good idea.
46
+ **ObjectForge** is particularly useful when:
47
+ - the objects being built are plain Ruby objects rather than database-backed records
48
+ - object generation is needed outside of tests, such as in services, scripts, or fixtures
49
+ - multiple independent sets of factories need to coexist in the same project
50
+ - construction behavior should be explicit and configurable rather than hidden behind framework conventions
42
51
 
43
- I notice that there is also a problem of thinking that Rails's "convention-over-configuration" approach is always appropriate, but then making configuration convoluted, instead of making it easy for the user to do the things they want in the way they want in the first place.
52
+ The project is intentionally small in scope. Rather than trying to model every style of factory workflow, it focuses on a compact, understandable core:
53
+ - a DSL for defining attributes, sequences, and traits
54
+ - forges (factories) and forgeyards (registries)
55
+ - several object molds (constructors)
56
+ - a couple other helper components
44
57
 
45
- There are some projects that tried to address these issues, like [Progenitor](https://github.com/pavlos/progenitor) (the closest to **ObjectForge**) and [Workbench](https://github.com/leadtune/workbench), but they still didn't manage to go around the pitfalls.
46
- Most factory projects are also quite dead, having not been updated in *many* years.
58
+ The goal is to have a simple, composable tool that you can easily reach for when heavier libraries don't fit or feel like overkill.
47
59
 
48
60
  ## Installation
49
61
 
@@ -56,6 +68,7 @@ Or, if using Bundler, add to your Gemfile:
56
68
  ```ruby
57
69
  gem "object_forge"
58
70
  ```
71
+ and run `bundle install`.
59
72
 
60
73
  ## Usage
61
74
 
@@ -63,9 +76,48 @@ gem "object_forge"
63
76
  > - Latest documentation from `main` branch is automatically deployed to [GitHub Pages](https://trinistr.github.io/object_forge).
64
77
  > - Documentation for published versions is available on [RubyDoc](https://rubydoc.info/gems/object_forge).
65
78
 
79
+ ### Quick start
80
+
81
+ Create your domain logic class:
82
+ ```ruby
83
+ class Rectangle
84
+ def initialize(length:, width:)
85
+ @length = length
86
+ @width = width
87
+ end
88
+
89
+ def area = @length * @width
90
+
91
+ def inspect = "[#{@length}x#{@width}]"
92
+ end
93
+ ```
94
+
95
+ Define a forge:
96
+ ```ruby
97
+ require "object_forge"
98
+ ObjectForge.define(:rectangle, Rectangle) do |f|
99
+ f.mold = ObjectForge::Molds::KeywordsMold.new
100
+
101
+ f.length { rand(1..100) }
102
+ f.width { rand(1..100) }
103
+
104
+ f.trait :square do |t|
105
+ t.width { length }
106
+ end
107
+ end
108
+ ```
109
+
110
+ Forge some objects!
111
+ ```ruby
112
+ ObjectForge.forge(:rectangle) # => [63x27]
113
+ ObjectForge.forge(:rectangle, :square) # => [56x56]
114
+ ObjectForge.forge(:rectangle, width: 3333) # => [79x3333]
115
+ ObjectForge.forge(:rectangle, :square, length: 123) # => [123x123]
116
+ ```
117
+
66
118
  ### Basics
67
119
 
68
- In the simplest cases, **ObjectForge** can be used much like other factory libraries, with definitions living in a global object (`ObjectForge::DEFAULT_YARD`).
120
+ In the simplest cases, **ObjectForge** can be used much like other factory libraries, with definitions living in a global object (`ObjectForge::DEFAULT_YARD`). In this case, methods are called directly on `ObjectForge` module.
69
121
 
70
122
  Forges are defined using a DSL:
71
123
  ```ruby
@@ -104,19 +156,19 @@ end
104
156
  A forge builds objects, using attributes hash:
105
157
  ```ruby
106
158
  ObjectForge.call(:point)
107
- # => #<Point:0x00007f6109dcad40 @id="a", @x=0.17176955469852973, @y=0.3423901951181103>
159
+ # => #<struct Point id="a", x=0.17176955469852973, y=0.3423901951181103>
108
160
  # Positional arguments define used traits:
109
161
  ObjectForge.build(:point, :z)
110
- # => #<Point:0x00007f61099e7980 @id="Z_b", @x=0.0, @y=0.0>
162
+ # => #<struct Point id="Z_b", x=0.0, y=0.0>
111
163
  # Attributes can be overridden with keyword arguments:
112
164
  ObjectForge.forge(:point, x: 10)
113
- # => #<Point:0x00007f6109aabf88 @id="c", @x=10, @y=-0.3458802496120402>
165
+ # => #<struct Point id="c", x=10, y=-0.3458802496120402>
114
166
  # Traits and overrides are combined in the given order:
115
167
  ObjectForge.call(:point, :z, :invalid, id: "NaN")
116
- # => #<Point:0x00007f6109b82e48 @id="NaN", @x=0.0, @y=NaN>
168
+ # => #<struct Point id="NaN", x=0.0, y=NaN>
117
169
  # A Proc override behaves the same as an attribute definition:
118
170
  ObjectForge.call(:point, :z, x: -> { rand(100..200) + delta })
119
- # => #<Point:0x00007f6109932418 @id="Z_d", @x=135.0, @y=0.0>
171
+ # => #<struct Point id="Z_d", x=135.0, y=0.0>
120
172
  # A block can be passed to do something with the created object:
121
173
  ObjectForge.call(:point, :z) { puts "#{_1.id}: #{_1.x},#{_1.y}" }
122
174
  # outputs "Z_e: 0.0,0.0"
@@ -124,12 +176,12 @@ ObjectForge.call(:point, :z) { puts "#{_1.id}: #{_1.x},#{_1.y}" }
124
176
  > [!TIP]
125
177
  > Forging can be done through any of `#call`, `#forge`, or `#build` methods, they are aliases.
126
178
 
127
- ### Separate forgeyards and forges
179
+ ### Independent forgeyards and forges
128
180
 
129
181
  It is possible and *encouraged* to create multiple forgeyards, each with its own set of forges:
130
182
  ```ruby
131
183
  forgeyard = ObjectForge::Forgeyard.new
132
- forgeyard.define(:point, Point) do |f|
184
+ forgeyard.define(:dot, Point) do |f|
133
185
  f.sequence(:id, "a")
134
186
  f.x { rand(-radius..radius) }
135
187
  f.y { rand(-radius..radius) }
@@ -140,14 +192,14 @@ end
140
192
 
141
193
  Now, this forgeyard can be used just like the default one:
142
194
  ```ruby
143
- forgeyard.forge(:point, :z, id: "0")
144
- # => #<Point:0x00007f6109b719e0 @id="0", @x=0, @y=0>
195
+ forgeyard.forge(:dot, :z, id: "0")
196
+ # => #<struct Point id="0", x=0, y=0>
145
197
  ```
146
198
 
147
199
  Note how the forge isn't registered in the default forgeyard:
148
200
  ```ruby
149
- ObjectForge.forge(:point)
150
- # ArgumentError: unknown forge: point
201
+ ObjectForge.forge(:dot)
202
+ # KeyError: key not found
151
203
  ```
152
204
 
153
205
  If you find it more convenient not to use a forgeyard (for example, if you only need a single forge for your service), you can create individual forges:
@@ -159,55 +211,79 @@ forge = ObjectForge::Forge.define(Point) do |f|
159
211
  f.radius { 0.5 }
160
212
  f.trait :z do f.radius { 0 } end
161
213
  end
162
- forge.(:z, id: "0")
163
- # => #<Point:0x00007f6109b719e0 @id="0", @x=0, @y=0>
164
214
  ```
165
215
 
166
216
  **Forge** has the same building interface as a **Forgeyard**, but it doesn't have the name argument:
167
217
  ```ruby
168
218
  forge.build
169
- # => #<Point:0x00007f610deae578 @id="a", @x=0.3317733939650964, @y=-0.1363936629550252>
219
+ # => #<struct Point id="a", x=0.3317733939650964, y=-0.1363936629550252>
170
220
  forge.forge(:z)
171
- # => #<Point:0x00007f61099f6520 @id="b", @x=0, @y=0>
221
+ # => #<struct Point id="b", x=0, y=0>
172
222
  forge.(radius: 500)
173
- # => #<Point:0x00007f6109960048 @id="c", @x=-141, @y=109>
223
+ # => #<struct Point id="c", x=-141, y=109>
174
224
  ```
175
225
 
176
- ### Molds: customized forging
226
+ ### Molds: configuring object construction
177
227
 
178
- If you use core Ruby data containers, such as `Struct`, `Data` or even `Hash`, they will "just work". However, if a custom class is used, forging will probably fail, unless your class happens to take a hash of attributes in `#initialize`. It would be *really* bad if **ObjectForge** placed requirements on your classes, and indeed there is a solution.
228
+ If you use core Ruby data containers, such as `Struct`, `Data` or even `Hash`, they will "just work". However, if a custom class is used, forging will probably fail, unless your class happens to take a hash of attributes in `#initialize`. It would be against the goal of **ObjectForge** to place requirements on your classes, and indeed there is a solution.
179
229
 
180
- Whenever you need to change how your objects are built, you specify a *mold*. Molds are just `#call`able objects (including `Proc`s!) with specific arguments. They are specified in forge definition:
230
+ Whenever you need to change how your objects are built, you specify a *mold*. Molds are just `call`able objects (including `Proc`s!) with specific arguments. They are set in forge definition:
181
231
  ```ruby
182
232
  forge = ObjectForge::Forge.define(Point) do |f|
183
- f.mold = ->(forged:, attributes:, **) do
184
- puts "Pointing at #{attributes[:x]},#{attributes[:y]}"
185
- forged.new(attributes[:id], attributes[:x], attributes[:y])
233
+ f.mold = ->(forge_target:, attributes:, **) do
234
+ forge_target.new(attributes[:id], attributes[:x].round(3), attributes[:y].round(3))
186
235
  end
187
- #... rest of the definition
236
+ #... rest of the definition from the previous example
188
237
  end
189
238
  ```
190
239
 
191
240
  Now the specified **mold** will be called to build your objects:
192
241
  ```ruby
193
242
  forge.forge
194
- # Pointing at 0.3317733939650964,-0.1363936629550252
195
- # => #<Point:0x00007f610deae578 @id="a", @x=0.3317733939650964, @y=-0.1363936629550252>
243
+ # => #<struct Point id="a", x=0.331, y=-0.136>
196
244
  ```
197
245
 
198
246
  Of course, you can abuse this to your heart's content. Look at the documentation for `ObjectForge::Molds` for inspiration.
199
247
 
200
248
  **ObjectForge** comes pre-equipped with a selection of molds for common cases:
201
- - `ObjectForge::Molds::SingleArgumentMold` (*the default*) calls `new(attributes)`, suitable for **Dry::Struct**, for example;
202
- - `ObjectForge::Molds::KeywordsMold` calls `new(**attributes)`, suitable for **Data** and similar classes;
203
- - `ObjectForge::Molds::HashMold` allows building **Hash** (including subclasses), providing a way to easily use hashes to carry data;
204
- - `ObjectForge::Molds::StructMold` handles all possible cases of `keyword_init` for **Struct** subclasses.
249
+ - `ObjectForge::Molds::SingleArgumentMold` (*the default*) calls `new(attributes)`, suitable for **ActiveModel**-style objects and **Dry::Struct**, as an example
250
+ - `ObjectForge::Molds::KeywordsMold` calls `new(**attributes)`, suitable for **Data** and similar classes
251
+ - `ObjectForge::Molds::HashMold` allows building **Hash** (including subclasses), providing a way to easily use build hashes
252
+ - `ObjectForge::Molds::StructMold` handles all possible cases of `keyword_init` for **Struct**s
253
+
254
+ > [!NOTE]
255
+ > If you don't specify a mold, **ObjectForge** will infer one for core data containers, including **Hash**, **Struct**, and **Data** subclasses.
205
256
 
206
257
  > [!TIP]
207
- > **HashMold** and **StructMold** will be used automatically, based on the forged class, if you don't specify any mold.
258
+ > It is recommended to use molds instances directly. Using classes causes memory churn and lowered performance. Not only that, but having a stateful mold is a code smell and probably represents a significant design issue.
259
+
260
+ ### After-build customization
261
+
262
+ If there is a need to modify the object or perform additional actions after it is forged, there are two mechanisms you can employ:
263
+ - after-forge hook
264
+ - customization block
265
+
266
+ After-forge hook is a `call`able object specified as part of forge definition. It runs every time forging happens:
267
+ ```ruby
268
+ forge = ObjectForge::Forge.define(Rectangle) do |f|
269
+ # can also be specified as `after_build`
270
+ f.after_forge = ->(rect) { puts "Used #{rect.area} sq. units" }
271
+ #... rest of the definition from the Quick start example
272
+ end
273
+ forge.forge
274
+ # Used 621 sq. units
275
+ # => [23x27]
276
+ ```
208
277
 
278
+ Customization block is an optional block argument to `#forge` and is only executed in that specific invocation:
279
+ ```ruby
280
+ forge.forge { |rect| RectangleRepository.save(rect); puts "persisted!" }
281
+ # Used 621 sq. units
282
+ # persisted!
283
+ # => [23x27]
284
+ ```
209
285
 
210
- I strongly recommend directly using mold instances and not classes. Doing this prevents memory churn which causes performance issues. Not only that, but having a stateful mold is a code smell and probably represents a significant design issue.
286
+ If both hook and block are used, the hook runs before the block.
211
287
 
212
288
  ### Performance tips
213
289
 
@@ -222,24 +298,24 @@ If you are used to FactoryBot, be aware that there are quite a few differences i
222
298
 
223
299
  General:
224
300
  - The user (you) is responsible for loading forge definitions, there are no search paths. If **ObjectForge** is used in tests, it should be enough to add something like `Dir["spec/forges/**/*.rb].each { require _1 }` to your `spec_helper.rb` (or `rails_helper.rb`).
225
- - `Forgeyard.define` *is* the forge definition block, you don't need to nest it inside another `factory` block.
226
- - There is no forge inheritance or nesting, though it may be added in the future.
301
+ - `Forgeyard.define` *is* the forge definition block, there is no separate `factory` block.
227
302
 
228
303
  Forge definition:
229
304
  - Class specification for a forge is non-optional, there is no assumption about the class name.
230
305
  - If the DSL block declares a block argument, `self` context is not changed, and DSL methods can't be called with an implicit receiver.
306
+ - There is no forge inheritance or nesting.
231
307
 
232
308
  Attributes:
233
- - For now, transient attributes have no difference to regular ones, they just aren't set in the final object.
309
+ - Currently, there is no concept of transient attributes. Attribute selection needs to be handled by the mold.
234
310
  - *There are no associations*. If nested objects are required, they should be created and set in the block for the attribute.
235
311
 
236
312
  Traits:
237
- - Traits can't be defined inside of other traits. (I feel that nesting is needlessly confusing.)
313
+ - Traits can't be defined inside of other traits.
238
314
  - Traits can't be called from other traits. This may change in the future.
239
315
  - There are no default traits.
240
316
 
241
317
  Sequences:
242
- - There is no explicit way to define shared sequences, unless you pass the same object yourself to multiple `sequence` calls.
318
+ - There is no explicit way to define shared sequences, but a freestanding `Sequence` can be created manually and passed into `sequence` calls.
243
319
  - Sequences work with values implementing `#succ`, not `#next`, expressly prohibiting `Enumerator`. This may be relaxed in the future.
244
320
 
245
321
  ## Current and planned features (roadmap)
@@ -255,10 +331,11 @@ kanban
255
331
  [Tapping into built objects for post-processing]
256
332
  [Custom builders / molds]
257
333
  [Built-in Hash, Struct, Data builders / molds]
258
- [⚗️ To do]
259
334
  [Ability to replace resolver]
260
335
  [After-build hook]
261
- [❔Under consideration]
336
+ [⚗️ To do]
337
+ [Transient attributes / attribute filtering]
338
+ [❔ Maybe, maybe not]
262
339
  [Calling traits from traits]
263
340
  [Default traits]
264
341
  [Forge inheritance]
@@ -268,7 +345,9 @@ kanban
268
345
 
269
346
  ## Development
270
347
 
271
- After checking out the repo, run `bundle install` to install dependencies. Then, run `rake spec` to run the tests, `rake rubocop` to lint code and check style compliance, `rake rbs` to validate signatures or just `rake` to do everything above. There is also `rake steep` to check typing, and `rake docs` to generate YARD documentation.
348
+ After checking out the repo, run `bundle install` to install dependencies. If you will be running typing checks (RBS/Steep), also execute `rbs collection install`.
349
+
350
+ Then, run `rake spec` to run the tests, `rake rubocop` to lint code and check style compliance, `rake rbs` to validate signatures or just `rake` to do everything above. There is also `rake steep` to check typing, and `rake docs` to generate YARD documentation.
272
351
 
273
352
  You can also run `bin/console` for an interactive prompt that will allow you to experiment, or `bin/benchmark` to run a benchmark script and generate a StackProf flamegraph.
274
353
 
@@ -11,9 +11,26 @@ module ObjectForge
11
11
  # but it's not a private API.
12
12
  #
13
13
  # @thread_safety Attribute resolution is idempotent,
14
- # but modifies instance variables, making it unsafe to share the Crucible
14
+ # and using {.call} is thread-safe.
15
15
  # @since 0.1.0
16
16
  class Crucible < UnBasicObject
17
+ class << self
18
+ # Resolve all attributes by calling their +Proc+s,
19
+ # using a new instance as evaluation context.
20
+ #
21
+ # @note This method destructively modifies initial attributes.
22
+ # @see #resolve!
23
+ # @thread_safety This method is thread-safe.
24
+ #
25
+ # @param attributes [Hash{Symbol => Proc, Any}] initial attributes
26
+ # @return [Hash{Symbol => Any}] resolved attributes
27
+ def call(attributes)
28
+ new(attributes).resolve!
29
+ end
30
+
31
+ alias resolve call
32
+ end
33
+
17
34
  %i[rand].each { |m| private define_method(m, ::Kernel.instance_method(m)) }
18
35
 
19
36
  # @param attributes [Hash{Symbol => Proc, Any}] initial attributes
@@ -21,6 +38,7 @@ module ObjectForge
21
38
  super()
22
39
  @attributes = attributes
23
40
  @resolved_attributes = ::Set.new
41
+ @resolving_attributes = []
24
42
  end
25
43
 
26
44
  # Resolve all attributes by calling their +Proc+s,
@@ -29,11 +47,13 @@ module ObjectForge
29
47
  # Attributes can freely refer to each other inside +Proc+s
30
48
  # through bareword names or +#[]+.
31
49
  # However, make sure to avoid cyclic dependencies:
32
- # they aren't specially detected or handled, and will cause +SystemStackError+.
50
+ # they can't be resolved and will raise {CircularAttributeDependencyError}.
33
51
  #
34
52
  # @note This method destructively modifies initial attributes.
35
53
  #
36
54
  # @return [Hash{Symbol => Any}] resolved attributes
55
+ #
56
+ # @raise [CircularAttributeDependencyError] if a dependency cycle is detected
37
57
  def resolve!
38
58
  @attributes.each_key { |name| method_missing(name) }
39
59
  @attributes
@@ -52,27 +72,36 @@ module ObjectForge
52
72
  # description: -> { name.downcase },
53
73
  # duration: -> { rand(1000) }
54
74
  # }
55
- # Crucible.new(attrs).resolve!
56
- # # => { name: "Name", description: "name", duration: 123 }
75
+ # Crucible.call(attrs)
76
+ # # => { name: "Name", description: "name", duration: 123 }
77
+ #
57
78
  # @example using conflicting and reserved names
58
79
  # attrs = {
59
80
  # "[]": -> { "Brackets" },
60
81
  # "[]=": -> { "#{self[:[]]} are brackets" },
61
82
  # "!": -> { "#{self[:[]=]}!" }
62
83
  # }
63
- # Crucible.new(attrs).resolve!
64
- # # => { "[]": "Brackets", "[]=": "Brackets are brackets", "!": "Brackets are brackets!" }
84
+ # Crucible.resolve(attrs)
85
+ # # => { "[]": "Brackets", "[]=": "Brackets are brackets", "!": "Brackets are brackets!" }
65
86
  #
66
87
  # @param name [Symbol]
67
88
  # @return [Any]
68
- def method_missing(name)
89
+ #
90
+ # @raise [CircularAttributeDependencyError] if a dependency cycle is detected
91
+ def method_missing(name) # rubocop:disable Metrics/MethodLength
69
92
  if @attributes.key?(name)
70
- if @resolved_attributes.include?(name) || !(::Proc === @attributes[name])
71
- @attributes[name]
72
- else
73
- @resolved_attributes << name
74
- @attributes[name] = instance_exec(&@attributes[name])
93
+ if @resolving_attributes.include?(name)
94
+ raise_circular_dependency_error!(name)
95
+ elsif !@resolved_attributes.include?(name) && (::Proc === @attributes[name])
96
+ begin
97
+ @resolving_attributes << name
98
+ @attributes[name] = instance_exec(&@attributes[name])
99
+ @resolved_attributes << name
100
+ ensure
101
+ @resolving_attributes.pop
102
+ end
75
103
  end
104
+ @attributes[name]
76
105
  else
77
106
  super
78
107
  end
@@ -81,7 +110,14 @@ module ObjectForge
81
110
  alias [] method_missing
82
111
 
83
112
  def respond_to_missing?(name, _include_all)
84
- @attributes.key?(name)
113
+ @attributes.key?(name) || super
114
+ end
115
+
116
+ def raise_circular_dependency_error!(name)
117
+ loop_start = @resolving_attributes.index(name)
118
+ loop = @resolving_attributes[loop_start..] # : Array[Symbol]
119
+ raise CircularAttributeDependencyError,
120
+ "attribute depends on itself: #{loop.join(" -> ")} -> #{name}"
85
121
  end
86
122
  end
87
123
  end