procore-sift 0.12.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.
@@ -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: []