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