Skip to content

Commit d6e1aa1

Browse files
authored
Implement mold system (#8)
* Add settings molds through DSL * Change object building to use molds * Provide essential molds out-of-the-box * Add support for Struct, Data, Hash out-of-the-box
1 parent c4f0c93 commit d6e1aa1

25 files changed

+911
-27
lines changed

.ruby-version

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.2.9
1+
3.3.9

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Next]
99

10+
This update brings a huge and necessary enhancement: ability to change how objects are built.
11+
This is achieved with the *mold* (builder) system. You can now precisely control (or abuse) the process,
12+
making it possible to consume attributes in any way you want.
13+
14+
**Added**
15+
- Add setting the mold for a Forge through the `ForgeDSL#mold=` method, using any object (or class) with a `#call` method.
16+
- Add `Molds::SingleArgumentMold` as a default mold, mimicing previous behavior, and `Molds::KeywordsMold` as an alternative.
17+
- Add `Molds::HashMold` and `Molds::StructMold` to support Hashes and Structs out-of-the-box.
18+
- Add `Molds::WrappedMold` to support complex user-provided molds.
19+
20+
**Changed**
21+
- [Breaking] `Forge::Parameters` interface now includes `#mold`.
22+
- `Forge` will automatically use `Molds::MoldMold` to determine the mold if none is provided.
23+
1024
[Compare v0.1.1...main](https://github.com/trinistr/object_forge/compare/v0.1.1...main)
1125

1226
## [v0.1.1]

README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ However, such gems make a lot of assumptions about why, how and what for they wi
2323
- assuming that streamlined object generation is only useful for testing;
2424
- (related to the previous point) assuming that there will never be a need to
2525
have more than one configuration of a library in the same project
26-
(I believe, this anti-pattern was popularised by Rails);
27-
- (and related again) assuming that adding global methods or objects is a good idea.
26+
(I believe this anti-pattern was popularised by Rails);
27+
- assuming that adding global methods or objects is a good idea.
2828

2929
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.
3030

@@ -196,12 +196,10 @@ kanban
196196
[Default global forgeyard]
197197
[Thread-safe behavior]
198198
[Tapping into built objects for post-processing]
199+
[Custom builders (molds)]
200+
[Built-in Hash, Struct, Data builders (molds)]
199201
[⚗️ To do]
200202
[Ability to replace resolver]
201-
[Custom builders]
202-
[Struct builder]
203-
[Data builder]
204-
[Hash builder]
205203
[After-build hook]
206204
[❔Under consideration]
207205
[Calling traits from traits]

lib/object_forge.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# frozen_string_literal: true
22

3-
Dir["#{__dir__}/object_forge/**/*.rb"].each { require _1 }
3+
require_relative "object_forge/forgeyard"
4+
require_relative "object_forge/sequence"
5+
require_relative "object_forge/version"
46

57
# A simple all-purpose factory library with minimal assumptions.
68
#
@@ -76,7 +78,7 @@ def self.sequence(...)
7678
# @since 0.1.0
7779
#
7880
# @param name [Symbol] forge name
79-
# @param forged [Class] class to forge
81+
# @param forged [Class, Any] class or object to forge
8082
# @yieldparam f [ForgeDSL]
8183
# @yieldreturn [void]
8284
# @return [Forge] forge

lib/object_forge/crucible.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ module ObjectForge
1212
#
1313
# @thread_safety Attribute resolution is idempotent,
1414
# but modifies instance variables, making it unsafe to share the Crucible
15-
#
1615
# @since 0.1.0
1716
class Crucible < UnBasicObject
1817
%i[rand].each { |m| private define_method(m, ::Object.instance_method(m)) }

lib/object_forge/forge.rb

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative "crucible"
44
require_relative "forge_dsl"
5+
require_relative "molds"
56

67
module ObjectForge
78
# Object instantitation forge.
@@ -19,14 +20,22 @@ class Forge
1920
# @!attribute [r] traits
2021
# Attributes belonging to traits.
2122
# @return [Hash{Symbol => Hash{Symbol => Any}}]
22-
Parameters = Struct.new(:attributes, :traits, keyword_init: true)
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
2332

2433
# Define (and create) a forge using DSL.
2534
#
2635
# @see ForgeDSL
2736
# @thread_safety Thread-safe if DSL definition is thread-safe.
2837
#
29-
# @param forged [Class] class to forge
38+
# @param forged [Class, Any] class or object to forge
3039
# @param name [Symbol, nil] forge name
3140
# @yieldparam f [ForgeDSL]
3241
# @yieldreturn [void]
@@ -38,19 +47,20 @@ def self.define(forged, name: nil, &)
3847
# @return [Symbol, nil] forge name
3948
attr_reader :name
4049

41-
# @return [Class] class to forge
50+
# @return [Class, Any] class or object to forge
4251
attr_reader :forged
4352

4453
# @return [Parameters, ForgeDSL] forge parameters
4554
attr_reader :parameters
4655

47-
# @param forged [Class] class to forge
56+
# @param forged [Class, Any] class or object to forge
4857
# @param parameters [Parameters, ForgeDSL] forge parameters
4958
# @param name [Symbol, nil] forge name
5059
def initialize(forged, parameters, name: nil)
5160
@name = name
5261
@forged = forged
5362
@parameters = parameters
63+
@mold = parameters.mold || MOLD_MOLD.call(forged: forged)
5464
end
5565

5666
# Forge a new instance.
@@ -67,7 +77,7 @@ def initialize(forged, parameters, name: nil)
6777
# If a block is given, forged instance is yielded to it after being built.
6878
#
6979
# @thread_safety Forging is thread-safe if {#parameters},
70-
# +traits+ and +overrides+ are thread-safe.
80+
# +traits+ and +overrides+ are thread-safe.
7181
#
7282
# @param traits [Array<Symbol>] traits to apply
7383
# @param overrides [Hash{Symbol => Any}] attribute overrides
@@ -76,7 +86,7 @@ def initialize(forged, parameters, name: nil)
7686
# @return [Any] built instance
7787
def forge(*traits, **overrides)
7888
resolved_attributes = resolve_attributes(traits, overrides)
79-
instance = build_instance(resolved_attributes)
89+
instance = @mold.call(forged: @forged, attributes: resolved_attributes)
8090
yield instance if block_given?
8191
instance
8292
end
@@ -90,9 +100,5 @@ def resolve_attributes(traits, overrides)
90100
attributes = @parameters.attributes.merge(*@parameters.traits.values_at(*traits), overrides)
91101
Crucible.new(attributes).resolve!
92102
end
93-
94-
def build_instance(attributes)
95-
forged.new(attributes)
96-
end
97103
end
98104
end

lib/object_forge/forge_dsl.rb

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
require_relative "sequence"
44
require_relative "un_basic_object"
55

6+
require_relative "molds/wrapped_mold"
7+
68
module ObjectForge
79
# DSL for defining a forge.
810
#
@@ -14,7 +16,6 @@ module ObjectForge
1416
# especially in attribute definitions.
1517
# The instance itself is frozen after initialization,
1618
# so it should be safe to share.
17-
#
1819
# @since 0.1.0
1920
class ForgeDSL < UnBasicObject
2021
# @return [Hash{Symbol => Proc}] attribute definitions
@@ -26,6 +27,9 @@ class ForgeDSL < UnBasicObject
2627
# @return [Hash{Symbol => Hash{Symbol => Proc}}] trait definitions
2728
attr_reader :traits
2829

30+
# @return [#call, nil] forge mold
31+
attr_reader :mold
32+
2933
# Define forge's parameters through DSL.
3034
#
3135
# If the block has a parameter, an object will be yielded,
@@ -71,9 +75,34 @@ def freeze
7175
@attributes.freeze
7276
@sequences.freeze
7377
@traits.freeze
78+
@mold.freeze
7479
self
7580
end
7681

82+
# Set the forge mold.
83+
#
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.
89+
#
90+
# @since 0.2.0
91+
#
92+
# @param mold [Class, #call, nil]
93+
# @return [Class, #call, nil]
94+
#
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"
103+
end
104+
end
105+
77106
# Define an attribute, possibly transient.
78107
#
79108
# DSL does not know or care what attributes the forged class has,

lib/object_forge/forgeyard.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
require "concurrent/map"
44

5+
require_relative "forge"
6+
57
module ObjectForge
68
# A registry for forges, making them accessible by name.
79
#
@@ -20,7 +22,7 @@ def initialize
2022
# @see Forge.define
2123
#
2224
# @param name [Symbol] name to register forge under
23-
# @param forged [Class] class to forge
25+
# @param forged [Class, Any] class or object to forge
2426
# @yieldparam f [ForgeDSL]
2527
# @yieldreturn [void]
2628
# @return [Forge] forge

lib/object_forge/molds.rb

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# frozen_string_literal: true
2+
3+
module ObjectForge
4+
# This module provides a collection of predefined molds to be used in common cases.
5+
#
6+
# Molds are +#call+able objects responsible for actually building objects produced by factories
7+
# (or doing other, interesting things with them (truly, only the code review is the limit!)).
8+
# They are supposed to be immutable, shareable, and persistent:
9+
# initialize once, use for the whole runtime.
10+
#
11+
# A simple mold can easily be just a +Proc+.
12+
# All molds must have the following +#call+ signature: +call(forged:, attributes:, **)+.
13+
# The extra keywords are for future extensions.
14+
#
15+
# @example A very basic FactoryBot replacement
16+
# creator = ->(forged:, attributes:, **) do
17+
# instance = forged.new
18+
# attributes.each_pair { instance.public_send(:"#{_1}=", _2) }
19+
# instance.save!
20+
# end
21+
# creator.call(forged: User, attributes: { name: "John", age: 30 })
22+
# # => <User name="John" age=30>
23+
# @example Using a mold to serialize collection of objects (contrivedly)
24+
# dumpy = ->(forged:, attributes:, **) do
25+
# Enumerator.new(attributes.size) do |y|
26+
# attributes.each_pair { y << forged.dump(_1 => _2) }
27+
# end
28+
# end
29+
# dumpy.call(forged: JSON, attributes: {a:1, b:2}).to_a
30+
# # => ["{\"a\":1}", "{\"b\":2}"]
31+
# dumpy.call(forged: YAML, attributes: {a:1, b:2}).to_a
32+
# # => ["---\n:a: 1\n", "---\n:b: 2\n"]
33+
# @example Abstract factory pattern (kind of)
34+
# class FurnitureFactory
35+
# def call(forged:, attributes:, **)
36+
# concrete_factory = concrete_factory(forged)
37+
# attributes[:pieces].map do |piece|
38+
# concrete_factory.public_send(piece, attributes.dig(:color, piece))
39+
# end
40+
# end
41+
# private def concrete_factory(style)
42+
# case style
43+
# when :hitech
44+
# HiTechFactory.new
45+
# when :retro
46+
# RetroFactory.new
47+
# end
48+
# end
49+
# end
50+
# FurnitureFactory.new.call(forged: :hitech, attributes: {
51+
# pieces: [:chair, :table], color: { chair: :black, table: :white }
52+
# })
53+
# # => [<#HiTech::Chair color=:black>, <#HiTech::Table color=:white>]
54+
# @example Abusing molds
55+
# printer = ->(forged:, attributes:, **) { PP.pp(attributes, forged) }
56+
# printer.call(forged: $stderr, attributes: {a:1, b:2})
57+
# # outputs "{:a=>1, :b=>2}" to $stderr
58+
#
59+
# @since 0.2.0
60+
module Molds
61+
Dir["#{__dir__}/molds/*.rb"].each { require_relative _1 }
62+
end
63+
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# frozen_string_literal: true
2+
3+
module ObjectForge
4+
module Molds
5+
# Mold for constructing Hashes.
6+
#
7+
# @thread_safety Thread-safe on its own,
8+
# but using unshareable default value or block is not thread-safe.
9+
#
10+
# @since 0.2.0
11+
class HashMold
12+
# Default value to be assigned to each produced hash.
13+
# @return [Any, nil]
14+
attr_reader :default
15+
# Default proc to be assigned to each produced hash.
16+
# @return [Proc, nil]
17+
attr_reader :default_proc
18+
19+
# Initialize new HashMold with default value or default proc
20+
# to be assigned to each produced hash.
21+
#
22+
# The same exact objects are used for each hash.
23+
# It is not advised to use mutable objects as default values.
24+
# Be aware that using a default proc with assignment
25+
# is inherently not safe, see this Ruby issue:
26+
# https://bugs.ruby-lang.org/issues/19237.
27+
#
28+
# @see Hash.new
29+
#
30+
# @param default_value [Any]
31+
# @yieldparam hash [Hash]
32+
# @yieldparam key [Any]
33+
# @yieldreturn [Any]
34+
def initialize(default_value = nil, &default_proc)
35+
@default = default_value
36+
@default_proc = default_proc
37+
end
38+
39+
# Build a new hash using +forged.[]+.
40+
#
41+
# @see Hash.[]
42+
#
43+
# @param forged [Class] Hash or a subclass of Hash
44+
# @param attributes [Hash{Symbol => Any}]
45+
# @return [Hash]
46+
def call(forged:, attributes:, **_)
47+
hash = forged[attributes]
48+
hash.default = @default if @default
49+
hash.default_proc = @default_proc if @default_proc
50+
hash
51+
end
52+
end
53+
end
54+
end

0 commit comments

Comments
 (0)