Skip to content
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

Support nested query params #122

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Graduation < ActiveRecord::Base
scope :featured, -> { where(featured: true) }
scope :by_degree, -> degree { where(degree: degree) }
scope :by_period, -> started_at, ended_at { where("started_at = ? AND ended_at = ?", started_at, ended_at) }
scope :by_department, -> department { where(department: department) }
end
```

Expand All @@ -20,6 +21,8 @@ You can use those named scopes as filters by declaring them on your controller:
class GraduationsController < ApplicationController
has_scope :featured, type: :boolean
has_scope :by_degree
has_scope :by_period, using: %i[started_at ended_at], type: :hash
has_scope :by_department, as: [:degree_info, :department]
end
```

Expand All @@ -30,6 +33,7 @@ class GraduationsController < ApplicationController
has_scope :featured, type: :boolean
has_scope :by_degree
has_scope :by_period, using: %i[started_at ended_at], type: :hash
has_scope :by_department, as: [:degree_info, :department]

def index
@graduations = apply_scopes(Graduation).all
Expand All @@ -51,6 +55,9 @@ Then for each request:

/graduations?featured=true&by_degree=phd
#=> brings featured graduations with phd degree

/graduations?featured=true&degree_info[department]=biology
#=> brings featured graduations in biology department
```

You can retrieve all the scopes applied in one action with `current_scopes` method.
Expand Down Expand Up @@ -78,11 +85,11 @@ HasScope supports several options:

* `:except` - In which actions the scope is not applied.

* `:as` - The key in the params hash expected to find the scope. Defaults to the scope name.
* `:as` - The key in the params hash expected to find the scope. Defaults to the scope name. Provide an array to accept nested parameters. If you also provide `:in`, this acts more like `:using` than its own nested array.

* `:using` - The subkeys to be used as args when type is a hash.

* `:in` - A shortcut for combining the `:using` option with nested hashes.
* `:in` - A shortcut for combining the `:using` option with nested hashes. Looks for a query parameter matching the scope name in the provided values. Provide an array for more than one level of nested hashes.

* `:if` - Specifies a method or proc to call to determine if the scope should apply. Passing a string is deprecated and it will be removed in a future version.

Expand Down Expand Up @@ -135,6 +142,49 @@ has_scope :not_voted_by_me, type: :boolean do |controller, scope|
end
```

## Nested query parameters
Use `:in` and `:as` with array arguments to specify nested hashes and arrays in query parameters.

```ruby
class Graduation < ActiveRecord::Base
scope :president, -> president { where(college: { president: president }) }
scope :args_gpa, -> gte, lte { where("gpa > ? AND gpa < ?", gte, lte) }
scope :number_failed_classes, -> n { where(number_failed_classes: n) }
scope :recipient_first_name, -> first_name { where(recipient: { first_name: first_name}) }
end
```

Your controller can look for nested query parameters that use these scopes.
```ruby
class GraduationsController < ApplicationController

# e.g. find graduations where the college president was 'Tsai'
# /graduations?degree_info[college][president]=Tsai
has_scope :president, in: [:degree_info, :college]

# e.g. find graduations where the student's GPA was between 2.5 and 3.5
# /graduations?transcript[gpa][gte]=2.5&transcript[gpa][lte]=3.5
has_scope :args_gpa, in: [:transcript, :gpa], as: [:gte, :lte]

# e.g. find graduations where the student failed 5 classes
# /graduations?transcript[failed_classes]=5
has_scope :number_failed_classes, as: [:transcript, :failed_classes]

# e.g. find graduations where recipient's first name is 'Kelly'
# /graduations?recipient[first_name]=Kelly
has_scope :recipient_first_name, as: [:recipient, :first_name]
end
```

Note that the following are equivalent:
```ruby
# These are all equivalent:
has_scope :president, in: [:degree_info, :college]
has_scope :president, in: [:degree_info, :college], as: [:president]
has_scope :president, as: [:degree_info, :college], using: [:president]
has_scope :president, as: [:degree_info, :college, :president]
```

## Keyword arguments

Scopes with keyword arguments need to be called in a block:
Expand Down
54 changes: 35 additions & 19 deletions lib/has_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ module ClassMethods
# * <tt>:except</tt> - In which actions the scope is not applied. By default is :none.
#
# * <tt>:as</tt> - The key in the params hash expected to find the scope.
# Defaults to the scope name.
# Defaults to the scope name. Provide an array to accept nested parameters.
#
# * <tt>:using</tt> - If type is a hash, you can provide :using to convert the hash to
# a named scope call with several arguments.
#
# * <tt>:in</tt> - A shortcut for combining the `:using` option with nested hashes.
# * <tt>:in</tt> - A shortcut for combining the `:using` option with nested hashes. Looks for a query parameter
# matching the scope name in the provided values. Provide an array for more
# than one level of nested hashes.
#
# * <tt>:if</tt> - Specifies a method, proc or string to call to determine
# if the scope should apply
Expand Down Expand Up @@ -72,8 +74,8 @@ def has_scope(*scopes, &block)
options.assert_valid_keys(:type, :only, :except, :if, :unless, :default, :as, :using, :allow_blank, :in)

if options.key?(:in)
options[:using] = options[:as] || scopes
options[:as] = options[:in]
options[:using] = scopes

if options.key?(:default) && !options[:default].is_a?(Hash)
options[:default] = scopes.each_with_object({}) { |scope, hash| hash[scope] = options[:default] }
Expand All @@ -96,7 +98,9 @@ def has_scope(*scopes, &block)
self.scopes_configuration = scopes_configuration.dup

scopes.each do |scope|
scopes_configuration[scope] ||= { as: scope, type: :default, block: block }
scopes_configuration[scope] ||= {
as: Array(options.delete(:as).presence || scope), type: :default, block: block
}
scopes_configuration[scope] = self.scopes_configuration[scope].merge(options)
end
end
Expand All @@ -118,31 +122,37 @@ def has_scope(*scopes, &block)
def apply_scopes(target, hash = params)
scopes_configuration.each do |scope, options|
next unless apply_scope_to_action?(options)
key = options[:as]

if hash.key?(key)
value, call_scope = hash[key], true
*parent_keys, key = options[:as]

if parent_keys.empty? && hash.key?(key)
value = hash[key]
elsif options.key?(:using) &&
ALLOWED_TYPES[:hash].first.any? { |klass| hash.dig(*parent_keys, key).is_a?(klass) }
value = hash.dig(*parent_keys)[key]
elsif (parent_keys.present? ? hash.dig(*parent_keys) : hash)&.key?(key)
value = (parent_keys.present? ? hash.dig(*parent_keys) : hash)[key]
elsif options.key?(:default)
value, call_scope = options[:default], true
value = options[:default]
if value.is_a?(Proc)
value = value.arity == 0 ? value.call : value.call(self)
end
else
next
end

value = parse_value(options[:type], value)
value = normalize_blanks(value)

if value && options.key?(:using)
value = value.slice(*options[:using])
scope_value = value.values_at(*options[:using])
call_scope &&= scope_value.all?(&:present?) || options[:allow_blank]
else
scope_value = value
call_scope &&= value.present? || options[:allow_blank]
end

if call_scope
current_scopes[key] = value
target = call_scope_by_type(options[:type], scope, target, scope_value, options)
if scope_value.all?(&:present?) || options[:allow_blank]
(current_scopes(parent_keys)[key] ||= {}).merge!(value)
target = call_scope_by_type(options[:type], scope, target, scope_value, options)
end
elsif value.present? || options[:allow_blank]
current_scopes(parent_keys)[key] = value
target = call_scope_by_type(options[:type], scope, target, value, options)
end
end

Expand Down Expand Up @@ -217,8 +227,14 @@ def applicable?(string_proc_or_symbol, expected) #:nodoc:
end

# Returns the scopes used in this action.
def current_scopes
def current_scopes(keys = [])
@current_scopes ||= {}
cs = @current_scopes
keys.each do |k|
cs[k] ||= {}
cs = cs[k]
end
cs
end
end

Expand Down
112 changes: 110 additions & 2 deletions test/has_scope_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,20 @@ class TreesController < ApplicationController
has_scope :paginate_default, type: :hash, default: { page: 1, per_page: 10 }, only: :edit
has_scope :args_paginate, type: :hash, using: [:page, :per_page]
has_scope :args_paginate_blank, using: [:page, :per_page], allow_blank: true
has_scope :nested_args_paginate, as: [:args, :paginate], using: [:page, :per_page]
has_scope :args_paginate_default, using: [:page, :per_page], default: { page: 1, per_page: 10 }, only: :edit
has_scope :categories, type: :array
has_scope :title, in: :q
has_scope :content, in: :q
has_scope :metadata, in: :q
has_scope :user_agent, in: [:q, :headers, :client]
has_scope :metadata_blank, in: :q, allow_blank: true
has_scope :metadata_default, in: :q, default: "default", only: :edit
has_scope :args_time, in: [:log, :time], as: [:before, :after]
has_scope :ip_address, as: [:log, :client, :ip]
has_scope :log_level, as: [:log, :level]
has_scope :debug_mode, as: [:log, :debug, :mode], type: :boolean, default: false
has_scope :largest_size, as: [:sizes, 2, :name]
has_scope :conifer, type: :boolean, allow_blank: true
has_scope :eval_plant, if: "params[:eval_plant].present?", unless: "params[:skip_eval_plant].present?"
has_scope :proc_plant, if: -> c { c.params[:proc_plant].present? }, unless: -> c { c.params[:skip_proc_plant].present? }
Expand Down Expand Up @@ -454,8 +461,109 @@ def test_scope_with_other_block_types
assert_equal({ by_category: 'for' }, current_scopes)
end

def test_scope_with_nested_hash_and_in_option
hash = { 'title' => 'the-title', 'content' => 'the-content' }
def test_scope_with_nested_array
Tree.expects(:largest_size).with('L').returns(Tree)
Tree.expects(:all).returns([mock_tree])

get :index, params: { 'sizes' => [{'name' => 'S'}, {'name' => 'M'}, {'name' => 'L'}] }

assert_equal([mock_tree], assigns(:@trees))
assert_equal({ :sizes => { 2 => {:name => 'L'} } }, current_scopes)
end

def test_nested_with_in_option
hash = { 'title' => 'the-title', 'content' => 'the-content', :headers => { :client => { 'user_agent' => 'the-user-agent'} } }
Tree.expects(:user_agent).with('the-user-agent').returns(Tree)
Tree.expects(:title).with('the-title').returns(Tree)
Tree.expects(:content).with('the-content').returns(Tree)
Tree.expects(:metadata).never
Tree.expects(:metadata_blank).with(nil).returns(Tree)
Tree.expects(:all).returns([mock_tree])

get :index, params: { q: hash }

assert_equal([mock_tree], assigns(:@trees))
assert_equal({ q: hash }, current_scopes)
end

def test_nested_with_as_option
Tree.expects(:ip_address).with('127.0.0.1').returns(Tree)
Tree.expects(:log_level).never
Tree.expects(:all).returns([mock_tree])

get :index, params: { 'log' => { 'client' => { 'ip' => '127.0.0.1', 'unused' => 'jibberish' } } }

assert_equal([mock_tree], assigns(:@trees))
assert_equal({ :log => { :client => { :ip => '127.0.0.1' } } }, current_scopes)
end

def test_nested_as_with_using_option
Tree.expects(:nested_args_paginate).with("1", "10").returns(Tree)
Tree.expects(:all).returns([mock_tree])

get :index, params: {"args": {"paginate": { "page" => "1", "per_page" => "10" } }}

assert_equal([mock_tree], assigns(:@trees))
assert_equal( {"args": {"paginate": { "page" => "1", "per_page" => "10" } }}, current_scopes)
end

def test_nested_with_using_option
Tree.expects(:args_time).with('10am', '9am').returns(Tree)
Tree.expects(:all).returns([mock_tree])

get :index, params: { 'log' => { 'time' => { 'before' => '10am', 'after' => '9am' } } }

assert_equal([mock_tree], assigns(:@trees))
assert_equal({ :log => { :time => { 'before' => '10am', 'after' => '9am' } } }, current_scopes)
end

def test_nested_with_as_option_different_nested_levels
hash = { :log => { :client => { :ip => '127.0.0.1' }, :level => 'info' } }
Tree.expects(:ip_address).with('127.0.0.1').returns(Tree)
Tree.expects(:log_level).with('info').returns(Tree)
Tree.expects(:all).returns([mock_tree])

get :index, params: hash

assert_equal([mock_tree], assigns(:@trees))
assert_equal(hash, current_scopes)
end

def test_nested_with_as_option_different_nested_levels
hash = { :log => { :client => { :ip => '127.0.0.1' }, :level => 'info' } }
Tree.expects(:ip_address).with('127.0.0.1').returns(Tree)
Tree.expects(:log_level).with('info').returns(Tree)
Tree.expects(:all).returns([mock_tree])

get :index, params: hash

assert_equal([mock_tree], assigns(:@trees))
assert_equal(hash, current_scopes)
end

def test_nested_with_boolean_type
Tree.expects(:debug_mode).with().returns(Tree)
Tree.expects(:all).returns([mock_tree])

get :index, params: { :log => { :debug => { :mode => 'true' } } }

assert_equal([mock_tree], assigns(:@trees))
assert_equal({ :log => { :debug => { :mode => true } } }, current_scopes)
end

def test_nested_with_boolean_type_default
Tree.expects(:debug_mode).never
Tree.expects(:all).returns([mock_tree])

get :index, params: { :log => { :debug => { :unused => 'unused' } } }

assert_equal([mock_tree], assigns(:@trees))
assert_equal({ }, current_scopes)
end

def test_nested_with_using_option_array
hash = { 'title' => 'the-title', 'content' => 'the-content', :headers => { :client => { 'user_agent' => 'the-user-agent'} } }
Tree.expects(:user_agent).with('the-user-agent').returns(Tree)
Tree.expects(:title).with('the-title').returns(Tree)
Tree.expects(:content).with('the-content').returns(Tree)
Tree.expects(:metadata).never
Expand Down