diff --git a/README.md b/README.md
index 3328031..bc0d0f4 100644
--- a/README.md
+++ b/README.md
@@ -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
```
@@ -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
```
@@ -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
@@ -51,6 +55,9 @@ Then for each request:
/graduations?featured=true&by_degree=phd
#=> brings featured graduations with phd degree
+
+/graduations?featured=true°ree_info[department]=biology
+#=> brings featured graduations in biology department
```
You can retrieve all the scopes applied in one action with `current_scopes` method.
@@ -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.
@@ -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:
diff --git a/lib/has_scope.rb b/lib/has_scope.rb
index d9e62df..a730a70 100644
--- a/lib/has_scope.rb
+++ b/lib/has_scope.rb
@@ -34,12 +34,14 @@ module ClassMethods
# * :except - In which actions the scope is not applied. By default is :none.
#
# * :as - 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.
#
# * :using - If type is a hash, you can provide :using to convert the hash to
# a named scope call with several arguments.
#
- # * :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, proc or string to call to determine
# if the scope should apply
@@ -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] }
@@ -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
@@ -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
@@ -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
diff --git a/test/has_scope_test.rb b/test/has_scope_test.rb
index eddd7f0..144d473 100644
--- a/test/has_scope_test.rb
+++ b/test/has_scope_test.rb
@@ -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? }
@@ -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