cheesecloth 0.1.0

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.
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+ source "https://rubygems.org"
3
+
4
+ # Specify your gem's dependencies in cheesecloth.gemspec
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Steven Petryk
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,414 @@
1
+ # CheeseCloth
2
+
3
+ Makes filtering in Rails based on params less of a pain. CheeseCloth provides a transparent, tiny
4
+ DSL to help you chain filters together that only run if a given param is present.
5
+
6
+ * [Introduction](#introduction)
7
+ * [Installation](#installation)
8
+ * [Examples](#examples)
9
+ * [Filtering based on a single parameter](#filtering-based-on-a-single-parameter)
10
+ * [Filtering based on multiple parameters](#filtering-based-on-multiple-parameters)
11
+ * [Applying a filter unconditionally](#applying-a-filter-unconditionally)
12
+ * [Overriding the starting scope](#overriding-the-starting-scope)
13
+ * [Validating parameters](#validating-parameters)
14
+ * [Real world example (Virtus + ActiveModel)](#real-world-example-virtus--activemodel)
15
+ * [Development](#contributing)
16
+ * [Contributing](#contributing)
17
+ * [License](#contributing)
18
+
19
+ ---
20
+
21
+ ## Introduction
22
+
23
+ **Want to skip the intro? Check out the [examples section](#examples).**
24
+
25
+ Dealing with filtering based on params in Rails is a pain.
26
+
27
+ Let's say the boss tells you that you need to implement an endpoint for fetching Events. This
28
+ endpoint needs to allow you to filter by a (possibly one-sided) date range, and also optionally only
29
+ include events that the current user is attending.
30
+
31
+ ```
32
+ GET /api/events
33
+ ?filter[start_date]=2016-10-1
34
+ &filter[end_date]=2016-11-1
35
+ &filter[current_user_attending]=true
36
+ ```
37
+
38
+ Your controller action quickly becomes a nightmare. But wait—you're a
39
+ good developer, and you extract these filters out into an `EventFilterer` object:
40
+
41
+ ```rb
42
+ class EventFilterer
43
+ attr_reader :scope, :user, :params
44
+
45
+ def initialize(params, user:, scope: Event.all)
46
+ @params = params
47
+ @user = user
48
+ @scope = scope
49
+ end
50
+
51
+ def filtered_scope
52
+ if start_date
53
+ @scope = @scope.where("starts_at > ?", start_date)
54
+ end
55
+
56
+ if end_date
57
+ @scope = @scope.where("ends_at < ?", end_date)
58
+ end
59
+
60
+ if current_user_attending?
61
+ @scope = @scope.where_user_attending(user)
62
+ end
63
+
64
+ @scope
65
+ end
66
+
67
+ private
68
+
69
+ def start_date
70
+ parse_date(params[:start_date])
71
+ end
72
+
73
+ def end_date
74
+ parse_date(params[:end_date])
75
+ end
76
+
77
+ def current_user_attending?
78
+ parse_boolean(params[:current_user_attending])
79
+ end
80
+
81
+ def parse_date(iso_string)
82
+ Time.zone.parse(iso_string || "")
83
+ end
84
+
85
+ def parse_boolean(bool_string)
86
+ !["f", "false", "0", ""].includes?(bool_string)
87
+ end
88
+ end
89
+ ```
90
+
91
+ This is a win, right? Sure! At least, it flies with your boss. But there's so much boilerplate. We
92
+ can do better.
93
+
94
+ ```rb
95
+ class EventFilterer
96
+ include CheeseCloth
97
+
98
+ attr_reader :user, :params
99
+
100
+ def initialize(params, user:)
101
+ @params = params
102
+ @user = user
103
+ end
104
+
105
+ scope -> { Event.all }
106
+
107
+ filter :start_date do
108
+ scope.where("starts_at > ?", start_date)
109
+ end
110
+
111
+ filter :end_date do
112
+ scope.where("ends_at < ?", end_date)
113
+ end
114
+
115
+ filter :current_user_attending? do
116
+ scope.where_user_attending(user)
117
+ end
118
+
119
+ private
120
+
121
+ def start_date
122
+ parse_date(params[:start_date])
123
+ end
124
+
125
+ def end_date
126
+ parse_date(params[:end_date])
127
+ end
128
+
129
+ def current_user_attending?
130
+ parse_boolean(params[:current_user_attending])
131
+ end
132
+
133
+ def parse_date(iso_string)
134
+ Time.zone.parse(iso_string || "")
135
+ end
136
+
137
+ def parse_boolean(bool_string)
138
+ !["f", "false", "0", ""].includes?(bool_string)
139
+ end
140
+ end
141
+ ```
142
+
143
+ Neat! We could stop here, and we'd be fully utilizing CheeseCloth—but deserializing params is a
144
+ solved problem, and you have many options. I like using Virtus to do it, but you can use anything
145
+ that makes your params accessible via methods. Let's see what that looks like.
146
+
147
+ ```rb
148
+ class EventFilterer
149
+ include CheeseCloth
150
+ include Virtus.model
151
+
152
+ attribute :start_date, DateTime
153
+ attribute :end_date, DateTime
154
+ attribute :current_user_attending, Boolean
155
+
156
+ def initialize(params, user:)
157
+ @user = user
158
+ super(params) # mass-assignment via Virtus
159
+ end
160
+
161
+ scope -> { Event.all }
162
+
163
+ filter :start_date do
164
+ scope.where("starts_at > ?", start_date)
165
+ end
166
+
167
+ filter :end_date do
168
+ scope.where("ends_at < ?", end_date)
169
+ end
170
+
171
+ filter :current_user_attending? do
172
+ scope.where_user_attending(user)
173
+ end
174
+ end
175
+ ```
176
+
177
+ Now we're talkin'. While there's no hard dependency, CheeseCloth works _really_ well when paired
178
+ with Virtus. Here's our controller, by the way:
179
+
180
+ ```rb
181
+ class EventsController < ApplicationController
182
+ def index
183
+ render json: filterer.filtered_scope
184
+ end
185
+
186
+ private
187
+
188
+ def filterer
189
+ EventFilterer.new(params[:filter], user: current_user)
190
+ end
191
+ end
192
+ ```
193
+
194
+ Nice and simple. You can check out [more use cases](#examples) below.
195
+
196
+ ## Installation
197
+
198
+ Add this line to your application's Gemfile:
199
+
200
+ ```ruby
201
+ gem "cheesecloth"
202
+ ```
203
+
204
+ And then execute:
205
+
206
+ $ bundle
207
+
208
+ Or install it yourself as:
209
+
210
+ $ gem install cheesecloth
211
+
212
+ ## Examples
213
+
214
+ ### Filtering based on a single parameter
215
+
216
+ ```rb
217
+ class FooFilterer
218
+ include CheeseCloth
219
+
220
+ attr_reader :foo
221
+
222
+ def initialize(foo:)
223
+ @foo = foo
224
+ end
225
+
226
+ scope -> { [1, 2, 3] }
227
+
228
+ filter :foo do
229
+ # this will only run if self.foo is truthy.
230
+ scope.reverse
231
+ end
232
+ end
233
+
234
+ FooFilterer.new(foo: true).filtered_scope #=> [3, 2, 1]
235
+ FooFilterer.new(foo: false).filtered_scope #=> [1, 2, 3]
236
+ ```
237
+
238
+ ### Filtering based on multiple parameters
239
+
240
+ ```rb
241
+ class FooFilterer
242
+ include CheeseCloth
243
+
244
+ attr_reader :foo, :bar
245
+
246
+ def initialize(foo:, bar:)
247
+ @foo, @bar = foo, bar
248
+ end
249
+
250
+ scope -> { [1, 2, 3] }
251
+
252
+ filter [:foo, :bar] do
253
+ # this will only run if self.foo && self.bar
254
+ scope - [2]
255
+ end
256
+ end
257
+
258
+ FooFilterer.new(foo: true, bar: true).filtered_scope #=> [1, 3]
259
+ FooFilterer.new(foo: true, bar: false).filtered_scope #=> [1, 2, 3]
260
+ ```
261
+
262
+ ### Applying a filter unconditionally
263
+
264
+ ```rb
265
+ class FooFilterer
266
+ include CheeseCloth
267
+
268
+ scope -> { [1, 2, 3] }
269
+
270
+ filter do
271
+ scope + [4, 5, 6]
272
+ # this will always run
273
+ end
274
+ end
275
+
276
+ FooFilterer.new.filtered_scope #=> [1, 2, 3, 4, 5, 6]
277
+ ```
278
+
279
+ ### Overriding the starting scope
280
+
281
+ If you need to, you can override the starting scope at "runtime" (a.k.a, right before the filters
282
+ are ran). `#filtered_scope` takes an optional `scope` keyword argument.
283
+
284
+ ```rb
285
+ class FooFilterer
286
+ include CheeseCloth
287
+
288
+ scope -> { [1, 2, 3] }
289
+
290
+ filter do
291
+ scope + [4, 5, 6]
292
+ # this will always run
293
+ end
294
+ end
295
+
296
+ FooFilterer.new.filtered_scope(scope: [1]) #=> [1, 4, 5, 6]
297
+ ```
298
+
299
+ ### Validating parameters
300
+
301
+ CheeseCloth doesn't have any mechanism for validation by design. I'd recommend turning your filterer
302
+ into an ActiveModel:
303
+
304
+ ```rb
305
+ class FooFilterer
306
+ include CheeseCloth
307
+ include ActiveModel::Model
308
+
309
+ # ...
310
+
311
+ validates :foo, presence: true
312
+ end
313
+
314
+ class FooController < ActionController::Base
315
+ def index
316
+ if filterer.valid?
317
+ render json: filterer.filtered_scope
318
+ else
319
+ render json: filterer.errors
320
+ end
321
+ end
322
+
323
+ private
324
+
325
+ def filterer
326
+ FooFilterer.new(...)
327
+ end
328
+ end
329
+ ```
330
+
331
+ ### Real-world example (Virtus + ActiveModel)
332
+
333
+ The previous examples could have, of course, been simplified with the use of Virtus to handle
334
+ mass assignment and deserialization, and using ActiveModel's validations. Here's a real-world
335
+ scenario, with a corresponding controller action. Imagine our endpoint had the following criteria:
336
+
337
+ * Venue type must be specified.
338
+ * Start date and end date will either both be specified, or neither will be. If only one is
339
+ specified, don't filter based on date.
340
+
341
+ ```rb
342
+ class EventsFilterer
343
+ include CheeseCloth
344
+ include Virtus.model
345
+ include ActiveModel::Model
346
+
347
+ attribute :venue_type, String
348
+ attribute :start_date, DateTime
349
+ attribute :end_date, DateTime
350
+
351
+ validates :venue_type, presence: true
352
+
353
+ scope -> { Event.all }
354
+
355
+ filter :venue_type do
356
+ scope.at_venue_type(venue_type)
357
+ end
358
+
359
+ filter [:start_date, :end_date] do
360
+ scope.within_dates(start_date, end_date)
361
+ end
362
+ end
363
+
364
+ class EventsController < ApplicationController
365
+ def index
366
+ if filterer.valid?
367
+ # Note that we limit the scope to only the current user's events. Nifty!
368
+ render json: filterer.filtered_scope(scope: current_user.events)
369
+ else
370
+ render json: filterer
371
+ end
372
+ end
373
+
374
+ private
375
+
376
+ def filterer
377
+ EventsFilterer.new(params[:filter])
378
+ end
379
+ end
380
+
381
+ class Event < ApplicationRecord
382
+ scope :at_venue_type, ->(type) { where(venue_type: type) }
383
+ scope :within_dates, ->(start_date, end_date) do
384
+ where("starts_at BETWEEN ? and ?", start_date, end_date)
385
+ end
386
+
387
+ # ...
388
+ end
389
+ ```
390
+
391
+ ## Development
392
+
393
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run
394
+ the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
395
+
396
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new
397
+ version, update the version number in `version.rb`, and then run `bundle exec rake release`, which
398
+ will create a git tag for the version, push git commits and tags, and push the `.gem` file to
399
+ [rubygems.org](https://rubygems.org).
400
+
401
+ ## Contributing
402
+
403
+ 1. Fork this repo
404
+ 2. Add your feature in a branch
405
+ 3. Open a pull request
406
+
407
+ Before making a commit, please run `rake spec` and `rubocop` to ensure it will pass CI.
408
+
409
+ Please write [good commit messages](https://robots.thoughtbot.com/5-useful-tips-for-a-better-commit-message),
410
+ be polite, and be open to discussing ways to improve on the code you've contributed.
411
+
412
+ ## License
413
+
414
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+ require "bundler/gem_tasks"
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task default: :spec