value_semantics 3.1.0 → 3.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4ff5d3aeb6f4aed20276acec36dd859f5fc1e7e8836d3d5414208d70ef9216f
4
- data.tar.gz: bb73f5257fcd66d69029cbaf080e52a53d8dd5e3101e540bb0eb48be5891ede7
3
+ metadata.gz: '069ef7cc15f7d9b6eae3af432d486595bfa3f89c0f68ce68ab3eda2fb5d5f9f4'
4
+ data.tar.gz: f8d1839f7176743cb9d7a144d5452f999048a03a5d1d86d8b86b81b648a7bfd3
5
5
  SHA512:
6
- metadata.gz: 4d8db67bb3b5c5c083a0d7f8a42ce1ff0419409b44c8c45feb15865d4853253401a252fd9f550e320db5e4eda62993d3527088a094682dfcf1a7125e0d518552
7
- data.tar.gz: ea6e43b825c9e5d17f63a050122435fce3e98c8fc3f488a088f8df1f70e91abe6b24c08e676caec9dbb82a4ae14fc0f326135ac647254052b83de6772f820d56
6
+ metadata.gz: cea465484d60d6343815072b4e81639e399b20ce528a12ce643be39e3a06051fdaddb4c58037ffac5237f7c62b823b49d888bc8d6fed925b181515751b4cfb46
7
+ data.tar.gz: 553a990a508956e7a2b6f96ea2ac65ee97737bc6ea5da1ea337f9d0d06b294aa61fb3e10ef1d8ce6ac5fff826b3f1bdbf17b9e805ac38b933308152929335efd
@@ -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
  [![Gem Version](https://badge.fury.io/rb/value_semantics.svg)](https://badge.fury.io/rb/value_semantics)
2
2
  [![Build Status](https://travis-ci.org/tomdalling/value_semantics.svg?branch=master)](https://travis-ci.org/tomdalling/value_semantics)
3
- ![Mutation Coverage](https://img.shields.io/badge/mutation%20coverage-100%25-brightgreen.svg)
3
+ ![Mutation Coverage](https://img.shields.io/badge/mutation%20coverage-to%20the%20MAX-brightgreen.svg)
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 the [announcement blog post][] for some of the rationale behind the gem, and some [discussion on Reddit].
18
+ See:
19
19
 
20
- [announcement blog post]: https://www.rubypigeon.com/posts/value-semantics-gem-for-making-value-classes/
21
- [discussion on Reddit]: https://www.reddit.com/r/ruby/comments/akz4fs/valuesemanticsa_gem_for_making_value_classes/
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: 2018-09-04 ((2458366j,0s,0n),+0s,2299161j)>>
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
- The curly bracket syntax used with `ValueSemantics.for_attributes` is, unfortunately,
54
- mandatory due to Ruby's precedence rules.
55
- The `do`/`end` syntax will not work unless you surround the whole thing with parenthesis.
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.name #=> "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
- old_tom = tom.with(age: 99)
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=2018-12-21 18:42:01 +1100>
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', ...) # works
152
- Person.new(name: 5, ...)
153
- #=> ArgumentError:
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", ...) # works
157
- Person.new(birthday: "hello", ...)
158
- #=> ArgumentError:
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 Odd
242
+ module DottedQuad
201
243
  def self.===(value)
202
- value.odd?
244
+ value.split('.').all? do |part|
245
+ ('0'..'255').cover?(part)
246
+ end
203
247
  end
204
248
  end
205
249
 
206
- class Person
250
+ class Server
207
251
  include ValueSemantics.for_attributes {
208
- age Odd
252
+ address DottedQuad
209
253
  }
210
254
  end
211
255
 
212
- Person.new(age: 9) # works
213
- Person.new(age: 8)
214
- #=> ArgumentError:
215
- #=> Value for attribute 'age' is not valid: 8
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 `IPAddr` attribute may allow string values,
228
- which are then coerced into `IPAddr` objects.
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
- class Server
280
+ require 'pathname'
281
+
282
+ class Document
236
283
  include ValueSemantics.for_attributes {
237
- address IPAddr, coerce: true
284
+ path Pathname, coerce: true
238
285
  }
239
286
 
240
- def self.coerce_address(value)
287
+ def self.coerce_path(value)
241
288
  if value.is_a?(String)
242
- IPAddr.new(value)
289
+ Pathname.new(value)
243
290
  else
244
291
  value
245
292
  end
246
293
  end
247
294
  end
248
295
 
249
- Server.new(address: '127.0.0.1')
250
- #=> #<Server address=#<IPAddr: IPv4:127.0.0.1/255.255.255.255>>
296
+ Document.new(path: '~/Documents/whatever.doc')
297
+ #=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
251
298
 
252
- Server.new(address: IPAddr.new('127.0.0.1'))
253
- #=> #<Server address=#<IPAddr: IPv4:127.0.0.1/255.255.255.255>>
299
+ Document.new(path: Pathname.new('~/Documents/whatever.doc'))
300
+ #=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
254
301
 
255
- Server.new(address: 42)
256
- #=> ArgumentError:
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 Server
310
+ class Document
265
311
  include ValueSemantics.for_attributes {
266
- address IPAddr, coerce: ->(value) { IPAddr.new(value) }
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 MyAddressCoercer
320
+ class MyPathCoercer
275
321
  def call(value)
276
- IPAddr.new(value)
322
+ Pathname.new(value)
277
323
  end
278
324
  end
279
325
 
280
- class Server
326
+ class Document
281
327
  include ValueSemantics.for_attributes {
282
- address IPAddr, coerce: MyAddressCoercer.new
328
+ path Pathname, coerce: MyPathCoercer.new
283
329
  }
284
330
  end
285
331
  ```
286
332
 
287
- Or reuse an existing class method:
333
+ Or reuse an existing method:
288
334
 
289
335
  ```ruby
290
- class Server
336
+ class Document
291
337
  include ValueSemantics.for_attributes {
292
- address IPAddr, coerce: IPAddr.method(:new)
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 `IPAddr` object.
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 possible.
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
 
@@ -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 Hash of attributes
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
- # @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
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(given_attrs = {})
75
- remaining_attrs = given_attrs.dup
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
- unrecognised = remaining_attrs.keys.map(&:inspect).join(', ')
85
- raise UnrecognizedAttributes, "Unrecognized attributes: #{unrecognised}"
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 '#{name}' can not have both a :default and a :default_generator"
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, "Value missing for attribute '#{name}'"
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 ArgumentError, "Value for attribute '#{name}' is not valid: #{coerced_value.inspect}"
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 def_attr(*args)
298
- __attributes << Attribute.define(*args)
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
@@ -0,0 +1,3 @@
1
+ require 'value_semantics'
2
+
3
+ ValueSemantics.monkey_patch!
@@ -1,3 +1,3 @@
1
1
  module ValueSemantics
2
- VERSION = "3.1.0"
2
+ VERSION = "3.5.0"
3
3
  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.1.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Dalling
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2019-06-30 00:00:00.000000000 Z
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.0
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.0
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.4
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
@@ -1,12 +0,0 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
10
-
11
- # rspec failure tracking
12
- .rspec_status
data/.rspec DELETED
@@ -1,2 +0,0 @@
1
- --format documentation
2
- --color
@@ -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
@@ -1,8 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- source 'https://oss:fLUos7k6c7Ak7zjhwbYphPJbwBk1Uuew@gem.mutant.dev' do
4
- gem 'mutant-license'
5
- end
6
-
7
- # Specify your gem's dependencies in the gemspec
8
- gemspec
@@ -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
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here
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
@@ -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