rack-reducer 0.1.1 → 0.1.2
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.
- checksums.yaml +4 -4
- data/README.md +91 -78
- data/spec/_rails_example/app/models/rails_example/artist.rb +2 -1
- data/spec/behavior.rb +7 -0
- data/spec/middleware_spec.rb +2 -1
- data/spec/roda_spec.rb +1 -3
- data/spec/sinatra_mixin_spec.rb +1 -5
- data/spec/spec_helper.rb +2 -1
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a41029921e31a738165238e25af3300edc47c9a131b537ce597a313ba2060b4d
|
4
|
+
data.tar.gz: 2b1c09a05bb3f5af59344d9e65bbdb166a54c1d8d8bffbe34ba3bb8e768d6c4e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7c6c6a069e7a9b6b9cdb1cf423143175cfa257215f69a2c7aaa7e027ac935d14073ed8192030af702f263441d38046671f26f635bd559d3cfe3c0743a1cb12a8
|
7
|
+
data.tar.gz: 9839e1d5a9cd4f4557ad0a87c24c1cee4a4ec5357f085baa894c81c7e8981eadc01f65de84656818ce13357ea7a6ef9d7cabef22c0a7b2d96e65cd1d32a455b3
|
data/README.md
CHANGED
@@ -3,8 +3,8 @@ Rack::Reducer
|
|
3
3
|
[](https://travis-ci.org/chrisfrank/rack-reducer)
|
4
4
|
[](https://codeclimate.com/github/chrisfrank/rack-reducer/maintainability)
|
5
5
|
|
6
|
-
Dynamically filter
|
7
|
-
|
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
|
-
|
27
|
-
|
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
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
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
|
-
|
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
|
-
|
44
|
-
|
45
|
-
|
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
|
58
|
-
or functional.
|
42
|
+
You can use Rack::Reducer in your choice of two styles: mixin or functional.
|
59
43
|
|
60
|
-
###
|
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
|
-
...
|
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
|
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
|
-
#
|
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
|
-
->(
|
72
|
+
->(sort:) { order(sort.to_sym) },
|
89
73
|
]
|
90
74
|
end
|
91
75
|
```
|
92
76
|
|
93
|
-
###
|
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
|
-
|
100
|
-
|
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
|
-
->(
|
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
|
117
|
-
|
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
|
146
|
-
|
147
|
-
|
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
|
-
->(
|
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.
|
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
|
-
->(
|
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.
|
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
|
-
->(
|
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.
|
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
|
-
->(
|
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.
|
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[:
|
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
|
-
->(
|
306
|
+
->(sort: 'name') { order(sort.to_sym) }
|
296
307
|
])
|
297
|
-
@artists
|
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
|
-
->(
|
326
|
+
->(sort:) { order(sort.to_sym) }
|
316
327
|
])
|
317
|
-
@artists
|
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
|
-
->(
|
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
|
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
|
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
|
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.
|
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,
|
408
|
-
been solving this problem since
|
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']
|
data/spec/middleware_spec.rb
CHANGED
@@ -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
data/spec/sinatra_mixin_spec.rb
CHANGED
@@ -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.
|
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-
|
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.
|
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.
|