value_semantics 3.3.0 → 3.4.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: 9917cde0bb66e10e931d39308a1d968973e10f5de4b60b15898cb3d390f77fb9
4
- data.tar.gz: b9ab7670cc254252d357a8276c1fb98305e9c3e60634354883e078179bad7b03
3
+ metadata.gz: e86c0e4467ff36d89870545718de723b0a0b62ff21dc50d30a3f6e1c0766c4c1
4
+ data.tar.gz: '011313548dfe90ee7b686bc91338b5e0f403ce2dd26f98fd607fdf16ef2c5ce7'
5
5
  SHA512:
6
- metadata.gz: 40c1a0ef2a51f2e18465013660a71ca3dbd50fd615dd1e0e7dd4e6e0a65ba3fc5f742132b62467178dad75f5e14452d8ebc38742fa8928254baad8709e89dfd6
7
- data.tar.gz: 9d1eebc8605d6aed14be4eb0fec9fb78f7eca69894e3ee3d79c34a8c41763542b4a8ffc8fd57eba2848735b0732286d8cacbb46a64d509c5fe89a519c06afbe3
6
+ metadata.gz: ecd428749dd01e806df9bbd4a361084f1b066298d464fea3a04ff7f66214ba224b99b93c7902f513c1c4f89fc52690779f4a14bb2c7e78106a212456a8f2c14d
7
+ data.tar.gz: 86c31d6565594493891581dde7feac4f9a9a5c1ff39b86874fd1be74d6bf3827b9a3311369dcaf9a76d9c41ca03be9abc464736ac86d09d316125e9472534554
@@ -5,6 +5,23 @@ 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.4.0] - 2020-08-01
9
+ ### Added
10
+ - Value objects can be instantiated from any object that responds to `#to_h`.
11
+ Previously attributes were required to be given as a `Hash`.
12
+
13
+ - Added monkey patching for super-convenient attribute definitions. This is
14
+ **not** available by default, and needs to be explicitly enabled with
15
+ `ValueSemantics.monkey_patch!` or `require 'value_semantics/monkey_patched'`.
16
+
17
+ ### Changed
18
+ - Improved exception messages for easier development experience
19
+
20
+ - Raises `ValueSemantics::InvalidValue` instead of `ArgumentError` when
21
+ attempting to initialize with an invalid value. `ValueSemantics::InvalidValue`
22
+ is a subclass of `ArgumentError`, so this change should be backward
23
+ compatible.
24
+
8
25
  ## [3.3.0] - 2020-07-17
9
26
  ### Added
10
27
  - Added support for pattern matching in Ruby 2.7
data/README.md CHANGED
@@ -15,10 +15,15 @@ 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
+ - Some [discussion on Reddit][reddit]
23
+
24
+ [blog post]: https://www.rubypigeon.com/posts/value-semantics-gem-for-making-value-classes/
25
+ [rubytapas]: https://www.rubytapas.com/2019/07/09/from-hash-to-value-object/
26
+ [reddit]: https://www.reddit.com/r/ruby/comments/akz4fs/valuesemanticsa_gem_for_making_value_classes/
22
27
 
23
28
 
24
29
  Defining and Creating Value Objects
@@ -50,9 +55,13 @@ Person.new(birthday: nil)
50
55
  #=> #<Person name="Anon Emous" birthday=nil>
51
56
  ```
52
57
 
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.
58
+ Value objects are typically initialized with keyword arguments or a `Hash`, but
59
+ will accept any object that responds to `#to_h`.
60
+
61
+ The curly bracket syntax used with `ValueSemantics.for_attributes` is,
62
+ unfortunately, mandatory due to Ruby's precedence rules. For a shorter
63
+ alternative method that works better with `do`/`end`, see [Convenience (Monkey
64
+ Patch)](#convenience-monkey-patch) below.
56
65
 
57
66
 
58
67
  Using Value Objects
@@ -71,48 +80,71 @@ end
71
80
  tom = Person.new(name: 'Tom')
72
81
 
73
82
 
74
- #
75
83
  # Read-only attributes
76
- #
77
84
  tom.name #=> "Tom"
78
85
  tom.age #=> 31
79
86
 
80
87
 
81
- #
82
88
  # Convert to Hash
83
- #
84
89
  tom.to_h #=> { :name => "Tom", :age => 31 }
85
90
 
86
91
 
87
- #
88
92
  # Non-destructive updates
89
- #
90
93
  old_tom = tom.with(age: 99)
91
-
92
94
  old_tom #=> #<Person name="Tom" age=99>
93
95
  tom #=> #<Person name="Tom" age=31> (unchanged)
94
96
 
95
97
 
96
- #
97
98
  # Equality
98
- #
99
99
  other_tom = Person.new(name: 'Tom', age: 31)
100
-
101
100
  tom == other_tom #=> true
102
101
  tom.eql?(other_tom) #=> true
103
102
  tom.hash == other_tom.hash #=> true
104
103
 
105
104
 
106
- #
107
105
  # Ruby 2.7+ pattern matching
108
- #
109
106
  case tom
110
- in { name: "Tom", age: }
107
+ in name: "Tom", age:
111
108
  puts age # outputs: 31
112
109
  end
113
110
  ```
114
111
 
115
112
 
113
+ Convenience (Monkey Patch)
114
+ --------------------------
115
+
116
+ There is a shorter way to define value attributes:
117
+
118
+ ```ruby
119
+ class Person
120
+ value_semantics do
121
+ name String
122
+ age Integer
123
+ end
124
+ end
125
+ ```
126
+
127
+ **This is disabled by default**, to avoid polluting every class with an extra
128
+ class method.
129
+
130
+ This convenience method can be enabled in two ways:
131
+
132
+ 1. Add a `require:` option to your `Gemfile` like this:
133
+
134
+ ```ruby
135
+ gem 'value_semantics', '~> 3.3', require: 'value_semantics/monkey_patched'
136
+ ```
137
+
138
+ 2. Alternatively, you can call `ValueSemantics.monkey_patch!` somewhere early
139
+ in the boot sequence of your code -- at the top of your script, for example,
140
+ or `config/boot.rb` if it's a Rails project.
141
+
142
+ ```ruby
143
+ require 'value_semantics'
144
+ ValueSemantics.monkey_patch!
145
+ ```
146
+
147
+
116
148
  Defaults
117
149
  --------
118
150
 
@@ -159,13 +191,13 @@ end
159
191
 
160
192
  Person.new(name: 'Tom', ...) # works
161
193
  Person.new(name: 5, ...)
162
- #=> ArgumentError:
163
- #=> Value for attribute 'name' is not valid: 5
194
+ #=> ValueSemantics::InvalidValue:
195
+ #=> Attribute `Person#name` is invalid: 5
164
196
 
165
197
  Person.new(birthday: "1970-01-01", ...) # works
166
198
  Person.new(birthday: "hello", ...)
167
- #=> ArgumentError:
168
- #=> Value for attribute 'birthday' is not valid: "hello"
199
+ #=> ValueSemantics::InvalidValue:
200
+ #=> Attribute 'Person#birthday' is invalid: "hello"
169
201
  ```
170
202
 
171
203
 
@@ -220,8 +252,8 @@ end
220
252
 
221
253
  Person.new(age: 9) # works
222
254
  Person.new(age: 8)
223
- #=> ArgumentError:
224
- #=> Value for attribute 'age' is not valid: 8
255
+ #=> ValueSemantics::InvalidValue:
256
+ #=> Attribute 'Person#age' is invalid: 8
225
257
  ```
226
258
 
227
259
  Default attribute values also pass through validation.
@@ -233,46 +265,48 @@ Coercion
233
265
  Coercion allows non-standard or "convenience" values to be converted into
234
266
  proper, valid values, where possible.
235
267
 
236
- For example, an object with an `IPAddr` attribute may allow string values,
237
- which are then coerced into `IPAddr` objects.
268
+ For example, an object with an `Pathname` attribute may allow string values,
269
+ which are then coerced into `Pathname` objects.
238
270
 
239
271
  Using the option `coerce: true`,
240
272
  coercion happens through a custom class method called `coerce_#{attr}`,
241
273
  which takes the raw value as an argument, and returns the coerced value.
242
274
 
243
275
  ```ruby
244
- class Server
276
+ require 'pathname'
277
+
278
+ class Document
245
279
  include ValueSemantics.for_attributes {
246
- address IPAddr, coerce: true
280
+ path Pathname, coerce: true
247
281
  }
248
282
 
249
- def self.coerce_address(value)
283
+ def self.coerce_path(value)
250
284
  if value.is_a?(String)
251
- IPAddr.new(value)
285
+ Pathname.new(value)
252
286
  else
253
287
  value
254
288
  end
255
289
  end
256
290
  end
257
291
 
258
- Server.new(address: '127.0.0.1')
259
- #=> #<Server address=#<IPAddr: IPv4:127.0.0.1/255.255.255.255>>
292
+ Document.new(path: '~/Documents/whatever.doc')
293
+ #=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
260
294
 
261
- Server.new(address: IPAddr.new('127.0.0.1'))
262
- #=> #<Server address=#<IPAddr: IPv4:127.0.0.1/255.255.255.255>>
295
+ Document.new(path: Pathname.new('~/Documents/whatever.doc'))
296
+ #=> #<Document path=#<Pathname:~/Documents/whatever.doc>>
263
297
 
264
- Server.new(address: 42)
265
- #=> ArgumentError:
266
- #=> Value for attribute 'address' is not valid: 42
298
+ Document.new(path: 42)
299
+ #=> ValueSemantics::InvalidValue:
300
+ #=> Attribute 'Document#path' is invalid: 42
267
301
  ```
268
302
 
269
303
  You can also use any callable object as a coercer.
270
304
  That means, you could use a lambda:
271
305
 
272
306
  ```ruby
273
- class Server
307
+ class Document
274
308
  include ValueSemantics.for_attributes {
275
- address IPAddr, coerce: ->(value) { IPAddr.new(value) }
309
+ path Pathname, coerce: ->(value) { Pathname.new(value) }
276
310
  }
277
311
  end
278
312
  ```
@@ -280,25 +314,25 @@ end
280
314
  Or a custom class:
281
315
 
282
316
  ```ruby
283
- class MyAddressCoercer
317
+ class MyPathCoercer
284
318
  def call(value)
285
- IPAddr.new(value)
319
+ Pathname.new(value)
286
320
  end
287
321
  end
288
322
 
289
- class Server
323
+ class Document
290
324
  include ValueSemantics.for_attributes {
291
- address IPAddr, coerce: MyAddressCoercer.new
325
+ path Pathname, coerce: MyPathCoercer.new
292
326
  }
293
327
  end
294
328
  ```
295
329
 
296
- Or reuse an existing class method:
330
+ Or reuse an existing method:
297
331
 
298
332
  ```ruby
299
- class Server
333
+ class Document
300
334
  include ValueSemantics.for_attributes {
301
- address IPAddr, coerce: IPAddr.method(:new)
335
+ path Pathname, coerce: Pathname.method(:new)
302
336
  }
303
337
  end
304
338
  ```
@@ -310,7 +344,7 @@ Another option is to raise an error within the coercion method.
310
344
 
311
345
  Default attribute values also pass through coercion.
312
346
  For example, the default value could be a string,
313
- which would then be coerced into an `IPAddr` object.
347
+ which would then be coerced into an `Pathname` object.
314
348
 
315
349
 
316
350
  ## ValueSemantics::Struct
@@ -350,7 +384,15 @@ Or install it yourself as:
350
384
  Bug reports and pull requests are welcome on GitHub at:
351
385
  https://github.com/tomdalling/value_semantics
352
386
 
353
- Keep in mind that this gem aims to be as close to 100% backwards compatible as possible.
387
+ Keep in mind that this gem aims to be as close to 100% backwards compatible as
388
+ possible.
389
+
390
+ I'm happy to accept PRs that:
391
+
392
+ - Improve error messages for a better developer experience, especially those
393
+ that support a TDD workflow.
394
+ - Add new, helpful validators
395
+ - Implement automatic freezing of value objects (must be opt-in)
354
396
 
355
397
  ## License
356
398
 
@@ -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,39 @@ 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 `include ValueSemantics.for_attributes`.
51
+ # Instead of:
52
+ #
53
+ # class Person
54
+ # include ValueSemantics.for_attributes {
55
+ # name String
56
+ # }
57
+ # end
58
+ #
59
+ # You can just write:
60
+ #
61
+ # class Person
62
+ # value_semantics do
63
+ # name String
64
+ # end
65
+ # end
66
+ #
67
+ # Alternatively, you can `require 'value_semantics/monkey_patched'`, which
68
+ # will call this method automatically.
69
+ #
70
+ def self.monkey_patch!
71
+ Class.class_eval do
72
+ # @!visibility private
73
+ def value_semantics(&block)
74
+ include ValueSemantics.for_attributes(&block)
75
+ end
76
+ private :value_semantics
77
+ end
78
+ end
79
+
46
80
  #
47
81
  # All the class methods available on ValueSemantics classes
48
82
  #
@@ -55,6 +89,11 @@ module ValueSemantics
55
89
  # was included into this class.
56
90
  #
57
91
  def value_semantics
92
+ if block_given?
93
+ # caller is trying to use the monkey-patched Class method
94
+ raise "`#{self}` has already included ValueSemantics"
95
+ end
96
+
58
97
  self::VALUE_SEMANTICS_RECIPE__
59
98
  end
60
99
  end
@@ -64,15 +103,31 @@ module ValueSemantics
64
103
  #
65
104
  module InstanceMethods
66
105
  #
67
- # Creates a value object based on a Hash of attributes
106
+ # Creates a value object based on a hash of attributes
68
107
  #
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
108
+ # @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`.
73
110
  #
74
- def initialize(given_attrs = {})
75
- remaining_attrs = given_attrs.dup
111
+ # @raise [UnrecognizedAttributes] if given_attrs contains keys that are not
112
+ # attributes
113
+ # @raise [MissingAttributes] if given_attrs is missing any attributes that
114
+ # do not have defaults
115
+ # @raise [InvalidValue] if any attribute values do no pass their validators
116
+ # @raise [TypeError] if the argument does not respond to `#to_h`
117
+ #
118
+ def initialize(attributes = nil)
119
+ attributes_hash =
120
+ if attributes.respond_to?(:to_h)
121
+ attributes.to_h
122
+ else
123
+ raise TypeError, <<-END_MESSAGE.strip.gsub(/\s+/, ' ')
124
+ Can not initialize a `#{self.class}` with a `#{attributes.class}`
125
+ object. This argument is typically a `Hash` of attributes, but can
126
+ be any object that responds to `#to_h`.
127
+ END_MESSAGE
128
+ end
129
+
130
+ remaining_attrs = attributes_hash.dup
76
131
 
77
132
  self.class.value_semantics.attributes.each do |attr|
78
133
  key, value = attr.determine_from!(remaining_attrs, self.class)
@@ -81,8 +136,14 @@ module ValueSemantics
81
136
  end
82
137
 
83
138
  unless remaining_attrs.empty?
84
- unrecognised = remaining_attrs.keys.map(&:inspect).join(', ')
85
- raise UnrecognizedAttributes, "Unrecognized attributes: #{unrecognised}"
139
+ raise(
140
+ UnrecognizedAttributes,
141
+ "`#{self.class}` does not define attributes: " +
142
+ remaining_attrs
143
+ .keys
144
+ .map { |k| '`' + k.inspect + '`' }
145
+ .join(', ')
146
+ )
86
147
  end
87
148
  end
88
149
 
@@ -183,7 +244,7 @@ module ValueSemantics
183
244
  coerce: nil)
184
245
  generator = begin
185
246
  if default_generator && !default.equal?(NOT_SPECIFIED)
186
- raise ArgumentError, "Attribute '#{name}' can not have both a :default and a :default_generator"
247
+ raise ArgumentError, "Attribute `#{name}` can not have both a `:default` and a `:default_generator`"
187
248
  elsif default_generator
188
249
  default_generator
189
250
  elsif !default.equal?(NOT_SPECIFIED)
@@ -204,7 +265,7 @@ module ValueSemantics
204
265
  def determine_from!(attr_hash, klass)
205
266
  raw_value = attr_hash.fetch(name) do
206
267
  if default_generator.equal?(NO_DEFAULT_GENERATOR)
207
- raise MissingAttributes, "Value missing for attribute '#{name}'"
268
+ raise MissingAttributes, "Attribute `#{klass}\##{name}` has no value"
208
269
  else
209
270
  default_generator.call
210
271
  end
@@ -215,7 +276,7 @@ module ValueSemantics
215
276
  if validate?(coerced_value)
216
277
  [name, coerced_value]
217
278
  else
218
- raise ArgumentError, "Value for attribute '#{name}' is not valid: #{coerced_value.inspect}"
279
+ raise InvalidValue, "Attribute `#{klass}\##{name}` is invalid: #{coerced_value.inspect}"
219
280
  end
220
281
  end
221
282
 
@@ -0,0 +1,3 @@
1
+ require 'value_semantics'
2
+
3
+ ValueSemantics.monkey_patch!
@@ -1,3 +1,3 @@
1
1
  module ValueSemantics
2
- VERSION = "3.3.0"
2
+ VERSION = "3.4.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.3.0
4
+ version: 3.4.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-07-17 00:00:00.000000000 Z
11
+ date: 2020-08-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
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
@@ -107,6 +121,7 @@ files:
107
121
  - LICENSE.txt
108
122
  - README.md
109
123
  - lib/value_semantics.rb
124
+ - lib/value_semantics/monkey_patched.rb
110
125
  - lib/value_semantics/version.rb
111
126
  homepage: https://github.com/tomdalling/value_semantics
112
127
  licenses:
@@ -114,7 +129,7 @@ licenses:
114
129
  metadata:
115
130
  bug_tracker_uri: https://github.com/tomdalling/value_semantics/issues
116
131
  changelog_uri: https://github.com/tomdalling/value_semantics/blob/master/CHANGELOG.md
117
- documentation_uri: https://github.com/tomdalling/value_semantics/blob/v3.3.0/README.md
132
+ documentation_uri: https://github.com/tomdalling/value_semantics/blob/v3.4.0/README.md
118
133
  source_code_uri: https://github.com/tomdalling/value_semantics
119
134
  post_install_message:
120
135
  rdoc_options: []
@@ -131,8 +146,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
146
  - !ruby/object:Gem::Version
132
147
  version: '0'
133
148
  requirements: []
134
- rubyforge_project:
135
- rubygems_version: 2.7.7
149
+ rubygems_version: 3.0.8
136
150
  signing_key:
137
151
  specification_version: 4
138
152
  summary: Makes value classes, with lightweight validation and coercion.