value_semantics 3.4.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: 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: []