rack-reducer 1.0.1 → 1.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.
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