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.
@@ -0,0 +1,11 @@
1
+ module ValueSemantics
2
+ #
3
+ # Validator that matches any and all values
4
+ #
5
+ module Anything
6
+ # @return [true]
7
+ def self.===(_)
8
+ true
9
+ end
10
+ end
11
+ end
@@ -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,11 @@
1
+ module ValueSemantics
2
+ #
3
+ # Validator that only matches `true` and `false`
4
+ #
5
+ module Bool
6
+ # @return [Boolean]
7
+ def self.===(value)
8
+ true.equal?(value) || false.equal?(value)
9
+ end
10
+ end
11
+ 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