value_semantics 3.2.0 → 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,35 @@
1
+ %w(
2
+ anything
3
+ array_coercer
4
+ array_of
5
+ attribute
6
+ bool
7
+ class_methods
8
+ dsl
9
+ either
10
+ hash_of
11
+ hash_coercer
12
+ instance_methods
13
+ range_of
14
+ recipe
15
+ struct
16
+ value_object_coercer
17
+ version
18
+ ).each do |filename|
19
+ require_relative "value_semantics/#{filename}"
20
+ end
21
+
1
22
  module ValueSemantics
2
23
  class Error < StandardError; end
3
24
  class UnrecognizedAttributes < Error; end
4
- class NoDefaultValue < Error; end
5
25
  class MissingAttributes < Error; end
26
+ class InvalidValue < ArgumentError; end
27
+
28
+ # @deprecated Use {Attribute::NOT_SPECIFIED} instead
29
+ NOT_SPECIFIED = Attribute::NOT_SPECIFIED
6
30
 
7
- NOT_SPECIFIED = Object.new.freeze
31
+ # @deprecated Use {Attribute#optional?} to check if there is a default or not
32
+ class NoDefaultValue < Error; end
8
33
 
9
34
  #
10
35
  # Creates a module via the DSL
@@ -44,344 +69,34 @@ module ValueSemantics
44
69
  end
45
70
 
46
71
  #
47
- # All the class methods available on ValueSemantics classes
72
+ # Makes the +.value_semantics+ convenience method available to all classes
48
73
  #
49
- # When a ValueSemantics module is included into a class,
50
- # the class is extended by this module.
74
+ # +.value_semantics+ is a shortcut for {.for_attributes}. Instead of:
51
75
  #
52
- module ClassMethods
53
- #
54
- # @return [Recipe] the recipe used to build the ValueSemantics module that
55
- # was included into this class.
56
- #
57
- def value_semantics
58
- self::VALUE_SEMANTICS_RECIPE__
59
- end
60
- end
61
-
76
+ # class Person
77
+ # include ValueSemantics.for_attributes {
78
+ # name String
79
+ # }
80
+ # end
62
81
  #
63
- # All the instance methods available on ValueSemantics objects
82
+ # You can just write:
64
83
  #
65
- module InstanceMethods
66
- #
67
- # Creates a value object based on a Hash of attributes
68
- #
69
- # @param given_attrs [Hash] a hash of attributes, with symbols for keys
70
- # @raise [UnrecognizedAttributes] if given_attrs contains keys that are not attributes
71
- # @raise [MissingAttributes] if given_attrs is missing any attributes that do not have defaults
72
- # @raise [ArgumentError] if any attribute values do no pass their validators
73
- #
74
- def initialize(given_attrs = {})
75
- remaining_attrs = given_attrs.dup
76
-
77
- self.class.value_semantics.attributes.each do |attr|
78
- key, value = attr.determine_from!(remaining_attrs, self.class)
79
- instance_variable_set(attr.instance_variable, value)
80
- remaining_attrs.delete(key)
81
- end
82
-
83
- unless remaining_attrs.empty?
84
- unrecognised = remaining_attrs.keys.map(&:inspect).join(', ')
85
- raise UnrecognizedAttributes, "Unrecognized attributes: #{unrecognised}"
86
- end
87
- end
88
-
89
- #
90
- # Creates a copy of this object, with the given attributes changed (non-destructive update)
91
- #
92
- # @param new_attrs [Hash] the attributes to change
93
- # @return A new object, with the attribute changes applied
94
- #
95
- def with(new_attrs)
96
- self.class.new(to_h.merge(new_attrs))
97
- end
98
-
99
- #
100
- # @return [Hash] all of the attributes
101
- #
102
- def to_h
103
- self.class.value_semantics.attributes
104
- .map { |attr| [attr.name, public_send(attr.name)] }
105
- .to_h
106
- end
107
-
108
- #
109
- # Loose equality
110
- #
111
- # @return [Boolean] whether all attributes are equal, and the object
112
- # classes are ancestors of eachother in any way
113
- #
114
- def ==(other)
115
- (other.is_a?(self.class) || is_a?(other.class)) && other.to_h.eql?(to_h)
116
- end
117
-
118
- #
119
- # Strict equality
120
- #
121
- # @return [Boolean] whether all attribuets are equal, and both objects
122
- # has the exact same class
123
- #
124
- def eql?(other)
125
- other.class.equal?(self.class) && other.to_h.eql?(to_h)
126
- end
127
-
128
- #
129
- # Unique-ish integer, based on attributes and class of the object
130
- #
131
- def hash
132
- to_h.hash ^ self.class.hash
133
- end
134
-
135
- def inspect
136
- attrs = to_h
137
- .map { |key, value| "#{key}=#{value.inspect}" }
138
- .join(" ")
139
-
140
- "#<#{self.class} #{attrs}>"
141
- end
142
-
143
- def pretty_print(pp)
144
- pp.object_group(self) do
145
- to_h.each do |attr, value|
146
- pp.breakable
147
- pp.text("#{attr}=")
148
- pp.pp(value)
149
- end
150
- end
151
- end
152
- end
153
-
84
+ # class Person
85
+ # value_semantics do
86
+ # name String
87
+ # end
88
+ # end
154
89
  #
155
- # Represents a single attribute of a value class
90
+ # Alternatively, you can +require 'value_semantics/monkey_patched'+, which
91
+ # will call this method automatically.
156
92
  #
157
- class Attribute
158
- NO_DEFAULT_GENERATOR = lambda do
159
- raise NoDefaultValue, "Attribute does not have a default value"
160
- end
161
-
162
- attr_reader :name, :validator, :coercer, :default_generator
163
-
164
- def initialize(name:,
165
- default_generator: NO_DEFAULT_GENERATOR,
166
- validator: Anything,
167
- coercer: nil)
168
- @name = name.to_sym
169
- @default_generator = default_generator
170
- @validator = validator
171
- @coercer = coercer
172
- freeze
173
- end
174
-
175
- def self.define(name,
176
- validator=Anything,
177
- default: NOT_SPECIFIED,
178
- default_generator: nil,
179
- coerce: nil)
180
- generator = begin
181
- if default_generator && !default.equal?(NOT_SPECIFIED)
182
- raise ArgumentError, "Attribute '#{name}' can not have both a :default and a :default_generator"
183
- elsif default_generator
184
- default_generator
185
- elsif !default.equal?(NOT_SPECIFIED)
186
- ->{ default }
187
- else
188
- NO_DEFAULT_GENERATOR
189
- end
93
+ def self.monkey_patch!
94
+ Class.class_eval do
95
+ # @!visibility private
96
+ def value_semantics(&block)
97
+ include ValueSemantics.for_attributes(&block)
190
98
  end
191
-
192
- new(
193
- name: name,
194
- validator: validator,
195
- default_generator: generator,
196
- coercer: coerce,
197
- )
198
- end
199
-
200
- def determine_from!(attr_hash, klass)
201
- raw_value = attr_hash.fetch(name) do
202
- if default_generator.equal?(NO_DEFAULT_GENERATOR)
203
- raise MissingAttributes, "Value missing for attribute '#{name}'"
204
- else
205
- default_generator.call
206
- end
207
- end
208
-
209
- coerced_value = coerce(raw_value, klass)
210
-
211
- if validate?(coerced_value)
212
- [name, coerced_value]
213
- else
214
- raise ArgumentError, "Value for attribute '#{name}' is not valid: #{coerced_value.inspect}"
215
- end
216
- end
217
-
218
- def coerce(attr_value, klass)
219
- return attr_value unless coercer # coercion not enabled
220
-
221
- if coercer.equal?(true)
222
- klass.public_send(coercion_method, attr_value)
223
- else
224
- coercer.call(attr_value)
225
- end
226
- end
227
-
228
- def validate?(value)
229
- validator === value
230
- end
231
-
232
- def instance_variable
233
- '@' + name.to_s.chomp('!').chomp('?')
234
- end
235
-
236
- def coercion_method
237
- "coerce_#{name}"
99
+ private :value_semantics
238
100
  end
239
101
  end
240
-
241
- #
242
- # Contains all the configuration necessary to bake a ValueSemantics module
243
- #
244
- # @see ValueSemantics.bake_module
245
- #
246
- class Recipe
247
- attr_reader :attributes
248
-
249
- def initialize(attributes:)
250
- @attributes = attributes
251
- freeze
252
- end
253
- end
254
-
255
- #
256
- # Builds a {Recipe} via DSL methods
257
- #
258
- # DSL blocks are <code>instance_eval</code>d against an object of this class.
259
- #
260
- # @see Recipe
261
- # @see ValueSemantics.for_attributes
262
- #
263
- class DSL
264
- #
265
- # Builds a {Recipe} from a DSL block
266
- #
267
- # @yield to the block containing the DSL
268
- # @return [Recipe]
269
- def self.run(&block)
270
- dsl = new
271
- dsl.instance_eval(&block)
272
- Recipe.new(attributes: dsl.__attributes.freeze)
273
- end
274
-
275
- attr_reader :__attributes
276
-
277
- def initialize
278
- @__attributes = []
279
- end
280
-
281
- def Bool
282
- Bool
283
- end
284
-
285
- def Either(*subvalidators)
286
- Either.new(subvalidators)
287
- end
288
-
289
- def Anything
290
- Anything
291
- end
292
-
293
- def ArrayOf(element_validator)
294
- ArrayOf.new(element_validator)
295
- end
296
-
297
- def def_attr(*args)
298
- __attributes << Attribute.define(*args)
299
- end
300
-
301
- def method_missing(name, *args)
302
- if respond_to_missing?(name)
303
- def_attr(name, *args)
304
- else
305
- super
306
- end
307
- end
308
-
309
- def respond_to_missing?(method_name, _include_private=nil)
310
- first_letter = method_name.to_s.each_char.first
311
- first_letter.eql?(first_letter.downcase)
312
- end
313
- end
314
-
315
- #
316
- # Validator that only matches `true` and `false`
317
- #
318
- module Bool
319
- # @return [Boolean]
320
- def self.===(value)
321
- true.equal?(value) || false.equal?(value)
322
- end
323
- end
324
-
325
- #
326
- # Validator that matches any and all values
327
- #
328
- module Anything
329
- # @return [true]
330
- def self.===(_)
331
- true
332
- end
333
- end
334
-
335
- #
336
- # Validator that matches if any of the given subvalidators matches
337
- #
338
- class Either
339
- attr_reader :subvalidators
340
-
341
- def initialize(subvalidators)
342
- @subvalidators = subvalidators
343
- freeze
344
- end
345
-
346
- # @return [Boolean]
347
- def ===(value)
348
- subvalidators.any? { |sv| sv === value }
349
- end
350
- end
351
-
352
- #
353
- # Validator that matches arrays if each element matches a given subvalidator
354
- #
355
- class ArrayOf
356
- attr_reader :element_validator
357
-
358
- def initialize(element_validator)
359
- @element_validator = element_validator
360
- freeze
361
- end
362
-
363
- # @return [Boolean]
364
- def ===(value)
365
- Array === value && value.all? { |element| element_validator === element }
366
- end
367
- end
368
-
369
- #
370
- # ValueSemantics equivalent of the Struct class from the Ruby standard
371
- # library
372
- #
373
- class Struct
374
- #
375
- # Creates a new Class with ValueSemantics mixed in
376
- #
377
- # @yield a block containing ValueSemantics DSL
378
- # @return [Class] the newly created class
379
- #
380
- def self.new(&block)
381
- klass = Class.new
382
- klass.include(ValueSemantics.for_attributes(&block))
383
- klass
384
- end
385
- end
386
-
387
102
  end
@@ -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