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 +4 -4
- data/README.md +372 -0
- data/lib/object_forge/crucible.rb +50 -14
- data/lib/object_forge/forge.rb +126 -34
- data/lib/object_forge/forge_dsl.rb +61 -42
- data/lib/object_forge/forgeyard.rb +25 -14
- data/lib/object_forge/molds/hash_mold.rb +4 -4
- data/lib/object_forge/molds/keywords_mold.rb +7 -6
- data/lib/object_forge/molds/single_argument_mold.rb +5 -5
- data/lib/object_forge/molds/struct_mold.rb +19 -13
- data/lib/object_forge/molds/wrapped_mold.rb +4 -2
- data/lib/object_forge/molds.rb +124 -15
- data/lib/object_forge/sequence.rb +2 -2
- data/lib/object_forge/un_basic_object.rb +5 -4
- data/lib/object_forge/version.rb +1 -1
- data/lib/object_forge.rb +98 -34
- data/sig/object_forge/molds.rbs +7 -12
- data/sig/object_forge.rbs +64 -19
- metadata +24 -20
- data/lib/object_forge/molds/mold_mold.rb +0 -40
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9481504e1456ec667e1c0182ecef5456cb2c9094b62ba6e09da62f0c1c79a00e
|
|
4
|
+
data.tar.gz: d2c2fdcfd6807211ea82987c998f4d2d8c6e0b1bbc334178330f97dfd8d7df7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 85b341cae5396f0eee11f00e6084bfbdc4561070c4657d3e7265570b48f195789c4a8763220d81f38f2dd4c31573a36064aecf346f9e6ffac8e07dbcbcf86720
|
|
7
|
+
data.tar.gz: dc7faa45a80ce51ac2b4f57f7a88134b8c6a44ad3889f06b896962aa6de711c7fee36b70b7952ba80e19b95d036e838c169abebc375712a76b29e21e7ff54815
|
data/README.md
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# ObjectForge
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/object_forge)
|
|
4
|
+
[](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
|
-
#
|
|
14
|
+
# and using {.call} is thread-safe.
|
|
15
15
|
# @since 0.1.0
|
|
16
16
|
class Crucible < UnBasicObject
|
|
17
|
-
|
|
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
|
|
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.
|
|
56
|
-
#
|
|
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.
|
|
64
|
-
#
|
|
84
|
+
# Crucible.resolve(attrs)
|
|
85
|
+
# # => { "[]": "Brackets", "[]=": "Brackets are brackets", "!": "Brackets are brackets!" }
|
|
65
86
|
#
|
|
66
87
|
# @param name [Symbol]
|
|
67
88
|
# @return [Any]
|
|
68
|
-
|
|
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 @
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|