shaped 0.6.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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.
@@ -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).
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task(default: :spec)
@@ -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')
@@ -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__)
@@ -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')