value_semantics 3.2.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 +81 -0
- data/README.md +242 -72
- data/lib/value_semantics.rb +48 -333
- 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/monkey_patched.rb +3 -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 +85 -18
- data/.gitignore +0 -12
- data/.rspec +0 -2
- data/.travis.yml +0 -24
- data/Gemfile +0 -8
- data/bin/console +0 -14
- data/bin/setup +0 -8
- data/bin/test +0 -15
- data/value_semantics.gemspec +0 -34
data/lib/value_semantics.rb
CHANGED
@@ -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
|
-
|
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
|
-
#
|
72
|
+
# Makes the +.value_semantics+ convenience method available to all classes
|
48
73
|
#
|
49
|
-
#
|
50
|
-
# the class is extended by this module.
|
74
|
+
# +.value_semantics+ is a shortcut for {.for_attributes}. Instead of:
|
51
75
|
#
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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
|
-
#
|
82
|
+
# You can just write:
|
64
83
|
#
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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
|
-
#
|
90
|
+
# Alternatively, you can +require 'value_semantics/monkey_patched'+, which
|
91
|
+
# will call this method automatically.
|
156
92
|
#
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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,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
|