cheesecloth 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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