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.
@@ -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