value_semantics 3.5.0 → 3.6.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|