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
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module Reducer
5
+ module Warnings
6
+ MESSAGES = {
7
+ new: [
8
+ 'Rack::Reducer.new will become an alias of ::create in v2.',
9
+ 'To mount middleware that will still work in 2.0, write',
10
+ '"use Rack::Reducer::Middleware" instead of "use Rack::Reducer"',
11
+ ],
12
+ reduces: [
13
+ 'Rack::Reducer’s mixin-style is deprecated and may be removed in v2.',
14
+ 'To keep using Rack::Reducer in your models, use a Reducer constant.',
15
+ 'class MyModel',
16
+ ' MyReducer = Rack::Reducer.create(dataset, *filter_functions)',
17
+ 'end',
18
+ 'MyModel::MyReducer.call(params)',
19
+ ]
20
+ }.freeze
21
+
22
+ def self.[](key)
23
+ MESSAGES.fetch(key, []).join("\n")
24
+ end
25
+ end
26
+ end
27
+ end
data/lib/rack/reducer.rb CHANGED
@@ -1,38 +1,64 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'reducer/reduction'
2
4
  require_relative 'reducer/middleware'
5
+ require_relative 'reducer/warnings'
3
6
 
4
7
  module Rack
5
- # Use request params to apply filters to a dataset
8
+ # Declaratively filter data via URL params, in any Rack app.
6
9
  module Reducer
7
- # Filter a dataset
10
+ # Create a Reduction object that can filter +dataset+ via +#apply+.
11
+ # @param [Object] dataset an ActiveRecord::Relation, Sequel::Dataset,
12
+ # or other class with chainable methods
13
+ # @param [Array<Proc>] filters An array of lambdas whose keyword arguments
14
+ # name the URL params you will use as filters
15
+ # @return Rack::Reducer::Reduction
16
+ # @example Create a reducer and use it in a Sinatra app
17
+ # DB = Sequel.connect(ENV['DATABASE_URL'])
18
+ # MyReducer = Rack::Reducer.create(
19
+ # DB[:artists],
20
+ # lambda { |name:| where(name: name) },
21
+ # lambda { |genre:| where(genre: genre) },
22
+ # )
23
+ #
24
+ # get '/artists' do
25
+ # @artists = MyReducer.apply(params)
26
+ # @artists.to_json
27
+ # end
28
+ def self.create(dataset, *filters)
29
+ Reduction.new(dataset, *filters)
30
+ end
31
+
32
+ # Filter a dataset without creating a Reducer first.
33
+ # Note that this approach is a bit slower and less memory-efficient than
34
+ # creating a Reducer via ::create. Use ::create when you can.
35
+ #
8
36
  # @param params [Hash] Rack-compatible URL params
9
37
  # @param dataset [Object] A dataset, e.g. one of your App's models
10
38
  # @param filters [Array<Proc>] An array of lambdas with keyword arguments
11
39
  # @example Call Rack::Reducer as a function in a Sinatra app
12
- # ArtistReducer = {
13
- # dataset: Artist,
14
- # filters: [
40
+ # get '/artists' do
41
+ # @artists = Rack::Reducer.call(params, dataset: Artist.all, filters: [
15
42
  # lambda { |name:| where(name: name) },
16
43
  # lambda { |genre:| where(genre: genre) },
17
- # ]
18
- # }
19
- # get '/artists' do
20
- # @artists = Rack::Reducer.call(params, ArtistReducer)
44
+ # ])
21
45
  # end
22
46
  def self.call(params, dataset:, filters:)
23
- Reduction.new(
24
- params: params,
25
- filters: filters,
26
- dataset: dataset
27
- ).reduce
47
+ Reduction.new(dataset, *filters).apply(params)
28
48
  end
29
49
 
30
50
  # Mount Rack::Reducer as middleware
51
+ # @deprecated
52
+ # Rack::Reducer.new will become an alias of ::create in v2.0.
53
+ # To mount middleware that will still work in 2.0, write
54
+ # "use Rack::Reducer::Middleware" instead of "use Rack::Reducer"
31
55
  def self.new(app, options = {})
56
+ warn "#{caller(1..1).first}}\n#{Warnings[:new]}"
32
57
  Middleware.new(app, options)
33
58
  end
34
59
 
35
60
  # Extend Rack::Reducer to get +reduce+ and +reduces+ as class-methods
61
+ #
36
62
  # @example Make an "Artists" model reducible
37
63
  # class Artist < SomeORM::Model
38
64
  # extend Rack::Reducer
@@ -41,15 +67,20 @@ module Rack
41
67
  # lambda { |genre:| where(genre: genre) },
42
68
  # ]
43
69
  # end
44
- #
45
70
  # Artist.reduce(params)
71
+ #
72
+ # @deprecated
73
+ # Rack::Reducer's mixin-style is deprecated and may be removed in 2.0.
74
+ # To keep using Rack::Reducer in your models, create a Reducer constant.
75
+ # class MyModel < ActiveRecord::Base
76
+ # MyReducer = Rack::Reducer.create(dataset, *filter_functions)
77
+ # end
78
+ # MyModel::MyReducer.call(params)
46
79
  def reduces(dataset, filters:)
80
+ warn "#{caller(1..1).first}}\n#{Warnings[:reduces]}"
81
+ reducer = Reduction.new(dataset, *filters)
47
82
  define_singleton_method :reduce do |params|
48
- Reduction.new(
49
- params: params,
50
- filters: filters,
51
- dataset: dataset,
52
- ).reduce
83
+ reducer.apply(params)
53
84
  end
54
85
  end
55
86
  end
data/spec/benchmarks.rb CHANGED
@@ -1,40 +1,70 @@
1
- require_relative 'spec_helper'
2
- require 'sinatra/base'
1
+ require 'rack'
2
+ require 'pry'
3
3
  require 'json'
4
+ require_relative '../lib/rack/reducer'
4
5
  require 'benchmark/ips'
6
+ require_relative 'fixtures'
7
+ require 'sequel'
5
8
 
6
- Conditionals = lambda do |params = {}|
9
+ DB = Sequel.sqlite.tap do |db|
10
+ db.create_table(:artists) do
11
+ primary_key :id
12
+ String :name
13
+ String :genre
14
+ Integer :release_count
15
+ end
16
+ Fixtures::DB[:artists].each { |row| db[:artists].insert(row) }
17
+ end
18
+
19
+ conditional_app = lambda do |env|
20
+ params = Rack::Request.new(env).params
7
21
  @artists = DB[:artists]
8
- if (genre = params[:genre])
9
- @artists = @artists.grep(:genre, "%#{genre}%", case_insensitive: true)
22
+ if (genre = params['genre'])
23
+ @artists = @artists.where(genre: genre.to_s)
10
24
  end
11
- if (name = params[:name])
25
+ if (name = params['name'])
12
26
  @artists = @artists.grep(:name, "%#{name}%", case_insensitive: true)
13
27
  end
14
-
15
- @artists.to_json
28
+ Rack::Response.new(@artists).finish
16
29
  end
17
30
 
18
- Reduction = lambda do |params = {}|
19
- @artists = Rack::Reducer.call(params, dataset: DB[:artists], filters: [
20
- ->(genre:) { grep(:genre, "%#{genre}%", case_insensitive: true) },
21
- ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
22
- ])
31
+ TestReducer = Rack::Reducer.create(
32
+ DB[:artists],
33
+ ->(genre:) { where(genre: genre.to_s) },
34
+ ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
35
+ )
23
36
 
24
- @artists.to_json
37
+ reducer_app = lambda do |env|
38
+ params = Rack::Request.new(env).params
39
+ @artists = TestReducer.apply(params)
40
+ Rack::Response.new(@artists).finish
25
41
  end
26
42
 
27
- Benchmark.ips(3) do |bm|
28
- bm.report('conditionals, empty params') { Conditionals.call }
43
+ Benchmark.ips do |bm|
44
+ env = {
45
+ 'REQUEST_METHOD' => 'GET',
46
+ 'PATH_INFO' => '/',
47
+ 'rack.input' => StringIO.new('')
48
+ }
29
49
 
30
- bm.report('reduction, empty params') { Reduction.call }
50
+ query = {
51
+ 'QUERY_STRING' => 'name=blake&genre=electronic',
52
+ }
53
+
54
+ bm.report('Conditionals (full)') do
55
+ conditional_app.call env.merge(query)
56
+ end
57
+
58
+ bm.report('Reducer (full)') do
59
+ reducer_app.call env.merge(query)
60
+ end
31
61
 
32
- bm.report('conditionals, full params') do
33
- Conditionals.call({ name: 'blake', genre: 'electric' })
62
+ bm.report('Conditionals (empty)') do
63
+ conditional_app.call env.dup
34
64
  end
35
65
 
36
- bm.report('reduction, full params') do
37
- Reduction.call({ name: 'blake', genre: 'electric' })
66
+ bm.report('Reducer (empty)') do
67
+ reducer_app.call env.dup
38
68
  end
39
69
 
40
70
  bm.compare!
data/spec/fixtures.rb ADDED
@@ -0,0 +1,30 @@
1
+ module Fixtures
2
+ DB = {
3
+ artists: [
4
+ { name: 'Blake Mills', genre: 'alternative', release_count: 3 },
5
+ { name: 'Björk', genre: 'electronic', release_count: 3 },
6
+ { name: 'James Blake', genre: 'electronic', release_count: 3 },
7
+ { name: 'Janelle Monae', genre: 'alt-soul', release_count: 3 },
8
+ { name: 'SZA', genre: 'alt-soul', release_count: 3 },
9
+ { name: 'Chris Frank', genre: 'alt-soul', release_count: nil },
10
+ ]
11
+ }
12
+
13
+ FILTERS = [
14
+ ->(genre:) {
15
+ select { |item| item[:genre].match(/#{genre}/i) }
16
+ },
17
+ ->(name:) {
18
+ select { |item| item[:name].match(/#{name}/i) }
19
+ },
20
+ ->(sort:) {
21
+ sort_by { |item| item[sort.to_sym] }
22
+ },
23
+ ->(releases:) {
24
+ select { |item| item[:release_count].to_i == releases.to_i }
25
+ },
26
+ ]
27
+
28
+ ArtistReducer = Rack::Reducer.create(DB[:artists], *FILTERS)
29
+ end
30
+
@@ -1,31 +1,63 @@
1
1
  require 'spec_helper'
2
- require 'json'
2
+ require_relative 'fixtures'
3
3
 
4
- # mount Rack::Reducer as middleware, let it filter data into env['rack.reduction'],
5
- # and respond with env['rack.reduction'].to_json
6
- module MiddlewareTest
7
- DEFAULTS = {
8
- dataset: DB[:artists].all,
9
- filters: [
10
- ->(genre:) { select { |item| item[:genre].match(/#{genre}/i) } },
11
- ->(name:) { select { |item| item[:name].match(/#{name}/i) } },
12
- ->(order:) { sort_by { |item| item[order.to_sym] } },
13
- ->(releases:) { select { |item| item[:release_count] == releases.to_i } },
14
- ]
15
- }
4
+ RSpec.describe Rack::Reducer::Middleware do
5
+ using SpecRefinements
6
+ module AppFactory
7
+ def self.create(key: nil, middleware_class: Rack::Reducer::Middleware)
8
+ Rack::Builder.new do
9
+ use(
10
+ middleware_class,
11
+ dataset: Fixtures::DB[:artists],
12
+ filters: Fixtures::FILTERS,
13
+ key: key
14
+ )
15
+ run ->(env) { [200, {}, [env.to_json]] }
16
+ end
17
+ end
18
+ end
19
+
20
+ describe 'without a key set' do
21
+ let(:app) { AppFactory.create }
22
+ it 'responds with unfiltered data when filter params are empty' do
23
+ get('/') do |res|
24
+ reduction = res.json['rack.reduction']
25
+ expect(reduction.count).to eq(Fixtures::DB[:artists].count)
26
+ end
27
+ end
16
28
 
17
- def self.app(options = {}, key = options[:key] || 'rack.reduction')
18
- Rack::Builder.new do
19
- use Rack::Reducer, DEFAULTS.merge(options)
20
- run ->(env) { [200, {}, [env[key].to_json]] }
29
+ it 'filters by a single param, e.g. name' do
30
+ get('/artists?name=Blake') do |res|
31
+ reduction = res.body
32
+ expect(reduction).to include('Blake Mills')
33
+ expect(reduction).to include('James Blake')
34
+ expect(reduction).not_to include('SZA')
35
+ end
21
36
  end
22
37
  end
23
- end
24
38
 
25
- describe MiddlewareTest.app do
26
- it_behaves_like Rack::Reducer
27
- end
39
+ describe 'with a custom key' do
40
+ let(:app) { AppFactory.create(key: 'custom_key') }
41
+ it 'stores reducer data at env[custom_key]' do
42
+ get('/') do |res|
43
+ expect(res.json['custom_key'].class).to eq(Array)
44
+ end
45
+ end
46
+ end
47
+
48
+ describe 'using Rack::Reducer instead of Rack::Reducer::Middleware' do
49
+ before do
50
+ @warnings = []
51
+ allow(Rack::Reducer).to receive(:warn) do |msg|
52
+ @warnings << msg
53
+ end
54
+ end
28
55
 
29
- describe MiddlewareTest.app(key: 'some.custom.key') do
30
- it_behaves_like Rack::Reducer
56
+ let(:app) { AppFactory.create(middleware_class: Rack::Reducer) }
57
+
58
+ it 'emits a deprecation warning' do
59
+ get('/')
60
+ expect(@warnings.last).to include('alias of ::create')
61
+ end
62
+ end
31
63
  end
data/spec/rails_spec.rb CHANGED
@@ -1,6 +1,36 @@
1
1
  require 'spec_helper'
2
- require_relative '_rails_example/config/environment'
2
+ require_relative 'fixtures'
3
+ require 'action_controller/railtie'
4
+ require 'securerandom'
3
5
 
4
- describe Rails.application do
5
- it_behaves_like Rack::Reducer
6
+ class RailsApp < Rails::Application
7
+ routes.append do
8
+ get "/", to: "artists#index"
9
+ get "/query", to: "artists#query"
10
+ end
11
+
12
+ config.api_only = true
13
+ config.eager_load = true
14
+ config.secret_key_base = SecureRandom.hex(64)
15
+ end
16
+
17
+ class ArtistsController < ActionController::API
18
+ def index
19
+ @artists = Fixtures::ArtistReducer.apply(params)
20
+ render json: @artists
21
+ end
22
+
23
+ def query
24
+ @artists = Fixtures::ArtistReducer.apply(request.query_parameters)
25
+ render json: @artists
26
+ end
27
+ end
28
+
29
+ RSpec.describe RailsApp do
30
+ let(:app) { RailsApp.initialize! }
31
+
32
+ it 'works with ActionController::Parameters and a plain hash' do
33
+ get('/') { |res| expect(res.status).to eq(200) }
34
+ get('/query') { |res| expect(res.status).to eq(200) }
35
+ end
6
36
  end
@@ -0,0 +1,104 @@
1
+ require 'spec_helper'
2
+ require_relative 'fixtures'
3
+
4
+ RSpec.describe Rack::Reducer do
5
+ using SpecRefinements
6
+
7
+ let(:app) do
8
+ lambda do |env|
9
+ req = Rack::Request.new(env)
10
+ res = Fixtures::ArtistReducer.apply(req.params).to_json
11
+ [200, { 'Content-Type' => 'application/json' }, [res]]
12
+ end
13
+ end
14
+
15
+ it 'responds with unfiltered data when filter params are empty' do
16
+ get('/') do |res|
17
+ expect(res.json.count).to eq(Fixtures::DB[:artists].count)
18
+ end
19
+ end
20
+
21
+ it 'filters by a single param, e.g. name' do
22
+ get('/artists?name=Blake') do |response|
23
+ expect(response.body).to include('Blake Mills')
24
+ expect(response.body).to include('James Blake')
25
+ expect(response.body).not_to include('SZA')
26
+ end
27
+ end
28
+
29
+ it 'resets state between requests' do
30
+ get('/artists?name=Blake')
31
+ get('/artists') do |res|
32
+ expect(res.json.count).to eq(Fixtures::DB[:artists].count)
33
+ end
34
+ end
35
+
36
+ it 'filters by a single param, e.g. genre' do
37
+ get('/artists?genre=electronic') do |response|
38
+ expect(response.body).to include('Björk')
39
+ expect(response.body).to include('James Blake')
40
+ expect(response.body).not_to include('Blake Mills')
41
+ end
42
+
43
+ get '/artists?genre=soul' do |response|
44
+ expect(response.body).to include('Janelle Monae')
45
+ expect(response.body).not_to include('Björk')
46
+ end
47
+ end
48
+
49
+ it 'chains multiple filters' do
50
+ get('/artists?genre=electronic&name=blake') do |response|
51
+ expect(response.body).to include('James Blake')
52
+ expect(response.body).not_to include('Blake Mills')
53
+ end
54
+ end
55
+
56
+ it 'handles falsy values' do
57
+ get('/artists?releases=0') do |response|
58
+ expect(response.body).to include('Chris Frank')
59
+ expect(JSON.parse(response.body).length).to eq(1)
60
+ end
61
+ end
62
+
63
+ it 'accepts nil as params' do
64
+ expect(Fixtures::ArtistReducer.apply(nil)).to be_truthy
65
+ end
66
+
67
+ it 'can sort' do
68
+ get '/artists?order=genre' do |response|
69
+ genre = response.json[0]['genre']
70
+ expect(genre).to eq('alternative')
71
+ end
72
+ end
73
+
74
+ describe 'ad-hoc style via ::call' do
75
+ let(:params) { { 'genre' => 'electronic', 'name' => 'blake' } }
76
+ it 'works just like the primary style, but slower' do
77
+ result = Rack::Reducer.call(
78
+ params,
79
+ dataset: Fixtures::DB[:artists],
80
+ filters: Fixtures::FILTERS,
81
+ )
82
+ expect(result.count).to eq(1)
83
+ expect(result[0][:name]).to eq('James Blake')
84
+ end
85
+ end
86
+
87
+ describe 'mixin-style' do
88
+ before { @warnings = [] }
89
+
90
+ let(:model) do
91
+ dataset = Fixtures::DB[:artists].dup
92
+ allow(dataset).to(receive(:warn)) { |msg| @warnings << msg }
93
+ dataset.extend Rack::Reducer
94
+ dataset.reduces dataset, filters: Fixtures::FILTERS
95
+ dataset
96
+ end
97
+
98
+ it 'is still supported, but with a deprecation warning' do
99
+ params = { 'genre' => 'electronic', 'name' => 'blake' }
100
+ expect(model.reduce(params).count).to eq(1)
101
+ expect(@warnings.first).to include('mixin-style is deprecated')
102
+ end
103
+ end
104
+ end
data/spec/spec_helper.rb CHANGED
@@ -1,26 +1,17 @@
1
1
  require 'bundler/setup'
2
2
  Bundler.setup
3
- require 'rspec'
4
3
  require 'pry'
5
4
  require 'rack/test'
6
5
  require 'rack/reducer'
7
- require 'sequel'
8
- require_relative 'behavior'
9
- ENV['RACK_ENV'] = ENV['RAILS_ENV'] = 'test'
10
- DB = Sequel.connect "sqlite://#{__dir__}/fixtures.sqlite"
11
-
12
- SEQUEL_QUERY = {
13
- dataset: DB[:artists],
14
- filters: [
15
- ->(genre:) { grep(:genre, "%#{genre}%", case_insensitive: true) },
16
- ->(name:) { grep(:name, "%#{name}%", case_insensitive: true) },
17
- ->(order: 'genre') { order(order.to_sym) },
18
- ->(releases: ) { where(release_count: releases.to_i) },
19
- ]
20
- }.freeze
21
6
 
22
7
  RSpec.configure do |config|
23
8
  config.color = true
24
9
  config.order = :random
25
10
  config.include Rack::Test::Methods
26
11
  end
12
+
13
+ module SpecRefinements
14
+ refine Rack::MockResponse do
15
+ define_method(:json) { JSON.parse(body) }
16
+ end
17
+ end