shaped 0.6.1
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 +7 -0
- data/.gitignore +11 -0
- data/.rspec +3 -0
- data/.rubocop.yml +142 -0
- data/.ruby-version +1 -0
- data/.travis.yml +14 -0
- data/CHANGELOG.md +135 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +367 -0
- data/Rakefile +8 -0
- data/bin/_guard-core +31 -0
- data/bin/console +15 -0
- data/bin/guard +31 -0
- data/bin/release +16 -0
- data/bin/rspec +34 -0
- data/bin/rubocop +30 -0
- data/bin/setup +8 -0
- data/lib/shaped.rb +45 -0
- data/lib/shaped/shape.rb +15 -0
- data/lib/shaped/shapes/array.rb +21 -0
- data/lib/shaped/shapes/callable.rb +18 -0
- data/lib/shaped/shapes/class.rb +54 -0
- data/lib/shaped/shapes/equality.rb +15 -0
- data/lib/shaped/shapes/hash.rb +37 -0
- data/lib/shaped/shapes/or.rb +25 -0
- data/lib/shaped/version.rb +5 -0
- data/shaped.gemspec +31 -0
- metadata +102 -0
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2020 David Runger
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,367 @@
|
|
1
|
+
[](https://codecov.io/gh/davidrunger/shaped)
|
2
|
+
[](https://travis-ci.com/davidrunger/shaped)
|
3
|
+
|
4
|
+
# Shaped
|
5
|
+
|
6
|
+
Validate the "shape" of Ruby objects!
|
7
|
+
|
8
|
+
# Table of Contents
|
9
|
+
|
10
|
+
<!--ts-->
|
11
|
+
* [Shaped](#shaped)
|
12
|
+
* [Table of Contents](#table-of-contents)
|
13
|
+
* [Context](#context)
|
14
|
+
* [Installation](#installation)
|
15
|
+
* [Usage](#usage)
|
16
|
+
* [Shape types](#shape-types)
|
17
|
+
* [Shaped::Shape(...) constructor method](#shapedshape-constructor-method)
|
18
|
+
* [Shaped::Shapes::Hash](#shapedshapeshash)
|
19
|
+
* [Shaped::Shapes::Array](#shapedshapesarray)
|
20
|
+
* [Shaped::Shapes::Class](#shapedshapesclass)
|
21
|
+
* [ActiveModel validations](#activemodel-validations)
|
22
|
+
* [Shaped::Shapes::Callable](#shapedshapescallable)
|
23
|
+
* [Shaped::Shapes::Equality](#shapedshapesequality)
|
24
|
+
* [Shaped::Shapes::Or](#shapedshapesor)
|
25
|
+
* [#to_s](#to_s)
|
26
|
+
* [Development](#development)
|
27
|
+
* [For maintainers](#for-maintainers)
|
28
|
+
* [License](#license)
|
29
|
+
|
30
|
+
<!-- Added by: david, at: Fri Jun 19 21:29:30 PDT 2020 -->
|
31
|
+
|
32
|
+
<!--te-->
|
33
|
+
|
34
|
+
# Context
|
35
|
+
|
36
|
+
The primary purpose of this gem, for now, is to serve as a dependency for the
|
37
|
+
[`active_actions`](https://github.com/davidrunger/active_actions/) gem.
|
38
|
+
|
39
|
+
The gem probably has other potential uses, too (for example, a `have_shape` RSpec matcher might be
|
40
|
+
useful), but for now supporting `active_actions` is `shaped`'s *raison d'être*.
|
41
|
+
|
42
|
+
# Installation
|
43
|
+
|
44
|
+
Add the gem to your application's `Gemfile`. Because the gem is not released via RubyGems, you will
|
45
|
+
need to install it from GitHub.
|
46
|
+
|
47
|
+
```rb
|
48
|
+
gem 'shaped', git: 'https://github.com/davidrunger/shaped.git'
|
49
|
+
```
|
50
|
+
|
51
|
+
And then execute:
|
52
|
+
|
53
|
+
```
|
54
|
+
$ bundle install
|
55
|
+
```
|
56
|
+
|
57
|
+
If you want to install the gem on your system independent of a project with a `Gemfile`, then you
|
58
|
+
can easily do so via [`specific_install`](https://github.com/rdp/specific_install):
|
59
|
+
|
60
|
+
```
|
61
|
+
$ gem install specific_install
|
62
|
+
$ gem specific_install davidrunger/shaped
|
63
|
+
```
|
64
|
+
|
65
|
+
# Usage
|
66
|
+
|
67
|
+
The core concept of `shaped` is a "shape", by which we mean "an object that describes some
|
68
|
+
characteristic(s) that we want to be able to test other objects against".
|
69
|
+
|
70
|
+
Here's an example:
|
71
|
+
```rb
|
72
|
+
require 'shaped'
|
73
|
+
|
74
|
+
shape = Shaped::Shapes::Hash.new({ email: String, age: Integer })
|
75
|
+
|
76
|
+
shape.matched_by?({ email: 'dhh@hey.com', age: 44 }) # matches the expected hash "shape"
|
77
|
+
# => true
|
78
|
+
shape.matched_by?({ name: 'David', age: 44 }) # has a `name` key instead of `email`
|
79
|
+
# => false
|
80
|
+
shape.matched_by?({ email: 'dhh@hey.com', age: 44.4 }) # `age` is a Float, not Integer
|
81
|
+
# => false
|
82
|
+
```
|
83
|
+
|
84
|
+
## Shape types
|
85
|
+
|
86
|
+
That example references the `Shaped::Shapes::Hash` class, which is one of `shaped`'s six shape
|
87
|
+
types (all of which inherit from `Shaped::Shape`):
|
88
|
+
|
89
|
+
1. `Shaped::Shapes::Hash`
|
90
|
+
1. `Shaped::Shapes::Array`
|
91
|
+
1. `Shaped::Shapes::Class`
|
92
|
+
1. `Shaped::Shapes::Callable`
|
93
|
+
1. `Shaped::Shapes::Equality`
|
94
|
+
1. `Shaped::Shapes::Or`
|
95
|
+
|
96
|
+
Examples illustrating the use of each shape type are below.
|
97
|
+
|
98
|
+
## `Shaped::Shape(...)` constructor method
|
99
|
+
|
100
|
+
In the example above, we built an instance of `Shaped::Shapes::Hash` by calling
|
101
|
+
`Shaped::Shapes::Hash.new(...)`, but usually an easier/better way to build a shape object is using
|
102
|
+
the `Shaped::Shape` constructor method.
|
103
|
+
|
104
|
+
```rb
|
105
|
+
# functionally equivalent to `Shaped::Shapes::Hash.new({ email: String, age: Integer })`
|
106
|
+
shape = Shaped::Shape(email: String, age: Integer)
|
107
|
+
shape.class
|
108
|
+
# => Shaped::Shapes::Hash
|
109
|
+
shape.matched_by?(email: 'hello@example.com', age: 22)
|
110
|
+
# => true
|
111
|
+
```
|
112
|
+
|
113
|
+
The `Shaped::Shape` constructor method will automatically build the appropriate type of shape object
|
114
|
+
(one of the six types listed above), depending on the arguments provided. In this example, because
|
115
|
+
the argument to `Shaped::Shape` was a `Hash`, the `Shaped::Shape` constructor method built and
|
116
|
+
returned an instance of `Shaped::Shapes::Hash`.
|
117
|
+
|
118
|
+
## Shaped::Shapes::Hash
|
119
|
+
|
120
|
+
```rb
|
121
|
+
shape = Shaped::Shape(emails: { work: String, personal: String })
|
122
|
+
|
123
|
+
shape.matched_by?(emails: { work: 'david@google.com', personal: 'david@gmail.com' })
|
124
|
+
# => true
|
125
|
+
|
126
|
+
# the `:work` key is missing in the sub-hash
|
127
|
+
shape.matched_by?(emails: { personal: 'david@gmail.com' })
|
128
|
+
# => false
|
129
|
+
|
130
|
+
# the `'emails'` key is a String; the shape specifies that it should be a symbol
|
131
|
+
shape.matched_by?('emails' => { work: 'david@google.com', personal: 'david@gmail.com' })
|
132
|
+
# => false
|
133
|
+
```
|
134
|
+
|
135
|
+
## Shaped::Shapes::Array
|
136
|
+
```rb
|
137
|
+
shape = Shaped::Shape([String])
|
138
|
+
|
139
|
+
shape.matched_by?(['hi', 'there!']) # all elements are of the specified class
|
140
|
+
# => true
|
141
|
+
shape.matched_by?(['eight', 4, 11, 'six']) # some elements are of the wrong class
|
142
|
+
# => false
|
143
|
+
shape.matched_by?([]) # note that an empty array is considered to match
|
144
|
+
# => true
|
145
|
+
```
|
146
|
+
|
147
|
+
Note that you can specify more than one allowed class for the elements in the array:
|
148
|
+
```rb
|
149
|
+
shape = Shaped::Shape([Integer, Float])
|
150
|
+
|
151
|
+
shape.matched_by?([3.6, 10, 27, 81.99]) # all elements are either an Integer or Float
|
152
|
+
# => true
|
153
|
+
```
|
154
|
+
|
155
|
+
## Shaped::Shapes::Class
|
156
|
+
|
157
|
+
This shape is straightforward; it tests that the provided object is an instance of the specified
|
158
|
+
class (checked via `is_a?(...)`).
|
159
|
+
|
160
|
+
```rb
|
161
|
+
shape = Shaped::Shape(Numeric)
|
162
|
+
|
163
|
+
shape.matched_by?(99) # 99.is_a?(Numeric) is true
|
164
|
+
# => true
|
165
|
+
|
166
|
+
shape.matched_by?(Integer) # `Integer` is not an _instance_ of `Numeric`
|
167
|
+
# => false
|
168
|
+
|
169
|
+
shape.matched_by?('five') # 'five' is not a Numeric
|
170
|
+
# => false
|
171
|
+
```
|
172
|
+
|
173
|
+
### ActiveModel validations
|
174
|
+
|
175
|
+
`shaped` depends on the [`activemodel` gem](https://rubygems.org/gems/activemodel) (provided by the
|
176
|
+
Ruby on Rails web framework) and leverages ActiveModel to allow for the specification of additional
|
177
|
+
validations when using the `Shaped::Shapes::Class` shape.
|
178
|
+
|
179
|
+
ActiveModel makes many different validations available! They are listed in the [Active Record
|
180
|
+
Validations](https://guides.rubyonrails.org/active_record_validations.html) Rails guide. Just a few
|
181
|
+
examples are shown below.
|
182
|
+
|
183
|
+
(These additional ActiveModel-style validations are optional; as seen in the examples above, you can
|
184
|
+
also merely check that an object is an instance of a class, without any other additional
|
185
|
+
validations.)
|
186
|
+
|
187
|
+
```rb
|
188
|
+
shape = Shaped::Shape(Numeric, numericality: { greater_than: 0 })
|
189
|
+
|
190
|
+
shape.matched_by?(77)
|
191
|
+
# => true
|
192
|
+
shape.matched_by?(-273.15)
|
193
|
+
# => false
|
194
|
+
```
|
195
|
+
|
196
|
+
```rb
|
197
|
+
shape = Shaped::Shape(String, format: { with: /.+@.+/ }, length: { minimum: 6 })
|
198
|
+
|
199
|
+
shape.matched_by?('james@protonmail.com')
|
200
|
+
# => true
|
201
|
+
shape.matched_by?('@tenderlove') # doesn't have a character preceding the "@"
|
202
|
+
# => false
|
203
|
+
shape.matched_by?('a@b.c') # too short
|
204
|
+
# => false
|
205
|
+
```
|
206
|
+
|
207
|
+
## Shaped::Shapes::Callable
|
208
|
+
|
209
|
+
This shape is very powerful if you need a very customized shape definition; you can define any
|
210
|
+
number of conditions/checks and they can be defined however you like. The only condition is that the
|
211
|
+
"shape definition" provided to the `Shaped::Shape(...)` constructor method must have a `#call`
|
212
|
+
instance method. For example, all Ruby procs/lambdas have a `#call` instance method.
|
213
|
+
|
214
|
+
```rb
|
215
|
+
shape = Shaped::Shape(->(num) { (2..6).cover?(num) && num.even? })
|
216
|
+
|
217
|
+
shape.matched_by?(4) # the lamdba returns a truthy value when called with `4`
|
218
|
+
# => true
|
219
|
+
|
220
|
+
shape.matched_by?(5) # fails the `#even?` check
|
221
|
+
# => false
|
222
|
+
|
223
|
+
shape.matched_by?(10) # fails the `#cover?` check (10 is too high)
|
224
|
+
# => false
|
225
|
+
```
|
226
|
+
You can also provide an instance of a custom class that implements a `#call` instance method:
|
227
|
+
|
228
|
+
```rb
|
229
|
+
class EvenParityTester
|
230
|
+
def call(number)
|
231
|
+
@number = number
|
232
|
+
number_is_even?
|
233
|
+
end
|
234
|
+
|
235
|
+
private
|
236
|
+
|
237
|
+
def number_is_even?
|
238
|
+
@number.even?
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
shape = Shaped::Shape(EvenParityTester.new)
|
243
|
+
|
244
|
+
shape.matched_by?(2) # two is even
|
245
|
+
# => true
|
246
|
+
|
247
|
+
shape.matched_by?(7) # seven is not even
|
248
|
+
# => false
|
249
|
+
```
|
250
|
+
|
251
|
+
## Shaped::Shapes::Equality
|
252
|
+
|
253
|
+
`Shaped::Shapes::Equality` is the simplest shape of all; it just checks that an object is equal to
|
254
|
+
the provided "shape definition" (checked via `==`). This "shape" probably isn't very useful, in
|
255
|
+
practice.
|
256
|
+
|
257
|
+
```rb
|
258
|
+
shape = Shaped::Shape('this is the string')
|
259
|
+
|
260
|
+
shape.matched_by?('this is the string')
|
261
|
+
# => true
|
262
|
+
|
263
|
+
shape.matched_by?('this is NOT the string')
|
264
|
+
# => false
|
265
|
+
```
|
266
|
+
|
267
|
+
The `Equality` shape might be useful when it gets used behind the scenes to build another type of
|
268
|
+
shape, like a hash:
|
269
|
+
|
270
|
+
```rb
|
271
|
+
shape = Shaped::Shape(verification_code: 'abc123', new_role: String)
|
272
|
+
shape.class
|
273
|
+
# => Shaped::Shapes::Hash
|
274
|
+
shape.to_s
|
275
|
+
# => { :verification_code => "abc123", :new_role => String }
|
276
|
+
|
277
|
+
shape.matched_by?(verification_code: 'abc123', new_role: 'SuperAdmin')
|
278
|
+
# => true
|
279
|
+
|
280
|
+
# the `:verification_code` does not equal 'abc123', so the shape doesn't match
|
281
|
+
shape.matched_by?(verification_code: '321cba', new_role: 'SuperAdmin')
|
282
|
+
# => false
|
283
|
+
```
|
284
|
+
|
285
|
+
## Shaped::Shapes::Or
|
286
|
+
|
287
|
+
This shape is used behind the scenes to build "compound matchers", such as an Array shape that
|
288
|
+
allows multiple different classes:
|
289
|
+
|
290
|
+
```rb
|
291
|
+
shape = Shaped::Shape([Rational, Integer])
|
292
|
+
shape.to_s
|
293
|
+
# => [Rational OR Integer]
|
294
|
+
|
295
|
+
shape.matched_by?([Rational(1, 3), 55])
|
296
|
+
# => true
|
297
|
+
|
298
|
+
shape.matched_by?([0.333, 55])
|
299
|
+
# => false
|
300
|
+
```
|
301
|
+
|
302
|
+
You can build an `Or` shape by invoking the `Shaped::Shape` constructor with more than one argument.
|
303
|
+
Below is a (rather artificial) example illustrating this. To match this `shape`, an object must be
|
304
|
+
either greater than zero OR an Integer (or both).
|
305
|
+
|
306
|
+
```rb
|
307
|
+
shape = Shaped::Shape(->(num) { num > 0 }, Integer)
|
308
|
+
|
309
|
+
shape.matched_by?(-10) # it's an Integer
|
310
|
+
# => true
|
311
|
+
|
312
|
+
shape.matched_by?(11.5) # it's greater than 0
|
313
|
+
# => true
|
314
|
+
|
315
|
+
shape.matched_by?(-11.5) # it's neither greater than 0 nor an Integer
|
316
|
+
# => false
|
317
|
+
```
|
318
|
+
|
319
|
+
## `#to_s`
|
320
|
+
|
321
|
+
Each Shape type implements a `#to_s` instance method that aims to provide a relatively clear
|
322
|
+
description of what the shape is checking for.
|
323
|
+
|
324
|
+
```rb
|
325
|
+
Shaped::Shape(number_of_widgets: Integer).to_s
|
326
|
+
# => { :number_of_widgets => Integer }
|
327
|
+
|
328
|
+
Shaped::Shape([Hash, OpenStruct]).to_s
|
329
|
+
# => [Hash OR OpenStruct]
|
330
|
+
|
331
|
+
Shaped::Shape(File).to_s
|
332
|
+
# => File
|
333
|
+
|
334
|
+
Shaped::Shape(->(string) { string.include?('@') }).to_s
|
335
|
+
# => Proc test defined at shaped_test.rb:5
|
336
|
+
|
337
|
+
Shaped::Shape('this will be an equality check').to_s
|
338
|
+
# => "this will be an equality check"
|
339
|
+
|
340
|
+
Shaped::Shape('allowed string one', 'allowed string two').to_s
|
341
|
+
# => "allowed string one" OR "allowed string two"
|
342
|
+
```
|
343
|
+
|
344
|
+
# Development
|
345
|
+
|
346
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `bin/rspec` to
|
347
|
+
run the tests. You can also run `bin/console` for an interactive prompt that will allow you to
|
348
|
+
experiment.
|
349
|
+
|
350
|
+
To install this gem onto your local machine from a development copy of the code, run `bundle exec
|
351
|
+
rake install`.
|
352
|
+
|
353
|
+
# For maintainers
|
354
|
+
|
355
|
+
To release a new version:
|
356
|
+
1. check out the `master` branch
|
357
|
+
2. update `CHANGELOG.md`
|
358
|
+
3. update the version number in `version.rb`
|
359
|
+
4. `bundle install` (which will update `Gemfile.lock`)
|
360
|
+
5. commit the changes with a message like `Prepare to release v0.1.1`
|
361
|
+
6. push the changes to `origin/master` (GitHub) via `git push`
|
362
|
+
7. run `bin/release`, which will create a git tag for the version and push git commits and tags
|
363
|
+
|
364
|
+
# License
|
365
|
+
|
366
|
+
The gem is available as open source under the terms of the [MIT
|
367
|
+
License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bin/_guard-core
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application '_guard-core' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require 'pathname'
|
12
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path(
|
13
|
+
'../../Gemfile',
|
14
|
+
Pathname.new(__FILE__).realpath,
|
15
|
+
)
|
16
|
+
|
17
|
+
bundle_binstub = File.expand_path('bundle', __dir__)
|
18
|
+
|
19
|
+
if File.file?(bundle_binstub)
|
20
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
21
|
+
load(bundle_binstub)
|
22
|
+
else
|
23
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
24
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
require 'rubygems'
|
29
|
+
require 'bundler/setup'
|
30
|
+
|
31
|
+
load Gem.bin_path('guard', '_guard-core')
|
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'shaped'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require 'irb'
|
15
|
+
IRB.start(__FILE__)
|
data/bin/guard
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'guard' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require 'pathname'
|
12
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path(
|
13
|
+
'../../Gemfile',
|
14
|
+
Pathname.new(__FILE__).realpath,
|
15
|
+
)
|
16
|
+
|
17
|
+
bundle_binstub = File.expand_path('bundle', __dir__)
|
18
|
+
|
19
|
+
if File.file?(bundle_binstub)
|
20
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
21
|
+
load(bundle_binstub)
|
22
|
+
else
|
23
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
24
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
require 'rubygems'
|
29
|
+
require 'bundler/setup'
|
30
|
+
|
31
|
+
load Gem.bin_path('guard', 'guard')
|