value_semantics 3.1.0 → 3.5.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 +37 -0
- data/README.md +212 -69
- data/lib/value_semantics.rb +216 -16
- data/lib/value_semantics/monkey_patched.rb +3 -0
- data/lib/value_semantics/version.rb +1 -1
- metadata +42 -17
- 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 -19
- data/value_semantics.gemspec +0 -34
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '069ef7cc15f7d9b6eae3af432d486595bfa3f89c0f68ce68ab3eda2fb5d5f9f4'
|
4
|
+
data.tar.gz: f8d1839f7176743cb9d7a144d5452f999048a03a5d1d86d8b86b81b648a7bfd3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cea465484d60d6343815072b4e81639e399b20ce528a12ce643be39e3a06051fdaddb4c58037ffac5237f7c62b823b49d888bc8d6fed925b181515751b4cfb46
|
7
|
+
data.tar.gz: 553a990a508956e7a2b6f96ea2ac65ee97737bc6ea5da1ea337f9d0d06b294aa61fb3e10ef1d8ce6ac5fff826b3f1bdbf17b9e805ac38b933308152929335efd
|
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,43 @@ 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.5.0] - 2020-08-17
|
9
|
+
### Added
|
10
|
+
- Square bracket attr reader like `person[:name]`
|
11
|
+
- `HashOf` built-in validator, similar to `ArrayOf`
|
12
|
+
- `.coercer` class method, to help when composing value objects
|
13
|
+
- `ArrayCoercer` DSL method, to help when composing value objects
|
14
|
+
|
15
|
+
## [3.4.0] - 2020-08-01
|
16
|
+
### Added
|
17
|
+
- Value objects can be instantiated from any object that responds to `#to_h`.
|
18
|
+
Previously attributes were required to be given as a `Hash`.
|
19
|
+
|
20
|
+
- Added monkey patching for super-convenient attribute definitions. This is
|
21
|
+
**not** available by default, and needs to be explicitly enabled with
|
22
|
+
`ValueSemantics.monkey_patch!` or `require 'value_semantics/monkey_patched'`.
|
23
|
+
|
24
|
+
### Changed
|
25
|
+
- Improved exception messages for easier development experience
|
26
|
+
|
27
|
+
- Raises `ValueSemantics::InvalidValue` instead of `ArgumentError` when
|
28
|
+
attempting to initialize with an invalid value. `ValueSemantics::InvalidValue`
|
29
|
+
is a subclass of `ArgumentError`, so this change should be backward
|
30
|
+
compatible.
|
31
|
+
|
32
|
+
## [3.3.0] - 2020-07-17
|
33
|
+
### Added
|
34
|
+
- Added support for pattern matching in Ruby 2.7
|
35
|
+
|
36
|
+
## [3.2.1] - 2020-07-11
|
37
|
+
### Fixed
|
38
|
+
- Fix warnings new to Ruby 2.7 about keyword arguments
|
39
|
+
|
40
|
+
## [3.2.0] - 2019-09-30
|
41
|
+
### Added
|
42
|
+
- `ValueSemantics::Struct`, a convenience for creating a new class and mixing
|
43
|
+
in ValueSemantics in a single step.
|
44
|
+
|
8
45
|
## [3.1.0] - 2019-06-30
|
9
46
|
### Added
|
10
47
|
- Built-in PP support for value classes
|
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
[](https://badge.fury.io/rb/value_semantics)
|
2
2
|
[](https://travis-ci.org/tomdalling/value_semantics)
|
3
|
-

|
4
4
|
|
5
5
|
ValueSemantics
|
6
6
|
==============
|
@@ -15,10 +15,16 @@ These are intended for internal use, as opposed to validating user input like Ac
|
|
15
15
|
Invalid or missing attributes cause an exception for developers,
|
16
16
|
not an error message intended for application users.
|
17
17
|
|
18
|
-
See
|
18
|
+
See:
|
19
19
|
|
20
|
-
[announcement blog post]
|
21
|
-
[
|
20
|
+
- The [announcement blog post][blog post] for some of the rationale behind the gem
|
21
|
+
- [RubyTapas episode #584][rubytapas] for an example usage scenario
|
22
|
+
- The [API documentation](https://rubydoc.info/gems/value_semantics)
|
23
|
+
- Some [discussion on Reddit][reddit]
|
24
|
+
|
25
|
+
[blog post]: https://www.rubypigeon.com/posts/value-semantics-gem-for-making-value-classes/
|
26
|
+
[rubytapas]: https://www.rubytapas.com/2019/07/09/from-hash-to-value-object/
|
27
|
+
[reddit]: https://www.reddit.com/r/ruby/comments/akz4fs/valuesemanticsa_gem_for_making_value_classes/
|
22
28
|
|
23
29
|
|
24
30
|
Defining and Creating Value Objects
|
@@ -44,15 +50,19 @@ Person.new(name: "Tom", birthday: "2020-12-25")
|
|
44
50
|
#=> #<Person name="Tom" birthday=#<Date: 2020-12-25 ((2459209j,0s,0n),+0s,2299161j)>>
|
45
51
|
|
46
52
|
Person.new(birthday: Date.today)
|
47
|
-
#=> #<Person name="Anon Emous" birthday=#<Date:
|
53
|
+
#=> #<Person name="Anon Emous" birthday=#<Date: 2020-08-04 ((2459066j,0s,0n),+0s,2299161j)>>
|
48
54
|
|
49
55
|
Person.new(birthday: nil)
|
50
56
|
#=> #<Person name="Anon Emous" birthday=nil>
|
51
57
|
```
|
52
58
|
|
53
|
-
|
54
|
-
|
55
|
-
|
59
|
+
Value objects are typically initialized with keyword arguments or a `Hash`, but
|
60
|
+
will accept any object that responds to `#to_h`.
|
61
|
+
|
62
|
+
The curly bracket syntax used with `ValueSemantics.for_attributes` is,
|
63
|
+
unfortunately, mandatory due to Ruby's precedence rules. For a shorter
|
64
|
+
alternative method that works better with `do`/`end`, see [Convenience (Monkey
|
65
|
+
Patch)](#convenience-monkey-patch) below.
|
56
66
|
|
57
67
|
|
58
68
|
Using Value Objects
|
@@ -71,39 +81,69 @@ end
|
|
71
81
|
tom = Person.new(name: 'Tom')
|
72
82
|
|
73
83
|
|
74
|
-
#
|
75
84
|
# Read-only attributes
|
76
|
-
|
77
|
-
tom
|
78
|
-
tom.age #=> 31
|
79
|
-
|
85
|
+
tom.name #=> "Tom"
|
86
|
+
tom[:name] #=> "Tom"
|
80
87
|
|
81
|
-
#
|
82
88
|
# Convert to Hash
|
83
|
-
|
84
|
-
tom.to_h #=> { :name => "Tom", :age => 31 }
|
89
|
+
tom.to_h #=> {:name=>"Tom", :age=>31}
|
85
90
|
|
86
|
-
|
87
|
-
#
|
88
91
|
# Non-destructive updates
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
old_tom #=> #<Person name="Tom" age=99>
|
93
|
-
tom #=> #<Person name="Tom" age=31> (unchanged)
|
94
|
-
|
92
|
+
tom.with(age: 99) #=> #<Person name="Tom" age=99>
|
93
|
+
tom # (unchanged) #=> #<Person name="Tom" age=31>
|
95
94
|
|
96
|
-
#
|
97
95
|
# Equality
|
98
|
-
#
|
99
96
|
other_tom = Person.new(name: 'Tom', age: 31)
|
100
|
-
|
101
97
|
tom == other_tom #=> true
|
102
98
|
tom.eql?(other_tom) #=> true
|
103
99
|
tom.hash == other_tom.hash #=> true
|
100
|
+
|
101
|
+
# Ruby 2.7+ pattern matching
|
102
|
+
case tom
|
103
|
+
in name: "Tom", age:
|
104
|
+
puts age
|
105
|
+
end
|
106
|
+
# outputs: 31
|
104
107
|
```
|
105
108
|
|
106
109
|
|
110
|
+
Convenience (Monkey Patch)
|
111
|
+
--------------------------
|
112
|
+
|
113
|
+
There is a shorter way to define value attributes:
|
114
|
+
|
115
|
+
```ruby
|
116
|
+
require 'value_semantics/monkey_patched'
|
117
|
+
|
118
|
+
class Monkey
|
119
|
+
value_semantics do
|
120
|
+
name String
|
121
|
+
age Integer
|
122
|
+
end
|
123
|
+
end
|
124
|
+
```
|
125
|
+
|
126
|
+
**This is disabled by default**, to avoid polluting every class with an extra
|
127
|
+
class method.
|
128
|
+
|
129
|
+
This convenience method can be enabled in two ways:
|
130
|
+
|
131
|
+
1. Add a `require:` option to your `Gemfile` like this:
|
132
|
+
|
133
|
+
```ruby
|
134
|
+
gem 'value_semantics', '~> 3.3', require: 'value_semantics/monkey_patched'
|
135
|
+
```
|
136
|
+
|
137
|
+
2. Alternatively, you can call `ValueSemantics.monkey_patch!` somewhere early
|
138
|
+
in the boot sequence of your code -- at the top of your script, for example,
|
139
|
+
or `config/boot.rb` if it's a Rails project.
|
140
|
+
|
141
|
+
```ruby
|
142
|
+
require 'value_semantics'
|
143
|
+
ValueSemantics.monkey_patch!
|
144
|
+
```
|
145
|
+
|
146
|
+
|
107
147
|
Defaults
|
108
148
|
--------
|
109
149
|
|
@@ -119,7 +159,7 @@ class Cat
|
|
119
159
|
end
|
120
160
|
|
121
161
|
Cat.new
|
122
|
-
#=> #<Cat paws=4 born_at=
|
162
|
+
#=> #<Cat paws=4 born_at=2020-08-04 00:16:35.15632 +1000>
|
123
163
|
```
|
124
164
|
|
125
165
|
The `default` option is a single value.
|
@@ -148,15 +188,13 @@ class Person
|
|
148
188
|
}
|
149
189
|
end
|
150
190
|
|
151
|
-
Person.new(name: 'Tom',
|
152
|
-
Person.new(name: 5,
|
153
|
-
#=>
|
154
|
-
#=> Value for attribute 'name' is not valid: 5
|
191
|
+
Person.new(name: 'Tom', birthday: '2000-01-01') # works
|
192
|
+
Person.new(name: 5, birthday: '2000-01-01')
|
193
|
+
#=> !!! ValueSemantics::InvalidValue: Attribute `Person#name` is invalid: 5
|
155
194
|
|
156
|
-
Person.new(birthday: "1970-01-01"
|
157
|
-
Person.new(birthday: "hello"
|
158
|
-
#=>
|
159
|
-
#=> Value for attribute 'birthday' is not valid: "hello"
|
195
|
+
Person.new(name: 'Tom', birthday: "1970-01-01") # works
|
196
|
+
Person.new(name: 'Tom', birthday: "hello")
|
197
|
+
#=> !!! ValueSemantics::InvalidValue: Attribute `Person#birthday` is invalid: "hello"
|
160
198
|
```
|
161
199
|
|
162
200
|
|
@@ -168,13 +206,15 @@ for common situations:
|
|
168
206
|
```ruby
|
169
207
|
class LightSwitch
|
170
208
|
include ValueSemantics.for_attributes {
|
171
|
-
|
172
209
|
# Bool: only allows `true` or `false`
|
173
210
|
on? Bool()
|
174
211
|
|
175
212
|
# ArrayOf: validates elements in an array
|
176
213
|
light_ids ArrayOf(Integer)
|
177
214
|
|
215
|
+
# HashOf: validates keys/values of a homogeneous hash
|
216
|
+
toggle_stats HashOf(Symbol => Integer)
|
217
|
+
|
178
218
|
# Either: value must match at least one of a list of validators
|
179
219
|
color Either(Integer, String, nil)
|
180
220
|
|
@@ -186,9 +226,11 @@ end
|
|
186
226
|
LightSwitch.new(
|
187
227
|
on?: true,
|
188
228
|
light_ids: [11, 12, 13],
|
229
|
+
toggle_stats: { day: 42, night: 69 },
|
189
230
|
color: "#FFAABB",
|
190
231
|
wierd_attr: [true, false, true, true],
|
191
232
|
)
|
233
|
+
#=> #<LightSwitch on?=true light_ids=[11, 12, 13] toggle_stats={:day=>42, :night=>69} color="#FFAABB" wierd_attr=[true, false, true, true]>
|
192
234
|
```
|
193
235
|
|
194
236
|
|
@@ -197,22 +239,25 @@ LightSwitch.new(
|
|
197
239
|
A custom validator might look something like this:
|
198
240
|
|
199
241
|
```ruby
|
200
|
-
module
|
242
|
+
module DottedQuad
|
201
243
|
def self.===(value)
|
202
|
-
value.
|
244
|
+
value.split('.').all? do |part|
|
245
|
+
('0'..'255').cover?(part)
|
246
|
+
end
|
203
247
|
end
|
204
248
|
end
|
205
249
|
|
206
|
-
class
|
250
|
+
class Server
|
207
251
|
include ValueSemantics.for_attributes {
|
208
|
-
|
252
|
+
address DottedQuad
|
209
253
|
}
|
210
254
|
end
|
211
255
|
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
256
|
+
Server.new(address: '127.0.0.1')
|
257
|
+
#=> #<Server address="127.0.0.1">
|
258
|
+
|
259
|
+
Server.new(address: '127.0.0.999')
|
260
|
+
#=> !!! ValueSemantics::InvalidValue: Attribute `Server#address` is invalid: "127.0.0.999"
|
216
261
|
```
|
217
262
|
|
218
263
|
Default attribute values also pass through validation.
|
@@ -224,46 +269,47 @@ Coercion
|
|
224
269
|
Coercion allows non-standard or "convenience" values to be converted into
|
225
270
|
proper, valid values, where possible.
|
226
271
|
|
227
|
-
For example, an object with an `
|
228
|
-
which are then coerced into `
|
272
|
+
For example, an object with an `Pathname` attribute may allow string values,
|
273
|
+
which are then coerced into `Pathname` objects.
|
229
274
|
|
230
275
|
Using the option `coerce: true`,
|
231
276
|
coercion happens through a custom class method called `coerce_#{attr}`,
|
232
277
|
which takes the raw value as an argument, and returns the coerced value.
|
233
278
|
|
234
279
|
```ruby
|
235
|
-
|
280
|
+
require 'pathname'
|
281
|
+
|
282
|
+
class Document
|
236
283
|
include ValueSemantics.for_attributes {
|
237
|
-
|
284
|
+
path Pathname, coerce: true
|
238
285
|
}
|
239
286
|
|
240
|
-
def self.
|
287
|
+
def self.coerce_path(value)
|
241
288
|
if value.is_a?(String)
|
242
|
-
|
289
|
+
Pathname.new(value)
|
243
290
|
else
|
244
291
|
value
|
245
292
|
end
|
246
293
|
end
|
247
294
|
end
|
248
295
|
|
249
|
-
|
250
|
-
#=> #<
|
296
|
+
Document.new(path: '~/Documents/whatever.doc')
|
297
|
+
#=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
|
251
298
|
|
252
|
-
|
253
|
-
#=> #<
|
299
|
+
Document.new(path: Pathname.new('~/Documents/whatever.doc'))
|
300
|
+
#=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
|
254
301
|
|
255
|
-
|
256
|
-
#=>
|
257
|
-
#=> Value for attribute 'address' is not valid: 42
|
302
|
+
Document.new(path: 42)
|
303
|
+
#=> !!! ValueSemantics::InvalidValue: Attribute `Document#path` is invalid: 42
|
258
304
|
```
|
259
305
|
|
260
306
|
You can also use any callable object as a coercer.
|
261
307
|
That means, you could use a lambda:
|
262
308
|
|
263
309
|
```ruby
|
264
|
-
class
|
310
|
+
class Document
|
265
311
|
include ValueSemantics.for_attributes {
|
266
|
-
|
312
|
+
path Pathname, coerce: ->(value) { Pathname.new(value) }
|
267
313
|
}
|
268
314
|
end
|
269
315
|
```
|
@@ -271,25 +317,25 @@ end
|
|
271
317
|
Or a custom class:
|
272
318
|
|
273
319
|
```ruby
|
274
|
-
class
|
320
|
+
class MyPathCoercer
|
275
321
|
def call(value)
|
276
|
-
|
322
|
+
Pathname.new(value)
|
277
323
|
end
|
278
324
|
end
|
279
325
|
|
280
|
-
class
|
326
|
+
class Document
|
281
327
|
include ValueSemantics.for_attributes {
|
282
|
-
|
328
|
+
path Pathname, coerce: MyPathCoercer.new
|
283
329
|
}
|
284
330
|
end
|
285
331
|
```
|
286
332
|
|
287
|
-
Or reuse an existing
|
333
|
+
Or reuse an existing method:
|
288
334
|
|
289
335
|
```ruby
|
290
|
-
class
|
336
|
+
class Document
|
291
337
|
include ValueSemantics.for_attributes {
|
292
|
-
|
338
|
+
path Pathname, coerce: Pathname.method(:new)
|
293
339
|
}
|
294
340
|
end
|
295
341
|
```
|
@@ -301,7 +347,96 @@ Another option is to raise an error within the coercion method.
|
|
301
347
|
|
302
348
|
Default attribute values also pass through coercion.
|
303
349
|
For example, the default value could be a string,
|
304
|
-
which would then be coerced into an `
|
350
|
+
which would then be coerced into an `Pathname` object.
|
351
|
+
|
352
|
+
|
353
|
+
## Nesting
|
354
|
+
|
355
|
+
It is fairly common to nest value objects inside each other. This
|
356
|
+
works as expected, but coercion is not automatic. For nested coercion,
|
357
|
+
use the `.coercer` class method and `ArrayCoercer` DSL method that
|
358
|
+
ValueSemantics provides.
|
359
|
+
|
360
|
+
```ruby
|
361
|
+
class CrabClaw
|
362
|
+
include ValueSemantics.for_attributes {
|
363
|
+
size Either(:big, :small)
|
364
|
+
}
|
365
|
+
end
|
366
|
+
|
367
|
+
class Crab
|
368
|
+
include ValueSemantics.for_attributes {
|
369
|
+
left_claw CrabClaw, coerce: CrabClaw.coercer
|
370
|
+
right_claw CrabClaw, coerce: CrabClaw.coercer
|
371
|
+
}
|
372
|
+
end
|
373
|
+
|
374
|
+
class Ocean
|
375
|
+
include ValueSemantics.for_attributes {
|
376
|
+
crabs ArrayOf(Crab), coerce: ArrayCoercer(Crab.coercer)
|
377
|
+
}
|
378
|
+
end
|
379
|
+
|
380
|
+
ocean = Ocean.new(
|
381
|
+
crabs: [
|
382
|
+
{
|
383
|
+
left_claw: { size: :small },
|
384
|
+
right_claw: { size: :small },
|
385
|
+
}, {
|
386
|
+
left_claw: { size: :big },
|
387
|
+
right_claw: { size: :big },
|
388
|
+
}
|
389
|
+
]
|
390
|
+
)
|
391
|
+
|
392
|
+
ocean.crabs.first #=> #<Crab left_claw=#<CrabClaw size=:small> right_claw=#<CrabClaw size=:small>>
|
393
|
+
ocean.crabs.first.right_claw.size #=> :small
|
394
|
+
```
|
395
|
+
|
396
|
+
|
397
|
+
## ValueSemantics::Struct
|
398
|
+
|
399
|
+
This is a convenience for making a new class and including ValueSemantics in
|
400
|
+
one step, similar to how `Struct` works from the Ruby standard library. For
|
401
|
+
example:
|
402
|
+
|
403
|
+
```ruby
|
404
|
+
Pigeon = ValueSemantics::Struct.new do
|
405
|
+
name String, default: "Jannie"
|
406
|
+
end
|
407
|
+
|
408
|
+
Pigeon.new.name #=> "Jannie"
|
409
|
+
```
|
410
|
+
|
411
|
+
|
412
|
+
## Known Issues
|
413
|
+
|
414
|
+
Some valid attribute names result in invalid Ruby syntax when using the DSL.
|
415
|
+
In these situations, you can use the DSL method `def_attr` instead.
|
416
|
+
|
417
|
+
For example, if you want an attribute named `then`:
|
418
|
+
|
419
|
+
```ruby
|
420
|
+
# Can't do this:
|
421
|
+
class Conditional
|
422
|
+
include ValueSemantics.for_attributes {
|
423
|
+
then String
|
424
|
+
else String
|
425
|
+
}
|
426
|
+
end
|
427
|
+
#=> !!! SyntaxError: README.md:375: syntax error, unexpected `then'
|
428
|
+
#=* then String
|
429
|
+
#=* ^~~~
|
430
|
+
|
431
|
+
|
432
|
+
# This will work
|
433
|
+
class Conditional
|
434
|
+
include ValueSemantics.for_attributes {
|
435
|
+
def_attr :then, String
|
436
|
+
def_attr :else, String
|
437
|
+
}
|
438
|
+
end
|
439
|
+
```
|
305
440
|
|
306
441
|
|
307
442
|
## Installation
|
@@ -326,7 +461,15 @@ Or install it yourself as:
|
|
326
461
|
Bug reports and pull requests are welcome on GitHub at:
|
327
462
|
https://github.com/tomdalling/value_semantics
|
328
463
|
|
329
|
-
Keep in mind that this gem aims to be as close to 100% backwards compatible as
|
464
|
+
Keep in mind that this gem aims to be as close to 100% backwards compatible as
|
465
|
+
possible.
|
466
|
+
|
467
|
+
I'm happy to accept PRs that:
|
468
|
+
|
469
|
+
- Improve error messages for a better developer experience, especially those
|
470
|
+
that support a TDD workflow.
|
471
|
+
- Add new, helpful validators
|
472
|
+
- Implement automatic freezing of value objects (must be opt-in)
|
330
473
|
|
331
474
|
## License
|
332
475
|
|
data/lib/value_semantics.rb
CHANGED
@@ -3,6 +3,7 @@ module ValueSemantics
|
|
3
3
|
class UnrecognizedAttributes < Error; end
|
4
4
|
class NoDefaultValue < Error; end
|
5
5
|
class MissingAttributes < Error; end
|
6
|
+
class InvalidValue < ArgumentError; end
|
6
7
|
|
7
8
|
NOT_SPECIFIED = Object.new.freeze
|
8
9
|
|
@@ -43,6 +44,38 @@ module ValueSemantics
|
|
43
44
|
end
|
44
45
|
end
|
45
46
|
|
47
|
+
#
|
48
|
+
# Makes the +.value_semantics+ convenience method available to all classes
|
49
|
+
#
|
50
|
+
# +.value_semantics+ is a shortcut for {.for_attributes}. Instead of:
|
51
|
+
#
|
52
|
+
# class Person
|
53
|
+
# include ValueSemantics.for_attributes {
|
54
|
+
# name String
|
55
|
+
# }
|
56
|
+
# end
|
57
|
+
#
|
58
|
+
# You can just write:
|
59
|
+
#
|
60
|
+
# class Person
|
61
|
+
# value_semantics do
|
62
|
+
# name String
|
63
|
+
# end
|
64
|
+
# end
|
65
|
+
#
|
66
|
+
# Alternatively, you can +require 'value_semantics/monkey_patched'+, which
|
67
|
+
# will call this method automatically.
|
68
|
+
#
|
69
|
+
def self.monkey_patch!
|
70
|
+
Class.class_eval do
|
71
|
+
# @!visibility private
|
72
|
+
def value_semantics(&block)
|
73
|
+
include ValueSemantics.for_attributes(&block)
|
74
|
+
end
|
75
|
+
private :value_semantics
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
46
79
|
#
|
47
80
|
# All the class methods available on ValueSemantics classes
|
48
81
|
#
|
@@ -55,8 +88,33 @@ module ValueSemantics
|
|
55
88
|
# was included into this class.
|
56
89
|
#
|
57
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
|
+
|
58
96
|
self::VALUE_SEMANTICS_RECIPE__
|
59
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
|
60
118
|
end
|
61
119
|
|
62
120
|
#
|
@@ -64,15 +122,31 @@ module ValueSemantics
|
|
64
122
|
#
|
65
123
|
module InstanceMethods
|
66
124
|
#
|
67
|
-
# Creates a value object based on a
|
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+.
|
68
129
|
#
|
69
|
-
# @
|
70
|
-
#
|
71
|
-
# @raise [MissingAttributes] if given_attrs is missing any attributes that
|
72
|
-
#
|
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+
|
73
136
|
#
|
74
|
-
def initialize(
|
75
|
-
|
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
|
76
150
|
|
77
151
|
self.class.value_semantics.attributes.each do |attr|
|
78
152
|
key, value = attr.determine_from!(remaining_attrs, self.class)
|
@@ -81,8 +155,34 @@ module ValueSemantics
|
|
81
155
|
end
|
82
156
|
|
83
157
|
unless remaining_attrs.empty?
|
84
|
-
|
85
|
-
|
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}`"
|
86
186
|
end
|
87
187
|
end
|
88
188
|
|
@@ -149,6 +249,10 @@ module ValueSemantics
|
|
149
249
|
end
|
150
250
|
end
|
151
251
|
end
|
252
|
+
|
253
|
+
def deconstruct_keys(_)
|
254
|
+
to_h
|
255
|
+
end
|
152
256
|
end
|
153
257
|
|
154
258
|
#
|
@@ -179,7 +283,7 @@ module ValueSemantics
|
|
179
283
|
coerce: nil)
|
180
284
|
generator = begin
|
181
285
|
if default_generator && !default.equal?(NOT_SPECIFIED)
|
182
|
-
raise ArgumentError, "Attribute
|
286
|
+
raise ArgumentError, "Attribute `#{name}` can not have both a `:default` and a `:default_generator`"
|
183
287
|
elsif default_generator
|
184
288
|
default_generator
|
185
289
|
elsif !default.equal?(NOT_SPECIFIED)
|
@@ -200,7 +304,7 @@ module ValueSemantics
|
|
200
304
|
def determine_from!(attr_hash, klass)
|
201
305
|
raw_value = attr_hash.fetch(name) do
|
202
306
|
if default_generator.equal?(NO_DEFAULT_GENERATOR)
|
203
|
-
raise MissingAttributes, "
|
307
|
+
raise MissingAttributes, "Attribute `#{klass}\##{name}` has no value"
|
204
308
|
else
|
205
309
|
default_generator.call
|
206
310
|
end
|
@@ -211,7 +315,7 @@ module ValueSemantics
|
|
211
315
|
if validate?(coerced_value)
|
212
316
|
[name, coerced_value]
|
213
317
|
else
|
214
|
-
raise
|
318
|
+
raise InvalidValue, "Attribute `#{klass}\##{name}` is invalid: #{coerced_value.inspect}"
|
215
319
|
end
|
216
320
|
end
|
217
321
|
|
@@ -242,6 +346,8 @@ module ValueSemantics
|
|
242
346
|
# Contains all the configuration necessary to bake a ValueSemantics module
|
243
347
|
#
|
244
348
|
# @see ValueSemantics.bake_module
|
349
|
+
# @see ClassMethods#value_semantics
|
350
|
+
# @see DSL.run
|
245
351
|
#
|
246
352
|
class Recipe
|
247
353
|
attr_reader :attributes
|
@@ -294,13 +400,48 @@ module ValueSemantics
|
|
294
400
|
ArrayOf.new(element_validator)
|
295
401
|
end
|
296
402
|
|
297
|
-
def
|
298
|
-
|
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)
|
299
440
|
end
|
300
441
|
|
301
|
-
def method_missing(name, *args)
|
442
|
+
def method_missing(name, *args, **kwargs)
|
302
443
|
if respond_to_missing?(name)
|
303
|
-
def_attr(name, *args)
|
444
|
+
def_attr(name, *args, **kwargs)
|
304
445
|
else
|
305
446
|
super
|
306
447
|
end
|
@@ -366,4 +507,63 @@ module ValueSemantics
|
|
366
507
|
end
|
367
508
|
end
|
368
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
|
+
|
369
569
|
end
|
metadata
CHANGED
@@ -1,27 +1,27 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: value_semantics
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Tom Dalling
|
8
8
|
autorequire:
|
9
|
-
bindir:
|
9
|
+
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2020-08-17 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
19
|
version: '1.15'
|
20
20
|
type: :development
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
|
-
- - "
|
24
|
+
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
26
|
version: '1.15'
|
27
27
|
- !ruby/object:Gem::Dependency
|
@@ -30,14 +30,28 @@ dependencies:
|
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version: 3.7
|
33
|
+
version: '3.7'
|
34
34
|
type: :development
|
35
35
|
prerelease: false
|
36
36
|
version_requirements: !ruby/object:Gem::Requirement
|
37
37
|
requirements:
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
|
-
version: 3.7
|
40
|
+
version: '3.7'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: super_diff
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: mutant-rspec
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -94,6 +108,20 @@ dependencies:
|
|
94
108
|
- - ">="
|
95
109
|
- !ruby/object:Gem::Version
|
96
110
|
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: eceval
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
97
125
|
description: "\n Generates modules that provide conventional value semantics for
|
98
126
|
a given set of attributes.\n The behaviour is similar to an immutable `Struct`
|
99
127
|
class,\n plus extensible, lightweight validation and coercion.\n "
|
@@ -103,23 +131,20 @@ executables: []
|
|
103
131
|
extensions: []
|
104
132
|
extra_rdoc_files: []
|
105
133
|
files:
|
106
|
-
- ".gitignore"
|
107
|
-
- ".rspec"
|
108
|
-
- ".travis.yml"
|
109
134
|
- CHANGELOG.md
|
110
|
-
- Gemfile
|
111
135
|
- LICENSE.txt
|
112
136
|
- README.md
|
113
|
-
- bin/console
|
114
|
-
- bin/setup
|
115
|
-
- bin/test
|
116
137
|
- lib/value_semantics.rb
|
138
|
+
- lib/value_semantics/monkey_patched.rb
|
117
139
|
- lib/value_semantics/version.rb
|
118
|
-
- value_semantics.gemspec
|
119
140
|
homepage: https://github.com/tomdalling/value_semantics
|
120
141
|
licenses:
|
121
142
|
- MIT
|
122
|
-
metadata:
|
143
|
+
metadata:
|
144
|
+
bug_tracker_uri: https://github.com/tomdalling/value_semantics/issues
|
145
|
+
changelog_uri: https://github.com/tomdalling/value_semantics/blob/master/CHANGELOG.md
|
146
|
+
documentation_uri: https://github.com/tomdalling/value_semantics/blob/v3.5.0/README.md
|
147
|
+
source_code_uri: https://github.com/tomdalling/value_semantics
|
123
148
|
post_install_message:
|
124
149
|
rdoc_options: []
|
125
150
|
require_paths:
|
@@ -135,7 +160,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
135
160
|
- !ruby/object:Gem::Version
|
136
161
|
version: '0'
|
137
162
|
requirements: []
|
138
|
-
rubygems_version: 3.0.
|
163
|
+
rubygems_version: 3.0.8
|
139
164
|
signing_key:
|
140
165
|
specification_version: 4
|
141
166
|
summary: Makes value classes, with lightweight validation and coercion.
|
data/.gitignore
DELETED
data/.rspec
DELETED
data/.travis.yml
DELETED
@@ -1,24 +0,0 @@
|
|
1
|
-
language: ruby
|
2
|
-
script: bin/test
|
3
|
-
|
4
|
-
# test old Ruby versions WITHOUT mutation testing
|
5
|
-
rvm:
|
6
|
-
- 2.3.8
|
7
|
-
- 2.4.5
|
8
|
-
- 2.5.3
|
9
|
-
env: MUTATION_TEST=false
|
10
|
-
|
11
|
-
# test the latest Ruby version WITH mutation testing
|
12
|
-
matrix:
|
13
|
-
include:
|
14
|
-
- rvm: 2.6.0
|
15
|
-
env: MUTATION_TEST=true
|
16
|
-
|
17
|
-
# deploy gem on tagged commits, on the latest Ruby version only
|
18
|
-
deploy:
|
19
|
-
provider: rubygems
|
20
|
-
on:
|
21
|
-
tags: true
|
22
|
-
env: MUTATION_TEST=true
|
23
|
-
api_key:
|
24
|
-
secure: nL74QuUczEpA0qbhSBN2zjGdviWgKB3wR6vFvwervv1MZNWmwOQUYe99Oq9kPeyc8/x2MR/H6PQm5qbrk/WAfRede01WxlZ/EBUW+9CYGrxcBsGONx9IULO8A0I8/yN/YJHW2vjo3dfR66EwVsXTVWq8U63PRRcwJIyTqnIiUm2sxauMQoPRBbXG+pD9v/EJSn3ugpdtxp0lVYDn8LDKk5Ho4/wbpY4ML11XUJa9mz9CyR/GsAzdy5FTXaDMOwuWOVEx9cab7m4qPOBhmlJY4TrmooFpxTxRwChcvByjq1IboEd2M3RT5on7Q/xDTlHSOuT0OS8mnS2AocGT4a1gC+W/xOlghgEcN+xs2V5mfucR6+iUYlCy32uz1w3ey7T2X5xN4ubut09r1xLi7eu1NisAoAc+GOJ4TIxQNqkeRhY4X/fs8j7SMfOEMDr6pPxSLKZxgSvExt+IbdcZD/uQ7rTBQkadYCbc9MX5dHazBievmar3ZsFffbIf+n13FVDXsaPgRt7DlFM5dqGrEwVwt1jFRhdFuDCjkj4QWOLn7E1uY3XqgrqGvgUBlF8Znwc6qicW8zxV4SIWhqIzCOH6L9WIZGLHNq0remoCd9sq9Ter9av3jL+6UmZRRAr+JceeZfZmsYIXKomECzleM9FXMx7FXlpjJKOlf3JnrfeCTwI=
|
data/Gemfile
DELETED
data/bin/console
DELETED
@@ -1,14 +0,0 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
require "bundler/setup"
|
4
|
-
require "value_semantics"
|
5
|
-
|
6
|
-
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
-
# with your gem easier. You can also use a different console, if you like.
|
8
|
-
|
9
|
-
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
-
# require "pry"
|
11
|
-
# Pry.start
|
12
|
-
|
13
|
-
require "irb"
|
14
|
-
IRB.start(__FILE__)
|
data/bin/setup
DELETED
data/bin/test
DELETED
@@ -1,19 +0,0 @@
|
|
1
|
-
#!/bin/bash
|
2
|
-
set -ue
|
3
|
-
|
4
|
-
MUTANT_PATTERN=${1:-ValueSemantics*}
|
5
|
-
|
6
|
-
# if $MUTATION_TEST is false, just run RSpec
|
7
|
-
if [[ "${MUTATION_TEST:-true}" == "false" ]] ; then
|
8
|
-
bundle exec rspec
|
9
|
-
else
|
10
|
-
bundle exec mutant \
|
11
|
-
--include lib \
|
12
|
-
--require value_semantics \
|
13
|
-
--use rspec "$MUTANT_PATTERN" \
|
14
|
-
# Mutant 0.8.24 introduces new mutations that cause infinite recursion inside
|
15
|
-
# #method_missing. These --ignore-subject lines prevent that from happening
|
16
|
-
#--ignore-subject "ValueSemantics::DSL#method_missing" \
|
17
|
-
#--ignore-subject "ValueSemantics::DSL#respond_to_missing?" \
|
18
|
-
#--ignore-subject "ValueSemantics::DSL#def_attr" \
|
19
|
-
fi
|
data/value_semantics.gemspec
DELETED
@@ -1,34 +0,0 @@
|
|
1
|
-
# coding: utf-8
|
2
|
-
lib = File.expand_path("../lib", __FILE__)
|
3
|
-
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
-
require "value_semantics/version"
|
5
|
-
|
6
|
-
Gem::Specification.new do |spec|
|
7
|
-
spec.name = "value_semantics"
|
8
|
-
spec.version = ValueSemantics::VERSION
|
9
|
-
spec.authors = ["Tom Dalling"]
|
10
|
-
spec.email = [["tom", "@", "tomdalling.com"].join]
|
11
|
-
|
12
|
-
spec.summary = %q{Makes value classes, with lightweight validation and coercion.}
|
13
|
-
spec.description = %q{
|
14
|
-
Generates modules that provide conventional value semantics for a given set of attributes.
|
15
|
-
The behaviour is similar to an immutable `Struct` class,
|
16
|
-
plus extensible, lightweight validation and coercion.
|
17
|
-
}
|
18
|
-
spec.homepage = "https://github.com/tomdalling/value_semantics"
|
19
|
-
spec.license = "MIT"
|
20
|
-
|
21
|
-
spec.files = `git ls-files -z`.split("\x0").reject do |f|
|
22
|
-
f.match(%r{^(test|spec|features)/})
|
23
|
-
end
|
24
|
-
spec.bindir = "exe"
|
25
|
-
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
26
|
-
spec.require_paths = ["lib"]
|
27
|
-
|
28
|
-
spec.add_development_dependency "bundler", "~> 1.15"
|
29
|
-
spec.add_development_dependency "rspec", "~> 3.7.0"
|
30
|
-
spec.add_development_dependency "mutant-rspec"
|
31
|
-
spec.add_development_dependency "yard"
|
32
|
-
spec.add_development_dependency "byebug"
|
33
|
-
spec.add_development_dependency "gem-release"
|
34
|
-
end
|