rack-reducer 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +112 -252
  3. data/lib/rack/reducer/middleware.rb +17 -2
  4. data/lib/rack/reducer/reduction.rb +19 -16
  5. data/lib/rack/reducer/refinements.rb +13 -1
  6. data/lib/rack/reducer/version.rb +3 -1
  7. data/lib/rack/reducer/warnings.rb +27 -0
  8. data/lib/rack/reducer.rb +51 -20
  9. data/spec/benchmarks.rb +51 -21
  10. data/spec/fixtures.rb +30 -0
  11. data/spec/middleware_spec.rb +55 -23
  12. data/spec/rails_spec.rb +33 -3
  13. data/spec/reducer_spec.rb +104 -0
  14. data/spec/spec_helper.rb +6 -15
  15. metadata +34 -136
  16. data/lib/rack/reducer/parser.rb +0 -26
  17. data/spec/_hanami_example/apps/web/application.rb +0 -326
  18. data/spec/_hanami_example/apps/web/config/routes.rb +0 -4
  19. data/spec/_hanami_example/apps/web/controllers/artists/index.rb +0 -12
  20. data/spec/_hanami_example/apps/web/views/application_layout.rb +0 -7
  21. data/spec/_hanami_example/config/boot.rb +0 -2
  22. data/spec/_hanami_example/config/environment.rb +0 -29
  23. data/spec/_hanami_example/lib/hanami_example/entities/artist.rb +0 -2
  24. data/spec/_hanami_example/lib/hanami_example/repositories/artist_repository.rb +0 -9
  25. data/spec/_hanami_example/lib/hanami_example.rb +0 -5
  26. data/spec/_rails_example/app/channels/application_cable/channel.rb +0 -4
  27. data/spec/_rails_example/app/channels/application_cable/connection.rb +0 -4
  28. data/spec/_rails_example/app/controllers/application_controller.rb +0 -2
  29. data/spec/_rails_example/app/controllers/artists_controller.rb +0 -8
  30. data/spec/_rails_example/app/jobs/application_job.rb +0 -2
  31. data/spec/_rails_example/app/mailers/application_mailer.rb +0 -4
  32. data/spec/_rails_example/app/models/application_record.rb +0 -3
  33. data/spec/_rails_example/app/models/rails_example/artist.rb +0 -21
  34. data/spec/_rails_example/config/application.rb +0 -35
  35. data/spec/_rails_example/config/boot.rb +0 -3
  36. data/spec/_rails_example/config/environment.rb +0 -5
  37. data/spec/_rails_example/config/environments/development.rb +0 -47
  38. data/spec/_rails_example/config/environments/production.rb +0 -83
  39. data/spec/_rails_example/config/environments/test.rb +0 -42
  40. data/spec/_rails_example/config/initializers/application_controller_renderer.rb +0 -8
  41. data/spec/_rails_example/config/initializers/backtrace_silencers.rb +0 -7
  42. data/spec/_rails_example/config/initializers/cors.rb +0 -16
  43. data/spec/_rails_example/config/initializers/filter_parameter_logging.rb +0 -4
  44. data/spec/_rails_example/config/initializers/inflections.rb +0 -16
  45. data/spec/_rails_example/config/initializers/mime_types.rb +0 -4
  46. data/spec/_rails_example/config/initializers/wrap_parameters.rb +0 -14
  47. data/spec/_rails_example/config/puma.rb +0 -56
  48. data/spec/_rails_example/config/routes.rb +0 -4
  49. data/spec/_rails_example/db/seeds.rb +0 -7
  50. data/spec/behavior.rb +0 -51
  51. data/spec/hanami_spec.rb +0 -6
  52. data/spec/roda_spec.rb +0 -13
  53. data/spec/sinatra_functional_spec.rb +0 -26
  54. data/spec/sinatra_mixin_spec.rb +0 -20
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d400c7b68a3ecf7da56af02f7f5e86ab18177ff306d6871a10ef8f9e7bcb7109
4
- data.tar.gz: ff1b1f50ccb165d041a04fced63adbd946d8976f06460ce83c30b66c91599c17
3
+ metadata.gz: 96e9d87cd9c12373fb42f37eedb4246d6296009faab5bb5f1831236a4c200b0f
4
+ data.tar.gz: cb1e793d45ba3e71f588b22df0469358a40f56ab3167d8dc734b105306a841f0
5
5
  SHA512:
6
- metadata.gz: 0dadfa90dce4dfd009c5dab96bc6c32557863a8413cf7888a10fe0f5af9d1479c2a93c29b79ea501abaa89dafa0de1daf95ac2665bae2534884d3c9a8cd4bf17
7
- data.tar.gz: da77f4e36e9a979be6a52d37a996f10636935dc9097dc47dc22cb972d5e59a5634b327dcae60d7c9573a4e62b6b9fa789dbd0511459724a15103a619b49ccc63
6
+ metadata.gz: ebb52de791e396e475cbfb76040645120e4892f12d9fc9f3b7901d86a9b52adf2d7314b6795472640fb5a3eb2c1f44b49191a823457a9b94588fea129a530870
7
+ data.tar.gz: c373da2d40a7e3da1b281408636c38107430a7583155f3923539e8db7ce85c09cffd2b1233fc867dc1f56dd94e86281dd7820a9131d5ed544f1dd3439def249b
data/README.md CHANGED
@@ -3,15 +3,7 @@ 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 and sort data via URL params, with controller logic as
7
- succint as
8
-
9
- ```ruby
10
- @artists = Artist.reduce(params)
11
- ```
12
-
13
- Rack::Reducer works in any Rack-compatible app, with any ORM, and has no
14
- dependencies beyond Rack itself.
6
+ Declaratively filter data via URL params, in any Rack app, with any ORM.
15
7
 
16
8
  Install
17
9
  ------------------------------------------
@@ -21,85 +13,45 @@ Add `rack-reducer` to your Gemfile:
21
13
  gem 'rack-reducer', require: 'rack/reducer'
22
14
  ```
23
15
 
16
+ Rack::Reducer has zero dependencies beyond Rack itself.
17
+
24
18
  Use
25
19
  ------------------------------------------
26
20
  If your app needs to render a list of database records, you probably want those
27
21
  records to be filterable via URL params, like so:
28
22
 
29
23
  ```
30
- GET /artists?name=blake` => artists named 'blake'
31
- GET /artists?genre=electronic&sort=name => electronic artists, sorted by name
32
24
  GET /artists => all artists
25
+ GET /artists?name=blake` => artists named 'blake'
26
+ GET /artists?genre=electronic&name=blake => electronic artists named 'blake'
33
27
  ```
34
28
 
35
- You _could_ conditionally apply filters with hand-written `if` statements, but
36
- that approach gets uglier the more filters you have.
37
-
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.
41
-
42
- You can use Rack::Reducer in your choice of two styles: **mixin** or
43
- **functional**.
44
-
45
- ### Mixin style
46
- Call `Model.reduce(params)` in your controllers...
29
+ Rack::Reducer can help. It applies incoming URL params to an array of filter
30
+ functions you define, runs only the relevant filters, and returns your filtered
31
+ data. Here’s how you might use it in a Rails controller:
47
32
 
48
33
  ```ruby
49
34
  # app/controllers/artists_controller.rb
50
35
  class ArtistsController < ApplicationController
51
- def index
52
- @artists = Artist.reduce(params)
53
- render json: @artists
54
- end
55
- end
56
- ```
57
-
58
- ...and `extend Rack::Reducer` in your models:
59
-
60
- ```ruby
61
- # app/models/artist.rb
62
- class Artist < ActiveRecord::Base
63
- extend Rack::Reducer
64
-
65
- # Configure by calling
66
- # `reduces(some_initial_scope, filters: [an, array, of, lambdas])`
67
- #
68
- # Filters can use any methods your initial dataset understands,
69
- # in this case Artist class methods and scopes
70
- reduces self.all, filters: [
36
+ # Step 1: Create a reducer
37
+ ArtistReducer = Rack::Reducer.create(
38
+ Artist.all,
71
39
  ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
72
40
  ->(genre:) { where(genre: genre) },
73
- ->(sort:) { order(sort.to_sym) },
74
- ]
75
- end
76
- ```
41
+ )
77
42
 
78
- ### Functional style
79
- Call Rack::Reducer as a function, maybe right in your controllers, maybe in
80
- a dedicated [query object][query_obj], or really anywhere you like:
81
-
82
- ```ruby
83
- # app/controllers/artists_controller.rb
84
- class ArtistsController < ApplicationController
43
+ # Step 2: Apply the reducer to incoming requests
85
44
  def index
86
- @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
87
- ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
88
- ->(genre:) { where(genre: genre) },
89
- ->(sort:) { order(sort.to_sym) },
90
- ])
45
+ @artists = ArtistReducer.apply(params)
91
46
  render json: @artists
92
47
  end
93
48
  end
94
49
  ```
95
50
 
96
- The mixin style is stylistically Railsier. The functional style is more
97
- flexible. Both styles are supported, tested, and handle requests identically.
98
-
99
- In the examples above:
51
+ This example app would handle requests as follows:
100
52
 
101
53
  ```ruby
102
- # GET /artists returns all artists, e.g.
54
+ # GET /artists => All artists:
103
55
  [
104
56
  { "name": "Blake Mills", "genre": "alternative" },
105
57
  { "name": "Björk", "genre": "electronic" },
@@ -108,79 +60,48 @@ In the examples above:
108
60
  { "name": "SZA", "genre": "alt-soul" }
109
61
  ]
110
62
 
111
- # GET /artists?name=blake returns artists named 'blake', e.g.
63
+ # GET /artists?name=blake => Artists named "blake":
112
64
  [
113
65
  { "name": "Blake Mills", "genre": "alternative" },
114
66
  { "name": "James Blake", "genre": "electronic" }
115
67
  ]
116
68
 
117
- # GET /artists?name=blake&genre=electronic returns e.g.
69
+ # GET /artists?name=blake&genre=electronic => Electronic artists named "blake"
118
70
  [{ "name": "James Blake", "genre": "electronic" }]
119
71
  ```
120
72
 
121
-
122
73
  Framework-specific Examples
123
74
  ---------------------------
124
75
  These examples apply Rack::Reducer in different frameworks and ORMs. The
125
76
  pairings of ORMs and frameworks are arbitrary, just to demonstrate a few
126
77
  possible stacks.
127
78
 
128
- - [Sinatra/Sequel](#sinatrasequel)
129
- - [Rack Middleware/Ruby Hash](#rack-middlewarehash)
130
- - [Roda](#roda)
131
- - [Hanami](#hanami)
132
- - [Advanced use in Rails and other frameworks](#advanced-use-in-rails-and-other-frameworks)
133
-
134
79
  ### Sinatra/Sequel
135
80
  This example uses [Sinatra][sinatra] to handle requests, and [Sequel][sequel]
136
81
  as an ORM.
137
82
 
138
- #### Functional-style
139
83
  ```ruby
140
84
  # sinatra_functional_style.rb
141
- class SinatraFunctionalApp < Sinatra::Base
85
+ class SinatraExample < Sinatra::Base
142
86
  DB = Sequel.connect ENV['DATABASE_URL']
143
87
 
144
88
  # dataset is a Sequel::Dataset, so filters use Sequel query methods
145
- QUERY = {
146
- dataset: DB[:artists],
147
- filters: [
148
- ->(genre:) { where(genre: genre) },
149
- ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
150
- ->(sort:) { order(sort.to_sym) },
151
- ]
152
- }
153
-
154
- get '/artists' do
155
- @artists = Rack::Reducer.call(params, QUERY)
156
- @artists.to_a.to_json
157
- end
158
- end
159
- ```
160
-
161
- #### Mixin-style
162
- ```ruby
163
- # sintra_mixin_style.rb
164
- class SinatraMixinApp < Sinatra::Base
165
- class Artist < Sequel::Model
166
- extend Rack::Reducer
167
- reduces self.dataset, filters: [
168
- ->(genre:) { where(genre: genre) },
169
- ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
170
- ->(sort:) { order(sort.to_sym) },
171
- ]
172
- end
89
+ ArtistReducer = Rack::Reducer.create(
90
+ DB[:artists],
91
+ ->(genre:) { where(genre: genre) },
92
+ ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
93
+ )
173
94
 
174
95
  get '/artists' do
175
- @artists = Artist.reduce(params)
176
- @artists.to_a.to_json
96
+ @artists = ArtistReducer.apply(params).all
97
+ @artists.to_json
177
98
  end
178
99
  end
179
100
  ```
180
101
 
181
- ### Rack Middleware/Hash
102
+ ### Rack Middleware/Ruby Array
182
103
  This example runs a raw Rack app with Rack::Reducer mounted as middleware.
183
- It doesn't use an ORM at all -- it just stores data in a ruby hash.
104
+ It doesn't use an ORM at all -- it just stores data in a ruby array.
184
105
 
185
106
  ```ruby
186
107
  # config.ru
@@ -197,8 +118,8 @@ ARTISTS = [
197
118
  ]
198
119
 
199
120
  app = Rack::Builder.new do
200
- # dataset is a hash, so filter functions use ruby hash methods
201
- use Rack::Reducer, dataset: ARTISTS, filters: [
121
+ # dataset is an Array, so filter functions use Array methods
122
+ use Rack::Reducer::Middleware, dataset: ARTISTS, filters: [
202
123
  ->(genre:) { select { |item| item[:genre].match(/#{genre}/i) } },
203
124
  ->(name:) { select { |item| item[:name].match(/#{name}/i) } },
204
125
  ->(sort:) { sort_by { |item| item[sort.to_sym] } },
@@ -215,161 +136,92 @@ change the `env` key by passing a new name as option to `use`:
215
136
 
216
137
  ```ruby
217
138
  # config.ru
218
- use Rack::Reducer, key: 'myapp.custom_key', dataset: ARTISTS, filters: [
219
- #an array of lambdas
139
+ use Rack::Reducer::Midleware, key: 'custom.key', dataset: ARTISTS, filters: [
140
+ # an array of lambdas
220
141
  ]
221
142
  ```
222
143
 
223
- ### Roda
224
- This example uses [Roda][roda] to handle requests, and [Sequel][sequel] as an
225
- ORM.
144
+ ### With Rails scopes
145
+ The Rails [quickstart example](#use) created a reducer inside a
146
+ controller, but if your filters use lots of ActiveRecord scopes, it might make
147
+ more sense to keep your reducers in your models instead.
226
148
 
227
149
  ```ruby
228
- # app.rb
229
- require 'roda'
230
- require 'sequel'
231
-
232
- class App < Roda
233
- plugin :json
234
-
235
- DB = Sequel.connect ENV['DATABASE_URL']
150
+ # app/models/artist.rb
151
+ class Artist < ApplicationRecord
152
+ # filters get instance_exec'd against the dataset you provide -- in this case
153
+ # it's `self.all` -- so filters can use query methods, scopes, etc
154
+ Reducer = Rack::Reducer.create(
155
+ self.all,
156
+ ->(name:) { by_name(name) },
157
+ ->(genre:) { where(genre: genre) },
158
+ ->(sort:) { order(sort.to_sym) }
159
+ ]
236
160
 
237
- QUERY = {
238
- dataset: DB[:artists],
239
- filters: [
240
- ->(genre:) { where(genre: genre) },
241
- ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
242
- ->(sort:) { order(sort.to_sym) },
243
- ]
161
+ scope :by_name, lambda { |name|
162
+ where('lower(name) like ?', "%#{name.downcase}%")
244
163
  }
245
- # Note that QUERY[:dataset] is a Sequel::Dataset, so the functions
246
- # in QUERY[:filters] use Sequel methods
247
-
248
- route do |r|
249
- r.get('artists') { Rack::Reducer.call(r.params, QUERY).to_a }
250
- end
251
- end
252
- ```
253
-
254
- ### Hanami
255
- This example uses [Hanami][hanami] to handle requests, and hanami-model as an
256
- ORM.
257
-
258
- ```ruby
259
- # apps/web/controllers/artists/index.rb
260
- module Web::Controllers::Artists
261
- class Index
262
- include Web::Action
263
-
264
- def call(params)
265
- @artists = ArtistRepository.new.reduce(params)
266
- self.body = @artists.to_a.to_json
267
- end
268
- end
269
164
  end
270
165
 
271
- # lib/app_name/repositories/artist_repository.rb
272
- class ArtistRepository < Hanami::Repository
273
- def reduce(params)
274
- Rack::Reducer.call(params, dataset: artists.dataset, filters: [
275
- ->(genre:) { where(genre: genre) },
276
- ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
277
- ->(sort:) { order(sort.to_sym) },
278
- ])
166
+ # app/controllers/artists_controller.rb
167
+ class ArtistsController < ApplicationController
168
+ def index
169
+ @artists = Artist::Reducer.apply(params)
170
+ render json: @artists
279
171
  end
280
172
  end
281
173
  ```
282
174
 
283
- ### Advanced use in Rails and other frameworks
284
- The examples in the [introduction](#use) cover basic Rails use. The examples
285
- below cover more advanced use.
286
-
287
- If you're comfortable in a non-Rails stack, you can apply these advanced
288
- techniques there too.
289
-
290
- #### Default filters
175
+ Default filters
176
+ ------------------------------------------
291
177
  Most of the time it makes sense to use *required* keyword arguments for each
292
178
  filter, and skip running the filter altogether when the keyword argments aren't
293
179
  present.
294
180
 
295
- But you may want to run a filter always, with a sensible default when the params
296
- don't specify a value. Ordering results is a common case.
297
-
298
- The code below will order by `params[:sort]` when it exists, and by name
299
- otherwise.
181
+ But sometimes you'll want to run a filter with a default value, even when the
182
+ required params are missing. The code below will order by `params[:sort]` when
183
+ it exists, and by name otherwise.
300
184
 
301
185
  ```ruby
302
186
  # app/controllers/artists_controller.rb
303
187
  class ArtistsController < ApplicationController
188
+ ArtistReducer = Rack::Reducer.create(
189
+ Artist.all,
190
+ ->(genre:) { where(genre: genre) },
191
+ ->(sort: 'name') { order(sort.to_sym) }
192
+ )
193
+
304
194
  def index
305
- @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
306
- ->(genre:) { where(genre: genre) },
307
- ->(sort: 'name') { order(sort.to_sym) }
308
- ])
195
+ @artists = ArtistReducer.apply(params)
309
196
  render json: @artists
310
197
  end
311
198
  end
312
199
  ```
313
200
 
314
- #### Dynamically setting Reducer's initial dataset
315
- Rack::Reducer's mixin style only lets you target one initial dataset for
316
- reduction. If you need different initial datasets in different contexts, use
317
- the functional style:
201
+ Calling Rack::Reducer as a function
202
+ -------------------------------------------
203
+ For a slight performance penalty (~5%), you can skip creating a reducer via
204
+ `::create` and just call Rack::Reducer as a function. This can be useful when
205
+ prototyping, mostly because you don't need to think about naming anything.
318
206
 
319
207
  ```ruby
320
208
  # app/controllers/artists_controller.rb
321
209
  class ArtistsController < ApplicationController
210
+ # Step 1: there is no step 2
322
211
  def index
323
- @scope = current_user.admin? ? Artist.all : Artist.signed
324
- @artists = Rack::Reducer.call(params, dataset: @scope, filters: [
325
- ->(name:) { by_name(name) },
212
+ @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
213
+ ->(name:) { where('lower(name) like ?', "%#{name.downcase}%") },
326
214
  ->(genre:) { where(genre: genre) },
327
- ->(sort:) { order(sort.to_sym) }
328
215
  ])
329
216
  render json: @artists
330
217
  end
331
218
  end
332
219
  ```
333
220
 
334
- #### Chaining reduce with other ActiveRecord query methods
335
- In the mixin-style, you can chain `Model.reduce` with other ActiveRecord
336
- queries, as long as `reduce` is the first call in the chain:
337
-
338
- ```ruby
339
- # app/models/artist.rb
340
- class Artist < ApplicationRecord
341
- extend Rack::Reducer
342
- reduces self.all, filters: [
343
- # filters get instance_exec'd against the initial dataset,
344
- # in this case `self.all`, so filters can use query methods, scopes, etc
345
- ->(name:) { by_name(name) },
346
- ->(genre:) { where(genre: genre) },
347
- ->(sort:) { order(sort.to_sym) }
348
- ]
349
-
350
- scope :by_name, lambda { |name|
351
- where('lower(name) like ?', "%#{name.downcase}%")
352
- }
353
-
354
- # here's a scope we're not using in our Reducer filters,
355
- # but will use in our controller
356
- scope :signed, lambda { where(signed: true) }
357
- end
358
-
359
- # app/controllers/artists_controller.rb
360
- class ArtistsController < ApplicationController
361
- def index
362
- # you can chain reduce with other ActiveRecord queries,
363
- # as long as reduce is first in the chain
364
- @artists = Artist.reduce(params).signed
365
- render json: @artists
366
- end
367
- end
368
- ```
369
221
 
370
222
  How Rack::Reducer Works
371
223
  --------------------------------------
372
- Rack::Reducer takes a dataset, a params hash, and an array of lambda functions.
224
+ Rack::Reducer takes a dataset, an array of lambdas, and a params hash.
373
225
 
374
226
  To return filtered data, it calls Enumerable#[reduce][reduce] on your array of
375
227
  lambdas, with the reduction's initial value set to `dataset`.
@@ -387,42 +239,53 @@ filter functions. Reducer doesn't need to know anything about ActiveRecord,
387
239
  Sequel, Mongoid, etc -- it just `instance_exec`s your own code against your
388
240
  own dataset.
389
241
 
390
- ### Security
391
- Rack::Reducer claims to "safely" map URL params to filters, but it accepts an
392
- unfiltered params hash. What gives?
242
+ Performance
243
+ ---------------------
244
+ For requests with empty params, Rack::Reducer has no measurable performance
245
+ impact. For requests with populated params, Rack::Reducer is about 10% slower
246
+ than a set of hand-coded conditionals, according to `spec/benchmarks.rb`.
393
247
 
394
- By using keyword arguments in your filter lambdas, you are explicitly naming
395
- the params you'll accept into your filters. Params that aren't keywords never
396
- get evaluated.
248
+ ```
249
+ Conditionals (full) 530.000 i/100ms
250
+ Reducer (full) 432.000 i/100ms
251
+ Conditionals (empty) 780.000 i/100ms
252
+ Reducer (empty) 808.000 i/100ms
253
+ Calculating -------------------------------------
254
+ Conditionals (full) 4.864k (± 2.3%) i/s - 24.380k in 5.015551s
255
+ Reducer (full) 4.384k (± 1.3%) i/s - 22.032k in 5.026651s
256
+ Conditionals (empty) 7.889k (± 1.7%) i/s - 39.780k in 5.043797s
257
+ Reducer (empty) 8.129k (± 1.7%) i/s - 41.208k in 5.070453s
258
+
259
+ Comparison:
260
+ Reducer (empty): 8129.5 i/s
261
+ Conditionals (empty): 7889.3 i/s - same-ish: difference falls within error
262
+ Conditionals (full): 4863.7 i/s - 1.67x slower
263
+ Reducer (full): 4383.8 i/s - 1.85x slower
264
+ ```
397
265
 
398
- For extra safety, you can typecast the params in your filters. Many ORMs
399
- handle this for you, but as an example:
266
+ In Rails, note that `params` is never empty, so use `request.query_parameters`
267
+ instead if you want to handle parameterless requests at top speed.
400
268
 
401
269
  ```ruby
402
- FILTERS = [
403
- # typecast params[:name] to a string
404
- ->(name:) { where(name: name.to_s) },
405
- # typecast params[:updated_before] and params[:updated_after]
406
- # to times, and set a default for updated_after if it's missing
407
- lambda |updated_before:, updated_after: 1.month.ago| {
408
- where(updated_at: updated_after.to_time..updated_before.to_time)
409
- }
410
- ]
411
- ```
270
+ # app/controllers/artists_controller.rb
271
+ class ArtistController < ApplicationController
272
+ # ArtistReducer = Rack::Reducer.create(...etc etc)
412
273
 
413
- ### Performance
414
- According to `spec/benchmarks.rb`, Rack::Reducer executes about 90% as quickly
415
- as a set of hard-coded conditional filters. It is unlikely to be a
416
- bottleneck in your application.
274
+ def index
275
+ @artists = ArtistReducer.apply(request.query_parameters)
276
+ render json: @artists
277
+ end
278
+ end
279
+ ```
417
280
 
418
281
  Alternatives
419
282
  -------------------
420
283
  If you're working in Rails, Plataformatec's excellent [HasScope][has_scope] has
421
- been solving this problem since 2009. I prefer keeping my query logic all in one
422
- place, though, instead of spreading it across my controllers and models.
284
+ been solving this problem since 2009. I prefer keeping my request logic all in
285
+ one place, though, instead of spreading it across my controllers and models.
423
286
 
424
- [Periscope][periscope], by laserlemon, seems like another good Rails option, and
425
- though it's Rails only, it supports more than just ActiveRecord.
287
+ [Periscope][periscope], by Steve Richert, seems like another solid Rails option.
288
+ It is Rails-only, but it supports more than just ActiveRecord.
426
289
 
427
290
  For Sinatra, Simon Courtois has a [Sinatra port of has_scope][sin_has_scope].
428
291
  It depends on ActiveRecord.
@@ -434,7 +297,7 @@ Please open [an issue](https://github.com/chrisfrank/rack-reducer/issues) on
434
297
  Github.
435
298
 
436
299
  ### Pull Requests
437
- Please include tests, following the style of the specs in `spec/*_spec.rb`.
300
+ PRs are welcome, and I'll do my best to review them promptly.
438
301
 
439
302
  License
440
303
  ----------
@@ -454,9 +317,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
454
317
  [sin_has_scope]: https://github.com/simonc/sinatra-has_scope
455
318
  [sinatra]: https://github.com/sinatra/sinatra
456
319
  [sequel]: https://github.com/jeremyevans/sequel
457
- [roda]: https://github.com/jeremyevans/roda
458
320
  [reduce]: http://ruby-doc.org/core-2.5.0/Enumerable.html#method-i-reduce
459
321
  [keywords]: https://robots.thoughtbot.com/ruby-2-keyword-arguments
460
- [query_obj]: https://robots.thoughtbot.com/using-yieldself-for-composable-activerecord-relations
461
322
  [periscope]: https://github.com/laserlemon/periscope
462
- [hanami]: http://hanamirb.org
@@ -1,21 +1,36 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'rack/request'
2
4
  require_relative 'reduction'
3
5
 
4
6
  module Rack
5
7
  module Reducer
6
8
  # Mount Rack::Reducer as middleware
9
+ # @example A microservice that filters artists
10
+ # ArtistService = Rack::Builder.new do
11
+ # use(
12
+ # Rack::Reducer::Middleware,
13
+ # dataset: Artist.all,
14
+ # filters: [
15
+ # lambda { |name:| where(name: name) },
16
+ # lambda { |genre:| where(genre: genre) },
17
+ # ]
18
+ # )
19
+ #
20
+ # run ->(env) { [200, {}, [env['rack.reduction'].to_json]] }
21
+ # end
7
22
  class Middleware
8
23
  def initialize(app, options = {})
9
24
  @app = app
10
25
  @key = options[:key] || 'rack.reduction'
11
- @props = options
26
+ @reducer = Rack::Reducer.create(options[:dataset], *options[:filters])
12
27
  end
13
28
 
14
29
  # Call the next app in the middleware stack, with env[key] set
15
30
  # to the ouput of a reduction
16
31
  def call(env)
17
32
  params = Rack::Request.new(env).params
18
- reduction = Reduction.new(@props.merge(params: params)).reduce
33
+ reduction = @reducer.apply(params)
19
34
  @app.call env.merge(@key => reduction)
20
35
  end
21
36
  end
@@ -1,5 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'refinements'
2
- require_relative 'parser'
3
4
 
4
5
  module Rack
5
6
  module Reducer
@@ -8,26 +9,28 @@ module Rack
8
9
  class Reduction
9
10
  using Refinements # define Proc#required_argument_names, #satisfies?, etc
10
11
 
11
- DEFAULTS = {
12
- dataset: [],
13
- filters: [],
14
- params: nil
15
- }.freeze
16
-
17
- def initialize(options)
18
- @props = DEFAULTS.merge(options)
19
- @params = Parser.call(@props[:params])
12
+ def initialize(dataset, *filters)
13
+ @dataset = dataset
14
+ @filters = filters
20
15
  end
21
16
 
22
- def reduce
23
- @props[:filters].reduce(@props[:dataset]) do |data, filter|
24
- next data unless filter.satisfies?(@params)
17
+ # Run +@filters+ against the params argument
18
+ # @param [Hash, ActionController::Parameters, nil] params
19
+ # a Rack-compatible params hash
20
+ # @return +@dataset+ with the matching filters applied
21
+ def apply(params)
22
+ return @dataset if !params || params.empty?
25
23
 
26
- data.instance_exec(@params.slice(*filter.all_argument_names), &filter)
24
+ symbolized_params = params.to_unsafe_h.symbolize_keys
25
+ @filters.reduce(@dataset) do |data, filter|
26
+ next data unless filter.satisfies?(symbolized_params)
27
+
28
+ data.instance_exec(
29
+ **symbolized_params.slice(*filter.all_argument_names),
30
+ &filter
31
+ )
27
32
  end
28
33
  end
29
34
  end
30
-
31
- private_constant :Reduction
32
35
  end
33
36
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  module Reducer
3
5
  # refine Proc and hash in this scope only
@@ -17,7 +19,7 @@ module Rack
17
19
  end
18
20
  end
19
21
 
20
- # backport Hash#slice for older rubies
22
+ # backport Hash#slice for Ruby < 2.4
21
23
  unless {}.respond_to?(:slice)
22
24
  refine Hash do
23
25
  def slice(*keys)
@@ -25,6 +27,16 @@ module Rack
25
27
  end
26
28
  end
27
29
  end
30
+
31
+ refine Hash do
32
+ def symbolize_keys
33
+ each_with_object({}) do |(key, val), hash|
34
+ hash[key.to_sym] = val.is_a?(Hash) ? symbolize(val) : val
35
+ end
36
+ end
37
+
38
+ alias_method :to_unsafe_h, :to_h
39
+ end
28
40
  end
29
41
 
30
42
  private_constant :Refinements
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rack
2
4
  module Reducer
3
- VERSION = '1.0.1'.freeze
5
+ VERSION = '1.1.0'
4
6
  end
5
7
  end