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.
- 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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8d3795e585d271a6b0b784ef28931a16ae7f1846b13f7d307bf97d9d175c5384
|
4
|
+
data.tar.gz: 8289168c0be7e6cd00e24e59239a9ab8a2026fc42895007f33388d6ffa67add4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1b2520d80b112810e2b257920d591d4657cd9da26007307492cceac3321149b266bb310fba7c49e957d21a31e92bc041668bd907820b906570efa704d02c7948
|
7
|
+
data.tar.gz: 9605e0334ff2ff61dbb1925983b44f1275fb3768196bb62dbed6a2ccd9f6590b6e22ff3555101b6283181b18173657d363d397f8f10d329b9e4dea21ef1eb73c
|
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,55 @@ Notable changes to this project will be documented in this file.
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
7
7
|
|
8
|
+
## [3.6.0] - 2020-09-01
|
9
|
+
### Added
|
10
|
+
- `RangeOf` built-in validator, for validating `Range` objects
|
11
|
+
- `HashCoercer` built-in coercer for homogeneous `Hash` objects
|
12
|
+
### Changed
|
13
|
+
- Optimised speed of value object initialization. It is now roughly 3x
|
14
|
+
slower than that of a hand-written class, which is 2-3x faster than
|
15
|
+
the previous version.
|
16
|
+
|
17
|
+
- Optimised memory allocation in object initialization. The happy path
|
18
|
+
(no exceptions raised) only allocates a single array object, under
|
19
|
+
normal circumstances. Extra allocations are likely caused by custom
|
20
|
+
validators, coercers, and default generators.
|
21
|
+
|
22
|
+
- Exceptions raised when initialising a value object are now
|
23
|
+
aggregated. Instead of telling you the problematic attributes one at
|
24
|
+
a time, you will get a list of all offending attributes in the
|
25
|
+
exception message. This applies to `MissingAttributes`,
|
26
|
+
`InvalidValue` and `UnrecognizedAttributes`. These will probably be
|
27
|
+
combined into a single exception in v4.0, so you can see all the
|
28
|
+
initialization problems at once.
|
29
|
+
|
30
|
+
- The exceptions `ValueSemantics::MissingAttributes` and
|
31
|
+
`ValueSemantics::InvalidValue` are now raised from inside
|
32
|
+
`initialize`. They were previously raised from inside of
|
33
|
+
`ValueSemantics::Attribute.determine_from!` which is an internal
|
34
|
+
implementation detail that is basically gibberish to any developer
|
35
|
+
reading it. The stack trace for this exception reads much better.
|
36
|
+
|
37
|
+
- The exception `ValueSemantics::UnrecognizedAttributes` is now raised
|
38
|
+
instead of `ValueSemantics::MissingAttributes` in the situation
|
39
|
+
where both exceptions would be raised. This makes it easier to debug
|
40
|
+
the problem where you attempt to initialize a value object using a
|
41
|
+
hash with string keys instead of symbol keys.
|
42
|
+
|
43
|
+
- The coercer returned from the `.coercer` class method is now
|
44
|
+
smarter. It handles string keys, handles objects that can be
|
45
|
+
converted to hashes.
|
46
|
+
### Deprecated
|
47
|
+
- `ValueSemantics::Attribute#determine_from!`. This was an internal
|
48
|
+
implementation detail, which is no longer used internally. Use the
|
49
|
+
`name`, `#coerce`, `#optional?`, `#default_generator` and
|
50
|
+
`#validate?` methods directly if you want to extract an attribute
|
51
|
+
from a hash.
|
52
|
+
- `ValueSemantics::NoDefaultError`. Use `Attribute#optional?` to check
|
53
|
+
whether there is a default.
|
54
|
+
|
55
|
+
|
56
|
+
|
8
57
|
## [3.5.0] - 2020-08-17
|
9
58
|
### Added
|
10
59
|
- Square bracket attr reader like `person[:name]`
|
data/README.md
CHANGED
@@ -50,7 +50,7 @@ Person.new(name: "Tom", birthday: "2020-12-25")
|
|
50
50
|
#=> #<Person name="Tom" birthday=#<Date: 2020-12-25 ((2459209j,0s,0n),+0s,2299161j)>>
|
51
51
|
|
52
52
|
Person.new(birthday: Date.today)
|
53
|
-
#=> #<Person name="Anon Emous" birthday=#<Date: 2020-08-
|
53
|
+
#=> #<Person name="Anon Emous" birthday=#<Date: 2020-08-30 ((2459092j,0s,0n),+0s,2299161j)>>
|
54
54
|
|
55
55
|
Person.new(birthday: nil)
|
56
56
|
#=> #<Person name="Anon Emous" birthday=nil>
|
@@ -159,7 +159,7 @@ class Cat
|
|
159
159
|
end
|
160
160
|
|
161
161
|
Cat.new
|
162
|
-
#=> #<Cat paws=4 born_at=2020-08-
|
162
|
+
#=> #<Cat paws=4 born_at=2020-08-30 22:27:12.237812 +1000>
|
163
163
|
```
|
164
164
|
|
165
165
|
The `default` option is a single value.
|
@@ -190,11 +190,13 @@ end
|
|
190
190
|
|
191
191
|
Person.new(name: 'Tom', birthday: '2000-01-01') # works
|
192
192
|
Person.new(name: 5, birthday: '2000-01-01')
|
193
|
-
#=> !!! ValueSemantics::InvalidValue:
|
193
|
+
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Person` are invalid:
|
194
|
+
#=* - name: 5
|
194
195
|
|
195
196
|
Person.new(name: 'Tom', birthday: "1970-01-01") # works
|
196
197
|
Person.new(name: 'Tom', birthday: "hello")
|
197
|
-
#=> !!! ValueSemantics::InvalidValue:
|
198
|
+
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Person` are invalid:
|
199
|
+
#=* - birthday: "hello"
|
198
200
|
```
|
199
201
|
|
200
202
|
|
@@ -215,6 +217,9 @@ class LightSwitch
|
|
215
217
|
# HashOf: validates keys/values of a homogeneous hash
|
216
218
|
toggle_stats HashOf(Symbol => Integer)
|
217
219
|
|
220
|
+
# RangeOf: validates ranges
|
221
|
+
levels RangeOf(Integer)
|
222
|
+
|
218
223
|
# Either: value must match at least one of a list of validators
|
219
224
|
color Either(Integer, String, nil)
|
220
225
|
|
@@ -227,10 +232,11 @@ LightSwitch.new(
|
|
227
232
|
on?: true,
|
228
233
|
light_ids: [11, 12, 13],
|
229
234
|
toggle_stats: { day: 42, night: 69 },
|
235
|
+
levels: (0..10),
|
230
236
|
color: "#FFAABB",
|
231
237
|
wierd_attr: [true, false, true, true],
|
232
238
|
)
|
233
|
-
#=> #<LightSwitch on?=true light_ids=[11, 12, 13] toggle_stats={:day=>42, :night=>69} color="#FFAABB" wierd_attr=[true, false, true, true]>
|
239
|
+
#=> #<LightSwitch on?=true light_ids=[11, 12, 13] toggle_stats={:day=>42, :night=>69} levels=0..10 color="#FFAABB" wierd_attr=[true, false, true, true]>
|
234
240
|
```
|
235
241
|
|
236
242
|
|
@@ -257,7 +263,8 @@ Server.new(address: '127.0.0.1')
|
|
257
263
|
#=> #<Server address="127.0.0.1">
|
258
264
|
|
259
265
|
Server.new(address: '127.0.0.999')
|
260
|
-
#=> !!! ValueSemantics::InvalidValue:
|
266
|
+
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Server` are invalid:
|
267
|
+
#=* - address: "127.0.0.999"
|
261
268
|
```
|
262
269
|
|
263
270
|
Default attribute values also pass through validation.
|
@@ -300,7 +307,8 @@ Document.new(path: Pathname.new('~/Documents/whatever.doc'))
|
|
300
307
|
#=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
|
301
308
|
|
302
309
|
Document.new(path: 42)
|
303
|
-
#=> !!! ValueSemantics::InvalidValue:
|
310
|
+
#=> !!! ValueSemantics::InvalidValue: Some attributes of `Document` are invalid:
|
311
|
+
#=* - path: 42
|
304
312
|
```
|
305
313
|
|
306
314
|
You can also use any callable object as a coercer.
|
@@ -350,12 +358,45 @@ For example, the default value could be a string,
|
|
350
358
|
which would then be coerced into an `Pathname` object.
|
351
359
|
|
352
360
|
|
361
|
+
## Built-in Coercers
|
362
|
+
|
363
|
+
ValueSemantics provides a few built-in coercer objects via the DSL.
|
364
|
+
|
365
|
+
```ruby
|
366
|
+
class Config
|
367
|
+
include ValueSemantics.for_attributes {
|
368
|
+
# ArrayCoercer: takes an element coercer
|
369
|
+
paths coerce: ArrayCoercer(Pathname.method(:new))
|
370
|
+
|
371
|
+
# HashCoercer: takes a key and value coercer
|
372
|
+
env coerce: HashCoercer(
|
373
|
+
keys: :to_sym.to_proc,
|
374
|
+
values: :to_i.to_proc,
|
375
|
+
)
|
376
|
+
}
|
377
|
+
end
|
378
|
+
|
379
|
+
config = Config.new(
|
380
|
+
paths: ['/a', '/b'],
|
381
|
+
env: { 'AAAA' => '1', 'BBBB' => '2' },
|
382
|
+
)
|
383
|
+
|
384
|
+
config.paths #=> [#<Pathname:/a>, #<Pathname:/b>]
|
385
|
+
config.env #=> {:AAAA=>1, :BBBB=>2}
|
386
|
+
```
|
387
|
+
|
388
|
+
|
353
389
|
## Nesting
|
354
390
|
|
355
391
|
It is fairly common to nest value objects inside each other. This
|
356
|
-
works as expected, but coercion is not automatic.
|
357
|
-
|
358
|
-
|
392
|
+
works as expected, but coercion is not automatic.
|
393
|
+
|
394
|
+
For nested coercion, use the `.coercer` class method that
|
395
|
+
ValueSemantics provides. It returns a coercer object that accepts
|
396
|
+
strings for attribute names, and will ignore attributes that the value
|
397
|
+
class does not define, instead of raising an error.
|
398
|
+
|
399
|
+
This works well in combination with `ArrayCoercer`.
|
359
400
|
|
360
401
|
```ruby
|
361
402
|
class CrabClaw
|
@@ -380,11 +421,12 @@ end
|
|
380
421
|
ocean = Ocean.new(
|
381
422
|
crabs: [
|
382
423
|
{
|
383
|
-
left_claw
|
384
|
-
right_claw
|
424
|
+
'left_claw' => { 'size' => :small },
|
425
|
+
'right_claw' => { 'size' => :small },
|
426
|
+
voiced_by: 'Samuel E. Wright', # this attr will be ignored
|
385
427
|
}, {
|
386
|
-
left_claw
|
387
|
-
right_claw
|
428
|
+
'left_claw' => { 'size' => :big },
|
429
|
+
'right_claw' => { 'size' => :big },
|
388
430
|
}
|
389
431
|
]
|
390
432
|
)
|
@@ -424,7 +466,7 @@ class Conditional
|
|
424
466
|
else String
|
425
467
|
}
|
426
468
|
end
|
427
|
-
#=> !!! SyntaxError: README.md:
|
469
|
+
#=> !!! SyntaxError: README.md:461: syntax error, unexpected `then'
|
428
470
|
#=* then String
|
429
471
|
#=* ^~~~
|
430
472
|
|
@@ -468,7 +510,7 @@ I'm happy to accept PRs that:
|
|
468
510
|
|
469
511
|
- Improve error messages for a better developer experience, especially those
|
470
512
|
that support a TDD workflow.
|
471
|
-
- Add new
|
513
|
+
- Add new and helpful built-in validators and coercers
|
472
514
|
- Implement automatic freezing of value objects (must be opt-in)
|
473
515
|
|
474
516
|
## License
|
data/lib/value_semantics.rb
CHANGED
@@ -1,11 +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
|
6
26
|
class InvalidValue < ArgumentError; end
|
7
27
|
|
8
|
-
NOT_SPECIFIED
|
28
|
+
# @deprecated Use {Attribute::NOT_SPECIFIED} instead
|
29
|
+
NOT_SPECIFIED = Attribute::NOT_SPECIFIED
|
30
|
+
|
31
|
+
# @deprecated Use {Attribute#optional?} to check if there is a default or not
|
32
|
+
class NoDefaultValue < Error; end
|
9
33
|
|
10
34
|
#
|
11
35
|
# Creates a module via the DSL
|
@@ -75,495 +99,4 @@ module ValueSemantics
|
|
75
99
|
private :value_semantics
|
76
100
|
end
|
77
101
|
end
|
78
|
-
|
79
|
-
#
|
80
|
-
# All the class methods available on ValueSemantics classes
|
81
|
-
#
|
82
|
-
# When a ValueSemantics module is included into a class,
|
83
|
-
# the class is extended by this module.
|
84
|
-
#
|
85
|
-
module ClassMethods
|
86
|
-
#
|
87
|
-
# @return [Recipe] the recipe used to build the ValueSemantics module that
|
88
|
-
# was included into this class.
|
89
|
-
#
|
90
|
-
def value_semantics
|
91
|
-
if block_given?
|
92
|
-
# caller is trying to use the monkey-patched Class method
|
93
|
-
raise "`#{self}` has already included ValueSemantics"
|
94
|
-
end
|
95
|
-
|
96
|
-
self::VALUE_SEMANTICS_RECIPE__
|
97
|
-
end
|
98
|
-
|
99
|
-
#
|
100
|
-
# A coercer object for the value class
|
101
|
-
#
|
102
|
-
# This is mostly useful when nesting value objects inside each other.
|
103
|
-
#
|
104
|
-
# The coercer will coerce hashes into an instance of the value class, using
|
105
|
-
# the hash for attribute values. It will return non-hash values unchanged.
|
106
|
-
#
|
107
|
-
# @return [#call] A callable object that can be used as a coercer
|
108
|
-
#
|
109
|
-
def coercer
|
110
|
-
->(obj) do
|
111
|
-
if Hash === obj
|
112
|
-
new(obj)
|
113
|
-
else
|
114
|
-
obj
|
115
|
-
end
|
116
|
-
end
|
117
|
-
end
|
118
|
-
end
|
119
|
-
|
120
|
-
#
|
121
|
-
# All the instance methods available on ValueSemantics objects
|
122
|
-
#
|
123
|
-
module InstanceMethods
|
124
|
-
#
|
125
|
-
# Creates a value object based on a hash of attributes
|
126
|
-
#
|
127
|
-
# @param attributes [#to_h] A hash of attribute values by name. Typically a
|
128
|
-
# +Hash+, but can be any object that responds to +#to_h+.
|
129
|
-
#
|
130
|
-
# @raise [UnrecognizedAttributes] if given_attrs contains keys that are not
|
131
|
-
# attributes
|
132
|
-
# @raise [MissingAttributes] if given_attrs is missing any attributes that
|
133
|
-
# do not have defaults
|
134
|
-
# @raise [InvalidValue] if any attribute values do no pass their validators
|
135
|
-
# @raise [TypeError] if the argument does not respond to +#to_h+
|
136
|
-
#
|
137
|
-
def initialize(attributes = nil)
|
138
|
-
attributes_hash =
|
139
|
-
if attributes.respond_to?(:to_h)
|
140
|
-
attributes.to_h
|
141
|
-
else
|
142
|
-
raise TypeError, <<-END_MESSAGE.strip.gsub(/\s+/, ' ')
|
143
|
-
Can not initialize a `#{self.class}` with a `#{attributes.class}`
|
144
|
-
object. This argument is typically a `Hash` of attributes, but can
|
145
|
-
be any object that responds to `#to_h`.
|
146
|
-
END_MESSAGE
|
147
|
-
end
|
148
|
-
|
149
|
-
remaining_attrs = attributes_hash.dup
|
150
|
-
|
151
|
-
self.class.value_semantics.attributes.each do |attr|
|
152
|
-
key, value = attr.determine_from!(remaining_attrs, self.class)
|
153
|
-
instance_variable_set(attr.instance_variable, value)
|
154
|
-
remaining_attrs.delete(key)
|
155
|
-
end
|
156
|
-
|
157
|
-
unless remaining_attrs.empty?
|
158
|
-
raise(
|
159
|
-
UnrecognizedAttributes,
|
160
|
-
"`#{self.class}` does not define attributes: " +
|
161
|
-
remaining_attrs
|
162
|
-
.keys
|
163
|
-
.map { |k| '`' + k.inspect + '`' }
|
164
|
-
.join(', ')
|
165
|
-
)
|
166
|
-
end
|
167
|
-
end
|
168
|
-
|
169
|
-
#
|
170
|
-
# Returns the value for the given attribute name
|
171
|
-
#
|
172
|
-
# @param attr_name [Symbol] The name of the attribute. Can not be a +String+.
|
173
|
-
# @return The value of the attribute
|
174
|
-
#
|
175
|
-
# @raise [UnrecognizedAttributes] if the attribute does not exist
|
176
|
-
#
|
177
|
-
def [](attr_name)
|
178
|
-
attr = self.class.value_semantics.attributes.find do |attr|
|
179
|
-
attr.name.equal?(attr_name)
|
180
|
-
end
|
181
|
-
|
182
|
-
if attr
|
183
|
-
public_send(attr_name)
|
184
|
-
else
|
185
|
-
raise UnrecognizedAttributes, "`#{self.class}` has no attribute named `#{attr_name.inspect}`"
|
186
|
-
end
|
187
|
-
end
|
188
|
-
|
189
|
-
#
|
190
|
-
# Creates a copy of this object, with the given attributes changed (non-destructive update)
|
191
|
-
#
|
192
|
-
# @param new_attrs [Hash] the attributes to change
|
193
|
-
# @return A new object, with the attribute changes applied
|
194
|
-
#
|
195
|
-
def with(new_attrs)
|
196
|
-
self.class.new(to_h.merge(new_attrs))
|
197
|
-
end
|
198
|
-
|
199
|
-
#
|
200
|
-
# @return [Hash] all of the attributes
|
201
|
-
#
|
202
|
-
def to_h
|
203
|
-
self.class.value_semantics.attributes
|
204
|
-
.map { |attr| [attr.name, public_send(attr.name)] }
|
205
|
-
.to_h
|
206
|
-
end
|
207
|
-
|
208
|
-
#
|
209
|
-
# Loose equality
|
210
|
-
#
|
211
|
-
# @return [Boolean] whether all attributes are equal, and the object
|
212
|
-
# classes are ancestors of eachother in any way
|
213
|
-
#
|
214
|
-
def ==(other)
|
215
|
-
(other.is_a?(self.class) || is_a?(other.class)) && other.to_h.eql?(to_h)
|
216
|
-
end
|
217
|
-
|
218
|
-
#
|
219
|
-
# Strict equality
|
220
|
-
#
|
221
|
-
# @return [Boolean] whether all attribuets are equal, and both objects
|
222
|
-
# has the exact same class
|
223
|
-
#
|
224
|
-
def eql?(other)
|
225
|
-
other.class.equal?(self.class) && other.to_h.eql?(to_h)
|
226
|
-
end
|
227
|
-
|
228
|
-
#
|
229
|
-
# Unique-ish integer, based on attributes and class of the object
|
230
|
-
#
|
231
|
-
def hash
|
232
|
-
to_h.hash ^ self.class.hash
|
233
|
-
end
|
234
|
-
|
235
|
-
def inspect
|
236
|
-
attrs = to_h
|
237
|
-
.map { |key, value| "#{key}=#{value.inspect}" }
|
238
|
-
.join(" ")
|
239
|
-
|
240
|
-
"#<#{self.class} #{attrs}>"
|
241
|
-
end
|
242
|
-
|
243
|
-
def pretty_print(pp)
|
244
|
-
pp.object_group(self) do
|
245
|
-
to_h.each do |attr, value|
|
246
|
-
pp.breakable
|
247
|
-
pp.text("#{attr}=")
|
248
|
-
pp.pp(value)
|
249
|
-
end
|
250
|
-
end
|
251
|
-
end
|
252
|
-
|
253
|
-
def deconstruct_keys(_)
|
254
|
-
to_h
|
255
|
-
end
|
256
|
-
end
|
257
|
-
|
258
|
-
#
|
259
|
-
# Represents a single attribute of a value class
|
260
|
-
#
|
261
|
-
class Attribute
|
262
|
-
NO_DEFAULT_GENERATOR = lambda do
|
263
|
-
raise NoDefaultValue, "Attribute does not have a default value"
|
264
|
-
end
|
265
|
-
|
266
|
-
attr_reader :name, :validator, :coercer, :default_generator
|
267
|
-
|
268
|
-
def initialize(name:,
|
269
|
-
default_generator: NO_DEFAULT_GENERATOR,
|
270
|
-
validator: Anything,
|
271
|
-
coercer: nil)
|
272
|
-
@name = name.to_sym
|
273
|
-
@default_generator = default_generator
|
274
|
-
@validator = validator
|
275
|
-
@coercer = coercer
|
276
|
-
freeze
|
277
|
-
end
|
278
|
-
|
279
|
-
def self.define(name,
|
280
|
-
validator=Anything,
|
281
|
-
default: NOT_SPECIFIED,
|
282
|
-
default_generator: nil,
|
283
|
-
coerce: nil)
|
284
|
-
generator = begin
|
285
|
-
if default_generator && !default.equal?(NOT_SPECIFIED)
|
286
|
-
raise ArgumentError, "Attribute `#{name}` can not have both a `:default` and a `:default_generator`"
|
287
|
-
elsif default_generator
|
288
|
-
default_generator
|
289
|
-
elsif !default.equal?(NOT_SPECIFIED)
|
290
|
-
->{ default }
|
291
|
-
else
|
292
|
-
NO_DEFAULT_GENERATOR
|
293
|
-
end
|
294
|
-
end
|
295
|
-
|
296
|
-
new(
|
297
|
-
name: name,
|
298
|
-
validator: validator,
|
299
|
-
default_generator: generator,
|
300
|
-
coercer: coerce,
|
301
|
-
)
|
302
|
-
end
|
303
|
-
|
304
|
-
def determine_from!(attr_hash, klass)
|
305
|
-
raw_value = attr_hash.fetch(name) do
|
306
|
-
if default_generator.equal?(NO_DEFAULT_GENERATOR)
|
307
|
-
raise MissingAttributes, "Attribute `#{klass}\##{name}` has no value"
|
308
|
-
else
|
309
|
-
default_generator.call
|
310
|
-
end
|
311
|
-
end
|
312
|
-
|
313
|
-
coerced_value = coerce(raw_value, klass)
|
314
|
-
|
315
|
-
if validate?(coerced_value)
|
316
|
-
[name, coerced_value]
|
317
|
-
else
|
318
|
-
raise InvalidValue, "Attribute `#{klass}\##{name}` is invalid: #{coerced_value.inspect}"
|
319
|
-
end
|
320
|
-
end
|
321
|
-
|
322
|
-
def coerce(attr_value, klass)
|
323
|
-
return attr_value unless coercer # coercion not enabled
|
324
|
-
|
325
|
-
if coercer.equal?(true)
|
326
|
-
klass.public_send(coercion_method, attr_value)
|
327
|
-
else
|
328
|
-
coercer.call(attr_value)
|
329
|
-
end
|
330
|
-
end
|
331
|
-
|
332
|
-
def validate?(value)
|
333
|
-
validator === value
|
334
|
-
end
|
335
|
-
|
336
|
-
def instance_variable
|
337
|
-
'@' + name.to_s.chomp('!').chomp('?')
|
338
|
-
end
|
339
|
-
|
340
|
-
def coercion_method
|
341
|
-
"coerce_#{name}"
|
342
|
-
end
|
343
|
-
end
|
344
|
-
|
345
|
-
#
|
346
|
-
# Contains all the configuration necessary to bake a ValueSemantics module
|
347
|
-
#
|
348
|
-
# @see ValueSemantics.bake_module
|
349
|
-
# @see ClassMethods#value_semantics
|
350
|
-
# @see DSL.run
|
351
|
-
#
|
352
|
-
class Recipe
|
353
|
-
attr_reader :attributes
|
354
|
-
|
355
|
-
def initialize(attributes:)
|
356
|
-
@attributes = attributes
|
357
|
-
freeze
|
358
|
-
end
|
359
|
-
end
|
360
|
-
|
361
|
-
#
|
362
|
-
# Builds a {Recipe} via DSL methods
|
363
|
-
#
|
364
|
-
# DSL blocks are <code>instance_eval</code>d against an object of this class.
|
365
|
-
#
|
366
|
-
# @see Recipe
|
367
|
-
# @see ValueSemantics.for_attributes
|
368
|
-
#
|
369
|
-
class DSL
|
370
|
-
#
|
371
|
-
# Builds a {Recipe} from a DSL block
|
372
|
-
#
|
373
|
-
# @yield to the block containing the DSL
|
374
|
-
# @return [Recipe]
|
375
|
-
def self.run(&block)
|
376
|
-
dsl = new
|
377
|
-
dsl.instance_eval(&block)
|
378
|
-
Recipe.new(attributes: dsl.__attributes.freeze)
|
379
|
-
end
|
380
|
-
|
381
|
-
attr_reader :__attributes
|
382
|
-
|
383
|
-
def initialize
|
384
|
-
@__attributes = []
|
385
|
-
end
|
386
|
-
|
387
|
-
def Bool
|
388
|
-
Bool
|
389
|
-
end
|
390
|
-
|
391
|
-
def Either(*subvalidators)
|
392
|
-
Either.new(subvalidators)
|
393
|
-
end
|
394
|
-
|
395
|
-
def Anything
|
396
|
-
Anything
|
397
|
-
end
|
398
|
-
|
399
|
-
def ArrayOf(element_validator)
|
400
|
-
ArrayOf.new(element_validator)
|
401
|
-
end
|
402
|
-
|
403
|
-
def HashOf(key_validator_to_value_validator)
|
404
|
-
unless key_validator_to_value_validator.size.equal?(1)
|
405
|
-
raise ArgumentError, "HashOf() takes a hash with one key and one value"
|
406
|
-
end
|
407
|
-
|
408
|
-
HashOf.new(
|
409
|
-
key_validator_to_value_validator.keys.first,
|
410
|
-
key_validator_to_value_validator.values.first,
|
411
|
-
)
|
412
|
-
end
|
413
|
-
|
414
|
-
def ArrayCoercer(element_coercer)
|
415
|
-
ArrayCoercer.new(element_coercer)
|
416
|
-
end
|
417
|
-
|
418
|
-
#
|
419
|
-
# Defines one attribute.
|
420
|
-
#
|
421
|
-
# This is the method that gets called under the hood, when defining
|
422
|
-
# attributes the typical +#method_missing+ way.
|
423
|
-
#
|
424
|
-
# You can use this method directly if your attribute name results in invalid
|
425
|
-
# Ruby syntax. For example, if you want an attribute named +then+, you
|
426
|
-
# can do:
|
427
|
-
#
|
428
|
-
# include ValueSemantics.for_attributes {
|
429
|
-
# # Does not work:
|
430
|
-
# then String, default: "whatever"
|
431
|
-
# #=> SyntaxError: syntax error, unexpected `then'
|
432
|
-
#
|
433
|
-
# # Works:
|
434
|
-
# def_attr :then, String, default: "whatever"
|
435
|
-
# }
|
436
|
-
#
|
437
|
-
#
|
438
|
-
def def_attr(*args, **kwargs)
|
439
|
-
__attributes << Attribute.define(*args, **kwargs)
|
440
|
-
end
|
441
|
-
|
442
|
-
def method_missing(name, *args, **kwargs)
|
443
|
-
if respond_to_missing?(name)
|
444
|
-
def_attr(name, *args, **kwargs)
|
445
|
-
else
|
446
|
-
super
|
447
|
-
end
|
448
|
-
end
|
449
|
-
|
450
|
-
def respond_to_missing?(method_name, _include_private=nil)
|
451
|
-
first_letter = method_name.to_s.each_char.first
|
452
|
-
first_letter.eql?(first_letter.downcase)
|
453
|
-
end
|
454
|
-
end
|
455
|
-
|
456
|
-
#
|
457
|
-
# Validator that only matches `true` and `false`
|
458
|
-
#
|
459
|
-
module Bool
|
460
|
-
# @return [Boolean]
|
461
|
-
def self.===(value)
|
462
|
-
true.equal?(value) || false.equal?(value)
|
463
|
-
end
|
464
|
-
end
|
465
|
-
|
466
|
-
#
|
467
|
-
# Validator that matches any and all values
|
468
|
-
#
|
469
|
-
module Anything
|
470
|
-
# @return [true]
|
471
|
-
def self.===(_)
|
472
|
-
true
|
473
|
-
end
|
474
|
-
end
|
475
|
-
|
476
|
-
#
|
477
|
-
# Validator that matches if any of the given subvalidators matches
|
478
|
-
#
|
479
|
-
class Either
|
480
|
-
attr_reader :subvalidators
|
481
|
-
|
482
|
-
def initialize(subvalidators)
|
483
|
-
@subvalidators = subvalidators
|
484
|
-
freeze
|
485
|
-
end
|
486
|
-
|
487
|
-
# @return [Boolean]
|
488
|
-
def ===(value)
|
489
|
-
subvalidators.any? { |sv| sv === value }
|
490
|
-
end
|
491
|
-
end
|
492
|
-
|
493
|
-
#
|
494
|
-
# Validator that matches arrays if each element matches a given subvalidator
|
495
|
-
#
|
496
|
-
class ArrayOf
|
497
|
-
attr_reader :element_validator
|
498
|
-
|
499
|
-
def initialize(element_validator)
|
500
|
-
@element_validator = element_validator
|
501
|
-
freeze
|
502
|
-
end
|
503
|
-
|
504
|
-
# @return [Boolean]
|
505
|
-
def ===(value)
|
506
|
-
Array === value && value.all? { |element| element_validator === element }
|
507
|
-
end
|
508
|
-
end
|
509
|
-
|
510
|
-
#
|
511
|
-
# Validator that matches +Hash+es with homogeneous keys and values
|
512
|
-
#
|
513
|
-
class HashOf
|
514
|
-
attr_reader :key_validator, :value_validator
|
515
|
-
|
516
|
-
def initialize(key_validator, value_validator)
|
517
|
-
@key_validator, @value_validator = key_validator, value_validator
|
518
|
-
freeze
|
519
|
-
end
|
520
|
-
|
521
|
-
# @return [Boolean]
|
522
|
-
def ===(value)
|
523
|
-
Hash === value && value.all? do |key, value|
|
524
|
-
key_validator === key && value_validator === value
|
525
|
-
end
|
526
|
-
end
|
527
|
-
end
|
528
|
-
|
529
|
-
class ArrayCoercer
|
530
|
-
attr_reader :element_coercer
|
531
|
-
|
532
|
-
def initialize(element_coercer = nil)
|
533
|
-
@element_coercer = element_coercer
|
534
|
-
freeze
|
535
|
-
end
|
536
|
-
|
537
|
-
def call(obj)
|
538
|
-
if obj.respond_to?(:to_a)
|
539
|
-
array = obj.to_a
|
540
|
-
if element_coercer
|
541
|
-
array.map { |element| element_coercer.call(element) }
|
542
|
-
else
|
543
|
-
array
|
544
|
-
end
|
545
|
-
else
|
546
|
-
obj
|
547
|
-
end
|
548
|
-
end
|
549
|
-
end
|
550
|
-
|
551
|
-
#
|
552
|
-
# ValueSemantics equivalent of the Struct class from the Ruby standard
|
553
|
-
# library
|
554
|
-
#
|
555
|
-
class Struct
|
556
|
-
#
|
557
|
-
# Creates a new Class with ValueSemantics mixed in
|
558
|
-
#
|
559
|
-
# @yield a block containing ValueSemantics DSL
|
560
|
-
# @return [Class] the newly created class
|
561
|
-
#
|
562
|
-
def self.new(&block)
|
563
|
-
klass = Class.new
|
564
|
-
klass.include(ValueSemantics.for_attributes(&block))
|
565
|
-
klass
|
566
|
-
end
|
567
|
-
end
|
568
|
-
|
569
102
|
end
|