object_forge 0.2.0 → 0.3.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: bd0b538c878ddc72f9041187f1612a1d779b55d7d7a4a72bb8be9c2c22d36073
4
+ data.tar.gz: 0cd2ec6ea0594f13f1780eb503220d5313489657d6aa31e771d075d1ceef4c9b
5
5
  SHA512:
6
- metadata.gz: e16b3091daf563ffffbb5da39b50a6cc2298a288a1e71528ab9de905e0c959718555ff8d3b6b2a3478f094fa3ebff8727f0a95e97c6adef05d3c7abb1ae6676f
7
- data.tar.gz: 04455c660052171b8a4456323b700f91805d0c05190329427baee1468b42ccfee3c315b79534f997b45a1f2cd716f85076e164f8007cad821f43047a5ea8fcbf
6
+ metadata.gz: 63431e04ef6c32990268ba7545564c964a72b72b23b9469315479d696c42a4106b24dd490a80481182f04b41e91cc78614f1de177f3516bb66d965c0f71b7df9
7
+ data.tar.gz: 1ed2a7e317671bb416bad5960d2bb227332ccf911d936835802470f785d98975223f3bf2070ebd7ca212b84932e773d1844bf64a718d857f8fc514c3cf2516e5
data/README.md ADDED
@@ -0,0 +1,293 @@
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** 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.
15
+
16
+ ## Table of contents
17
+
18
+ - [Motivation (why *another* another factory gem?)](#motivation-why-another-another-factory-gem)
19
+ - [Installation](#installation)
20
+ - [Usage](#usage)
21
+ - [Basics](#basics)
22
+ - [Separate forgeyards and forges](#separate-forgeyards-and-forges)
23
+ - [Molds: customized forging](#molds-customized-forging)
24
+ - [Performance tips](#performance-tips)
25
+ - [Differences and limitations (compared to FactoryBot)](#differences-and-limitations-compared-to-factorybot)
26
+ - [Current and planned features (roadmap)](#current-and-planned-features-roadmap)
27
+ - [Development](#development)
28
+ - [Contributing](#contributing)
29
+ - [License](#license)
30
+
31
+ ## Motivation (why *another* another factory gem?)
32
+
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.
42
+
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.
44
+
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.
47
+
48
+ ## Installation
49
+
50
+ Install with `gem`:
51
+ ```sh
52
+ gem install object_forge
53
+ ```
54
+
55
+ Or, if using Bundler, add to your Gemfile:
56
+ ```ruby
57
+ gem "object_forge"
58
+ ```
59
+
60
+ ## Usage
61
+
62
+ > [!Note]
63
+ > - Latest documentation from `main` branch is automatically deployed to [GitHub Pages](https://trinistr.github.io/object_forge).
64
+ > - Documentation for published versions is available on [RubyDoc](https://rubydoc.info/gems/object_forge).
65
+
66
+ ### Basics
67
+
68
+ In the simplest cases, **ObjectForge** can be used much like other factory libraries, with definitions living in a global object (`ObjectForge::DEFAULT_YARD`).
69
+
70
+ Forges are defined using a DSL:
71
+ ```ruby
72
+ # Example class:
73
+ Point = Struct.new(:id, :x, :y)
74
+
75
+ ObjectForge.define(:point, Point) do |f|
76
+ # Attributes can be defined using `#attribute` method:
77
+ f.attribute(:x) do
78
+ # Inside attribute definitions, other attributes can be referenced by name, in any order!
79
+ rand(-delta..delta)
80
+ end
81
+ # `#[]` is an alias of `#attribute`:
82
+ f[:y] { rand(-delta..delta) }
83
+ # There is also the familiar shortcut using `method_missing`:
84
+ f.delta { 0.5 * amplitude }
85
+ # Notice how transient attributes don't require any special syntax:
86
+ f.amplitude { 1 }
87
+ # `#sequence` defines a sequenced attribute (starting with 1 by default):
88
+ f.sequence(:id, "a")
89
+ # Traits allow to group and reuse related values:
90
+ f.trait :z do
91
+ f.amplitude { 0 }
92
+ # Sequence values are forge-global, but traits can redefine blocks:
93
+ f.sequence(:id) { |id| "Z_#{id}" }
94
+ end
95
+ # Trait's block can receive DSL object as a parameter:
96
+ f.trait :invalid do |tf|
97
+ tf.y { Float::NAN }
98
+ # `#[]` method inside attribute definition can be used to reference attributes:
99
+ tf.id { self[:x] }
100
+ end
101
+ end
102
+ ```
103
+
104
+ A forge builds objects, using attributes hash:
105
+ ```ruby
106
+ ObjectForge.call(:point)
107
+ # => #<Point:0x00007f6109dcad40 @id="a", @x=0.17176955469852973, @y=0.3423901951181103>
108
+ # Positional arguments define used traits:
109
+ ObjectForge.build(:point, :z)
110
+ # => #<Point:0x00007f61099e7980 @id="Z_b", @x=0.0, @y=0.0>
111
+ # Attributes can be overridden with keyword arguments:
112
+ ObjectForge.forge(:point, x: 10)
113
+ # => #<Point:0x00007f6109aabf88 @id="c", @x=10, @y=-0.3458802496120402>
114
+ # Traits and overrides are combined in the given order:
115
+ ObjectForge.call(:point, :z, :invalid, id: "NaN")
116
+ # => #<Point:0x00007f6109b82e48 @id="NaN", @x=0.0, @y=NaN>
117
+ # A Proc override behaves the same as an attribute definition:
118
+ ObjectForge.call(:point, :z, x: -> { rand(100..200) + delta })
119
+ # => #<Point:0x00007f6109932418 @id="Z_d", @x=135.0, @y=0.0>
120
+ # A block can be passed to do something with the created object:
121
+ ObjectForge.call(:point, :z) { puts "#{_1.id}: #{_1.x},#{_1.y}" }
122
+ # outputs "Z_e: 0.0,0.0"
123
+ ```
124
+ > [!TIP]
125
+ > Forging can be done through any of `#call`, `#forge`, or `#build` methods, they are aliases.
126
+
127
+ ### Separate forgeyards and forges
128
+
129
+ It is possible and *encouraged* to create multiple forgeyards, each with its own set of forges:
130
+ ```ruby
131
+ forgeyard = ObjectForge::Forgeyard.new
132
+ forgeyard.define(:point, Point) do |f|
133
+ f.sequence(:id, "a")
134
+ f.x { rand(-radius..radius) }
135
+ f.y { rand(-radius..radius) }
136
+ f.radius { 0.5 }
137
+ f.trait :z do f.radius { 0 } end
138
+ end
139
+ ```
140
+
141
+ Now, this forgeyard can be used just like the default one:
142
+ ```ruby
143
+ forgeyard.forge(:point, :z, id: "0")
144
+ # => #<Point:0x00007f6109b719e0 @id="0", @x=0, @y=0>
145
+ ```
146
+
147
+ Note how the forge isn't registered in the default forgeyard:
148
+ ```ruby
149
+ ObjectForge.forge(:point)
150
+ # ArgumentError: unknown forge: point
151
+ ```
152
+
153
+ 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:
154
+ ```ruby
155
+ forge = ObjectForge::Forge.define(Point) do |f|
156
+ f.sequence(:id, "a")
157
+ f.x { rand(-radius..radius) }
158
+ f.y { rand(-radius..radius) }
159
+ f.radius { 0.5 }
160
+ f.trait :z do f.radius { 0 } end
161
+ end
162
+ forge.(:z, id: "0")
163
+ # => #<Point:0x00007f6109b719e0 @id="0", @x=0, @y=0>
164
+ ```
165
+
166
+ **Forge** has the same building interface as a **Forgeyard**, but it doesn't have the name argument:
167
+ ```ruby
168
+ forge.build
169
+ # => #<Point:0x00007f610deae578 @id="a", @x=0.3317733939650964, @y=-0.1363936629550252>
170
+ forge.forge(:z)
171
+ # => #<Point:0x00007f61099f6520 @id="b", @x=0, @y=0>
172
+ forge.(radius: 500)
173
+ # => #<Point:0x00007f6109960048 @id="c", @x=-141, @y=109>
174
+ ```
175
+
176
+ ### Molds: customized forging
177
+
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.
179
+
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:
181
+ ```ruby
182
+ 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])
186
+ end
187
+ #... rest of the definition
188
+ end
189
+ ```
190
+
191
+ Now the specified **mold** will be called to build your objects:
192
+ ```ruby
193
+ forge.forge
194
+ # Pointing at 0.3317733939650964,-0.1363936629550252
195
+ # => #<Point:0x00007f610deae578 @id="a", @x=0.3317733939650964, @y=-0.1363936629550252>
196
+ ```
197
+
198
+ Of course, you can abuse this to your heart's content. Look at the documentation for `ObjectForge::Molds` for inspiration.
199
+
200
+ **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.
205
+
206
+ > [!TIP]
207
+ > **HashMold** and **StructMold** will be used automatically, based on the forged class, if you don't specify any mold.
208
+
209
+
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.
211
+
212
+ ### Performance tips
213
+
214
+ **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.
215
+ - 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.
216
+ - 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!).
217
+ - 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.
218
+
219
+ ## Differences and limitations (compared to FactoryBot)
220
+
221
+ If you are used to FactoryBot, be aware that there are quite a few differences in specifics.
222
+
223
+ General:
224
+ - 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.
227
+
228
+ Forge definition:
229
+ - Class specification for a forge is non-optional, there is no assumption about the class name.
230
+ - If the DSL block declares a block argument, `self` context is not changed, and DSL methods can't be called with an implicit receiver.
231
+
232
+ Attributes:
233
+ - For now, transient attributes have no difference to regular ones, they just aren't set in the final object.
234
+ - *There are no associations*. If nested objects are required, they should be created and set in the block for the attribute.
235
+
236
+ Traits:
237
+ - Traits can't be defined inside of other traits. (I feel that nesting is needlessly confusing.)
238
+ - Traits can't be called from other traits. This may change in the future.
239
+ - There are no default traits.
240
+
241
+ Sequences:
242
+ - There is no explicit way to define shared sequences, unless you pass the same object yourself to multiple `sequence` calls.
243
+ - Sequences work with values implementing `#succ`, not `#next`, expressly prohibiting `Enumerator`. This may be relaxed in the future.
244
+
245
+ ## Current and planned features (roadmap)
246
+
247
+ ```mermaid
248
+ kanban
249
+ [✅ Done]
250
+ [FactoryBot-like DSL: attributes, traits, sequences]
251
+ [Independent forges]
252
+ [Independent forgeyards]
253
+ [Default global forgeyard]
254
+ [Thread-safe behavior]
255
+ [Tapping into built objects for post-processing]
256
+ [Custom builders / molds]
257
+ [Built-in Hash, Struct, Data builders / molds]
258
+ [⚗️ To do]
259
+ [Ability to replace resolver]
260
+ [After-build hook]
261
+ [❔Under consideration]
262
+ [Calling traits from traits]
263
+ [Default traits]
264
+ [Forge inheritance]
265
+ [Premade performance forge: static DSL, epsilon resolver]
266
+ [Enumerator compatibility in sequences]
267
+ ```
268
+
269
+ ## Development
270
+
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.
272
+
273
+ 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
+
275
+ 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).
276
+
277
+ ## Contributing
278
+
279
+ Bug reports and pull requests are welcome on GitHub at https://github.com/trinistr/object_forge.
280
+
281
+ **Checklist for a new or updated feature**
282
+
283
+ - Running `rake spec` reports 100% coverage (unless it's impossible to achieve in one run).
284
+ - Running `rake rubocop` reports no offenses.
285
+ - Running `rake steep` reports no new warnings or errors.
286
+ - Tests cover the behavior and its interactions. 100% coverage *is not enough*, as it does not guarantee that all code paths are tested.
287
+ - Documentation is up-to-date: generate it with `rake docs` and read it.
288
+ - "*CHANGELOG.md*" lists the change if it has impact on users.
289
+ - "*README.md*" is updated if the feature should be visible there, including the Kanban board.
290
+
291
+ ## License
292
+
293
+ 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).
@@ -14,7 +14,7 @@ module ObjectForge
14
14
  # but modifies instance variables, making it unsafe to share the Crucible
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
+ %i[rand].each { |m| private define_method(m, ::Kernel.instance_method(m)) }
18
18
 
19
19
  # @param attributes [Hash{Symbol => Proc, Any}] initial attributes
20
20
  def initialize(attributes)
@@ -21,14 +21,13 @@ module ObjectForge
21
21
  # Attributes belonging to traits.
22
22
  # @return [Hash{Symbol => Hash{Symbol => Any}}]
23
23
  #
24
- # @!attribute [r] mold
25
- # An object that knows how to build the instance.
26
- # Must have a +call+ method that takes a class and a hash of attributes.
27
- # @since 0.2.0
28
- # @return [#call, nil]
29
- Parameters = Struct.new(:attributes, :traits, :mold, keyword_init: true)
30
-
31
- MOLD_MOLD = Molds::MoldMold.new.freeze
24
+ # @!attribute [r] settings
25
+ # A forge's settings.
26
+ # Must include a +:mold+ key, containing an object that knows how to build the instance
27
+ # with a +call+ method that takes a class and a hash of attributes.
28
+ # @since 0.3.0
29
+ # @return [Hash{Symbol => Any}]
30
+ Parameters = Struct.new(:attributes, :traits, :settings, keyword_init: true)
32
31
 
33
32
  # Define (and create) a forge using DSL.
34
33
  #
@@ -55,12 +54,13 @@ module ObjectForge
55
54
 
56
55
  # @param forged [Class, Any] class or object to forge
57
56
  # @param parameters [Parameters, ForgeDSL] forge parameters
58
- # @param name [Symbol, nil] forge name
57
+ # @param name [Symbol, nil] forge name;
58
+ # only used for identification purposes
59
59
  def initialize(forged, parameters, name: nil)
60
60
  @name = name
61
61
  @forged = forged
62
62
  @parameters = parameters
63
- @mold = parameters.mold || MOLD_MOLD.call(forged: forged)
63
+ @mold = determine_mold(forged, parameters.settings[:mold])
64
64
  end
65
65
 
66
66
  # Forge a new instance.
@@ -92,10 +92,32 @@ module ObjectForge
92
92
  end
93
93
 
94
94
  alias build forge
95
- alias [] forge
95
+ alias call forge
96
96
 
97
97
  private
98
98
 
99
+ # Get appropriate mold based on parameters.
100
+ #
101
+ # If +mold+ is already set, it will be used directly, or,
102
+ # if it is Class, it will be wrapped in {Molds::WrappedMold} if posssible.
103
+ # If +nil+, a mold will be selected based on +forged+ class.
104
+ #
105
+ # @param forged [Class, Any]
106
+ # @param mold [#call, Class, nil]
107
+ # @return [#call]
108
+ #
109
+ # @raise [MoldError]
110
+ #
111
+ # @since 0.3.0
112
+ def determine_mold(forged, mold)
113
+ Molds.wrap_mold(mold) || Molds.mold_for(forged)
114
+ end
115
+
116
+ # Resolve attributes using default attributes, specified traits and overrides.
117
+ #
118
+ # @param traits [Array<Symbol>]
119
+ # @param overrides [Hash{Symbol => Any}]
120
+ # @return [Hash{Symbol => Any}]
99
121
  def resolve_attributes(traits, overrides)
100
122
  attributes = @parameters.attributes.merge(*@parameters.traits.values_at(*traits), overrides)
101
123
  Crucible.new(attributes).resolve!
@@ -27,8 +27,8 @@ module ObjectForge
27
27
  # @return [Hash{Symbol => Hash{Symbol => Proc}}] trait definitions
28
28
  attr_reader :traits
29
29
 
30
- # @return [#call, nil] forge mold
31
- attr_reader :mold
30
+ # @return [Hash{Symbol => Any}] settings for forge, such as mold
31
+ attr_reader :settings
32
32
 
33
33
  # Define forge's parameters through DSL.
34
34
  #
@@ -39,6 +39,7 @@ module ObjectForge
39
39
  #
40
40
  # @example with block parameter
41
41
  # ForgeDSL.new do |f|
42
+ # f.mold = ObjectForge::Molds::KeywordsMolds.new
42
43
  # f.attribute(:name) { "Name" }
43
44
  # f[:description] { name.upcase }
44
45
  # f.duration { rand(1000) }
@@ -46,6 +47,7 @@ module ObjectForge
46
47
  #
47
48
  # @example without block parameter
48
49
  # ForgeDSL.new do
50
+ # self.mold = ::ObjectForge::Molds::KeywordsMolds.new
49
51
  # attribute(:name) { "Name" }
50
52
  # self[:description] { name.upcase }
51
53
  # duration { rand(1000) }
@@ -58,49 +60,53 @@ module ObjectForge
58
60
  @attributes = {}
59
61
  @sequences = {}
60
62
  @traits = {}
63
+ @settings = {}
61
64
 
62
65
  dsl.arity.zero? ? instance_exec(&dsl) : yield(self)
63
66
 
64
67
  freeze
65
68
  end
66
69
 
67
- # Freezes the instance, including +attributes+, +sequences+ and +traits+.
70
+ # Freezes the instance, including +settings+, +attributes+, +sequences+ and +traits+.
68
71
  # Prevents further responses through +#method_missing+.
69
72
  #
70
73
  # @note Called automatically in {#initialize}.
71
74
  #
72
75
  # @return [self]
73
76
  def freeze
74
- ::Object.instance_method(:freeze).bind_call(self)
77
+ ::Kernel.instance_method(:freeze).bind_call(self)
75
78
  @attributes.freeze
76
79
  @sequences.freeze
77
80
  @traits.freeze
78
- @mold.freeze
81
+ @settings.freeze
79
82
  self
80
83
  end
81
84
 
82
- # Set the forge mold.
85
+ # Set a value for a forge's setting.
83
86
  #
84
- # Mold is an object that knows how to take a hash of attributes
85
- # and create an object from them.
86
- # It can also be a class with +#call+, in which case a new mold will be instantiated
87
- # automatically for each build. If a single instance is enough,
88
- # please call +.new+ yourself once.
87
+ # Possible settings depend on used forge, but for default {Forge} a +mold+ is expected.
89
88
  #
90
- # @since 0.2.0
89
+ # It is also possible to set settings through +method_missing+, using name with a +=+ suffix.
91
90
  #
92
- # @param mold [Class, #call, nil]
93
- # @return [Class, #call, nil]
91
+ # @see Molds
94
92
  #
95
- # @raise [DSLError] if +mold+ does not respond to or implement +#call+
96
- def mold=(mold)
97
- if nil == mold || mold.respond_to?(:call) # rubocop:disable Style/YodaCondition
98
- @mold = mold
99
- elsif ::Class === mold && mold.public_method_defined?(:call)
100
- @mold = Molds::WrappedMold.new(mold)
101
- else
102
- raise DSLError, "mold must respond to or implement #call"
93
+ # @example
94
+ # f.setting(:mold, ->(forged:, attributes:, **) { forge.new(**attributes) })
95
+ # f.mold = ObjectForge::Molds::SingleArgumentMold.new
96
+ #
97
+ # @param name [Sumbol] setting name
98
+ # @param value [Any] value for the setting
99
+ # @return [Symbol] setting name
100
+ #
101
+ # @raise [ArgumentError] if +name+ is not a Symbol
102
+ def setting(name, value)
103
+ unless ::Symbol === name
104
+ raise ::ArgumentError, "setting name must be a Symbol, #{name.class} given"
103
105
  end
106
+
107
+ @settings[name] = value
108
+
109
+ name
104
110
  end
105
111
 
106
112
  # Define an attribute, possibly transient.
@@ -255,26 +261,30 @@ module ObjectForge
255
261
 
256
262
  private
257
263
 
258
- # Define an attribute using a shorthand.
264
+ # Define an attribute (like +name+) or set a setting (like +name=+) using a shorthand.
259
265
  #
260
- # Can not be used to define attributes with reserved names.
266
+ # Can not be used with reserved names.
261
267
  # Trying to use a conflicting name will lead to usual issues
262
268
  # with calling random methods.
263
- # When in doubt, use {#attribute} or {#[]} instead.
269
+ # When in doubt, use {#attribute} or {#setting} instead.
264
270
  #
265
271
  # Reserved names are:
266
- # - all names ending in +?+, +!+ or +=+
272
+ # - all names ending in +?+, +!+
267
273
  # - all names starting with a non-word ASCII character
268
274
  # (operators, +`+, +[]+, +[]=+)
269
275
  # - +rand+
270
276
  #
271
- # @param name [Symbol] attribute name
277
+ # @param name [Symbol] attribute or setting name
278
+ # @param value [Any] value for setting
272
279
  # @yieldreturn [Any] attribute value
273
- # @return [Symbol] attribute name
280
+ # @return [Symbol] attribute or setting name
274
281
  #
275
282
  # @raise [DSLError] if a reserved +name+ is used
276
- def method_missing(name, **nil, &)
277
- return super if frozen?
283
+ def method_missing(name, value = nil, **nil, &)
284
+ return super(name) if frozen?
285
+ if valid_setting_method?(name)
286
+ return setting(name[...-1].to_sym, value) # steep:ignore NoMethod
287
+ end
278
288
  return attribute(name, &) if respond_to_missing?(name, false)
279
289
 
280
290
  raise DSLError, "#{name.inspect} is a reserved name (in #{name.inspect})"
@@ -283,7 +293,11 @@ module ObjectForge
283
293
  def respond_to_missing?(name, _include_all)
284
294
  return false if frozen?
285
295
 
286
- !name.end_with?("?", "!", "=") && !name.match?(/\A(?=\p{ASCII})\P{Word}/) && name != :rand
296
+ !name.end_with?("?", "!") && !name.match?(/\A(?=\p{ASCII})\P{Word}/) && name != :rand
297
+ end
298
+
299
+ def valid_setting_method?(name)
300
+ name.match?(/\A\p{Word}.*=\z/)
287
301
  end
288
302
  end
289
303
  end
@@ -45,6 +45,16 @@ module ObjectForge
45
45
  @forges.put_if_absent(name, forge) || forge
46
46
  end
47
47
 
48
+ # Retrieve a forge by name.
49
+ #
50
+ # @param name [Symbol] name of the forge
51
+ # @return [Forge] registered forge
52
+ #
53
+ # @raise [KeyError] if forge with the specified name is not registered
54
+ def [](name)
55
+ @forges.fetch(name)
56
+ end
57
+
48
58
  # Build an instance using a forge.
49
59
  #
50
60
  # @see Forge#forge
@@ -58,10 +68,10 @@ module ObjectForge
58
68
  #
59
69
  # @raise [KeyError] if forge with the specified name is not registered
60
70
  def forge(name, ...)
61
- @forges.fetch(name).[](...)
71
+ @forges.fetch(name).call(...)
62
72
  end
63
73
 
64
74
  alias build forge
65
- alias [] forge
75
+ alias call forge
66
76
  end
67
77
  end
@@ -4,7 +4,8 @@ module ObjectForge
4
4
  module Molds
5
5
  # Basic mold which calls +forged.new(**attributes)+.
6
6
  #
7
- # Can be used instead of {SingleArgumentMold},
7
+ # Can be used instead of {SingleArgumentMold}
8
+ # due to how keyword arguments are treated in Ruby,
8
9
  # but performance is about 1.5 times worse.
9
10
  #
10
11
  # @thread_safety Thread-safe.
@@ -12,6 +12,8 @@ module ObjectForge
12
12
  # Does Struct automatically use keyword initialization
13
13
  # when +keyword_init+ is not specified / +nil+?
14
14
  #
15
+ # This should be true on Ruby 3.2.0 and later.
16
+ #
15
17
  # @return [Boolean]
16
18
  RUBY_FEATURE_AUTO_KEYWORDS = (::Struct.new(:a, :b).new(a: 1, b: 2).a == 1)
17
19
 
@@ -7,6 +7,8 @@ module ObjectForge
7
7
  # Wrapping a mold class is useful when its +#call+ is stateful,
8
8
  # making it unsafe to use multiple times or in shared environments.
9
9
  #
10
+ # This mold is usually automatically used through {Molds.wrap_mold}.
11
+ #
10
12
  # @thread_safety Thread-safe if {wrapped_mold} does not use global state.
11
13
  # @since 0.2.0
12
14
  class WrappedMold
@@ -3,14 +3,16 @@
3
3
  module ObjectForge
4
4
  # This module provides a collection of predefined molds to be used in common cases.
5
5
  #
6
- # Molds are +#call+able objects responsible for actually building objects produced by factories
6
+ # Mold is an object that knows how to take a hash of attributes
7
+ # and create an object from them. Molds are +#call+able objects
8
+ # responsible for actually building objects produced by factories
7
9
  # (or doing other, interesting things with them (truly, only the code review is the limit!)).
8
10
  # They are supposed to be immutable, shareable, and persistent:
9
11
  # initialize once, use for the whole runtime.
10
12
  #
11
13
  # A simple mold can easily be just a +Proc+.
12
14
  # All molds must have the following +#call+ signature: +call(forged:, attributes:, **)+.
13
- # The extra keywords are for future extensions.
15
+ # The extra keywords are ignored for possibility of future extensions.
14
16
  #
15
17
  # @example A very basic FactoryBot replacement
16
18
  # creator = ->(forged:, attributes:, **) do
@@ -59,5 +61,59 @@ module ObjectForge
59
61
  # @since 0.2.0
60
62
  module Molds
61
63
  Dir["#{__dir__}/molds/*.rb"].each { require_relative _1 }
64
+
65
+ # Get maybe appropriate mold for the given +forged+ class or object.
66
+ #
67
+ # Currently provides specific recognition for:
68
+ # - subclasses of +Struct+ ({StructMold}),
69
+ # - subclasses of +Data+ ({KeywordsMold}),
70
+ # - +Hash+ and subclasses ({HashMold}).
71
+ # Other objects just get {SingleArgumentMold}.
72
+ #
73
+ # @param forged [Class, Any]
74
+ # @return [#call] an instance of a mold
75
+ #
76
+ # @thread_safety Thread-safe.
77
+ # @since 0.3.0
78
+ def self.mold_for(forged)
79
+ if ::Class === forged
80
+ if forged < ::Struct
81
+ StructMold.new
82
+ elsif defined?(::Data) && forged < ::Data
83
+ KeywordsMold.new
84
+ elsif forged <= ::Hash
85
+ HashMold.new
86
+ else
87
+ SingleArgumentMold.new
88
+ end
89
+ else
90
+ SingleArgumentMold.new
91
+ end
92
+ end
93
+
94
+ # Wrap mold if needed.
95
+ #
96
+ # If +mold+ is +nil+ or a +call+able object, returns it.
97
+ # If it is a Class with +#call+, wraps it in {WrappedMold}.
98
+ # Otherwise, raises an error.
99
+ #
100
+ # @since 0.3.0
101
+ #
102
+ # @param mold [Class, #call, nil]
103
+ # @return [#call, nil]
104
+ #
105
+ # @raise [DSLError] if +mold+ does not respond to or implement +#call+
106
+ #
107
+ # @thread_safety Thread-safe.
108
+ # @since 0.3.0
109
+ def self.wrap_mold(mold)
110
+ if mold.nil? || mold.respond_to?(:call)
111
+ mold # : ObjectForge::mold?
112
+ elsif ::Class === mold && mold.public_method_defined?(:call)
113
+ WrappedMold.new(mold)
114
+ else
115
+ raise MoldError, "mold must respond to or implement #call"
116
+ end
117
+ end
62
118
  end
63
119
  end
@@ -34,13 +34,14 @@ module ObjectForge
34
34
  # @!method to_s
35
35
  # @see Object#to_s
36
36
  # @return [String]
37
- %i[class eql? freeze frozen? hash inspect is_a? respond_to? to_s].each do |m|
38
- define_method(m, ::Object.instance_method(m))
37
+ %i[class eql? freeze frozen? hash inspect is_a? kind_of? respond_to? to_s].each do |m|
38
+ define_method(m, ::Kernel.instance_method(m))
39
39
  end
40
- alias kind_of? is_a?
41
40
  # @!endgroup
42
41
 
43
- %i[block_given? raise].each { |m| private define_method(m, ::Object.instance_method(m)) }
42
+ %i[block_given? raise respond_to_missing?].each do |m|
43
+ private define_method(m, ::Kernel.instance_method(m))
44
+ end
44
45
 
45
46
  # @!macro pp_support
46
47
  # Support for +pp+ (and IRB).
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ObjectForge
4
4
  # Current version
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
data/lib/object_forge.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "object_forge/version"
22
22
  # It allows defining arbitrary attributes (possibly using sequences),
23
23
  # with support for traits (collections of attributes with non-default values).
24
24
  # - {Crucible} is used to resolve attributes.
25
+ # - {Molds} are objects used to instantiate objects in {Forge}.
25
26
  #
26
27
  # @example Quick example
27
28
  # Frobinator = Struct.new(:frob, :inator, keyword_init: true)
@@ -38,7 +39,7 @@ require_relative "object_forge/version"
38
39
  # # => #<struct Frobinator frob="Frobinator", inator=#<Proc:...>>
39
40
  # ObjectForge.build(:frobber, frob: -> { "Frob" + inator }, inator: "orn")
40
41
  # # => #<struct Frobinator frob="Froborn", inator="orn">
41
- # ObjectForge[:frobber, :static, inator: "Value"]
42
+ # ObjectForge.call(:frobber, :static, inator: "Value")
42
43
  # # => #<struct Frobinator frob="Static", inator="Value">
43
44
  module ObjectForge
44
45
  # Base error class for ObjectForge.
@@ -47,6 +48,9 @@ module ObjectForge
47
48
  # Error raised when a mistake is made in using DSL.
48
49
  # @since 0.1.0
49
50
  class DSLError < Error; end
51
+ # Error raised when object can not be used as a mold.
52
+ # @since 0.3.0
53
+ class MoldError < Error; end
50
54
 
51
55
  # Default {Forgeyard} that is used by {.define} and {.forge}.
52
56
  #
@@ -104,9 +108,7 @@ module ObjectForge
104
108
  end
105
109
 
106
110
  class << self
107
- # @since 0.1.0
108
111
  alias build forge
109
- # @since 0.1.0
110
- alias [] forge
112
+ alias call forge
111
113
  end
112
114
  end
@@ -1,14 +1,9 @@
1
1
  module ObjectForge
2
- interface _Mold
3
- def call
4
- : (forged: untyped, attributes: Hash[Symbol, untyped], **untyped) -> untyped
5
- end
6
-
7
2
  module Molds
8
- class MoldMold
9
- def call
10
- : (forged: untyped, **untyped) -> ObjectForge::_Mold
11
- end
3
+ def self.wrap_mold
4
+ : ((ObjectForge::mold | Class | nil) mold) -> ObjectForge::mold?
5
+ def self.mold_for
6
+ : (ObjectForge::_Forgable forged) -> ObjectForge::mold
12
7
 
13
8
  class SingleArgumentMold
14
9
  include ObjectForge::_Mold
data/sig/object_forge.rbs CHANGED
@@ -1,5 +1,5 @@
1
1
  module ObjectForge
2
- type mold = ObjectForge::_RespondTo & ObjectForge::_Mold
2
+ type mold = Object & ObjectForge::_Mold
3
3
  type sequenceable = ObjectForge::_RespondTo & ObjectForge::_Sequenceable
4
4
 
5
5
  interface _RespondTo
@@ -15,13 +15,19 @@ module ObjectForge
15
15
  interface _ForgeParameters
16
16
  def attributes: () -> Hash[Symbol, untyped]
17
17
  def traits: () -> Hash[Symbol, Hash[Symbol, untyped]]
18
- def mold: () -> ObjectForge::mold?
18
+ def settings: () -> Hash[Symbol, untyped]
19
+ end
20
+ interface _Mold
21
+ def call
22
+ : (forged: untyped, attributes: Hash[Symbol, untyped], **untyped) -> untyped
19
23
  end
20
24
 
21
25
  class Error < StandardError
22
26
  end
23
27
  class DSLError < Error
24
28
  end
29
+ class MoldError < Error
30
+ end
25
31
 
26
32
  VERSION: String
27
33
  DEFAULT_YARD: ObjectForge::Forgeyard
@@ -35,6 +41,10 @@ module ObjectForge
35
41
 
36
42
  def self.forge
37
43
  : (Symbol name, *Symbol traits, **untyped overrides) ?{ (untyped) -> void } -> ObjectForge::_Forgable
44
+ def self.build
45
+ : (Symbol name, *Symbol traits, **untyped overrides) ?{ (untyped) -> void } -> ObjectForge::_Forgable
46
+ def self.call
47
+ : (Symbol name, *Symbol traits, **untyped overrides) ?{ (untyped) -> void } -> ObjectForge::_Forgable
38
48
  end
39
49
 
40
50
  class ObjectForge::Sequence
@@ -63,11 +73,14 @@ class ObjectForge::Forgeyard
63
73
 
64
74
  def register
65
75
  : (Symbol name, ObjectForge::Forge forge) -> ObjectForge::Forge
76
+
77
+ def []
78
+ : (Symbol name) -> ObjectForge::Forge
66
79
 
67
80
  def forge
68
81
  : (Symbol name, *Symbol traits, **untyped overrides) ?{ (untyped) -> void } -> ObjectForge::_Forgable
69
82
  alias build forge
70
- alias [] forge
83
+ alias call forge
71
84
  end
72
85
 
73
86
  class ObjectForge::Forge
@@ -78,8 +91,6 @@ class ObjectForge::Forge
78
91
  : (attributes: Hash[Symbol, untyped], traits: Hash[Symbol, Hash[Symbol, untyped]]) -> void
79
92
  end
80
93
 
81
- MOLD_MOLD: ObjectForge::Molds::MoldMold
82
-
83
94
  attr_reader forged: ObjectForge::_Forgable
84
95
  attr_reader name: Symbol
85
96
 
@@ -93,10 +104,13 @@ class ObjectForge::Forge
93
104
  def forge
94
105
  : (*Symbol traits, **untyped overrides) ?{ (untyped) -> void } -> ObjectForge::_Forgable
95
106
  alias build forge
96
- alias [] forge
107
+ alias call forge
97
108
 
98
109
  private
99
110
 
111
+ def determine_mold
112
+ : (ObjectForge::_Forgable forged, ObjectForge::mold mold) -> ObjectForge::mold
113
+
100
114
  def resolve_attributes
101
115
  : (Array[Symbol] traits, Hash[Symbol, untyped] overrides) -> Hash[Symbol, untyped]
102
116
  end
@@ -109,6 +123,7 @@ class ObjectForge::ForgeDSL < ObjectForge::UnBasicObject
109
123
  @attributes: Hash[Symbol, Proc]
110
124
  @sequences: Hash[Symbol, ObjectForge::Sequence]
111
125
  @traits: Hash[Symbol, Hash[Symbol, Proc]]
126
+ @settings: Hash[Symbol, untyped]
112
127
 
113
128
  def initialize
114
129
  : () { (ObjectForge::ForgeDSL) -> void } -> void
@@ -116,8 +131,8 @@ class ObjectForge::ForgeDSL < ObjectForge::UnBasicObject
116
131
 
117
132
  def freeze: -> self
118
133
 
119
- def mold=
120
- : (ObjectForge::mold) -> void
134
+ def setting
135
+ : (Symbol name, untyped value) -> Symbol
121
136
 
122
137
  def attribute
123
138
  : (Symbol name) { [self: ObjectForge::Crucible] -> untyped } -> Symbol
@@ -140,8 +155,9 @@ class ObjectForge::ForgeDSL < ObjectForge::UnBasicObject
140
155
 
141
156
  def respond_to_missing?
142
157
  : (Symbol name, bool include_all) -> bool
143
-
144
- def rand: [T] (?(Float | Integer | Range[T])) -> (Float | Integer | T)
158
+
159
+ def valid_setting_method?
160
+ : (Symbol name) -> bool
145
161
  end
146
162
 
147
163
  class ObjectForge::Crucible < ObjectForge::UnBasicObject
@@ -178,8 +194,9 @@ class ObjectForge::UnBasicObject < BasicObject
178
194
  def inspect: -> String
179
195
 
180
196
  def is_a?: (Module klass) -> bool
197
+ def kind_of?: (Module klass) -> bool
181
198
 
182
- def respond_to?: (Symbol name, ?bool include_private) -> bool
199
+ def respond_to?: (Symbol name, ?bool include_all) -> bool
183
200
 
184
201
  def to_s: -> String
185
202
 
@@ -192,4 +209,6 @@ class ObjectForge::UnBasicObject < BasicObject
192
209
  def block_given?: -> bool
193
210
 
194
211
  def raise: (_Exception exception, ?String message, ?Array[String] backtrace, ?cause: _Exception) -> void
212
+
213
+ def respond_to_missing?: (Symbol name, bool include_all) -> bool
195
214
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: object_forge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
- - Alexandr Bulancov
8
- autorequire:
7
+ - Alexander Bulancov
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-08-20 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: concurrent-ruby
@@ -31,11 +30,12 @@ description: |
31
30
  To use, just define some factories and call them wherever you need,
32
31
  be it in tests, console, or application code.
33
32
  If needed, almost any part of the process can be easily replaced with a custom solution.
34
- email:
35
33
  executables: []
36
34
  extensions: []
37
- extra_rdoc_files: []
35
+ extra_rdoc_files:
36
+ - README.md
38
37
  files:
38
+ - README.md
39
39
  - lib/object_forge.rb
40
40
  - lib/object_forge/crucible.rb
41
41
  - lib/object_forge/forge.rb
@@ -44,7 +44,6 @@ files:
44
44
  - lib/object_forge/molds.rb
45
45
  - lib/object_forge/molds/hash_mold.rb
46
46
  - lib/object_forge/molds/keywords_mold.rb
47
- - lib/object_forge/molds/mold_mold.rb
48
47
  - lib/object_forge/molds/single_argument_mold.rb
49
48
  - lib/object_forge/molds/struct_mold.rb
50
49
  - lib/object_forge/molds/wrapped_mold.rb
@@ -59,14 +58,15 @@ licenses:
59
58
  metadata:
60
59
  homepage_uri: https://github.com/trinistr/object_forge
61
60
  bug_tracker_uri: https://github.com/trinistr/object_forge/issues
62
- documentation_uri: https://rubydoc.info/gems/object_forge/0.2.0
63
- source_code_uri: https://github.com/trinistr/object_forge/tree/v0.2.0
64
- changelog_uri: https://github.com/trinistr/object_forge/blob/v0.2.0/CHANGELOG.md
61
+ documentation_uri: https://rubydoc.info/gems/object_forge/0.3.0
62
+ source_code_uri: https://github.com/trinistr/object_forge/tree/v0.3.0
63
+ changelog_uri: https://github.com/trinistr/object_forge/blob/v0.3.0/CHANGELOG.md
65
64
  rubygems_mfa_required: 'true'
66
- post_install_message:
67
65
  rdoc_options:
68
66
  - "--tag"
69
67
  - thread_safety:Thread safety
68
+ - "--main"
69
+ - README.md
70
70
  require_paths:
71
71
  - lib
72
72
  required_ruby_version: !ruby/object:Gem::Requirement
@@ -80,8 +80,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
82
  requirements: []
83
- rubygems_version: 3.5.22
84
- signing_key:
83
+ rubygems_version: 3.6.9
85
84
  specification_version: 4
86
85
  summary: A simple factory for objects with minimal assumptions.
87
86
  test_files: []
@@ -1,40 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ObjectForge
4
- module Molds
5
- # Special "mold" that returns appropriate mold for the given forged object.
6
- # Probably not the best fit though.
7
- #
8
- # Currently provides specific recognition for:
9
- # - subclasses of +Struct+ ({StructMold}),
10
- # - subclasses of +Data+ ({KeywordsMold}),
11
- # - +Hash+ and subclasses ({HashMold}).
12
- # Other objects just get {SingleArgumentMold}.
13
- #
14
- # @thread_safety Thread-safe.
15
- # @since 0.2.0
16
- class MoldMold
17
- # Get maybe appropriate mold for the given forged object.
18
- #
19
- # @param forged [Class, Any]
20
- # @return [#call] an instance of a mold
21
- def call(forged:, **_)
22
- # rubocop:disable Style/YodaCondition
23
- if ::Class === forged
24
- if ::Struct > forged
25
- StructMold.new
26
- elsif defined?(::Data) && ::Data > forged
27
- KeywordsMold.new
28
- elsif ::Hash >= forged
29
- HashMold.new
30
- else
31
- SingleArgumentMold.new
32
- end
33
- else
34
- SingleArgumentMold.new
35
- end
36
- # rubocop:enable Style/YodaCondition
37
- end
38
- end
39
- end
40
- end