brita 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +194 -0
- data/Rakefile +37 -0
- data/lib/brita.rb +78 -0
- data/lib/brita/filter.rb +94 -0
- data/lib/brita/filter_validator.rb +67 -0
- data/lib/brita/filtrator.rb +53 -0
- data/lib/brita/parameter.rb +24 -0
- data/lib/brita/scope_handler.rb +25 -0
- data/lib/brita/sort.rb +106 -0
- data/lib/brita/subset_comparator.rb +11 -0
- data/lib/brita/type_validator.rb +48 -0
- data/lib/brita/validators/valid_int_validator.rb +20 -0
- data/lib/brita/version.rb +3 -0
- data/lib/brita/where_handler.rb +11 -0
- data/lib/tasks/filterable_tasks.rake +4 -0
- metadata +130 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: f5bb6a00b2f18b8868b43eb4b22803ed179bc63c
|
4
|
+
data.tar.gz: ff737c18910108bc92517f19f0b7a3fdc14aa0e7
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: fc412bdc2c69093bf76631f2498949c60a7d377640bdbc06bba4d6ae91f77ce97029c9a892aa295361ddd999badc6c63dfff1ebfd69f54e35b0552a4d1c70f10
|
7
|
+
data.tar.gz: 30830b49f18d75cac7d2249fb9a09e671c4caa1b38ecf3f1a73e3b7514cc2f7049b4ebf77e7f240b147a6ea1d5206f60aa49a9fef4ef29190bbbb78874d8cb5f
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2016 Adam Hess
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,194 @@
|
|
1
|
+
# Brita
|
2
|
+
|
3
|
+
[](https://travis-ci.org/procore/brita)
|
4
|
+
|
5
|
+
A tool to build your own filters!
|
6
|
+
|
7
|
+
## Developer Usage
|
8
|
+
Include Brita in your controllers, and define some filters.
|
9
|
+
|
10
|
+
```ruby
|
11
|
+
class PostsController < ApplicationController
|
12
|
+
include Brita
|
13
|
+
|
14
|
+
filter_on :title, type: :string
|
15
|
+
|
16
|
+
def index
|
17
|
+
render json: filtrate(Post.all)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
```
|
21
|
+
|
22
|
+
This will allow users to pass `?filters[title]=foo` and get the `Post`s with the title `foo`.
|
23
|
+
|
24
|
+
Brita will also handle rendering errors using the standard rails errors structure. You can add this to your controller by adding,
|
25
|
+
|
26
|
+
```ruby
|
27
|
+
before_action :render_filter_errors, unless: :filters_valid?
|
28
|
+
|
29
|
+
def render_filter_errors
|
30
|
+
render json: { errors: filter_errors } and return
|
31
|
+
end
|
32
|
+
```
|
33
|
+
|
34
|
+
to your controller.
|
35
|
+
|
36
|
+
These errors are based on the type that you told brita your param was.
|
37
|
+
|
38
|
+
### Filter Types
|
39
|
+
Every filter must have a type, so that Brita knows what to do with it. The current valid filter types are:
|
40
|
+
* int - Filter on an integer column
|
41
|
+
* decimal - Filter on a decimal column
|
42
|
+
* boolean - Filter on a boolean column
|
43
|
+
* string - Filter on a string column
|
44
|
+
* text - Filter on a text column
|
45
|
+
* date - Filter on a date column
|
46
|
+
* time - Filter on a time column
|
47
|
+
* datetime - Filter on a datetime column
|
48
|
+
* scope - Filter on an ActiveRecord scope
|
49
|
+
|
50
|
+
### Filter on Scopes
|
51
|
+
Just as your filter values are used to scope queries on a column, values you
|
52
|
+
pass to a scope filter will be used as arguments to that scope. For example:
|
53
|
+
|
54
|
+
```ruby
|
55
|
+
class Post < ActiveRecord::Base
|
56
|
+
scope :with_body, ->(text) { where(body: text) }
|
57
|
+
end
|
58
|
+
|
59
|
+
class PostsController < ApplicationController
|
60
|
+
include Brita
|
61
|
+
|
62
|
+
filter_on :with_body, type: :scope
|
63
|
+
|
64
|
+
def index
|
65
|
+
render json: filtrate(Post.all)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
```
|
69
|
+
|
70
|
+
Passing `?filters[with_body]=my_text` will call the `with_body` scope with
|
71
|
+
`my_text` as the argument.
|
72
|
+
|
73
|
+
Scopes that accept no arguments are currently not supported.
|
74
|
+
|
75
|
+
#### Accessing Params with Filter Scopes
|
76
|
+
|
77
|
+
Filters with `type: :scope` have access to the params hash by passing in the desired keys to the `scope_params`. The keys passed in will be returned as a hash with their associated values, and should always appear as the last argument in your scope.
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
class Post < ActiveRecord::Base
|
81
|
+
scope :user_posts_on_date, ->(date, options) {
|
82
|
+
where(user_id: options[:user_id], blog_id: options[:blog_id], date: date)
|
83
|
+
}
|
84
|
+
end
|
85
|
+
|
86
|
+
class UsersController < ApplicationController
|
87
|
+
include Brita
|
88
|
+
|
89
|
+
filter_on :user_posts_on_date, type: :scope, scope_params: [:user_id, :blog_id]
|
90
|
+
|
91
|
+
def show
|
92
|
+
render json: filtrate(Post.all)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
```
|
96
|
+
Passing `?filters[user_posts_on_date]=10/12/20` will call the `user_posts_on_date` scope with
|
97
|
+
`10/12/20` as the the first argument, and will grab the `user_id` and `blog_id` out of the params and pass them as a hash, as the second argument.
|
98
|
+
|
99
|
+
### Renaming Filter Params
|
100
|
+
|
101
|
+
A filter param can have a different field name than the column or scope. Use `internal_name` with the correct name of the column or scope.
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
class PostsController < ApplicationController
|
105
|
+
include Brita
|
106
|
+
|
107
|
+
filter_on :post_id, type: :int, internal_name: :id
|
108
|
+
|
109
|
+
end
|
110
|
+
```
|
111
|
+
|
112
|
+
### Sort Types
|
113
|
+
Every sort must have a type, so that Brita knows what to do with it. The current valid sort types are:
|
114
|
+
* int - Sort on an integer column
|
115
|
+
* decimal - Sort on a decimal column
|
116
|
+
* string - Sort on a string column
|
117
|
+
* text - Sort on a text column
|
118
|
+
* date - Sort on a date column
|
119
|
+
* time - Sort on a time column
|
120
|
+
* datetime - Sort on a datetime column
|
121
|
+
* scope - Sort on an ActiveRecord scope
|
122
|
+
|
123
|
+
### Sort on Scopes
|
124
|
+
Just as your sort values are used to scope queries on a column, values you
|
125
|
+
pass to a scope sort will be used as arguments to that scope. For example:
|
126
|
+
|
127
|
+
```ruby
|
128
|
+
class Post < ActiveRecord::Base
|
129
|
+
scope :order_on_body_no_params, -> { order(body: :asc) }
|
130
|
+
scope :order_on_body, ->(direction) { order(body: direction) }
|
131
|
+
scope :order_on_body_then_id, ->(body_direction, id_direction) { order(body: body_direction).order(id: id_direction) }
|
132
|
+
end
|
133
|
+
|
134
|
+
class PostsController < ApplicationController
|
135
|
+
include Brita
|
136
|
+
|
137
|
+
sort_on :order_by_body_ascending, internal_name: :order_on_body_no_params, type: :scope
|
138
|
+
sort_on :order_by_body, internal_name: :order_on_body, type: :scope, scope_params: [:direction]
|
139
|
+
sort_on :order_by_body_then_id, internal_name: :order_on_body_then_id, type: :scope, scope_params: [:direction, :asc]
|
140
|
+
|
141
|
+
|
142
|
+
def index
|
143
|
+
render json: filtrate(Post.all)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
```
|
147
|
+
|
148
|
+
`scope_params` takes an order-specific array of the scope's arguments. Passing in the param :direction allows the consumer to choose which direction to sort in (ex. `-order_by_body` will sort `:desc` while `order_by_body` will sort `:asc`)
|
149
|
+
|
150
|
+
Passing `?sort=-order_by_body` will call the `order_on_body` scope with
|
151
|
+
`:desc` as the argument. The direction is the only argument that the consumer has control over.
|
152
|
+
Passing `?sort=-order_by_body_then_id` will call the `order_on_body_then_id` scope where the `body_direction` is `:desc`, and the `id_direction` is `:asc`. Note: in this example the user has no control over id_direction. To demonstrate:
|
153
|
+
Passing `?sort=order_by_body_then_id` will call the `order_on_body_then_id` scope where the `body_direction` this time is `:asc`, but the `id_direction` remains `:asc`.
|
154
|
+
|
155
|
+
Scopes that accept no arguments are currently supported, but you should note that the user has no say in which direction it will sort on.
|
156
|
+
|
157
|
+
`scope_params` can also accept symbols that are keys in the `params` hash. The value will be fetched and passed on as an argument to the scope.
|
158
|
+
|
159
|
+
|
160
|
+
## Consumer Usage
|
161
|
+
|
162
|
+
Filter:
|
163
|
+
`?filters[<field_name>]=<value>`
|
164
|
+
|
165
|
+
Filters are translated to Active Record `where`s and are chained together. The order they are applied is not guarenteed.
|
166
|
+
|
167
|
+
Sort:
|
168
|
+
`?sort=-published_at,position`
|
169
|
+
|
170
|
+
Sort is translated to Active Record `order` The sorts are applied in the order they are passed by the client.
|
171
|
+
the `-` symbol means to sort in `desc` order. By default, keys are sorted in `asc` order.
|
172
|
+
|
173
|
+
## Installation
|
174
|
+
Add this line to your application's Gemfile:
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
gem 'brita'
|
178
|
+
```
|
179
|
+
|
180
|
+
And then execute:
|
181
|
+
```bash
|
182
|
+
$ bundle
|
183
|
+
```
|
184
|
+
|
185
|
+
Or install it yourself as:
|
186
|
+
```bash
|
187
|
+
$ gem install brita
|
188
|
+
```
|
189
|
+
|
190
|
+
## Contributing
|
191
|
+
Contribution directions go here.
|
192
|
+
|
193
|
+
## License
|
194
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
begin
|
2
|
+
require "bundler/setup"
|
3
|
+
rescue LoadError
|
4
|
+
puts "You must `gem install bundler` and `bundle install` to run rake tasks"
|
5
|
+
end
|
6
|
+
|
7
|
+
require "bundler/gem_tasks"
|
8
|
+
|
9
|
+
APP_RAKEFILE = File.expand_path("../test/dummy/Rakefile", __FILE__)
|
10
|
+
load "rails/tasks/engine.rake"
|
11
|
+
|
12
|
+
require "rdoc/task"
|
13
|
+
|
14
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
15
|
+
rdoc.rdoc_dir = "rdoc"
|
16
|
+
rdoc.title = "Brita"
|
17
|
+
rdoc.options << "--line-numbers"
|
18
|
+
rdoc.rdoc_files.include("README.md")
|
19
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
20
|
+
end
|
21
|
+
|
22
|
+
require "rake/testtask"
|
23
|
+
|
24
|
+
Rake::TestTask.new(:test) do |t|
|
25
|
+
t.libs << "lib"
|
26
|
+
t.libs << "test"
|
27
|
+
t.pattern = "test/**/*_test.rb"
|
28
|
+
t.verbose = false
|
29
|
+
end
|
30
|
+
|
31
|
+
require "rubocop/rake_task"
|
32
|
+
|
33
|
+
RuboCop::RakeTask.new(:rubocop) do |t|
|
34
|
+
t.options = ["--display-cop-names"]
|
35
|
+
end
|
36
|
+
|
37
|
+
task default: [:rubocop, :test]
|
data/lib/brita.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
require "brita/filter"
|
2
|
+
require "brita/filter_validator"
|
3
|
+
require "brita/filtrator"
|
4
|
+
require "brita/sort"
|
5
|
+
require "brita/subset_comparator"
|
6
|
+
require "brita/type_validator"
|
7
|
+
require "brita/parameter"
|
8
|
+
require "brita/scope_handler"
|
9
|
+
require "brita/where_handler"
|
10
|
+
require "brita/validators/valid_int_validator"
|
11
|
+
|
12
|
+
module Brita
|
13
|
+
extend ActiveSupport::Concern
|
14
|
+
|
15
|
+
def filtrate(collection)
|
16
|
+
Filtrator.filter(collection, params, filters)
|
17
|
+
end
|
18
|
+
|
19
|
+
def filter_params
|
20
|
+
params.fetch(:filters, {})
|
21
|
+
end
|
22
|
+
|
23
|
+
def sort_params
|
24
|
+
params.fetch(:sort, "").split(",") if filters.any? { |filter| filter.is_a?(Sort) }
|
25
|
+
end
|
26
|
+
|
27
|
+
def filters_valid?
|
28
|
+
filter_validator.valid?
|
29
|
+
end
|
30
|
+
|
31
|
+
def filter_errors
|
32
|
+
filter_validator.errors.messages
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def filter_validator
|
38
|
+
@_filter_validator ||= FilterValidator.build(
|
39
|
+
filters: filters,
|
40
|
+
sort_fields: self.class.sort_fields,
|
41
|
+
filter_params: filter_params,
|
42
|
+
sort_params: sort_params,
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
def filters
|
47
|
+
self.class.filters
|
48
|
+
end
|
49
|
+
|
50
|
+
def sorts_exist?
|
51
|
+
filters.any? { |filter| filter.is_a?(Sort) }
|
52
|
+
end
|
53
|
+
|
54
|
+
class_methods do
|
55
|
+
def filter_on(parameter, type:, internal_name: parameter, default: nil, validate: nil, scope_params: [])
|
56
|
+
filters << Filter.new(parameter, type, internal_name, default, validate, scope_params)
|
57
|
+
end
|
58
|
+
|
59
|
+
def filters
|
60
|
+
@_filters ||= []
|
61
|
+
end
|
62
|
+
|
63
|
+
# TODO: this is only used in tests, can I kill it?
|
64
|
+
def reset_filters
|
65
|
+
@_filters = []
|
66
|
+
end
|
67
|
+
|
68
|
+
def sort_fields
|
69
|
+
@_sort_fields ||= []
|
70
|
+
end
|
71
|
+
|
72
|
+
def sort_on(parameter, type:, internal_name: parameter, scope_params: [])
|
73
|
+
filters << Sort.new(parameter, type, internal_name, scope_params)
|
74
|
+
sort_fields << parameter.to_s
|
75
|
+
sort_fields << "-#{parameter}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
data/lib/brita/filter.rb
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
module Brita
|
2
|
+
# Filter describes the way a parameter maps to a database column
|
3
|
+
# and the type information helpful for validating input.
|
4
|
+
class Filter
|
5
|
+
attr_reader :parameter, :default, :custom_validate, :scope_params
|
6
|
+
|
7
|
+
def initialize(param, type, internal_name, default, custom_validate = nil, scope_params = [])
|
8
|
+
@parameter = Parameter.new(param, type, internal_name)
|
9
|
+
@default = default
|
10
|
+
@custom_validate = custom_validate
|
11
|
+
@scope_params = scope_params
|
12
|
+
raise ArgumentError, "scope_params must be an array of symbols" unless valid_scope_params?(scope_params)
|
13
|
+
raise "unknown filter type: #{type}" unless type_validator.valid_type?
|
14
|
+
end
|
15
|
+
|
16
|
+
def validation(_)
|
17
|
+
type_validator.validate
|
18
|
+
end
|
19
|
+
|
20
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
21
|
+
def apply!(collection, value:, active_sorts_hash:, params: {})
|
22
|
+
if not_processable?(value)
|
23
|
+
collection
|
24
|
+
elsif should_apply_default?(value)
|
25
|
+
default.call(collection)
|
26
|
+
else
|
27
|
+
handler.call(collection, parameterize(value), params, scope_params)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
31
|
+
|
32
|
+
def always_active?
|
33
|
+
false
|
34
|
+
end
|
35
|
+
|
36
|
+
def validation_field
|
37
|
+
parameter.param
|
38
|
+
end
|
39
|
+
|
40
|
+
def type_validator
|
41
|
+
@type_validator ||= Brita::TypeValidator.new(param, type)
|
42
|
+
end
|
43
|
+
|
44
|
+
def type
|
45
|
+
parameter.type
|
46
|
+
end
|
47
|
+
|
48
|
+
def param
|
49
|
+
parameter.param
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def not_processable?(value)
|
55
|
+
value.nil? && default.nil?
|
56
|
+
end
|
57
|
+
|
58
|
+
def should_apply_default?(value)
|
59
|
+
value.nil? && !default.nil?
|
60
|
+
end
|
61
|
+
|
62
|
+
def mapped_scope_params(params)
|
63
|
+
scope_params.each_with_object({}) do |scope_param, hash|
|
64
|
+
hash[scope_param] = params.fetch(scope_param)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def parameterize(value)
|
69
|
+
if supports_ranges? && value.to_s.include?("...")
|
70
|
+
Range.new(*value.split("..."))
|
71
|
+
elsif type == :boolean
|
72
|
+
if Rails.version.starts_with?("5")
|
73
|
+
ActiveRecord::Type::Boolean.new.cast(value)
|
74
|
+
else
|
75
|
+
ActiveRecord::Type::Boolean.new.type_cast_from_user(value)
|
76
|
+
end
|
77
|
+
else
|
78
|
+
value
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
def valid_scope_params?(scope_params)
|
83
|
+
scope_params.is_a?(Array) && scope_params.all? { |symbol| symbol.is_a?(Symbol) }
|
84
|
+
end
|
85
|
+
|
86
|
+
def handler
|
87
|
+
parameter.handler
|
88
|
+
end
|
89
|
+
|
90
|
+
def supports_ranges?
|
91
|
+
parameter.supports_ranges?
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# Here be dragons:
|
2
|
+
# there are two forms of metaprogramming in this file
|
3
|
+
# instance variables are being dynamically set based on the param name
|
4
|
+
# and we are class evaling `validates` to create dynamic validations
|
5
|
+
# based on the filters being validated.
|
6
|
+
module Brita
|
7
|
+
class FilterValidator
|
8
|
+
include ActiveModel::Validations
|
9
|
+
|
10
|
+
def self.build(filters:, sort_fields:, filter_params:, sort_params:)
|
11
|
+
unique_validations_filters = filters.uniq(&:validation_field)
|
12
|
+
|
13
|
+
klass = Class.new(self) do
|
14
|
+
def self.model_name
|
15
|
+
ActiveModel::Name.new(self, nil, "temp")
|
16
|
+
end
|
17
|
+
|
18
|
+
attr_accessor(*unique_validations_filters.map(&:validation_field))
|
19
|
+
|
20
|
+
unique_validations_filters.each do |filter|
|
21
|
+
if has_custom_validation?(filter, filter_params)
|
22
|
+
validate filter.custom_validate
|
23
|
+
elsif has_validation?(filter, filter_params, sort_fields)
|
24
|
+
validates filter.validation_field.to_sym, filter.validation(sort_fields)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
klass.new(filters, filter_params: filter_params, sort_params: sort_params)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.has_custom_validation?(filter, filter_params)
|
33
|
+
filter_params[filter.validation_field] && filter.custom_validate
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.has_validation?(filter, filter_params, sort_fields)
|
37
|
+
(filter_params[filter.validation_field] && filter.validation(sort_fields)) || filter.validation_field == :sort
|
38
|
+
end
|
39
|
+
|
40
|
+
def initialize(filters, filter_params:, sort_params:)
|
41
|
+
@filter_params = filter_params
|
42
|
+
@sort_params = sort_params
|
43
|
+
|
44
|
+
filters.each do |filter|
|
45
|
+
instance_variable_set("@#{filter.validation_field}", to_type(filter))
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
attr_reader(:filter_params, :sort_params)
|
52
|
+
|
53
|
+
def to_type(filter)
|
54
|
+
if filter.type == :boolean
|
55
|
+
if Rails.version.starts_with?("5")
|
56
|
+
ActiveRecord::Type::Boolean.new.cast(filter_params[filter.param])
|
57
|
+
else
|
58
|
+
ActiveRecord::Type::Boolean.new.type_cast_from_user(filter_params[filter.param])
|
59
|
+
end
|
60
|
+
elsif filter.validation_field == :sort
|
61
|
+
sort_params
|
62
|
+
else
|
63
|
+
filter_params[filter.param]
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Brita
|
2
|
+
# Filtrator takes a collection, params and a set of filters
|
3
|
+
# and applies them to create a new active record collection
|
4
|
+
# with those filters applied.
|
5
|
+
class Filtrator
|
6
|
+
attr_reader :collection, :params, :filters, :sort
|
7
|
+
|
8
|
+
def self.filter(collection, params, filters, sort = [])
|
9
|
+
new(collection, params, sort, filters).filter
|
10
|
+
end
|
11
|
+
|
12
|
+
def initialize(collection, params, _sort, filters = [])
|
13
|
+
@collection = collection
|
14
|
+
@params = params
|
15
|
+
@filters = filters
|
16
|
+
@sort = params.fetch(:sort, "").split(",") if filters.any? { |filter| filter.is_a?(Sort) }
|
17
|
+
end
|
18
|
+
|
19
|
+
def filter
|
20
|
+
active_filters.reduce(collection) do |col, filter|
|
21
|
+
apply(col, filter)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def apply(collection, filter)
|
28
|
+
filter.apply!(collection, value: filter_params[filter.param], active_sorts_hash: active_sorts_hash, params: params)
|
29
|
+
end
|
30
|
+
|
31
|
+
def filter_params
|
32
|
+
params.fetch(:filters, {})
|
33
|
+
end
|
34
|
+
|
35
|
+
def active_sorts_hash
|
36
|
+
active_sorts_hash = {}
|
37
|
+
Array(sort).each do |s|
|
38
|
+
if s.starts_with?("-")
|
39
|
+
active_sorts_hash[s[1..-1].to_sym] = :desc
|
40
|
+
else
|
41
|
+
active_sorts_hash[s.to_sym] = :asc
|
42
|
+
end
|
43
|
+
end
|
44
|
+
active_sorts_hash
|
45
|
+
end
|
46
|
+
|
47
|
+
def active_filters
|
48
|
+
filters.select do |filter|
|
49
|
+
filter_params[filter.param].present? || filter.default || filter.always_active?
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Brita
|
2
|
+
# Value Object that wraps some handling of filter params
|
3
|
+
class Parameter
|
4
|
+
attr_reader :param, :type, :internal_name
|
5
|
+
|
6
|
+
def initialize(param, type, internal_name = param)
|
7
|
+
@param = param
|
8
|
+
@type = type
|
9
|
+
@internal_name = internal_name
|
10
|
+
end
|
11
|
+
|
12
|
+
def supports_ranges?
|
13
|
+
![:string, :text, :scope].include?(type)
|
14
|
+
end
|
15
|
+
|
16
|
+
def handler
|
17
|
+
if type == :scope
|
18
|
+
ScopeHandler.new(self)
|
19
|
+
else
|
20
|
+
WhereHandler.new(self)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Brita
|
2
|
+
class ScopeHandler
|
3
|
+
def initialize(param)
|
4
|
+
@param = param
|
5
|
+
end
|
6
|
+
|
7
|
+
def call(collection, value, params, scope_params)
|
8
|
+
collection.public_send(@param.internal_name, *scope_parameters(value, params, scope_params))
|
9
|
+
end
|
10
|
+
|
11
|
+
def scope_parameters(value, params, scope_params)
|
12
|
+
if scope_params.empty?
|
13
|
+
[value]
|
14
|
+
else
|
15
|
+
[value, mapped_scope_params(params, scope_params)]
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def mapped_scope_params(params, scope_params)
|
20
|
+
scope_params.each_with_object({}) do |scope_param, hash|
|
21
|
+
hash[scope_param] = params.fetch(scope_param)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/brita/sort.rb
ADDED
@@ -0,0 +1,106 @@
|
|
1
|
+
module Brita
|
2
|
+
# Sort provides the same interface as a filter,
|
3
|
+
# but instead of applying a `where` to the collection
|
4
|
+
# it applies an `order`.
|
5
|
+
class Sort
|
6
|
+
attr_reader :parameter, :scope_params
|
7
|
+
|
8
|
+
WHITELIST_TYPES = [:int,
|
9
|
+
:decimal,
|
10
|
+
:string,
|
11
|
+
:text,
|
12
|
+
:date,
|
13
|
+
:time,
|
14
|
+
:datetime,
|
15
|
+
:scope].freeze
|
16
|
+
|
17
|
+
def initialize(param, type, internal_name = param, scope_params = [])
|
18
|
+
raise "unknown filter type: #{type}" unless WHITELIST_TYPES.include?(type)
|
19
|
+
raise "scope params must be an array" unless scope_params.is_a?(Array)
|
20
|
+
@parameter = Parameter.new(param, type, internal_name)
|
21
|
+
@scope_params = scope_params
|
22
|
+
end
|
23
|
+
|
24
|
+
def default
|
25
|
+
# TODO: we can support defaults here later
|
26
|
+
false
|
27
|
+
end
|
28
|
+
|
29
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
30
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
31
|
+
def apply!(collection, value:, active_sorts_hash:, params: {})
|
32
|
+
if type == :scope
|
33
|
+
if active_sorts_hash.keys.include?(param)
|
34
|
+
collection.public_send(internal_name, *mapped_scope_params(active_sorts_hash[param], params))
|
35
|
+
elsif default.present?
|
36
|
+
# Stubbed because currently Brita::Sort does not respect default
|
37
|
+
# default.call(collection)
|
38
|
+
collection
|
39
|
+
else
|
40
|
+
collection
|
41
|
+
end
|
42
|
+
elsif type == :string || type == :text
|
43
|
+
if active_sorts_hash.keys.include?(param)
|
44
|
+
collection.order("LOWER(#{internal_name}) #{individual_sort_hash(active_sorts_hash)[internal_name]}")
|
45
|
+
else
|
46
|
+
collection
|
47
|
+
end
|
48
|
+
else
|
49
|
+
collection.order(individual_sort_hash(active_sorts_hash))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
53
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
54
|
+
|
55
|
+
def always_active?
|
56
|
+
true
|
57
|
+
end
|
58
|
+
|
59
|
+
def validation_field
|
60
|
+
:sort
|
61
|
+
end
|
62
|
+
|
63
|
+
def validation(sort)
|
64
|
+
{
|
65
|
+
inclusion: { in: SubsetComparator.new(sort) },
|
66
|
+
allow_nil: true,
|
67
|
+
}
|
68
|
+
end
|
69
|
+
|
70
|
+
def type
|
71
|
+
parameter.type
|
72
|
+
end
|
73
|
+
|
74
|
+
def param
|
75
|
+
parameter.param
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def mapped_scope_params(direction, params)
|
81
|
+
scope_params.map do |scope_param|
|
82
|
+
if scope_param == :direction
|
83
|
+
direction
|
84
|
+
elsif scope_param.is_a?(Proc)
|
85
|
+
scope_param.call
|
86
|
+
elsif params.include?(scope_param)
|
87
|
+
params[scope_param]
|
88
|
+
else
|
89
|
+
scope_param
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def individual_sort_hash(active_sorts_hash)
|
95
|
+
if active_sorts_hash.include?(param)
|
96
|
+
{ internal_name => active_sorts_hash[param] }
|
97
|
+
else
|
98
|
+
{}
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def internal_name
|
103
|
+
parameter.internal_name
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Brita
|
2
|
+
# TypeValidator validates that the incoming param is of the specified type
|
3
|
+
class TypeValidator
|
4
|
+
RANGE_PATTERN = { format: { with: /\A.+(?:[^.]\.\.\.[^.]).+\z/, message: "must be a range" } }.freeze
|
5
|
+
DECIMAL_PATTERN = { numericality: true, allow_nil: true }.freeze
|
6
|
+
BOOLEAN_PATTERN = { inclusion: { in: [true, false] }, allow_nil: true }.freeze
|
7
|
+
|
8
|
+
WHITELIST_TYPES = [:int,
|
9
|
+
:decimal,
|
10
|
+
:boolean,
|
11
|
+
:string,
|
12
|
+
:text,
|
13
|
+
:date,
|
14
|
+
:time,
|
15
|
+
:datetime,
|
16
|
+
:scope].freeze
|
17
|
+
|
18
|
+
def initialize(param, type)
|
19
|
+
@param = param
|
20
|
+
@type = type
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_reader :param, :type
|
24
|
+
|
25
|
+
def validate
|
26
|
+
case type
|
27
|
+
when :datetime, :date, :time
|
28
|
+
RANGE_PATTERN
|
29
|
+
when :int
|
30
|
+
valid_int?
|
31
|
+
when :decimal
|
32
|
+
DECIMAL_PATTERN
|
33
|
+
when :boolean
|
34
|
+
BOOLEAN_PATTERN
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def valid_type?
|
39
|
+
WHITELIST_TYPES.include?(type)
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def valid_int?
|
45
|
+
{ valid_int: true }
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class ValidIntValidator < ActiveModel::EachValidator
|
2
|
+
def validate_each(record, attribute, value)
|
3
|
+
record.errors.add attribute, (options[:message] || "must be integer, array of integers, or range") unless
|
4
|
+
valid_int?(value)
|
5
|
+
end
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def valid_int?(value)
|
10
|
+
integer_array?(value) || integer_or_range?(value)
|
11
|
+
end
|
12
|
+
|
13
|
+
def integer_array?(value)
|
14
|
+
value.is_a?(Array) && value.any? && value.all? { |v| integer_or_range?(v) }
|
15
|
+
end
|
16
|
+
|
17
|
+
def integer_or_range?(value)
|
18
|
+
!!(/\A\d+(...\d+)?\z/ =~ value.to_s)
|
19
|
+
end
|
20
|
+
end
|
metadata
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: brita
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.9.3
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Procore Technologies
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-01-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 4.2.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 4.2.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: pry
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rubocop
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: sqlite3
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: Easily write arbitrary filters
|
84
|
+
email:
|
85
|
+
- dev@procore.com
|
86
|
+
executables: []
|
87
|
+
extensions: []
|
88
|
+
extra_rdoc_files: []
|
89
|
+
files:
|
90
|
+
- MIT-LICENSE
|
91
|
+
- README.md
|
92
|
+
- Rakefile
|
93
|
+
- lib/brita.rb
|
94
|
+
- lib/brita/filter.rb
|
95
|
+
- lib/brita/filter_validator.rb
|
96
|
+
- lib/brita/filtrator.rb
|
97
|
+
- lib/brita/parameter.rb
|
98
|
+
- lib/brita/scope_handler.rb
|
99
|
+
- lib/brita/sort.rb
|
100
|
+
- lib/brita/subset_comparator.rb
|
101
|
+
- lib/brita/type_validator.rb
|
102
|
+
- lib/brita/validators/valid_int_validator.rb
|
103
|
+
- lib/brita/version.rb
|
104
|
+
- lib/brita/where_handler.rb
|
105
|
+
- lib/tasks/filterable_tasks.rake
|
106
|
+
homepage: https://github.com/procore/brita
|
107
|
+
licenses:
|
108
|
+
- MIT
|
109
|
+
metadata: {}
|
110
|
+
post_install_message:
|
111
|
+
rdoc_options: []
|
112
|
+
require_paths:
|
113
|
+
- lib
|
114
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: 2.3.0
|
119
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - ">="
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '0'
|
124
|
+
requirements: []
|
125
|
+
rubyforge_project:
|
126
|
+
rubygems_version: 2.6.13
|
127
|
+
signing_key:
|
128
|
+
specification_version: 4
|
129
|
+
summary: Summary of Brita.
|
130
|
+
test_files: []
|