shaped 0.6.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![codecov](https://codecov.io/gh/davidrunger/shaped/branch/master/graph/badge.svg)](https://codecov.io/gh/davidrunger/shaped)
|
2
|
+
[![Build Status](https://travis-ci.com/davidrunger/shaped.svg?branch=master)](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')
|