rack-reducer 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1877d2e46030a53f725c98db2652b4523d292c8f2d069633e7ea41a03effc211
4
- data.tar.gz: 4681a5249f40b6669c4750ea8cfd7788c4f3cc01440d334aebe48b3b2cdf05c3
3
+ metadata.gz: a41029921e31a738165238e25af3300edc47c9a131b537ce597a313ba2060b4d
4
+ data.tar.gz: 2b1c09a05bb3f5af59344d9e65bbdb166a54c1d8d8bffbe34ba3bb8e768d6c4e
5
5
  SHA512:
6
- metadata.gz: b02f1193154c6ae870fb0de14d0876e50adae875189e698b10532569601495465afdfb3a218ab6b04ac4831fdab1ff5d334e17a3bdf806c87f517962885ef963
7
- data.tar.gz: ca39507186ea87517fa1148f29b30338b8326a79dc37f945823caecfbd06bf27e7bdd1a306fe05ce2c1e22dbbbe597c6dfa5928bc96ae2f68d50dce13f4076dd
6
+ metadata.gz: 7c6c6a069e7a9b6b9cdb1cf423143175cfa257215f69a2c7aaa7e027ac935d14073ed8192030af702f263441d38046671f26f635bd559d3cfe3c0743a1cb12a8
7
+ data.tar.gz: 9839e1d5a9cd4f4557ad0a87c24c1cee4a4ec5357f085baa894c81c7e8981eadc01f65de84656818ce13357ea7a6ef9d7cabef22c0a7b2d96e65cd1d32a455b3
data/README.md CHANGED
@@ -3,8 +3,8 @@ Rack::Reducer
3
3
  [![Build Status](https://travis-ci.org/chrisfrank/rack-reducer.svg?branch=master)](https://travis-ci.org/chrisfrank/rack-reducer)
4
4
  [![Maintainability](https://api.codeclimate.com/v1/badges/675e7a654c7e11c24b9f/maintainability)](https://codeclimate.com/github/chrisfrank/rack-reducer/maintainability)
5
5
 
6
- Dynamically filter, sort, and paginate data via URL params, with controller
7
- logic as simple as
6
+ Dynamically filter and sort data via URL params, with controller logic as
7
+ succint as
8
8
 
9
9
  ```ruby
10
10
  @artists = Artist.reduce(params)
@@ -23,41 +23,25 @@ gem 'rack-reducer', require: 'rack/reducer'
23
23
 
24
24
  Use
25
25
  ------------------------------------------
26
- Rack::Reducer safely maps incoming URL params to an array of filter functions
27
- you define, chains the applicable filters, and returns filtered data.
26
+ If your app needs to render a list of database records, you probably want those
27
+ records to be filterable via URL params, like so:
28
28
 
29
- Suppose you have some incoming requests like these...
30
-
31
- `GET /artists`
32
- `GET /artists?name=janelle+monae`
33
- `GET /artists?name=blake&genre=electronic`
34
-
35
- You want to filter your `artists` table by name and/or genre when those
36
- params are present, or return all artists otherwise.
37
-
38
- Even with just a few optional filters, running them conditonally via `if`
39
- statements gets messy.
29
+ ```
30
+ GET /artists?name=blake` => artists named 'blake'
31
+ GET /artists?genre=electronic&sort=name => electronic artists, sorted by name
32
+ GET /artists => all artists
33
+ ```
40
34
 
41
- ### A Mess
35
+ You _could_ conditionally apply filters with hand-written `if` statements, but
36
+ that approach gets uglier the more filters you have.
42
37
 
43
- ```ruby
44
- # app/controllers/artists_controller.rb
45
- class ArtistsController < ApplicationController
46
- def index
47
- @artists = Artist.all
48
- @artists = @artists.where('lower(name) like ?', "%#{name.downcase}%") if params[:name]
49
- @artists = @artists.where(genre: params[:genre]) if params[:genre]
50
- @artists = @artists.order(params[:order].to_sym) if params[:order]
51
- # ...
52
- # pages later...
53
- @artists.all.to_json
54
- end
55
- ```
38
+ Rack::Reducer can help. It maps incoming URL params to an array of filter
39
+ functions you define, applies only the applicable filters, and returns your
40
+ filtered data.
56
41
 
57
- Rack::Reducer helps you clean this mess up, in your choice of two styles: mixin
58
- or functional.
42
+ You can use Rack::Reducer in your choice of two styles: mixin or functional.
59
43
 
60
- ### Cleaned up, mixin-style
44
+ ### Mixin style
61
45
  Call `Model.reduce(params)` in your controllers...
62
46
 
63
47
  ```ruby
@@ -70,52 +54,46 @@ class ArtistsController < ApplicationController
70
54
  end
71
55
  ```
72
56
 
73
- ... and `extend Rack::Reducer` in your models:
57
+ ...and `extend Rack::Reducer` in your models:
74
58
 
75
59
  ```ruby
76
60
  # app/models/artist.rb
77
61
  class Artist < ActiveRecord::Base
78
- extend Rack::Reducer # makes `self.reduce` available at class level
62
+ extend Rack::Reducer
79
63
 
80
64
  # Configure by calling
81
65
  # `reduces(some_initial_scope, filters: [an, array, of, lambdas])`
82
66
  #
83
- # Filters can use any methods your initial dataset understands.
84
- # Here it's an ActiveRecord query, so filters use AR query methods.
67
+ # Filters can use any methods your initial dataset understands,
68
+ # in this case Artist class methods and scopes
85
69
  reduces self.all, filters: [
86
70
  ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
87
71
  ->(genre:) { where(genre: genre) },
88
- ->(order:) { order(order.to_sym) },
72
+ ->(sort:) { order(sort.to_sym) },
89
73
  ]
90
74
  end
91
75
  ```
92
76
 
93
- ### Cleaned up, functional-style
94
- Call Rack::Reducer as a function:
77
+ ### Functional style
78
+ Call Rack::Reducer as a function, maybe right in your controllers, maybe in
79
+ a dedicated [query object][query_obj], or really anywhere you like:
95
80
 
96
81
  ```ruby
97
82
  # app/controllers/artists_controller.rb
98
83
  class ArtistsController < ApplicationController
99
- # this is an options hash that we'll pass to Rack::Reducer
100
- QUERY = {
101
- dataset: Artist.all,
102
- filters: [
84
+ def index
85
+ @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
103
86
  ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
104
87
  ->(genre:) { where(genre: genre) },
105
- ->(order:) { order(order.to_sym) },
106
- ]
107
- }
108
-
109
- def index
110
- @artists = Rack::Reducer.call(params, QUERY)
88
+ ->(sort:) { order(sort.to_sym) },
89
+ ])
111
90
  render json: @artists
112
91
  end
113
92
  end
114
93
  ```
115
94
 
116
- The mixin style requires less boilerplate, and is stylistically Railsier.
117
- The functional style is more flexible, and keeps your filtering logic all in
118
- one place. Both styles are supported, tested, and handle requests identically.
95
+ The mixin style is stylistically Railsier. The functional style is more
96
+ flexible. Both styles are supported, tested, and handle requests identically.
119
97
 
120
98
  In the examples above:
121
99
 
@@ -142,12 +120,13 @@ In the examples above:
142
120
 
143
121
  Framework-specific Examples
144
122
  ---------------------------
145
- These examples apply Rack::Reducer in different frameworks, with a different
146
- ORM each time. The pairings of ORMs and frameworks are arbitrary, just to
147
- demonstrate a few possible stacks.
123
+ These examples apply Rack::Reducer in different frameworks and ORMs. The
124
+ pairings of ORMs and frameworks are arbitrary, just to demonstrate a few
125
+ possible stacks.
148
126
 
149
127
  - [Sinatra/Sequel](#sinatrasequel)
150
128
  - [Rack Middleware/Ruby Hash](#rack-middlewarehash)
129
+ - [Roda](#roda)
151
130
  - [Hanami](#hanami)
152
131
  - [Advanced use in Rails and other frameworks](#advanced-use-in-rails-and-other-frameworks)
153
132
 
@@ -167,13 +146,13 @@ class SinatraFunctionalApp < Sinatra::Base
167
146
  filters: [
168
147
  ->(genre:) { where(genre: genre) },
169
148
  ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
170
- ->(order:) { order(order.to_sym) },
149
+ ->(sort:) { order(sort.to_sym) },
171
150
  ]
172
151
  }
173
152
 
174
153
  get '/artists' do
175
154
  @artists = Rack::Reducer.call(params, QUERY).to_a
176
- @artists.all.to_json
155
+ @artists.to_json
177
156
  end
178
157
  end
179
158
  ```
@@ -187,13 +166,13 @@ class SinatraMixinApp < Sinatra::Base
187
166
  reduces self.dataset, filters: [
188
167
  ->(genre:) { where(genre: genre) },
189
168
  ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
190
- ->(order:) { order(order.to_sym) },
169
+ ->(sort:) { order(sort.to_sym) },
191
170
  ]
192
171
  end
193
172
 
194
173
  get '/artists' do
195
174
  @artists = Artist.reduce(params)
196
- @artists.all.to_json
175
+ @artists.to_a.to_json
197
176
  end
198
177
  end
199
178
  ```
@@ -221,7 +200,7 @@ app = Rack::Builder.new do
221
200
  use Rack::Reducer, dataset: ARTISTS, filters: [
222
201
  ->(genre:) { select { |item| item[:genre].match(/#{genre}/i) } },
223
202
  ->(name:) { select { |item| item[:name].match(/#{name}/i) } },
224
- ->(order:) { sort_by { |item| item[order.to_sym] } },
203
+ ->(sort:) { sort_by { |item| item[sort.to_sym] } },
225
204
  ]
226
205
  run ->(env) { [200, {}, [env['rack.reduction'].to_json]] }
227
206
  end
@@ -240,7 +219,40 @@ use Rack::Reducer, key: 'myapp.custom_key', dataset: ARTISTS, filters: [
240
219
  ]
241
220
  ```
242
221
 
222
+ ### Roda
223
+ This example uses [Roda][roda] to handle requests, and [Sequel][sequel] as an
224
+ ORM.
225
+
226
+ ```ruby
227
+ # app.rb
228
+ require 'roda'
229
+ require 'sequel'
230
+
231
+ class App < Roda
232
+ plugin :json
233
+
234
+ DB = Sequel.connect ENV['DATABASE_URL']
235
+
236
+ QUERY = {
237
+ dataset: DB[:artists],
238
+ filters: [
239
+ ->(genre:) { where(genre: genre) },
240
+ ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
241
+ ->(sort:) { order(sort.to_sym) },
242
+ ]
243
+ }
244
+ # Note that QUERY[:dataset] is a Sequel::Dataset, so the functions
245
+ # in QUERY[:filters] use Sequel methods
246
+
247
+ route do |r|
248
+ r.get('artists') { Rack::Reducer.call(r.params, QUERY).to_a }
249
+ end
250
+ end
251
+ ```
252
+
243
253
  ### Hanami
254
+ This example uses [Hanami][hanami] to handle requests, and hanami-model as an
255
+ ORM.
244
256
 
245
257
  ```ruby
246
258
  # apps/web/controllers/artists/index.rb
@@ -250,7 +262,7 @@ module Web::Controllers::Artists
250
262
 
251
263
  def call(params)
252
264
  @artists = ArtistRepository.new.reduce(params)
253
- self.body = @artists.all.to_json
265
+ self.body = @artists.to_a.to_json
254
266
  end
255
267
  end
256
268
  end
@@ -261,19 +273,18 @@ class ArtistRepository < Hanami::Repository
261
273
  Rack::Reducer.call(params, dataset: artists.dataset, filters: [
262
274
  ->(genre:) { where(genre: genre) },
263
275
  ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
264
- ->(order:) { order(order.to_sym) },
276
+ ->(sort:) { order(sort.to_sym) },
265
277
  ])
266
278
  end
267
279
  end
268
280
  ```
269
281
 
270
- ### Advanced use in Rails
282
+ ### Advanced use in Rails and other frameworks
271
283
  The examples in the [introduction](#use) cover basic Rails use. The examples
272
284
  below cover more advanced use.
273
285
 
274
286
  If you're comfortable in a non-Rails stack, you can apply these advanced
275
- techniques there too. I wholeheartedly endorse [Roda][roda], and use
276
- Rack::Reducer with Roda/Sequel in production.
287
+ techniques there too.
277
288
 
278
289
  #### Default filters
279
290
  Most of the time it makes sense to use *required* keyword arguments for each
@@ -283,7 +294,7 @@ present.
283
294
  But you may want to run a filter always, with a sensible default when the params
284
295
  don't specify a value. Ordering results is a common case.
285
296
 
286
- The code below will order by `params[:order]` when it exists, and by name
297
+ The code below will order by `params[:sort]` when it exists, and by name
287
298
  otherwise.
288
299
 
289
300
  ```ruby
@@ -292,9 +303,9 @@ class ArtistsController < ApplicationController
292
303
  def index
293
304
  @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
294
305
  ->(genre:) { where(genre: genre) },
295
- ->(order: 'name') { order(order.to_sym) }
306
+ ->(sort: 'name') { order(sort.to_sym) }
296
307
  ])
297
- @artists.to_json
308
+ render json: @artists
298
309
  end
299
310
  end
300
311
  ```
@@ -312,9 +323,9 @@ class ArtistsController < ApplicationController
312
323
  @artists = Rack::Reducer.call(params, dataset: @scope, filters: [
313
324
  ->(name:) { by_name(name) },
314
325
  ->(genre:) { where(genre: genre) },
315
- ->(order:) { order(order.to_sym) }
326
+ ->(sort:) { order(sort.to_sym) }
316
327
  ])
317
- @artists.to_json
328
+ render json: @artists
318
329
  end
319
330
  end
320
331
  ```
@@ -332,7 +343,7 @@ class Artist < ApplicationRecord
332
343
  # in this case `self.all`, so filters can use query methods, scopes, etc
333
344
  ->(name:) { by_name(name) },
334
345
  ->(genre:) { where(genre: genre) },
335
- ->(order:) { order(order.to_sym) }
346
+ ->(sort:) { order(sort.to_sym) }
336
347
  ]
337
348
 
338
349
  scope :by_name, lambda { |name|
@@ -350,7 +361,7 @@ class ArtistsController < ApplicationController
350
361
  # you can chain reduce with other ActiveRecord queries,
351
362
  # as long as reduce is first in the chain
352
363
  @artists = Artist.reduce(params).signed
353
- @artists.to_json
364
+ render json: @artists
354
365
  end
355
366
  end
356
367
  ```
@@ -359,8 +370,8 @@ How Rack::Reducer Works
359
370
  --------------------------------------
360
371
  Rack::Reducer takes a dataset, a params hash, and an array of lambda functions.
361
372
 
362
- To return filtered data, it calls [reduce][reduce] on your array of lambdas,
363
- with the reduction's initial value set to `dataset`.
373
+ To return filtered data, it calls Enumerable#[reduce][reduce] on your array of
374
+ lambdas, with the reduction's initial value set to `dataset`.
364
375
 
365
376
  Each reduction looks for keys in the `params` hash that match the
366
377
  current lambda's [keyword arguments][keywords]. If the keys exist, it
@@ -372,7 +383,8 @@ execute at all, and just pass the unaltered dataset down the chain.
372
383
 
373
384
  The reason Reducer works with any ORM is that *you* supply the dataset and
374
385
  filter functions. Reducer doesn't need to know anything about ActiveRecord,
375
- Mongoid, etc -- it just `instance_exec`s your own code against your own dataset.
386
+ Sequel, Mongoid, etc -- it just `instance_exec`s your own code against your
387
+ own dataset.
376
388
 
377
389
  ### Security
378
390
  Rack::Reducer claims to "safely" map URL params to filters, but it accepts an
@@ -382,7 +394,7 @@ By using keyword arguments in your filter lambdas, you are explicitly naming
382
394
  the params you'll accept into your filters. Params that aren't keywords never
383
395
  get evaluated.
384
396
 
385
- For extra safety, you can typecast the params in your filters. Most ORMs
397
+ For extra safety, you can typecast the params in your filters. Many ORMs
386
398
  handle this for you, but as an example:
387
399
 
388
400
  ```ruby
@@ -404,8 +416,8 @@ bottleneck in your application.
404
416
 
405
417
  Alternatives
406
418
  -------------------
407
- If you're working in Rails, Platformatec's excellent [HasScope][has_scope] has
408
- been solving this problem since 2013. I prefer keeping my query logic all in one
419
+ If you're working in Rails, Plataformatec's excellent [HasScope][has_scope] has
420
+ been solving this problem since 2009. I prefer keeping my query logic all in one
409
421
  place, though, instead of spreading it across my controllers and models.
410
422
 
411
423
  [Periscope][periscope], by laserlemon, seems like another good Rails option, and
@@ -446,3 +458,4 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
446
458
  [keywords]: https://robots.thoughtbot.com/ruby-2-keyword-arguments
447
459
  [query_obj]: https://robots.thoughtbot.com/using-yieldself-for-composable-activerecord-relations
448
460
  [periscope]: https://github.com/laserlemon/periscope
461
+ [hanami]: http://hanamirb.org
@@ -14,7 +14,8 @@ module RailsExample
14
14
  # or scopes...
15
15
  ->(name:) { by_name(name) },
16
16
  # or inline ActiveRecord queries
17
- ->(order:) { order(order.to_sym) }
17
+ ->(order:) { order(order.to_sym) },
18
+ ->(releases: ) { where(release_count: releases.to_i) },
18
19
  ]
19
20
  end
20
21
  end
data/spec/behavior.rb CHANGED
@@ -35,6 +35,13 @@ shared_examples_for Rack::Reducer do
35
35
  end
36
36
  end
37
37
 
38
+ it 'handles falsy values' do
39
+ get('/artists?releases=0') do |response|
40
+ expect(response.body).to include('Chris Frank')
41
+ expect(JSON.parse(response.body).length).to eq(1)
42
+ end
43
+ end
44
+
38
45
  it 'can sort as well as filter' do
39
46
  get '/artists?order=genre' do |response|
40
47
  genre = JSON.parse(response.body)[0]['genre']
@@ -9,7 +9,8 @@ module MiddlewareTest
9
9
  filters: [
10
10
  ->(genre:) { select { |item| item[:genre].match(/#{genre}/i) } },
11
11
  ->(name:) { select { |item| item[:name].match(/#{name}/i) } },
12
- ->(order:) { sort_by { |item| item[order.to_sym] } }
12
+ ->(order:) { sort_by { |item| item[order.to_sym] } },
13
+ ->(releases:) { select { |item| item[:release_count] == releases.to_i } },
13
14
  ]
14
15
  }
15
16
 
data/spec/roda_spec.rb CHANGED
@@ -4,9 +4,7 @@ require 'roda'
4
4
  class RodaTest < Roda
5
5
  plugin :json
6
6
  route do |r|
7
- r.on 'artists' do
8
- r.get { Rack::Reducer.call(r.params, SEQUEL_QUERY).to_a }
9
- end
7
+ r.get('artists') { Rack::Reducer.call(r.params, SEQUEL_QUERY).to_a }
10
8
  end
11
9
  end
12
10
 
@@ -6,11 +6,7 @@ class SinatraMixin < Sinatra::Base
6
6
  class Artist < Sequel::Model
7
7
  plugin :json_serializer
8
8
  extend Rack::Reducer
9
- reduces dataset, filters: [
10
- ->(genre:) { grep(:genre, "%#{genre}%", case_insensitive: true) },
11
- ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
12
- ->(order:) { order(order.to_sym) }
13
- ]
9
+ reduces dataset, filters: SEQUEL_QUERY[:filters]
14
10
  end
15
11
 
16
12
  get '/artists' do
data/spec/spec_helper.rb CHANGED
@@ -13,7 +13,8 @@ SEQUEL_QUERY = {
13
13
  filters: [
14
14
  ->(genre:) { grep(:genre, "%#{genre}%", case_insensitive: true) },
15
15
  ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
16
- ->(order: 'genre') { order(order.to_sym) }
16
+ ->(order: 'genre') { order(order.to_sym) },
17
+ ->(releases: ) { where(release_count: releases.to_i) },
17
18
  ]
18
19
  }.freeze
19
20
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-reducer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Frank
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-03-27 00:00:00.000000000 Z
11
+ date: 2018-04-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -288,7 +288,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
288
288
  version: '0'
289
289
  requirements: []
290
290
  rubyforge_project:
291
- rubygems_version: 2.7.3
291
+ rubygems_version: 2.7.6
292
292
  signing_key:
293
293
  specification_version: 4
294
294
  summary: Dynamically filter data via URL params, in any Rack app.