-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Sift supports specifying types of scope arguments #48
base: master
Are you sure you want to change the base?
Changes from all commits
79ccd3b
cd0c05c
32b8368
5bd2dd5
f79299c
4016e7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -96,6 +96,63 @@ end | |
Passing `?filters[user_posts_on_date]=10/12/20` will call the `user_posts_on_date` scope with | ||
`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. | ||
|
||
### Scope Arguments with Types | ||
|
||
Sometimes you need the argument to a scope to be parsed using Sift types. Scope argument types can be specified by passing a hash to the `type:` keyword, | ||
as follows: | ||
|
||
```ruby | ||
class Post < ActiveRecord::Base | ||
scope :with_priority, ->(int_array) { where(priority: int_array) } | ||
end | ||
|
||
class PostsController < ApplicationController | ||
include Sift | ||
|
||
filter_on :with_priority, type: { scope: :int } | ||
|
||
def index | ||
render json: filtrate(Post.all) | ||
end | ||
end | ||
``` | ||
Passing `?filters[with_priority]=[1,2]` will call the `with_priority` scope with the array, `[1, 2]`, instead of `"[1,2]"` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There doesn't seem to be much consensus over how arrays should be used in query parameters. But There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sift uses the JSON standard for array parameters. My(unpopular?) opinion is that we should use either the Rails standard There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do think this PR is not affected by that decision, however. We could change this code to parse however we want: https://github.com/procore/sift/blob/master/lib/sift/value_parser.rb#L24-L33 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a fan of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @njbbaer So you can POST with a body of
This requires no change to Vapid; it's supported by Rails out-of-the-box. |
||
`filters[with_priority]` can also be validated with the `:int` type using `filters_valid?` (see below). | ||
|
||
Types can also be specified for `scope_params` like this: | ||
|
||
```ruby | ||
class Post < ActiveRecord::Base | ||
scope :user_posts_on_date, ->(date, options) { | ||
where(user_id: options[:user_id], blog_id: options[:blog_id], date: date) | ||
} | ||
end | ||
|
||
class UsersController < ApplicationController | ||
include Sift | ||
|
||
filter_on :user_posts_on_date, | ||
type: { | ||
scope: [ | ||
:datetime, | ||
{ blog_id: :int } | ||
] | ||
}, | ||
scope_params: [:user_id, :blog_id] | ||
|
||
def show | ||
render json: filtrate(Post.all) | ||
end | ||
end | ||
``` | ||
Passing `?filters[user_posts_on_date]=2010-12-20&user_id=3blog_id=[4,5]` will result in the filter | ||
`Post.all.user_posts_on_date(DateTime.parse('2010-12-20'), user_id: "3", blog_id: [4, 5])`. | ||
Unfortunately, validation with `filters_valid?` is not yet supported on params used by scope options. | ||
|
||
Note that if `scope_params` is omitted it will default to the keys provided the scope option types. | ||
For example, `filter_on :foo, type: {scope: [:int, { bar: :int, baz: :int }]}` implicitly defines `scope_params` | ||
of `[:bar, :baz]` | ||
|
||
### Renaming Filter Params | ||
|
||
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. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,14 +2,16 @@ module Sift | |
# Filter describes the way a parameter maps to a database column | ||
# and the type information helpful for validating input. | ||
class Filter | ||
attr_reader :parameter, :default, :custom_validate, :scope_params | ||
attr_reader :parameter, :default, :custom_validate | ||
|
||
def initialize(param, type, internal_name, default, custom_validate = nil, scope_params = []) | ||
def self.scope_type?(type) | ||
type == :scope || type.respond_to?(:key?) | ||
end | ||
|
||
def initialize(param, type, internal_name, default, custom_validate = nil) | ||
@parameter = Parameter.new(param, type, internal_name) | ||
@default = default | ||
@custom_validate = custom_validate | ||
@scope_params = scope_params | ||
raise ArgumentError, "scope_params must be an array of symbols" unless valid_scope_params?(scope_params) | ||
raise "unknown filter type: #{type}" unless type_validator.valid_type? | ||
end | ||
|
||
|
@@ -21,10 +23,8 @@ def validation(_sort) | |
def apply!(collection, value:, active_sorts_hash:, params: {}) | ||
if not_processable?(value) | ||
collection | ||
elsif should_apply_default?(value) | ||
default.call(collection) | ||
else | ||
handler.call(collection, parameterize(value), params, scope_params) | ||
default_or_handler(value, params).call(collection) | ||
end | ||
end | ||
# rubocop:enable Lint/UnusedMethodArgument | ||
|
@@ -49,10 +49,21 @@ def param | |
parameter.param | ||
end | ||
|
||
protected | ||
|
||
# Subclasses should override. Default behavior is to return none | ||
def handler(_value, _params) | ||
->(collection) { collection.none } | ||
end | ||
|
||
private | ||
|
||
def parameterize(value) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The handlers take over responsibility for parsing the raw values from the request. This allows the scope handler to use its types to choose the right parsing strategy. |
||
ValueParser.new(value: value, type: parameter.type, options: parameter.parse_options).parse | ||
def default_or_handler(value, params) | ||
if should_apply_default?(value) | ||
default | ||
else | ||
handler(value, params) | ||
end | ||
end | ||
|
||
def not_processable?(value) | ||
|
@@ -63,22 +74,12 @@ def should_apply_default?(value) | |
value.nil? && !default.nil? | ||
end | ||
|
||
def mapped_scope_params(params) | ||
scope_params.each_with_object({}) do |scope_param, hash| | ||
hash[scope_param] = params.fetch(scope_param) | ||
def validation_type | ||
if type != :scope || scope_types.empty? | ||
type | ||
else | ||
scope_types.first | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only a single scope type is allowed right? If thats the case should the argument be changed to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another thought is that scope_type param is unrelated to other filters. Should we create a different filter class to have better argument cohesion for scopes and so that way we aren't complicating other filters with logic specific to scopes? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. scope_types can have something like [:int, {foo: :datetime}] or [:int]. I can definitely see making some kind of inheritance / mixin relationship between new classes WhereFilter and ScopeFilter. I think they both support the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you think of this @amayer171 ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. merged... |
||
end | ||
end | ||
|
||
def valid_scope_params?(scope_params) | ||
scope_params.is_a?(Array) && scope_params.all? { |symbol| symbol.is_a?(Symbol) } | ||
end | ||
|
||
def handler | ||
parameter.handler | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can't have Parameter be responsible for the handler, because scope filters need more than just the "chief" parameter to handle. |
||
end | ||
|
||
def supports_ranges? | ||
parameter.supports_ranges? | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
# frozen_string_literal: true | ||
|
||
module Sift | ||
class Filter | ||
class Scope < Sift::Filter | ||
attr_reader :scope_params, :scope_types | ||
|
||
def initialize(param, raw_type, internal_name, default, custom_validate = nil, scope_params = []) | ||
super(param, :scope, internal_name, default, custom_validate) | ||
@scope_types = [] | ||
@scope_params = scope_params || [] | ||
raise ArgumentError, "scope_params must be an array of symbols" unless valid_scope_params?(scope_params) | ||
|
||
if raw_type.respond_to?(:key?) | ||
@scope_types = Array(raw_type[:scope]).compact | ||
validate_scope_types! | ||
|
||
@type_validator = Sift::TypeValidator.new(param, @scope_types.first || :scope) | ||
end | ||
end | ||
|
||
protected | ||
|
||
def handler(value, params) | ||
Sift::ScopeHandler.new(value, mapped_scope_params(params), parameter, scope_types) | ||
end | ||
|
||
private | ||
|
||
def mapped_scope_params(params) | ||
scope_params.each_with_object({}) do |scope_param, hash| | ||
hash[scope_param] = params.fetch(scope_param) | ||
end | ||
end | ||
|
||
def valid_scope_params?(scope_params) | ||
scope_params.is_a?(Array) && scope_params.all? { |symbol| symbol.is_a?(Symbol) } | ||
end | ||
|
||
def validate_scope_types! | ||
return if scope_types.empty? | ||
|
||
unless Sift::TypeValidator.new(parameter.param, scope_types.first).valid_type? | ||
raise ArgumentError, "scope_types must contain a valid filter type for the scope parameter" | ||
end | ||
return if scope_types.size == 1 | ||
|
||
if scope_types.size > 2 || !valid_scope_option_types!(scope_types[1]) | ||
raise ArgumentError, "type: scope: expected to have this structure: [type, {#{scope_params.map { |sp| "#{sp}: type" }.join(', ')}}]" | ||
end | ||
end | ||
|
||
def valid_scope_option_types!(hash) | ||
valid_form = hash.respond_to?(:keys) && hash.all? { |key, value| Sift::TypeValidator.new(key, value).valid_type? } | ||
if valid_form && scope_params.empty? | ||
# default scope_params | ||
@scope_params = hash.keys | ||
end | ||
valid_form && (hash.keys - scope_params).empty? | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
# frozen_string_literal: true | ||
|
||
module Sift | ||
class Filter | ||
class Where < Sift::Filter | ||
protected | ||
|
||
def handler(value, _params) | ||
Sift::WhereHandler.new(value, parameter) | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,35 +8,5 @@ def initialize(param, type, internal_name = param) | |
@type = type | ||
@internal_name = internal_name | ||
end | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Parameter loses responsibility for parsing values because we want to parse things other than just the "chief" parameter. |
||
def parse_options | ||
{ | ||
supports_boolean: supports_boolean?, | ||
supports_ranges: supports_ranges?, | ||
supports_json: supports_json? | ||
} | ||
end | ||
|
||
def handler | ||
if type == :scope | ||
ScopeHandler.new(self) | ||
else | ||
WhereHandler.new(self) | ||
end | ||
end | ||
|
||
private | ||
|
||
def supports_ranges? | ||
![:string, :text, :scope].include?(type) | ||
end | ||
|
||
def supports_json? | ||
type == :int | ||
end | ||
|
||
def supports_boolean? | ||
type == :boolean | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,8 @@ | ||
module Sift | ||
class ValueParser | ||
def initialize(value:, type: nil, options: {}) | ||
@value = value | ||
@supports_boolean = options.fetch(:supports_boolean, false) | ||
@supports_ranges = options.fetch(:supports_ranges, false) | ||
@supports_json = options.fetch(:supports_json, false) | ||
def initialize(value:, type: nil) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ValueParser can be responsible for determining supports_boolean, supports_ranges, and supports_json from the type, so it doesn't need to be passed in. This helps us parse values for things other than the "chief" parameter. |
||
@value = normalized_value(value, type) | ||
@type = type | ||
end | ||
|
||
def parse | ||
|
@@ -34,22 +31,34 @@ def array_from_json | |
|
||
private | ||
|
||
attr_reader :value, :type, :supports_boolean, :supports_json, :supports_ranges | ||
def supports_ranges? | ||
![:string, :text, :scope].include?(type) | ||
end | ||
|
||
def supports_json? | ||
type == :int | ||
end | ||
|
||
def supports_boolean? | ||
type == :boolean | ||
end | ||
|
||
attr_reader :value, :type | ||
|
||
def parse_as_range?(raw_value=value) | ||
supports_ranges && raw_value.to_s.include?("...") | ||
def parse_as_range?(raw_value = value) | ||
supports_ranges? && raw_value.to_s.include?("...") | ||
end | ||
|
||
def range_value | ||
Range.new(*value.split("...")) | ||
end | ||
|
||
def parse_as_json? | ||
supports_json && value.is_a?(String) | ||
supports_json? && value.is_a?(String) | ||
end | ||
|
||
def parse_as_boolean? | ||
supports_boolean | ||
supports_boolean? | ||
end | ||
|
||
def boolean_value | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,3 @@ | ||
module Sift | ||
VERSION = "0.13.0".freeze | ||
VERSION = "0.14.0".freeze | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm bumping the minor here because if anything is using the Sift internals (Sift::ValueParser, Sift::Parameter) it could be broken. |
||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was driving me nuts, but I'm definitely willing to revert this.