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.
@@ -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')