object_forge 0.2.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: 83440b5319d1e909379d16b67830ad1f67f575358473dd4ba5914cb549c417b9
4
- data.tar.gz: 279a5756a157f96252081cb60ce0541bdffdf1e21a25fe72db19b622c7d717e8
3
+ metadata.gz: 9481504e1456ec667e1c0182ecef5456cb2c9094b62ba6e09da62f0c1c79a00e
4
+ data.tar.gz: d2c2fdcfd6807211ea82987c998f4d2d8c6e0b1bbc334178330f97dfd8d7df7b
5
5
  SHA512:
6
- metadata.gz: e16b3091daf563ffffbb5da39b50a6cc2298a288a1e71528ab9de905e0c959718555ff8d3b6b2a3478f094fa3ebff8727f0a95e97c6adef05d3c7abb1ae6676f
7
- data.tar.gz: 04455c660052171b8a4456323b700f91805d0c05190329427baee1468b42ccfee3c315b79534f997b45a1f2cd716f85076e164f8007cad821f43047a5ea8fcbf
6
+ metadata.gz: 85b341cae5396f0eee11f00e6084bfbdc4561070c4657d3e7265570b48f195789c4a8763220d81f38f2dd4c31573a36064aecf346f9e6ffac8e07dbcbcf86720
7
+ data.tar.gz: dc7faa45a80ce51ac2b4f57f7a88134b8c6a44ad3889f06b896962aa6de711c7fee36b70b7952ba80e19b95d036e838c169abebc375712a76b29e21e7ff54815
data/README.md ADDED
@@ -0,0 +1,372 @@
1
+ # ObjectForge
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/object_forge.svg?icon=si%3Arubygems)](https://rubygems.org/gems/object_forge)
4
+ [![CI](https://github.com/trinistr/object_forge/actions/workflows/CI.yaml/badge.svg)](https://github.com/trinistr/object_forge/actions/workflows/CI.yaml)
5
+
6
+ > [!TIP]
7
+ > You may be viewing documentation for an older (or newer) version of the gem than intended. Look at [Changelog](https://github.com/trinistr/object_forge/blob/main/CHANGELOG.md) to see all versions, including unreleased changes.
8
+
9
+ ***
10
+
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.
22
+
23
+ ## Table of contents
24
+
25
+ - [Motivation](#motivation)
26
+ - [Installation](#installation)
27
+ - [Usage](#usage)
28
+ - [Quick start](#quick-start)
29
+ - [Basics](#basics)
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)
33
+ - [Performance tips](#performance-tips)
34
+ - [Differences and limitations (compared to FactoryBot)](#differences-and-limitations-compared-to-factorybot)
35
+ - [Current and planned features (roadmap)](#current-and-planned-features-roadmap)
36
+ - [Development](#development)
37
+ - [Contributing](#contributing)
38
+ - [License](#license)
39
+
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.
45
+
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
51
+
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
57
+
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.
59
+
60
+ ## Installation
61
+
62
+ Install with `gem`:
63
+ ```sh
64
+ gem install object_forge
65
+ ```
66
+
67
+ Or, if using Bundler, add to your Gemfile:
68
+ ```ruby
69
+ gem "object_forge"
70
+ ```
71
+ and run `bundle install`.
72
+
73
+ ## Usage
74
+
75
+ > [!Note]
76
+ > - Latest documentation from `main` branch is automatically deployed to [GitHub Pages](https://trinistr.github.io/object_forge).
77
+ > - Documentation for published versions is available on [RubyDoc](https://rubydoc.info/gems/object_forge).
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
+
118
+ ### Basics
119
+
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.
121
+
122
+ Forges are defined using a DSL:
123
+ ```ruby
124
+ # Example class:
125
+ Point = Struct.new(:id, :x, :y)
126
+
127
+ ObjectForge.define(:point, Point) do |f|
128
+ # Attributes can be defined using `#attribute` method:
129
+ f.attribute(:x) do
130
+ # Inside attribute definitions, other attributes can be referenced by name, in any order!
131
+ rand(-delta..delta)
132
+ end
133
+ # `#[]` is an alias of `#attribute`:
134
+ f[:y] { rand(-delta..delta) }
135
+ # There is also the familiar shortcut using `method_missing`:
136
+ f.delta { 0.5 * amplitude }
137
+ # Notice how transient attributes don't require any special syntax:
138
+ f.amplitude { 1 }
139
+ # `#sequence` defines a sequenced attribute (starting with 1 by default):
140
+ f.sequence(:id, "a")
141
+ # Traits allow to group and reuse related values:
142
+ f.trait :z do
143
+ f.amplitude { 0 }
144
+ # Sequence values are forge-global, but traits can redefine blocks:
145
+ f.sequence(:id) { |id| "Z_#{id}" }
146
+ end
147
+ # Trait's block can receive DSL object as a parameter:
148
+ f.trait :invalid do |tf|
149
+ tf.y { Float::NAN }
150
+ # `#[]` method inside attribute definition can be used to reference attributes:
151
+ tf.id { self[:x] }
152
+ end
153
+ end
154
+ ```
155
+
156
+ A forge builds objects, using attributes hash:
157
+ ```ruby
158
+ ObjectForge.call(:point)
159
+ # => #<struct Point id="a", x=0.17176955469852973, y=0.3423901951181103>
160
+ # Positional arguments define used traits:
161
+ ObjectForge.build(:point, :z)
162
+ # => #<struct Point id="Z_b", x=0.0, y=0.0>
163
+ # Attributes can be overridden with keyword arguments:
164
+ ObjectForge.forge(:point, x: 10)
165
+ # => #<struct Point id="c", x=10, y=-0.3458802496120402>
166
+ # Traits and overrides are combined in the given order:
167
+ ObjectForge.call(:point, :z, :invalid, id: "NaN")
168
+ # => #<struct Point id="NaN", x=0.0, y=NaN>
169
+ # A Proc override behaves the same as an attribute definition:
170
+ ObjectForge.call(:point, :z, x: -> { rand(100..200) + delta })
171
+ # => #<struct Point id="Z_d", x=135.0, y=0.0>
172
+ # A block can be passed to do something with the created object:
173
+ ObjectForge.call(:point, :z) { puts "#{_1.id}: #{_1.x},#{_1.y}" }
174
+ # outputs "Z_e: 0.0,0.0"
175
+ ```
176
+ > [!TIP]
177
+ > Forging can be done through any of `#call`, `#forge`, or `#build` methods, they are aliases.
178
+
179
+ ### Independent forgeyards and forges
180
+
181
+ It is possible and *encouraged* to create multiple forgeyards, each with its own set of forges:
182
+ ```ruby
183
+ forgeyard = ObjectForge::Forgeyard.new
184
+ forgeyard.define(:dot, Point) do |f|
185
+ f.sequence(:id, "a")
186
+ f.x { rand(-radius..radius) }
187
+ f.y { rand(-radius..radius) }
188
+ f.radius { 0.5 }
189
+ f.trait :z do f.radius { 0 } end
190
+ end
191
+ ```
192
+
193
+ Now, this forgeyard can be used just like the default one:
194
+ ```ruby
195
+ forgeyard.forge(:dot, :z, id: "0")
196
+ # => #<struct Point id="0", x=0, y=0>
197
+ ```
198
+
199
+ Note how the forge isn't registered in the default forgeyard:
200
+ ```ruby
201
+ ObjectForge.forge(:dot)
202
+ # KeyError: key not found
203
+ ```
204
+
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:
206
+ ```ruby
207
+ forge = ObjectForge::Forge.define(Point) do |f|
208
+ f.sequence(:id, "a")
209
+ f.x { rand(-radius..radius) }
210
+ f.y { rand(-radius..radius) }
211
+ f.radius { 0.5 }
212
+ f.trait :z do f.radius { 0 } end
213
+ end
214
+ ```
215
+
216
+ **Forge** has the same building interface as a **Forgeyard**, but it doesn't have the name argument:
217
+ ```ruby
218
+ forge.build
219
+ # => #<struct Point id="a", x=0.3317733939650964, y=-0.1363936629550252>
220
+ forge.forge(:z)
221
+ # => #<struct Point id="b", x=0, y=0>
222
+ forge.(radius: 500)
223
+ # => #<struct Point id="c", x=-141, y=109>
224
+ ```
225
+
226
+ ### Molds: configuring object construction
227
+
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.
229
+
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:
231
+ ```ruby
232
+ forge = ObjectForge::Forge.define(Point) do |f|
233
+ f.mold = ->(forge_target:, attributes:, **) do
234
+ forge_target.new(attributes[:id], attributes[:x].round(3), attributes[:y].round(3))
235
+ end
236
+ #... rest of the definition from the previous example
237
+ end
238
+ ```
239
+
240
+ Now the specified **mold** will be called to build your objects:
241
+ ```ruby
242
+ forge.forge
243
+ # => #<struct Point id="a", x=0.331, y=-0.136>
244
+ ```
245
+
246
+ Of course, you can abuse this to your heart's content. Look at the documentation for `ObjectForge::Molds` for inspiration.
247
+
248
+ **ObjectForge** comes pre-equipped with a selection of molds for common cases:
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.
256
+
257
+ > [!TIP]
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
+ ```
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
+ ```
285
+
286
+ If both hook and block are used, the hook runs before the block.
287
+
288
+ ### Performance tips
289
+
290
+ **ObjectForge** is pretty fast for what it is. However, if you are worried, there are certain things that can be done to make it faster.
291
+ - The easiest thing is to enable [**YJIT**](https://docs.ruby-lang.org/en/master/yjit/yjit_md.html). It will probably speed up your whole application, but be aware that it is not always suitable and may even degrade performance on some workloads. It *is* considered production-ready though.
292
+ - Calling a **Forge** directly, instead of through **Forgeyard**, is faster due to not needing argument forwarding. This is consistent (but check on your system anyway!).
293
+ - Using `self[:name]` instead of plain `name` inside attribute definitions does not engage dynamic method dispatch, which *should* be faster. However, micro-benchmarking does not show conclusive results.
294
+
295
+ ## Differences and limitations (compared to FactoryBot)
296
+
297
+ If you are used to FactoryBot, be aware that there are quite a few differences in specifics.
298
+
299
+ General:
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`).
301
+ - `Forgeyard.define` *is* the forge definition block, there is no separate `factory` block.
302
+
303
+ Forge definition:
304
+ - Class specification for a forge is non-optional, there is no assumption about the class name.
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.
307
+
308
+ Attributes:
309
+ - Currently, there is no concept of transient attributes. Attribute selection needs to be handled by the mold.
310
+ - *There are no associations*. If nested objects are required, they should be created and set in the block for the attribute.
311
+
312
+ Traits:
313
+ - Traits can't be defined inside of other traits.
314
+ - Traits can't be called from other traits. This may change in the future.
315
+ - There are no default traits.
316
+
317
+ Sequences:
318
+ - There is no explicit way to define shared sequences, but a freestanding `Sequence` can be created manually and passed into `sequence` calls.
319
+ - Sequences work with values implementing `#succ`, not `#next`, expressly prohibiting `Enumerator`. This may be relaxed in the future.
320
+
321
+ ## Current and planned features (roadmap)
322
+
323
+ ```mermaid
324
+ kanban
325
+ [✅ Done]
326
+ [FactoryBot-like DSL: attributes, traits, sequences]
327
+ [Independent forges]
328
+ [Independent forgeyards]
329
+ [Default global forgeyard]
330
+ [Thread-safe behavior]
331
+ [Tapping into built objects for post-processing]
332
+ [Custom builders / molds]
333
+ [Built-in Hash, Struct, Data builders / molds]
334
+ [Ability to replace resolver]
335
+ [After-build hook]
336
+ [⚗️ To do]
337
+ [Transient attributes / attribute filtering]
338
+ [❔ Maybe, maybe not]
339
+ [Calling traits from traits]
340
+ [Default traits]
341
+ [Forge inheritance]
342
+ [Premade performance forge: static DSL, epsilon resolver]
343
+ [Enumerator compatibility in sequences]
344
+ ```
345
+
346
+ ## Development
347
+
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.
351
+
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.
353
+
354
+ To install this gem onto your local machine, run `rake install`. To release a new version, run `rake version:{major|minor|patch}`, and then run `rake release`, which will push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
355
+
356
+ ## Contributing
357
+
358
+ Bug reports and pull requests are welcome on GitHub at https://github.com/trinistr/object_forge.
359
+
360
+ **Checklist for a new or updated feature**
361
+
362
+ - Running `rake spec` reports 100% coverage (unless it's impossible to achieve in one run).
363
+ - Running `rake rubocop` reports no offenses.
364
+ - Running `rake steep` reports no new warnings or errors.
365
+ - Tests cover the behavior and its interactions. 100% coverage *is not enough*, as it does not guarantee that all code paths are tested.
366
+ - Documentation is up-to-date: generate it with `rake docs` and read it.
367
+ - "*CHANGELOG.md*" lists the change if it has impact on users.
368
+ - "*README.md*" is updated if the feature should be visible there, including the Kanban board.
369
+
370
+ ## License
371
+
372
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT), see [LICENSE.txt](https://github.com/trinistr/object_forge/blob/main/LICENSE.txt).
@@ -11,16 +11,34 @@ 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
- %i[rand].each { |m| private define_method(m, ::Object.instance_method(m)) }
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
+
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
20
37
  def initialize(attributes)
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