value_semantics 3.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e86c0e4467ff36d89870545718de723b0a0b62ff21dc50d30a3f6e1c0766c4c1
4
- data.tar.gz: '011313548dfe90ee7b686bc91338b5e0f403ce2dd26f98fd607fdf16ef2c5ce7'
3
+ metadata.gz: '069ef7cc15f7d9b6eae3af432d486595bfa3f89c0f68ce68ab3eda2fb5d5f9f4'
4
+ data.tar.gz: f8d1839f7176743cb9d7a144d5452f999048a03a5d1d86d8b86b81b648a7bfd3
5
5
  SHA512:
6
- metadata.gz: ecd428749dd01e806df9bbd4a361084f1b066298d464fea3a04ff7f66214ba224b99b93c7902f513c1c4f89fc52690779f4a14bb2c7e78106a212456a8f2c14d
7
- data.tar.gz: 86c31d6565594493891581dde7feac4f9a9a5c1ff39b86874fd1be74d6bf3827b9a3311369dcaf9a76d9c41ca03be9abc464736ac86d09d316125e9472534554
6
+ metadata.gz: cea465484d60d6343815072b4e81639e399b20ce528a12ce643be39e3a06051fdaddb4c58037ffac5237f7c62b823b49d888bc8d6fed925b181515751b4cfb46
7
+ data.tar.gz: 553a990a508956e7a2b6f96ea2ac65ee97737bc6ea5da1ea337f9d0d06b294aa61fb3e10ef1d8ce6ac5fff826b3f1bdbf17b9e805ac38b933308152929335efd
@@ -5,6 +5,13 @@ 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
+
8
15
  ## [3.4.0] - 2020-08-01
9
16
  ### Added
10
17
  - Value objects can be instantiated from any object that responds to `#to_h`.
data/README.md CHANGED
@@ -19,6 +19,7 @@ See:
19
19
 
20
20
  - The [announcement blog post][blog post] for some of the rationale behind the gem
21
21
  - [RubyTapas episode #584][rubytapas] for an example usage scenario
22
+ - The [API documentation](https://rubydoc.info/gems/value_semantics)
22
23
  - Some [discussion on Reddit][reddit]
23
24
 
24
25
  [blog post]: https://www.rubypigeon.com/posts/value-semantics-gem-for-making-value-classes/
@@ -49,7 +50,7 @@ Person.new(name: "Tom", birthday: "2020-12-25")
49
50
  #=> #<Person name="Tom" birthday=#<Date: 2020-12-25 ((2459209j,0s,0n),+0s,2299161j)>>
50
51
 
51
52
  Person.new(birthday: Date.today)
52
- #=> #<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)>>
53
54
 
54
55
  Person.new(birthday: nil)
55
56
  #=> #<Person name="Anon Emous" birthday=nil>
@@ -81,19 +82,15 @@ tom = Person.new(name: 'Tom')
81
82
 
82
83
 
83
84
  # Read-only attributes
84
- tom.name #=> "Tom"
85
- tom.age #=> 31
86
-
85
+ tom.name #=> "Tom"
86
+ tom[:name] #=> "Tom"
87
87
 
88
88
  # Convert to Hash
89
- tom.to_h #=> { :name => "Tom", :age => 31 }
90
-
89
+ tom.to_h #=> {:name=>"Tom", :age=>31}
91
90
 
92
91
  # Non-destructive updates
93
- old_tom = tom.with(age: 99)
94
- old_tom #=> #<Person name="Tom" age=99>
95
- tom #=> #<Person name="Tom" age=31> (unchanged)
96
-
92
+ tom.with(age: 99) #=> #<Person name="Tom" age=99>
93
+ tom # (unchanged) #=> #<Person name="Tom" age=31>
97
94
 
98
95
  # Equality
99
96
  other_tom = Person.new(name: 'Tom', age: 31)
@@ -101,12 +98,12 @@ tom == other_tom #=> true
101
98
  tom.eql?(other_tom) #=> true
102
99
  tom.hash == other_tom.hash #=> true
103
100
 
104
-
105
101
  # Ruby 2.7+ pattern matching
106
102
  case tom
107
103
  in name: "Tom", age:
108
- puts age # outputs: 31
104
+ puts age
109
105
  end
106
+ # outputs: 31
110
107
  ```
111
108
 
112
109
 
@@ -116,7 +113,9 @@ Convenience (Monkey Patch)
116
113
  There is a shorter way to define value attributes:
117
114
 
118
115
  ```ruby
119
- class Person
116
+ require 'value_semantics/monkey_patched'
117
+
118
+ class Monkey
120
119
  value_semantics do
121
120
  name String
122
121
  age Integer
@@ -160,7 +159,7 @@ class Cat
160
159
  end
161
160
 
162
161
  Cat.new
163
- #=> #<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>
164
163
  ```
165
164
 
166
165
  The `default` option is a single value.
@@ -189,15 +188,13 @@ class Person
189
188
  }
190
189
  end
191
190
 
192
- Person.new(name: 'Tom', ...) # works
193
- Person.new(name: 5, ...)
194
- #=> ValueSemantics::InvalidValue:
195
- #=> Attribute `Person#name` is invalid: 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
196
194
 
197
- Person.new(birthday: "1970-01-01", ...) # works
198
- Person.new(birthday: "hello", ...)
199
- #=> ValueSemantics::InvalidValue:
200
- #=> Attribute 'Person#birthday' is invalid: "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"
201
198
  ```
202
199
 
203
200
 
@@ -209,13 +206,15 @@ for common situations:
209
206
  ```ruby
210
207
  class LightSwitch
211
208
  include ValueSemantics.for_attributes {
212
-
213
209
  # Bool: only allows `true` or `false`
214
210
  on? Bool()
215
211
 
216
212
  # ArrayOf: validates elements in an array
217
213
  light_ids ArrayOf(Integer)
218
214
 
215
+ # HashOf: validates keys/values of a homogeneous hash
216
+ toggle_stats HashOf(Symbol => Integer)
217
+
219
218
  # Either: value must match at least one of a list of validators
220
219
  color Either(Integer, String, nil)
221
220
 
@@ -227,9 +226,11 @@ end
227
226
  LightSwitch.new(
228
227
  on?: true,
229
228
  light_ids: [11, 12, 13],
229
+ toggle_stats: { day: 42, night: 69 },
230
230
  color: "#FFAABB",
231
231
  wierd_attr: [true, false, true, true],
232
232
  )
233
+ #=> #<LightSwitch on?=true light_ids=[11, 12, 13] toggle_stats={:day=>42, :night=>69} color="#FFAABB" wierd_attr=[true, false, true, true]>
233
234
  ```
234
235
 
235
236
 
@@ -238,22 +239,25 @@ LightSwitch.new(
238
239
  A custom validator might look something like this:
239
240
 
240
241
  ```ruby
241
- module Odd
242
+ module DottedQuad
242
243
  def self.===(value)
243
- value.odd?
244
+ value.split('.').all? do |part|
245
+ ('0'..'255').cover?(part)
246
+ end
244
247
  end
245
248
  end
246
249
 
247
- class Person
250
+ class Server
248
251
  include ValueSemantics.for_attributes {
249
- age Odd
252
+ address DottedQuad
250
253
  }
251
254
  end
252
255
 
253
- Person.new(age: 9) # works
254
- Person.new(age: 8)
255
- #=> ValueSemantics::InvalidValue:
256
- #=> Attribute 'Person#age' is invalid: 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"
257
261
  ```
258
262
 
259
263
  Default attribute values also pass through validation.
@@ -296,8 +300,7 @@ Document.new(path: Pathname.new('~/Documents/whatever.doc'))
296
300
  #=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
297
301
 
298
302
  Document.new(path: 42)
299
- #=> ValueSemantics::InvalidValue:
300
- #=> Attribute 'Document#path' is invalid: 42
303
+ #=> !!! ValueSemantics::InvalidValue: Attribute `Document#path` is invalid: 42
301
304
  ```
302
305
 
303
306
  You can also use any callable object as a coercer.
@@ -347,6 +350,50 @@ For example, the default value could be a string,
347
350
  which would then be coerced into an `Pathname` object.
348
351
 
349
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
+
350
397
  ## ValueSemantics::Struct
351
398
 
352
399
  This is a convenience for making a new class and including ValueSemantics in
@@ -354,11 +401,41 @@ one step, similar to how `Struct` works from the Ruby standard library. For
354
401
  example:
355
402
 
356
403
  ```ruby
357
- Cat = ValueSemantics::Struct.new do
358
- name String, default: "Mittens"
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
+ }
359
426
  end
427
+ #=> !!! SyntaxError: README.md:375: syntax error, unexpected `then'
428
+ #=* then String
429
+ #=* ^~~~
430
+
360
431
 
361
- Cat.new.name #=> "Mittens"
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
362
439
  ```
363
440
 
364
441
 
@@ -45,10 +45,9 @@ module ValueSemantics
45
45
  end
46
46
 
47
47
  #
48
- # Makes the `.value_semantics` convenience method available to all classes
48
+ # Makes the +.value_semantics+ convenience method available to all classes
49
49
  #
50
- # `.value_semantics` is a shortcut for `include ValueSemantics.for_attributes`.
51
- # Instead of:
50
+ # +.value_semantics+ is a shortcut for {.for_attributes}. Instead of:
52
51
  #
53
52
  # class Person
54
53
  # include ValueSemantics.for_attributes {
@@ -64,7 +63,7 @@ module ValueSemantics
64
63
  # end
65
64
  # end
66
65
  #
67
- # Alternatively, you can `require 'value_semantics/monkey_patched'`, which
66
+ # Alternatively, you can +require 'value_semantics/monkey_patched'+, which
68
67
  # will call this method automatically.
69
68
  #
70
69
  def self.monkey_patch!
@@ -96,6 +95,26 @@ module ValueSemantics
96
95
 
97
96
  self::VALUE_SEMANTICS_RECIPE__
98
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
99
118
  end
100
119
 
101
120
  #
@@ -106,14 +125,14 @@ module ValueSemantics
106
125
  # Creates a value object based on a hash of attributes
107
126
  #
108
127
  # @param attributes [#to_h] A hash of attribute values by name. Typically a
109
- # `Hash`, but can be any object that responds to `#to_h`.
128
+ # +Hash+, but can be any object that responds to +#to_h+.
110
129
  #
111
130
  # @raise [UnrecognizedAttributes] if given_attrs contains keys that are not
112
131
  # attributes
113
132
  # @raise [MissingAttributes] if given_attrs is missing any attributes that
114
133
  # do not have defaults
115
134
  # @raise [InvalidValue] if any attribute values do no pass their validators
116
- # @raise [TypeError] if the argument does not respond to `#to_h`
135
+ # @raise [TypeError] if the argument does not respond to +#to_h+
117
136
  #
118
137
  def initialize(attributes = nil)
119
138
  attributes_hash =
@@ -147,6 +166,26 @@ module ValueSemantics
147
166
  end
148
167
  end
149
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
+
150
189
  #
151
190
  # Creates a copy of this object, with the given attributes changed (non-destructive update)
152
191
  #
@@ -307,6 +346,8 @@ module ValueSemantics
307
346
  # Contains all the configuration necessary to bake a ValueSemantics module
308
347
  #
309
348
  # @see ValueSemantics.bake_module
349
+ # @see ClassMethods#value_semantics
350
+ # @see DSL.run
310
351
  #
311
352
  class Recipe
312
353
  attr_reader :attributes
@@ -359,6 +400,41 @@ module ValueSemantics
359
400
  ArrayOf.new(element_validator)
360
401
  end
361
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
+ #
362
438
  def def_attr(*args, **kwargs)
363
439
  __attributes << Attribute.define(*args, **kwargs)
364
440
  end
@@ -431,6 +507,47 @@ module ValueSemantics
431
507
  end
432
508
  end
433
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
+
434
551
  #
435
552
  # ValueSemantics equivalent of the Struct class from the Ruby standard
436
553
  # library
@@ -1,3 +1,3 @@
1
1
  module ValueSemantics
2
- VERSION = "3.4.0"
2
+ VERSION = "3.5.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: value_semantics
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.4.0
4
+ version: 3.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Dalling
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-08-01 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
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
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'
111
125
  description: "\n Generates modules that provide conventional value semantics for
112
126
  a given set of attributes.\n The behaviour is similar to an immutable `Struct`
113
127
  class,\n plus extensible, lightweight validation and coercion.\n "
@@ -129,7 +143,7 @@ licenses:
129
143
  metadata:
130
144
  bug_tracker_uri: https://github.com/tomdalling/value_semantics/issues
131
145
  changelog_uri: https://github.com/tomdalling/value_semantics/blob/master/CHANGELOG.md
132
- documentation_uri: https://github.com/tomdalling/value_semantics/blob/v3.4.0/README.md
146
+ documentation_uri: https://github.com/tomdalling/value_semantics/blob/v3.5.0/README.md
133
147
  source_code_uri: https://github.com/tomdalling/value_semantics
134
148
  post_install_message:
135
149
  rdoc_options: []