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
@@ -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