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 +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
|
[![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-
|
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
|
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
|