value_semantics 3.5.0 → 3.6.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/CHANGELOG.md +49 -0
- data/README.md +58 -16
- data/lib/value_semantics.rb +26 -493
- data/lib/value_semantics/anything.rb +11 -0
- data/lib/value_semantics/array_coercer.rb +23 -0
- data/lib/value_semantics/array_of.rb +18 -0
- data/lib/value_semantics/attribute.rb +100 -0
- data/lib/value_semantics/bool.rb +11 -0
- data/lib/value_semantics/class_methods.rb +34 -0
- data/lib/value_semantics/dsl.rb +106 -0
- data/lib/value_semantics/either.rb +18 -0
- data/lib/value_semantics/hash_coercer.rb +30 -0
- data/lib/value_semantics/hash_of.rb +20 -0
- data/lib/value_semantics/instance_methods.rb +170 -0
- data/lib/value_semantics/range_of.rb +18 -0
- data/lib/value_semantics/recipe.rb +17 -0
- data/lib/value_semantics/struct.rb +19 -0
- data/lib/value_semantics/value_object_coercer.rb +44 -0
- data/lib/value_semantics/version.rb +1 -1
- metadata +46 -3
@@ -0,0 +1,23 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
class ArrayCoercer
|
3
|
+
attr_reader :element_coercer
|
4
|
+
|
5
|
+
def initialize(element_coercer = nil)
|
6
|
+
@element_coercer = element_coercer
|
7
|
+
freeze
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(obj)
|
11
|
+
if obj.respond_to?(:to_a)
|
12
|
+
array = obj.to_a
|
13
|
+
if element_coercer
|
14
|
+
array.map { |element| element_coercer.call(element) }
|
15
|
+
else
|
16
|
+
array
|
17
|
+
end
|
18
|
+
else
|
19
|
+
obj
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
#
|
3
|
+
# Validator that matches arrays if each element matches a given subvalidator
|
4
|
+
#
|
5
|
+
class ArrayOf
|
6
|
+
attr_reader :element_validator
|
7
|
+
|
8
|
+
def initialize(element_validator)
|
9
|
+
@element_validator = element_validator
|
10
|
+
freeze
|
11
|
+
end
|
12
|
+
|
13
|
+
# @return [Boolean]
|
14
|
+
def ===(value)
|
15
|
+
Array === value && value.all? { |element| element_validator === element }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
#
|
3
|
+
# Represents a single attribute of a value class
|
4
|
+
#
|
5
|
+
class Attribute
|
6
|
+
NOT_SPECIFIED = Object.new.freeze
|
7
|
+
NO_DEFAULT_GENERATOR = lambda do
|
8
|
+
raise NoDefaultValue, "Attribute does not have a default value"
|
9
|
+
end
|
10
|
+
|
11
|
+
attr_reader :name, :validator, :coercer, :default_generator,
|
12
|
+
:instance_variable, :optional
|
13
|
+
alias_method :optional?, :optional
|
14
|
+
|
15
|
+
def initialize(
|
16
|
+
name:,
|
17
|
+
default_generator: NO_DEFAULT_GENERATOR,
|
18
|
+
validator: Anything,
|
19
|
+
coercer: nil
|
20
|
+
)
|
21
|
+
@name = name.to_sym
|
22
|
+
@default_generator = default_generator
|
23
|
+
@validator = validator
|
24
|
+
@coercer = coercer
|
25
|
+
@instance_variable = '@' + name.to_s.chomp('!').chomp('?')
|
26
|
+
@optional = !default_generator.equal?(NO_DEFAULT_GENERATOR)
|
27
|
+
freeze
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.define(
|
31
|
+
name,
|
32
|
+
validator=Anything,
|
33
|
+
default: NOT_SPECIFIED,
|
34
|
+
default_generator: nil,
|
35
|
+
coerce: nil
|
36
|
+
)
|
37
|
+
# TODO: change how defaults are specified:
|
38
|
+
#
|
39
|
+
# - default: either a value, or a callable
|
40
|
+
# - default_value: always a value
|
41
|
+
# - default_generator: always a callable
|
42
|
+
#
|
43
|
+
# This would not be a backwards compatible change.
|
44
|
+
generator = begin
|
45
|
+
if default_generator && !default.equal?(NOT_SPECIFIED)
|
46
|
+
raise ArgumentError, "Attribute `#{name}` can not have both a `:default` and a `:default_generator`"
|
47
|
+
elsif default_generator
|
48
|
+
default_generator
|
49
|
+
elsif !default.equal?(NOT_SPECIFIED)
|
50
|
+
->{ default }
|
51
|
+
else
|
52
|
+
NO_DEFAULT_GENERATOR
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
new(
|
57
|
+
name: name,
|
58
|
+
validator: validator,
|
59
|
+
default_generator: generator,
|
60
|
+
coercer: coerce,
|
61
|
+
)
|
62
|
+
end
|
63
|
+
|
64
|
+
# @deprecated Use a combination of the other instance methods instead
|
65
|
+
def determine_from!(attr_hash, value_class)
|
66
|
+
raw_value = attr_hash.fetch(name) do
|
67
|
+
if default_generator.equal?(NO_DEFAULT_GENERATOR)
|
68
|
+
raise MissingAttributes, "Attribute `#{value_class}\##{name}` has no value"
|
69
|
+
else
|
70
|
+
default_generator.call
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
coerced_value = coerce(raw_value, value_class)
|
75
|
+
if validate?(coerced_value)
|
76
|
+
[name, coerced_value]
|
77
|
+
else
|
78
|
+
raise InvalidValue, "Attribute `#{value_class}\##{name}` is invalid: #{coerced_value.inspect}"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def coerce(attr_value, value_class)
|
83
|
+
return attr_value unless coercer # coercion not enabled
|
84
|
+
|
85
|
+
if coercer.equal?(true)
|
86
|
+
value_class.public_send(coercion_method, attr_value)
|
87
|
+
else
|
88
|
+
coercer.call(attr_value)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def validate?(value)
|
93
|
+
validator === value
|
94
|
+
end
|
95
|
+
|
96
|
+
def coercion_method
|
97
|
+
"coerce_#{name}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
#
|
3
|
+
# All the class methods available on ValueSemantics classes
|
4
|
+
#
|
5
|
+
# When a ValueSemantics module is included into a class,
|
6
|
+
# the class is extended by this module.
|
7
|
+
#
|
8
|
+
module ClassMethods
|
9
|
+
#
|
10
|
+
# @return [Recipe] the recipe used to build the ValueSemantics module that
|
11
|
+
# was included into this class.
|
12
|
+
#
|
13
|
+
def value_semantics
|
14
|
+
if block_given?
|
15
|
+
# caller is trying to use the monkey-patched Class method
|
16
|
+
raise "`#{self}` has already included ValueSemantics"
|
17
|
+
end
|
18
|
+
|
19
|
+
self::VALUE_SEMANTICS_RECIPE__
|
20
|
+
end
|
21
|
+
|
22
|
+
#
|
23
|
+
# A coercer object for the value class
|
24
|
+
#
|
25
|
+
# This is mostly useful when nesting value objects inside each other.
|
26
|
+
#
|
27
|
+
# @return [#call] A callable object that can be used as a coercer
|
28
|
+
# @see ValueObjectCoercer
|
29
|
+
#
|
30
|
+
def coercer
|
31
|
+
ValueObjectCoercer.new(self)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
#
|
3
|
+
# Builds a {Recipe} via DSL methods
|
4
|
+
#
|
5
|
+
# DSL blocks are <code>instance_eval</code>d against an object of this class.
|
6
|
+
#
|
7
|
+
# @see Recipe
|
8
|
+
# @see ValueSemantics.for_attributes
|
9
|
+
#
|
10
|
+
class DSL
|
11
|
+
#
|
12
|
+
# Builds a {Recipe} from a DSL block
|
13
|
+
#
|
14
|
+
# @yield to the block containing the DSL
|
15
|
+
# @return [Recipe]
|
16
|
+
def self.run(&block)
|
17
|
+
dsl = new
|
18
|
+
dsl.instance_eval(&block)
|
19
|
+
Recipe.new(attributes: dsl.__attributes.freeze)
|
20
|
+
end
|
21
|
+
|
22
|
+
attr_reader :__attributes
|
23
|
+
|
24
|
+
def initialize
|
25
|
+
@__attributes = []
|
26
|
+
end
|
27
|
+
|
28
|
+
def Bool
|
29
|
+
Bool
|
30
|
+
end
|
31
|
+
|
32
|
+
def Either(*subvalidators)
|
33
|
+
Either.new(subvalidators)
|
34
|
+
end
|
35
|
+
|
36
|
+
def Anything
|
37
|
+
Anything
|
38
|
+
end
|
39
|
+
|
40
|
+
def ArrayOf(element_validator)
|
41
|
+
ArrayOf.new(element_validator)
|
42
|
+
end
|
43
|
+
|
44
|
+
def HashOf(key_validator_to_value_validator)
|
45
|
+
unless key_validator_to_value_validator.size.equal?(1)
|
46
|
+
raise ArgumentError, "HashOf() takes a hash with one key and one value"
|
47
|
+
end
|
48
|
+
|
49
|
+
HashOf.new(
|
50
|
+
key_validator_to_value_validator.keys.first,
|
51
|
+
key_validator_to_value_validator.values.first,
|
52
|
+
)
|
53
|
+
end
|
54
|
+
|
55
|
+
def RangeOf(subvalidator)
|
56
|
+
RangeOf.new(subvalidator)
|
57
|
+
end
|
58
|
+
|
59
|
+
def ArrayCoercer(element_coercer)
|
60
|
+
ArrayCoercer.new(element_coercer)
|
61
|
+
end
|
62
|
+
|
63
|
+
IDENTITY_COERCER = :itself.to_proc
|
64
|
+
def HashCoercer(keys: IDENTITY_COERCER, values: IDENTITY_COERCER)
|
65
|
+
HashCoercer.new(key_coercer: keys, value_coercer: values)
|
66
|
+
end
|
67
|
+
|
68
|
+
#
|
69
|
+
# Defines one attribute.
|
70
|
+
#
|
71
|
+
# This is the method that gets called under the hood, when defining
|
72
|
+
# attributes the typical +#method_missing+ way.
|
73
|
+
#
|
74
|
+
# You can use this method directly if your attribute name results in invalid
|
75
|
+
# Ruby syntax. For example, if you want an attribute named +then+, you
|
76
|
+
# can do:
|
77
|
+
#
|
78
|
+
# include ValueSemantics.for_attributes {
|
79
|
+
# # Does not work:
|
80
|
+
# then String, default: "whatever"
|
81
|
+
# #=> SyntaxError: syntax error, unexpected `then'
|
82
|
+
#
|
83
|
+
# # Works:
|
84
|
+
# def_attr :then, String, default: "whatever"
|
85
|
+
# }
|
86
|
+
#
|
87
|
+
#
|
88
|
+
def def_attr(*args, **kwargs)
|
89
|
+
__attributes << Attribute.define(*args, **kwargs)
|
90
|
+
nil
|
91
|
+
end
|
92
|
+
|
93
|
+
def method_missing(name, *args, **kwargs)
|
94
|
+
if respond_to_missing?(name)
|
95
|
+
def_attr(name, *args, **kwargs)
|
96
|
+
else
|
97
|
+
super
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def respond_to_missing?(method_name, _include_private=nil)
|
102
|
+
first_letter = method_name.to_s.each_char.first
|
103
|
+
first_letter.eql?(first_letter.downcase)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
#
|
3
|
+
# Validator that matches if any of the given subvalidators matches
|
4
|
+
#
|
5
|
+
class Either
|
6
|
+
attr_reader :subvalidators
|
7
|
+
|
8
|
+
def initialize(subvalidators)
|
9
|
+
@subvalidators = subvalidators
|
10
|
+
freeze
|
11
|
+
end
|
12
|
+
|
13
|
+
# @return [Boolean]
|
14
|
+
def ===(value)
|
15
|
+
subvalidators.any? { |sv| sv === value }
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
class HashCoercer
|
3
|
+
attr_reader :key_coercer, :value_coercer
|
4
|
+
|
5
|
+
def initialize(key_coercer:, value_coercer:)
|
6
|
+
@key_coercer, @value_coercer = key_coercer, value_coercer
|
7
|
+
freeze
|
8
|
+
end
|
9
|
+
|
10
|
+
def call(obj)
|
11
|
+
hash = coerce_to_hash(obj)
|
12
|
+
return obj unless hash
|
13
|
+
|
14
|
+
{}.tap do |result|
|
15
|
+
hash.each do |key, value|
|
16
|
+
r_key = key_coercer.(key)
|
17
|
+
r_value = value_coercer.(value)
|
18
|
+
result[r_key] = r_value
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def coerce_to_hash(obj)
|
26
|
+
return nil unless obj.respond_to?(:to_h)
|
27
|
+
obj.to_h
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
#
|
3
|
+
# Validator that matches +Hash+es with homogeneous keys and values
|
4
|
+
#
|
5
|
+
class HashOf
|
6
|
+
attr_reader :key_validator, :value_validator
|
7
|
+
|
8
|
+
def initialize(key_validator, value_validator)
|
9
|
+
@key_validator, @value_validator = key_validator, value_validator
|
10
|
+
freeze
|
11
|
+
end
|
12
|
+
|
13
|
+
# @return [Boolean]
|
14
|
+
def ===(value)
|
15
|
+
Hash === value && value.all? do |key, value|
|
16
|
+
key_validator === key && value_validator === value
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
module ValueSemantics
|
2
|
+
#
|
3
|
+
# All the instance methods available on ValueSemantics objects
|
4
|
+
#
|
5
|
+
module InstanceMethods
|
6
|
+
#
|
7
|
+
# Creates a value object based on a hash of attributes
|
8
|
+
#
|
9
|
+
# @param attributes [#to_h] A hash of attribute values by name. Typically a
|
10
|
+
# +Hash+, but can be any object that responds to +#to_h+.
|
11
|
+
#
|
12
|
+
# @raise [UnrecognizedAttributes] if given_attrs contains keys that are not
|
13
|
+
# attributes
|
14
|
+
# @raise [MissingAttributes] if given_attrs is missing any attributes that
|
15
|
+
# do not have defaults
|
16
|
+
# @raise [InvalidValue] if any attribute values do no pass their validators
|
17
|
+
# @raise [TypeError] if the argument does not respond to +#to_h+
|
18
|
+
#
|
19
|
+
def initialize(attributes = nil)
|
20
|
+
attributes_hash =
|
21
|
+
if attributes.respond_to?(:to_h)
|
22
|
+
attributes.to_h
|
23
|
+
else
|
24
|
+
raise TypeError, <<-END_MESSAGE.strip.gsub(/\s+/, ' ')
|
25
|
+
Can not initialize a `#{self.class}` with a `#{attributes.class}`
|
26
|
+
object. This argument is typically a `Hash` of attributes, but can
|
27
|
+
be any object that responds to `#to_h`.
|
28
|
+
END_MESSAGE
|
29
|
+
end
|
30
|
+
|
31
|
+
remaining_attrs = attributes_hash.keys
|
32
|
+
missing_attrs = nil
|
33
|
+
invalid_attrs = nil
|
34
|
+
|
35
|
+
self.class.value_semantics.attributes.each do |attr|
|
36
|
+
if remaining_attrs.delete(attr.name)
|
37
|
+
value = attributes_hash.fetch(attr.name)
|
38
|
+
elsif attr.optional?
|
39
|
+
value = attr.default_generator.()
|
40
|
+
else
|
41
|
+
missing_attrs ||= []
|
42
|
+
missing_attrs << attr.name
|
43
|
+
next
|
44
|
+
end
|
45
|
+
|
46
|
+
coerced_value = attr.coerce(value, self.class)
|
47
|
+
if attr.validate?(coerced_value)
|
48
|
+
instance_variable_set(attr.instance_variable, coerced_value)
|
49
|
+
else
|
50
|
+
invalid_attrs ||= {}
|
51
|
+
invalid_attrs[attr.name] = coerced_value
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# TODO: aggregate all exceptions raised from #initialize into one big
|
56
|
+
# exception that explains everything that went wrong, instead of multiple
|
57
|
+
# smaller exceptions. Unfortunately, this would not be backwards
|
58
|
+
# compatible.
|
59
|
+
unless remaining_attrs.empty?
|
60
|
+
raise UnrecognizedAttributes.new(
|
61
|
+
"`#{self.class}` does not define attributes: " +
|
62
|
+
remaining_attrs.map { |k| '`' + k.inspect + '`' }.join(', ')
|
63
|
+
)
|
64
|
+
end
|
65
|
+
|
66
|
+
if missing_attrs
|
67
|
+
raise MissingAttributes.new(
|
68
|
+
"Some attributes required by `#{self.class}` are missing: " +
|
69
|
+
missing_attrs.map { |a| "`#{a}`" }.join(', ')
|
70
|
+
)
|
71
|
+
end
|
72
|
+
|
73
|
+
if invalid_attrs
|
74
|
+
raise InvalidValue.new(
|
75
|
+
"Some attributes of `#{self.class}` are invalid:\n" +
|
76
|
+
invalid_attrs.map { |k,v| " - #{k}: #{v.inspect}" }.join("\n") +
|
77
|
+
"\n"
|
78
|
+
)
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
#
|
83
|
+
# Returns the value for the given attribute name
|
84
|
+
#
|
85
|
+
# @param attr_name [Symbol] The name of the attribute. Can not be a +String+.
|
86
|
+
# @return The value of the attribute
|
87
|
+
#
|
88
|
+
# @raise [UnrecognizedAttributes] if the attribute does not exist
|
89
|
+
#
|
90
|
+
def [](attr_name)
|
91
|
+
attr = self.class.value_semantics.attributes.find do |attr|
|
92
|
+
attr.name.equal?(attr_name)
|
93
|
+
end
|
94
|
+
|
95
|
+
if attr
|
96
|
+
public_send(attr_name)
|
97
|
+
else
|
98
|
+
raise UnrecognizedAttributes, "`#{self.class}` has no attribute named `#{attr_name.inspect}`"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
#
|
103
|
+
# Creates a copy of this object, with the given attributes changed (non-destructive update)
|
104
|
+
#
|
105
|
+
# @param new_attrs [Hash] the attributes to change
|
106
|
+
# @return A new object, with the attribute changes applied
|
107
|
+
#
|
108
|
+
def with(new_attrs)
|
109
|
+
self.class.new(to_h.merge(new_attrs))
|
110
|
+
end
|
111
|
+
|
112
|
+
#
|
113
|
+
# @return [Hash] all of the attributes
|
114
|
+
#
|
115
|
+
def to_h
|
116
|
+
self.class.value_semantics.attributes
|
117
|
+
.map { |attr| [attr.name, public_send(attr.name)] }
|
118
|
+
.to_h
|
119
|
+
end
|
120
|
+
|
121
|
+
#
|
122
|
+
# Loose equality
|
123
|
+
#
|
124
|
+
# @return [Boolean] whether all attributes are equal, and the object
|
125
|
+
# classes are ancestors of eachother in any way
|
126
|
+
#
|
127
|
+
def ==(other)
|
128
|
+
(other.is_a?(self.class) || is_a?(other.class)) && other.to_h.eql?(to_h)
|
129
|
+
end
|
130
|
+
|
131
|
+
#
|
132
|
+
# Strict equality
|
133
|
+
#
|
134
|
+
# @return [Boolean] whether all attribuets are equal, and both objects
|
135
|
+
# has the exact same class
|
136
|
+
#
|
137
|
+
def eql?(other)
|
138
|
+
other.class.equal?(self.class) && other.to_h.eql?(to_h)
|
139
|
+
end
|
140
|
+
|
141
|
+
#
|
142
|
+
# Unique-ish integer, based on attributes and class of the object
|
143
|
+
#
|
144
|
+
def hash
|
145
|
+
to_h.hash ^ self.class.hash
|
146
|
+
end
|
147
|
+
|
148
|
+
def inspect
|
149
|
+
attrs = to_h
|
150
|
+
.map { |key, value| "#{key}=#{value.inspect}" }
|
151
|
+
.join(" ")
|
152
|
+
|
153
|
+
"#<#{self.class} #{attrs}>"
|
154
|
+
end
|
155
|
+
|
156
|
+
def pretty_print(pp)
|
157
|
+
pp.object_group(self) do
|
158
|
+
to_h.each do |attr, value|
|
159
|
+
pp.breakable
|
160
|
+
pp.text("#{attr}=")
|
161
|
+
pp.pp(value)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def deconstruct_keys(_)
|
167
|
+
to_h
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|