procore-sift 0.12.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: edabe4ead843ee8dd551b70d2408899b88dca98d
4
+ data.tar.gz: bbc128cae2da284235591505bfb60a4f7fcc3e93
5
+ SHA512:
6
+ metadata.gz: 5b699d7819a4ab9e29cedc03ccc26fa113e5603abff8c7e700421ecf2eca79bdd25b65c726898ef5e9018b57ca68e1ca4dfb10b7d13589dd71854e6b90d4391f
7
+ data.tar.gz: c5bec1b452aa2bf55192602c0316d367d044c32224b8c77d0db41eb6e2508a4725c325d024711e0e39f66d52a83548ffcab74823d781126555f4bcf45ba098b9
@@ -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.
@@ -0,0 +1,261 @@
1
+ # Sift
2
+
3
+ [![Build Status](https://travis-ci.org/procore/sift.svg?branch=master)](https://travis-ci.org/procore/sift)
4
+
5
+ A tool to build your own filters and sorts with Rails and Active Record!
6
+
7
+ ## Developer Usage
8
+ Include Sift in your controllers, and define some filters.
9
+
10
+ ```ruby
11
+ class PostsController < ApplicationController
12
+ include Sift
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
+ Sift 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 sift your param was.
37
+
38
+ ### Filter Types
39
+ Every filter must have a type, so that Sift 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 Sift
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 Sift
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 Sift
106
+
107
+ filter_on :post_id, type: :int, internal_name: :id
108
+
109
+ end
110
+ ```
111
+
112
+ ### Filter on Ranges
113
+ Some parameter types support ranges. Ranges are expected to be a string with the bounding values separated by `...`
114
+
115
+ For example `?filters[price]=3...50` would return records with a price between 3 and 50.
116
+
117
+ The following types support ranges
118
+ * int
119
+ * decimal
120
+ * boolean
121
+ * date
122
+ * time
123
+ * datetime
124
+
125
+ ### Filter on JSON Array
126
+ `int` type filters support sending the values as an array in the URL Query parameters. For example `?filters[id]=[1,2]`. This is a way to keep payloads smaller for GET requests. When URI encoded this will become `filters%5Bid%5D=%5B1,2%5D` which is much smaller the standard format of `filters%5Bid%5D%5B%5D=1&&filters%5Bid%5D%5B%5D=2`.
127
+
128
+ On the server side, the params will be received as:
129
+ ```ruby
130
+ # JSON array encoded result
131
+ "filters"=>{"id"=>"[1,2]"}
132
+
133
+ # standard array format
134
+ "filters"=>{"id"=>["1", "2"]}
135
+ ```
136
+
137
+ Note that this feature cannot currently be wrapped in an array and should not be used in combination with sending array parameters individually.
138
+ * `?filters[id][]=[1,2]` => invalid
139
+ * `?filters[id][]=[1,2]&filters[id][]=3` => invalid
140
+ * `?filters[id]=[1,2]&filters[id]=3` => valid but only 3 is passed through to the server
141
+ * `?filters[id]=[1,2]` => valid
142
+
143
+ #### A note on encoding for JSON Array feature
144
+ JSON arrays contain the reserved characters "`,`" and "`[`" and "`]`". When encoding a JSON array in the URL there are two different ways to handle the encoding. Both ways are supported by Rails.
145
+ For example, lets look at the following filter with a JSON array `?filters[id]=[1,2]`:
146
+ * `?filters%5Bid%5D=%5B1,2%5D`
147
+ * `?filters%5Bid%5D%3D%5B1%2C2%5D`
148
+
149
+ In both cases Rails will correctly decode to the expected result of
150
+ ```ruby
151
+ { "filters" => { "id" => "[1,2]" } }
152
+ ```
153
+
154
+ ### Sort Types
155
+ Every sort must have a type, so that Sift knows what to do with it. The current valid sort types are:
156
+ * int - Sort on an integer column
157
+ * decimal - Sort on a decimal column
158
+ * string - Sort on a string column
159
+ * text - Sort on a text column
160
+ * date - Sort on a date column
161
+ * time - Sort on a time column
162
+ * datetime - Sort on a datetime column
163
+ * scope - Sort on an ActiveRecord scope
164
+
165
+ ### Sort on Scopes
166
+ Just as your sort values are used to scope queries on a column, values you
167
+ pass to a scope sort will be used as arguments to that scope. For example:
168
+
169
+ ```ruby
170
+ class Post < ActiveRecord::Base
171
+ scope :order_on_body_no_params, -> { order(body: :asc) }
172
+ scope :order_on_body, ->(direction) { order(body: direction) }
173
+ scope :order_on_body_then_id, ->(body_direction, id_direction) { order(body: body_direction).order(id: id_direction) }
174
+ end
175
+
176
+ class PostsController < ApplicationController
177
+ include Sift
178
+
179
+ sort_on :order_by_body_ascending, internal_name: :order_on_body_no_params, type: :scope
180
+ sort_on :order_by_body, internal_name: :order_on_body, type: :scope, scope_params: [:direction]
181
+ sort_on :order_by_body_then_id, internal_name: :order_on_body_then_id, type: :scope, scope_params: [:direction, :asc]
182
+
183
+
184
+ def index
185
+ render json: filtrate(Post.all)
186
+ end
187
+ end
188
+ ```
189
+
190
+ `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`)
191
+
192
+ Passing `?sort=-order_by_body` will call the `order_on_body` scope with
193
+ `:desc` as the argument. The direction is the only argument that the consumer has control over.
194
+ 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:
195
+ 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`.
196
+
197
+ 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.
198
+
199
+ `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.
200
+
201
+
202
+ ## Consumer Usage
203
+
204
+ Filter:
205
+ `?filters[<field_name>]=<value>`
206
+
207
+ Filters are translated to Active Record `where`s and are chained together. The order they are applied is not guarenteed.
208
+
209
+ Sort:
210
+ `?sort=-published_at,position`
211
+
212
+ Sort is translated to Active Record `order` The sorts are applied in the order they are passed by the client.
213
+ the `-` symbol means to sort in `desc` order. By default, keys are sorted in `asc` order.
214
+
215
+ ## Installation
216
+ Add this line to your application's Gemfile:
217
+
218
+ ```ruby
219
+ gem 'procore-sift'
220
+ ```
221
+
222
+ And then execute:
223
+ ```bash
224
+ $ bundle
225
+ ```
226
+
227
+ Or install it yourself as:
228
+ ```bash
229
+ $ gem install procore-sift
230
+ ```
231
+
232
+ ## Without Rails
233
+
234
+ We have some future plans to remove the rails dependency so that other frameworks such as Sinatra could leverage this gem.
235
+
236
+ ## Contributing
237
+
238
+ Running tests:
239
+ ```bash
240
+ $ bundle exec rake test
241
+ ```
242
+
243
+ ## License
244
+
245
+ The gem is available as open source under the terms of the [MIT
246
+ License](http://opensource.org/licenses/MIT).
247
+
248
+ ## About Procore
249
+
250
+ <img
251
+ src="https://www.procore.com/images/procore_logo.png"
252
+ alt="Procore Logo"
253
+ width="250px"
254
+ />
255
+
256
+ The Procore Gem is maintained by Procore Technologies.
257
+
258
+ Procore - building the software that builds the world.
259
+
260
+ Learn more about the #1 most widely used construction management software at
261
+ [procore.com](https://www.procore.com/)
@@ -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", __dir__)
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 = "Sift"
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]
@@ -0,0 +1,80 @@
1
+ require "sift/filter"
2
+ require "sift/filter_validator"
3
+ require "sift/filtrator"
4
+ require "sift/sort"
5
+ require "sift/subset_comparator"
6
+ require "sift/type_validator"
7
+ require "sift/parameter"
8
+ require "sift/value_parser"
9
+ require "sift/scope_handler"
10
+ require "sift/where_handler"
11
+ require "sift/validators/valid_int_validator"
12
+ require "sift/validators/valid_date_range_validator"
13
+
14
+ module Sift
15
+ extend ActiveSupport::Concern
16
+
17
+ def filtrate(collection)
18
+ Filtrator.filter(collection, params, filters)
19
+ end
20
+
21
+ def filter_params
22
+ params.fetch(:filters, {})
23
+ end
24
+
25
+ def sort_params
26
+ params.fetch(:sort, "").split(",") if filters.any? { |filter| filter.is_a?(Sort) }
27
+ end
28
+
29
+ def filters_valid?
30
+ filter_validator.valid?
31
+ end
32
+
33
+ def filter_errors
34
+ filter_validator.errors.messages
35
+ end
36
+
37
+ private
38
+
39
+ def filter_validator
40
+ @_filter_validator ||= FilterValidator.build(
41
+ filters: filters,
42
+ sort_fields: self.class.sort_fields,
43
+ filter_params: filter_params,
44
+ sort_params: sort_params,
45
+ )
46
+ end
47
+
48
+ def filters
49
+ self.class.filters
50
+ end
51
+
52
+ def sorts_exist?
53
+ filters.any? { |filter| filter.is_a?(Sort) }
54
+ end
55
+
56
+ class_methods do
57
+ def filter_on(parameter, type:, internal_name: parameter, default: nil, validate: nil, scope_params: [])
58
+ filters << Filter.new(parameter, type, internal_name, default, validate, scope_params)
59
+ end
60
+
61
+ def filters
62
+ @_filters ||= []
63
+ end
64
+
65
+ # TODO: this is only used in tests, can I kill it?
66
+ def reset_filters
67
+ @_filters = []
68
+ end
69
+
70
+ def sort_fields
71
+ @_sort_fields ||= []
72
+ end
73
+
74
+ def sort_on(parameter, type:, internal_name: parameter, scope_params: [])
75
+ filters << Sort.new(parameter, type, internal_name, scope_params)
76
+ sort_fields << parameter.to_s
77
+ sort_fields << "-#{parameter}"
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,84 @@
1
+ module Sift
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(_sort)
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 ||= Sift::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 parameterize(value)
55
+ ValueParser.new(value: value, type: parameter.type, options: parameter.parse_options).parse
56
+ end
57
+
58
+ def not_processable?(value)
59
+ value.nil? && default.nil?
60
+ end
61
+
62
+ def should_apply_default?(value)
63
+ value.nil? && !default.nil?
64
+ end
65
+
66
+ def mapped_scope_params(params)
67
+ scope_params.each_with_object({}) do |scope_param, hash|
68
+ hash[scope_param] = params.fetch(scope_param)
69
+ end
70
+ end
71
+
72
+ def valid_scope_params?(scope_params)
73
+ scope_params.is_a?(Array) && scope_params.all? { |symbol| symbol.is_a?(Symbol) }
74
+ end
75
+
76
+ def handler
77
+ parameter.handler
78
+ end
79
+
80
+ def supports_ranges?
81
+ parameter.supports_ranges?
82
+ end
83
+ end
84
+ 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 Sift
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 Sift
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,42 @@
1
+ module Sift
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 parse_options
13
+ {
14
+ supports_boolean: supports_boolean?,
15
+ supports_ranges: supports_ranges?,
16
+ supports_json: supports_json?
17
+ }
18
+ end
19
+
20
+ def handler
21
+ if type == :scope
22
+ ScopeHandler.new(self)
23
+ else
24
+ WhereHandler.new(self)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def supports_ranges?
31
+ ![:string, :text, :scope].include?(type)
32
+ end
33
+
34
+ def supports_json?
35
+ type == :int
36
+ end
37
+
38
+ def supports_boolean?
39
+ type == :boolean
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ module Sift
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
@@ -0,0 +1,106 @@
1
+ module Sift
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 Sift::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,11 @@
1
+ module Sift
2
+ class SubsetComparator
3
+ def initialize(array)
4
+ @array = array
5
+ end
6
+
7
+ def include?(other)
8
+ @array.to_set >= other.to_set
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,48 @@
1
+ module Sift
2
+ # TypeValidator validates that the incoming param is of the specified type
3
+ class TypeValidator
4
+ DATETIME_RANGE_PATTERN = { format: { with: /\A.+(?:[^.]\.\.\.[^.]).+\z/, message: "must be a range" }, valid_date_range: true }.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
+ DATETIME_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 ValidDateRangeValidator < ActiveModel::EachValidator
2
+ def validate_each(record, attribute, value)
3
+ record.errors.add attribute, "is invalid" unless valid_date_range?(value)
4
+ end
5
+
6
+ private
7
+
8
+ def valid_date_range?(date_range)
9
+ from_date_string, end_date_string = date_range.to_s.split("...")
10
+ return true unless end_date_string # validated by other validator
11
+
12
+ [from_date_string, end_date_string].all? { |date| valid_date?(date) }
13
+ end
14
+
15
+ def valid_date?(date)
16
+ !!DateTime.parse(date.to_s)
17
+ rescue ArgumentError
18
+ false
19
+ end
20
+ end
@@ -0,0 +1,24 @@
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
+ if value.is_a?(String)
15
+ value = Sift::ValueParser.new(value: value).array_from_json
16
+ end
17
+
18
+ value.is_a?(Array) && value.any? && value.all? { |v| integer_or_range?(v) }
19
+ end
20
+
21
+ def integer_or_range?(value)
22
+ !!(/\A\d+(...\d+)?\z/ =~ value.to_s)
23
+ end
24
+ end
@@ -0,0 +1,86 @@
1
+ module Sift
2
+ class ValueParser
3
+ def initialize(value:, type: nil, options: {})
4
+ @value = value
5
+ @supports_boolean = options.fetch(:supports_boolean, false)
6
+ @supports_ranges = options.fetch(:supports_ranges, false)
7
+ @supports_json = options.fetch(:supports_json, false)
8
+ @value = normalized_value(value, type)
9
+ end
10
+
11
+ def parse
12
+ @_result ||=
13
+ if parse_as_range?
14
+ range_value
15
+ elsif parse_as_boolean?
16
+ boolean_value
17
+ elsif parse_as_json?
18
+ array_from_json
19
+ else
20
+ value
21
+ end
22
+ end
23
+
24
+ def array_from_json
25
+ result = JSON.parse(value)
26
+ if result.is_a?(Array)
27
+ result
28
+ else
29
+ value
30
+ end
31
+ rescue JSON::ParserError
32
+ value
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :value, :type, :supports_boolean, :supports_json, :supports_ranges
38
+
39
+ def parse_as_range?(raw_value=value)
40
+ supports_ranges && raw_value.to_s.include?("...")
41
+ end
42
+
43
+ def range_value
44
+ Range.new(*value.split("..."))
45
+ end
46
+
47
+ def parse_as_json?
48
+ supports_json && value.is_a?(String)
49
+ end
50
+
51
+ def parse_as_boolean?
52
+ supports_boolean
53
+ end
54
+
55
+ def boolean_value
56
+ if Rails.version.starts_with?("5")
57
+ ActiveRecord::Type::Boolean.new.cast(value)
58
+ else
59
+ ActiveRecord::Type::Boolean.new.type_cast_from_user(value)
60
+ end
61
+ end
62
+
63
+ def normalized_value(raw_value, type)
64
+ if type == :datetime && parse_as_range?(raw_value)
65
+ normalized_date_range(raw_value)
66
+ else
67
+ raw_value
68
+ end
69
+ end
70
+
71
+ def normalized_date_range(raw_value)
72
+ from_date_string, end_date_string = raw_value.split("...")
73
+ return unless end_date_string
74
+
75
+ parsed_dates = [from_date_string, end_date_string].map do |date_string|
76
+ begin
77
+ DateTime.parse(date_string.to_s)
78
+ rescue StandardError
79
+ date_string
80
+ end
81
+ end
82
+
83
+ parsed_dates.join("...")
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,3 @@
1
+ module Sift
2
+ VERSION = "0.12.0".freeze
3
+ end
@@ -0,0 +1,11 @@
1
+ module Sift
2
+ class WhereHandler
3
+ def initialize(param)
4
+ @param = param
5
+ end
6
+
7
+ def call(collection, value, _params, _scope_params)
8
+ collection.where(@param.internal_name => value)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :sift do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: procore-sift
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.12.0
5
+ platform: ruby
6
+ authors:
7
+ - Procore Technologies
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2019-06-20 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: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '5.1'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '5.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
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: rubocop
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
+ - !ruby/object:Gem::Dependency
84
+ name: sqlite3
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description: Easily write arbitrary filters
98
+ email:
99
+ - dev@procore.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - MIT-LICENSE
105
+ - README.md
106
+ - Rakefile
107
+ - lib/procore-sift.rb
108
+ - lib/sift/filter.rb
109
+ - lib/sift/filter_validator.rb
110
+ - lib/sift/filtrator.rb
111
+ - lib/sift/parameter.rb
112
+ - lib/sift/scope_handler.rb
113
+ - lib/sift/sort.rb
114
+ - lib/sift/subset_comparator.rb
115
+ - lib/sift/type_validator.rb
116
+ - lib/sift/validators/valid_date_range_validator.rb
117
+ - lib/sift/validators/valid_int_validator.rb
118
+ - lib/sift/value_parser.rb
119
+ - lib/sift/version.rb
120
+ - lib/sift/where_handler.rb
121
+ - lib/tasks/filterable_tasks.rake
122
+ homepage: https://github.com/procore/sift
123
+ licenses:
124
+ - MIT
125
+ metadata: {}
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - ">="
133
+ - !ruby/object:Gem::Version
134
+ version: 2.3.0
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
140
+ requirements: []
141
+ rubyforge_project:
142
+ rubygems_version: 2.6.14
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: Summary of Sift.
146
+ test_files: []