object_forge 0.3.0 → 0.4.1

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.
@@ -4,42 +4,47 @@ module ObjectForge
4
4
  # This module provides a collection of predefined molds to be used in common cases.
5
5
  #
6
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
7
+ # and create an object from them. Molds are +call+able objects
8
8
  # responsible for actually building objects produced by factories
9
9
  # (or doing other, interesting things with them (truly, only the code review is the limit!)).
10
10
  # They are supposed to be immutable, shareable, and persistent:
11
11
  # initialize once, use for the whole runtime.
12
12
  #
13
13
  # A simple mold can easily be just a +Proc+.
14
- # All molds must have the following +#call+ signature: +call(forged:, attributes:, **)+.
14
+ # All molds must have the following +#call+ signature: +call(forge_target:, attributes:, **)+.
15
15
  # The extra keywords are ignored for possibility of future extensions.
16
16
  #
17
17
  # @example A very basic FactoryBot replacement
18
- # creator = ->(forged:, attributes:, **) do
19
- # instance = forged.new
18
+ # creator = ->(forge_target:, attributes:, **) do
19
+ # instance = forge_target.new
20
20
  # attributes.each_pair { instance.public_send(:"#{_1}=", _2) }
21
21
  # instance.save!
22
22
  # end
23
- # creator.call(forged: User, attributes: { name: "John", age: 30 })
23
+ #
24
+ # creator.call(forge_target: User, attributes: { name: "John", age: 30 })
24
25
  # # => <User name="John" age=30>
26
+ #
25
27
  # @example Using a mold to serialize collection of objects (contrivedly)
26
- # dumpy = ->(forged:, attributes:, **) do
28
+ # dumpy = ->(forge_target:, attributes:, **) do
27
29
  # Enumerator.new(attributes.size) do |y|
28
- # attributes.each_pair { y << forged.dump(_1 => _2) }
30
+ # attributes.each_pair { y << forge_target.dump(_1 => _2) }
29
31
  # end
30
32
  # end
31
- # dumpy.call(forged: JSON, attributes: {a:1, b:2}).to_a
33
+ #
34
+ # dumpy.call(forge_target: JSON, attributes: {a:1, b:2}).to_a
32
35
  # # => ["{\"a\":1}", "{\"b\":2}"]
33
- # dumpy.call(forged: YAML, attributes: {a:1, b:2}).to_a
36
+ # dumpy.call(forge_target: YAML, attributes: {a:1, b:2}).to_a
34
37
  # # => ["---\n:a: 1\n", "---\n:b: 2\n"]
38
+ #
35
39
  # @example Abstract factory pattern (kind of)
36
40
  # class FurnitureFactory
37
- # def call(forged:, attributes:, **)
38
- # concrete_factory = concrete_factory(forged)
41
+ # def call(forge_target:, attributes:, **)
42
+ # concrete_factory = concrete_factory(forge_target)
39
43
  # attributes[:pieces].map do |piece|
40
44
  # concrete_factory.public_send(piece, attributes.dig(:color, piece))
41
45
  # end
42
46
  # end
47
+ #
43
48
  # private def concrete_factory(style)
44
49
  # case style
45
50
  # when :hitech
@@ -49,20 +54,70 @@ module ObjectForge
49
54
  # end
50
55
  # end
51
56
  # end
52
- # FurnitureFactory.new.call(forged: :hitech, attributes: {
57
+ #
58
+ # FurnitureFactory.new.call(forge_target: :hitech, attributes: {
53
59
  # pieces: [:chair, :table], color: { chair: :black, table: :white }
54
60
  # })
55
61
  # # => [<#HiTech::Chair color=:black>, <#HiTech::Table color=:white>]
62
+ #
56
63
  # @example Abusing molds
57
- # printer = ->(forged:, attributes:, **) { PP.pp(attributes, forged) }
58
- # printer.call(forged: $stderr, attributes: {a:1, b:2})
64
+ # printer = ->(forge_target:, attributes:, **) { PP.pp(attributes, forge_target) }
65
+ # printer.call(forge_target: $stderr, attributes: {a:1, b:2})
59
66
  # # outputs "{:a=>1, :b=>2}" to $stderr
60
67
  #
68
+ # @example Abuse above is just not enough, we need something even better
69
+ # class Character
70
+ # attr_reader :hp
71
+ #
72
+ # def initialize(hp)
73
+ # @hp = hp
74
+ # @damage_factory = ObjectForge::Forge.new(self, DamageParameters.new)
75
+ # end
76
+ #
77
+ # def hit(damage)
78
+ # if damage <= 0
79
+ # @damage_factory.call(:shielded, apply: ->(damage) { @hp -= damage })
80
+ # else
81
+ # @damage_factory.call(amount: damage, apply: ->(damage) { @hp -= damage })
82
+ # end
83
+ # end
84
+ #
85
+ # def heal(amount)
86
+ # @damage_factory.call(amount: amount, apply: ->(amount) { @hp += amount }) if amount >= 0
87
+ # end
88
+ #
89
+ # def die!
90
+ # puts "Character died!"
91
+ # end
92
+ # end
93
+ #
94
+ # class DamageParameters
95
+ # def attributes = {}
96
+ # def traits = { shielded: { amount: 1 } }
97
+ # def options
98
+ # {
99
+ # crucible: lambda(&:itself),
100
+ # mold: ->(forge_target:, attributes:, **) {
101
+ # attributes[:apply].call(attributes[:amount])
102
+ # forge_target
103
+ # },
104
+ # after_build: ->(forge_target) { forge_target.die! if forge_target.hp <= 0 }
105
+ # }
106
+ # end
107
+ # end
108
+ #
109
+ # mc = Character.new(100)
110
+ # mc.hit(50)
111
+ # mc.heal(25)
112
+ # mc.hit(-10)
113
+ # mc.hit(75)
114
+ # # outputs "Character died!"
115
+ #
61
116
  # @since 0.2.0
62
117
  module Molds
63
118
  Dir["#{__dir__}/molds/*.rb"].each { require_relative _1 }
64
119
 
65
- # Get maybe appropriate mold for the given +forged+ class or object.
120
+ # Get maybe appropriate mold for the given forge target.
66
121
  #
67
122
  # Currently provides specific recognition for:
68
123
  # - subclasses of +Struct+ ({StructMold}),
@@ -70,18 +125,18 @@ module ObjectForge
70
125
  # - +Hash+ and subclasses ({HashMold}).
71
126
  # Other objects just get {SingleArgumentMold}.
72
127
  #
73
- # @param forged [Class, Any]
128
+ # @param forge_target [Class, Any]
74
129
  # @return [#call] an instance of a mold
75
130
  #
76
131
  # @thread_safety Thread-safe.
77
132
  # @since 0.3.0
78
- def self.mold_for(forged)
79
- if ::Class === forged
80
- if forged < ::Struct
133
+ def self.mold_for(forge_target)
134
+ if ::Class === forge_target
135
+ if forge_target < ::Struct
81
136
  StructMold.new
82
- elsif defined?(::Data) && forged < ::Data
137
+ elsif defined?(::Data) && forge_target < ::Data
83
138
  KeywordsMold.new
84
- elsif forged <= ::Hash
139
+ elsif forge_target <= ::Hash
85
140
  HashMold.new
86
141
  else
87
142
  SingleArgumentMold.new
@@ -97,12 +152,10 @@ module ObjectForge
97
152
  # If it is a Class with +#call+, wraps it in {WrappedMold}.
98
153
  # Otherwise, raises an error.
99
154
  #
100
- # @since 0.3.0
101
- #
102
155
  # @param mold [Class, #call, nil]
103
156
  # @return [#call, nil]
104
157
  #
105
- # @raise [DSLError] if +mold+ does not respond to or implement +#call+
158
+ # @raise [ObjectInterfaceError] if +mold+ does not respond to or implement +#call+
106
159
  #
107
160
  # @thread_safety Thread-safe.
108
161
  # @since 0.3.0
@@ -112,7 +165,7 @@ module ObjectForge
112
165
  elsif ::Class === mold && mold.public_method_defined?(:call)
113
166
  WrappedMold.new(mold)
114
167
  else
115
- raise MoldError, "mold must respond to or implement #call"
168
+ raise ObjectInterfaceError, "mold must respond to or implement #call"
116
169
  end
117
170
  end
118
171
  end
@@ -25,10 +25,10 @@ module ObjectForge
25
25
  #
26
26
  # @param initial [#succ] initial value for the sequence
27
27
  #
28
- # @raise [ArgumentError] if +initial+ does not respond to #succ
28
+ # @raise [ObjectInterfaceError] if +initial+ does not respond to #succ
29
29
  def initialize(initial)
30
30
  unless initial.respond_to?(:succ)
31
- raise ArgumentError, "initial value must respond to #succ, #{initial.class} given"
31
+ raise ObjectInterfaceError, "initial value must respond to #succ"
32
32
  end
33
33
 
34
34
  @initial = initial
@@ -2,5 +2,5 @@
2
2
 
3
3
  module ObjectForge
4
4
  # Current version
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.1"
6
6
  end
data/lib/object_forge.rb CHANGED
@@ -4,53 +4,113 @@ require_relative "object_forge/forgeyard"
4
4
  require_relative "object_forge/sequence"
5
5
  require_relative "object_forge/version"
6
6
 
7
- # A simple all-purpose factory library with minimal assumptions.
7
+ # A small factory library for Ruby objects with minimal assumptions about framework, persistence,
8
+ # or runtime environment.
8
9
  #
9
10
  # These are the main classes you should be aware of:
11
+ # - {Forge} is a factory for objects.
12
+ # Usually created through {Forgeyard#define} or {Forge.define}
13
+ # using a DSL similar to FactoryBot, Forges can be used standalone,
14
+ # or as a part of a Forgeyard.
10
15
  # - {Forgeyard} is a registry of named related Forges.
11
16
  # A Forgeyard allows to {Forgeyard#define} a Forge,
12
17
  # and {Forgeyard#forge} a new object using a defined Forge.
13
- # - {Forge} is a factory for objects.
14
- # Usually created through {Forgeyard#define}/{Forge.define} in a manner similar to FactoryBot,
15
- # Forges can be used standalone, or as a part of a Forgeyard.
16
- # - {Sequence} is a representation of a sequence of values.
17
- # They are usually used implicitly through {ForgeDSL#sequence},
18
- # but can be created explicitly to be shared (or used outside of ObjectForge).
18
+ # - {Molds} are object constructors used by {Forge}s.
19
+ # Several common molds are shipped with ObjectForge, but you
20
+ # will probably find it useful to create your own.
19
21
  #
20
- # Additionally, successful use may depend on understanding these:
22
+ # Successful use may also depend on understanding these:
21
23
  # - {ForgeDSL} is a block-based DSL inspired by FactoryBot and ROM::Factory.
22
24
  # It allows defining arbitrary attributes (possibly using sequences),
23
25
  # with support for traits (collections of attributes with non-default values).
26
+ # - {Sequence} is a representation of a sequence of values.
27
+ # They are usually used implicitly through {ForgeDSL#sequence},
28
+ # but can be created explicitly to be shared (or used outside of ObjectForge).
24
29
  # - {Crucible} is used to resolve attributes.
25
- # - {Molds} are objects used to instantiate objects in {Forge}.
30
+ #
31
+ # *ObjectForge* itself provides a top-level convenience API for working with a singular
32
+ # {DEFAULT_YARD} when you expect to never need more than one Forgeyard, such as in test suites.
26
33
  #
27
34
  # @example Quick example
28
35
  # Frobinator = Struct.new(:frob, :inator, keyword_init: true)
29
- # # Forge's name and forged class are completely independent.
36
+ #
37
+ # # Forge's name and target class are completely independent.
30
38
  # ObjectForge.define(:frobber, Frobinator) do |f|
31
39
  # f.frob { "Frob" + inator.call }
32
40
  # f.inator { -> { "inator" } }
41
+ #
33
42
  # f.trait :static do |tf|
34
43
  # tf.frob { "Static" }
35
44
  # end
36
45
  # end
46
+ #
37
47
  # # These methods are aliases:
38
48
  # ObjectForge.forge(:frobber)
39
- # # => #<struct Frobinator frob="Frobinator", inator=#<Proc:...>>
49
+ # # => #<struct Frobinator frob="Frobinator", inator=#<Proc:...>>
40
50
  # ObjectForge.build(:frobber, frob: -> { "Frob" + inator }, inator: "orn")
41
- # # => #<struct Frobinator frob="Froborn", inator="orn">
51
+ # # => #<struct Frobinator frob="Froborn", inator="orn">
42
52
  # ObjectForge.call(:frobber, :static, inator: "Value")
43
- # # => #<struct Frobinator frob="Static", inator="Value">
53
+ # # => #<struct Frobinator frob="Static", inator="Value">
54
+ #
55
+ # @example A more involved example
56
+ # require "logger"
57
+ #
58
+ # # A custom mold is needed for Logger because it has positional and keyword parameters.
59
+ # logger_mold = ->(forge_target:, attributes:, **) {
60
+ # forge_target.new(
61
+ # attributes[:file],
62
+ # *attributes[:rotation],
63
+ # **attributes.slice(:level, :progname, :formatter)
64
+ # )
65
+ # }
66
+ #
67
+ # # This is a one-off, universal factory, so using a Forgeyard is not needed.
68
+ # $logger_factory = ObjectForge::Forge.define(Logger) do |f|
69
+ # f.mold = logger_mold
70
+ # # Default crucible is almost always good enough, but let's use a simpler and faster resolver.
71
+ # f.crucible = ->(attributes) { attributes.transform_values { _1.is_a?(Proc) ? _1.call : _1 } }
72
+ #
73
+ # f.file { $stderr }
74
+ #
75
+ # f.trait :stdout do |tf|
76
+ # tf.file { $stdout }
77
+ # end
78
+ #
79
+ # f.trait :logfiles do |tf|
80
+ # require "date"
81
+ # tf.file { "log-#{Date.today}.log" }
82
+ # tf.rotation { "daily" }
83
+ # end
84
+ # end
85
+ #
86
+ # class MyClass
87
+ # def initialize
88
+ # @logger = $logger_factory.build(:stdout, progname: self.class)
89
+ # end
90
+ #
91
+ # def call
92
+ # @logger.info("called!")
93
+ # end
94
+ # end
95
+ #
96
+ # MyClass.new.call
97
+ # # outputs "I, [2026-05-25T13:56:33.533669 #206330] INFO -- MyClass: called!" to $stdout
44
98
  module ObjectForge
45
- # Base error class for ObjectForge.
99
+ # Base domain error class for ObjectForge.
46
100
  # @since 0.1.0
47
101
  class Error < StandardError; end
48
102
  # Error raised when a mistake is made in using DSL.
49
103
  # @since 0.1.0
50
104
  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
105
+ # Error raised when attribute resolution surfaces a circular dependency.
106
+ # @since 0.4.0
107
+ class CircularAttributeDependencyError < Error; end
108
+
109
+ # Error raised when object does not conform to expected interface,
110
+ # most commonly lacking +#call+.
111
+ # @note This class inherits from +TypeError+, not {Error}.
112
+ # @since 0.4.0
113
+ class ObjectInterfaceError < ::TypeError; end
54
114
 
55
115
  # Default {Forgeyard} that is used by {.define} and {.forge}.
56
116
  #
@@ -58,51 +118,53 @@ module ObjectForge
58
118
  # @note
59
119
  # Default forgeyard is intended to be useful for non-shareable code,
60
120
  # like simple application tests and specs.
61
- # It should not be used in application code, and never in gems.
121
+ # It should not be used in application code, especially in gems.
62
122
  # @since 0.1.0
63
123
  DEFAULT_YARD = Forgeyard.new
64
124
 
65
- # @overload sequence(initial)
66
125
  # Create a sequence, to be used wherever it needs to be.
67
126
  #
68
127
  # @see Sequence.new
69
128
  # @since 0.1.0
70
129
  #
71
- # @param initial [#succ, Sequence]
72
- # @return [Sequence]
130
+ # @overload sequence(initial)
131
+ # @param initial [#succ, Sequence]
132
+ # @return [Sequence]
73
133
  def self.sequence(...)
74
134
  Sequence.new(...)
75
135
  end
76
136
 
77
- # @overload define(name, forged, &)
78
137
  # Define and create a forge in {DEFAULT_YARD}.
79
138
  #
80
139
  # @!macro default_forgeyard
81
140
  # @see Forgeyard#define
82
141
  # @since 0.1.0
83
142
  #
84
- # @param name [Symbol] forge name
85
- # @param forged [Class, Any] class or object to forge
86
- # @yieldparam f [ForgeDSL]
87
- # @yieldreturn [void]
88
- # @return [Forge] forge
143
+ # @overload define(name, forge_target)
144
+ # @param name [Symbol] name to register forge under
145
+ # @param forge_target [Class, Any] class or object to forge
146
+ # @yieldparam dsl [ForgeDSL]
147
+ # @yieldreturn [void]
148
+ # @return [Forge] forge
89
149
  def self.define(...)
90
150
  DEFAULT_YARD.define(...)
91
151
  end
92
152
 
93
- # @overload forge(name, *traits, **overrides, &)
94
153
  # Build an instance using a forge from {DEFAULT_YARD}.
95
154
  #
96
155
  # @!macro default_forgeyard
97
156
  # @see Forgeyard#forge
98
157
  # @since 0.1.0
99
158
  #
100
- # @param name [Symbol] name of the forge
101
- # @param traits [Array<Symbol>] traits to apply
102
- # @param overrides [Hash{Symbol => Any}] attribute overrides
103
- # @yieldparam object [Any] forged instance
104
- # @yieldreturn [void]
105
- # @return [Any] built instance
159
+ # @overload forge(name, *traits, **overrides)
160
+ # @param name [Symbol] name of the forge
161
+ # @param traits [Array<Symbol>] traits to apply
162
+ # @param overrides [Hash{Symbol => Any}] attribute overrides
163
+ # @yieldparam object [Any] forged instance
164
+ # @yieldreturn [void]
165
+ # @return [Any] forged instance
166
+ # @raise [ArgumentError] if a trait name is unknown
167
+ # @raise [KeyError] if forge with the specified name is not registered
106
168
  def self.forge(...)
107
169
  DEFAULT_YARD.forge(...)
108
170
  end
@@ -3,15 +3,7 @@ module ObjectForge
3
3
  def self.wrap_mold
4
4
  : ((ObjectForge::mold | Class | nil) mold) -> ObjectForge::mold?
5
5
  def self.mold_for
6
- : (ObjectForge::_Forgable forged) -> ObjectForge::mold
7
-
8
- class SingleArgumentMold
9
- include ObjectForge::_Mold
10
- end
11
-
12
- class KeywordsMold
13
- include ObjectForge::_Mold
14
- end
6
+ : (ObjectForge::forge_target) -> ObjectForge::mold
15
7
 
16
8
  class WrappedMold
17
9
  interface _MoldClass[T]
@@ -25,43 +17,53 @@ module ObjectForge
25
17
  def initialize: (_MoldClass[ObjectForge::mold] wrapped_mold) -> void
26
18
  end
27
19
 
20
+ class SingleArgumentMold
21
+ include ObjectForge::_Mold
22
+ end
23
+
24
+ class KeywordsMold
25
+ include ObjectForge::_Mold
26
+ end
27
+
28
28
  class StructMold
29
29
  interface _StructSubclass[T]
30
30
  def new
31
31
  : (*untyped) -> T
32
32
  | (**untyped) -> T
33
- | (Hash[Symbol, untyped]) -> T
33
+ | (Hash[Symbol, ObjectForge::attribute]) -> T
34
34
  def members: -> Array[Symbol]
35
35
  def keyword_init?: -> bool?
36
36
  end
37
37
 
38
38
  RUBY_FEATURE_AUTO_KEYWORDS: bool
39
+
39
40
  attr_reader lax: bool
41
+ alias lax? lax
40
42
 
41
43
  def initialize: (?lax: bool) -> void
42
44
 
43
45
  def call
44
- : [T < Struct] (forged: _StructSubclass[T], attributes: Hash[Symbol, untyped], **untyped) -> T
46
+ : [T < Struct] (forge_target: _StructSubclass[T], attributes: Hash[Symbol, ObjectForge::attribute], **untyped) -> T
45
47
 
46
48
  private
47
49
 
48
50
  def build_struct_with_unspecified_keyword_init
49
- : [T < Struct] (_StructSubclass[T] forged, Hash[Symbol, untyped] attributes) -> T
51
+ : [T < Struct] (_StructSubclass[T] forge_target, Hash[Symbol, ObjectForge::attribute] attributes) -> T
50
52
  end
51
53
 
52
54
  class HashMold
53
55
  interface _HashSubclass[T]
54
- def []: (Hash[untyped, untyped]) -> T
56
+ def []: (Hash[Symbol, ObjectForge::attribute]) -> T
55
57
  end
56
58
 
57
- attr_reader default: untyped?
59
+ attr_reader default: ObjectForge::attribute
58
60
  attr_reader default_proc: Proc?
59
61
 
60
62
  def initialize
61
- : (?untyped? default_value) ?{ (Hash[untyped, untyped] hash, untyped key) -> untyped} -> void
63
+ : [T < Hash] (?ObjectForge::attribute default_value) ?{ (T hash, untyped key) -> ObjectForge::attribute} -> void
62
64
 
63
65
  def call
64
- : [T < Hash] (forged: _HashSubclass[T], attributes: Hash[Symbol, untyped], **untyped) -> T
66
+ : [T < Hash] (forge_target: _HashSubclass[T], attributes: Hash[Symbol, ObjectForge::attribute], **untyped) -> T
65
67
  end
66
68
  end
67
69
  end