Class: Lupa::Search

Inherits:
Object
  • Object
show all
Defined in:
lib/lupa/search.rb

Overview

Base class for creating search filters using object-oriented design patterns.

Lupa::Search provides a structured way to build complex search functionality by defining search methods in a nested Scope class. Each search attribute maps to a method in the Scope class, allowing for clean, testable, and maintainable search logic.

Basic Structure

To create a search class:

  1. Inherit from ‘Lupa::Search`

  2. Define a nested ‘Scope` class

  3. Implement search methods in the Scope class

  4. Optionally define ‘default_search_attributes`

Features

  • Framework and ORM agnostic

  • Works with any object that supports method chaining (ActiveRecord, Array, etc.)

  • Automatic attribute symbolization and blank value filtering

  • Support for nested hash attributes

  • Default search attributes and default scope

  • Search class composition for reusability

  • Method delegation to search results

Examples:

Basic search class

class ProductSearch < Lupa::Search
  class Scope
    def name
      scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
    end

    def category
      scope.where(category_id: search_attributes[:category])
    end

    def in_stock
      scope.where(in_stock: search_attributes[:in_stock])
    end
  end
end

# Usage
search = ProductSearch.new(Product.all).search(name: 'chair', category: '23')
search.results  # => ActiveRecord::Relation
search.first    # => Product instance (delegates to results)
search.count    # => 5 (delegates to results)

With default scope

class ProductSearch < Lupa::Search
  class Scope
    def name
      scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
    end
  end

  def initialize(scope = Product.where(active: true))
    @scope = scope
  end
end

# Can use class method without passing scope
search = ProductSearch.search(name: 'chair')

With default search attributes

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end

    def in_stock
      scope.where(in_stock: search_attributes[:in_stock])
    end
  end

  def default_search_attributes
    { in_stock: true }
  end
end

# in_stock will always be applied unless overridden
search = ProductSearch.new(Product.all).search(category: '23')
search.search_attributes  # => { category: '23', in_stock: true }

Composing search classes

class DateRangeSearch < Lupa::Search
  class Scope
    def created_between
      return scope unless start_date && end_date
      scope.where(created_at: start_date..end_date)
    end

    private
      def start_date
        search_attributes[:created_between][:start_date]&.to_date
      end

      def end_date
        search_attributes[:created_between][:end_date]&.to_date
      end
  end
end

class ProductSearch < Lupa::Search
  class Scope
    def name
      scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
    end

    def created_between
      DateRangeSearch.new(scope)
        .search(created_between: search_attributes[:created_between])
        .results
    end
  end
end

Searching arrays

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

class NumberSearch < Lupa::Search
  class Scope
    def even
      return scope unless search_attributes[:even]
      scope.select(&:even?)
    end

    def greater_than
      return scope unless search_attributes[:greater_than]
      scope.select { |n| n > search_attributes[:greater_than] }
    end
  end
end

search = NumberSearch.new(numbers).search(even: true, greater_than: 5)
search.results  # => [6, 8, 10]

Author:

  • Ezequiel Delpero

Since:

  • 0.1.0

Defined Under Namespace

Classes: Scope

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(scope) ⇒ Lupa::Search

Creates a new search instance with the given scope.

The scope can be any object that supports method chaining, such as an ActiveRecord::Relation, Mongoid::Criteria, or even a plain Ruby Array.

Examples:

With ActiveRecord

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end
  end
end

products = Product.where(price: 20..30)
search = ProductSearch.new(products)

With a scoped relation

active_products = Product.where(active: true).includes(:category)
search = ProductSearch.new(active_products).search(name: 'chair')

With arrays

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
search = NumberSearch.new(numbers).search(even: true)

Defining a default scope

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end
  end

  def initialize(scope = Product.where(active: true))
    @scope = scope
  end
end

# Can now use without passing a scope
search = ProductSearch.search(category: '23')

Parameters:

  • scope (Object)

    the object to perform search operations on

Since:

  • 0.1.0



276
277
278
# File 'lib/lupa/search.rb', line 276

def initialize(scope)
  @scope = scope
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(method_sym, *arguments, &block) ⇒ Object

Note:

This is what allows search objects to be used directly in views without explicitly calling ‘.results` first

Delegates method calls to the search results.

This allows you to call any method that the results object responds to directly on the search instance, making the search object behave like the results collection.

Examples:

Delegating Array/Relation methods

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end
  end

  def initialize(scope = Product.all)
    @scope = scope
  end
end

search = ProductSearch.search(category: 'furniture')

# All these methods are delegated to results
search.first     # => #<Product:0x007f9c0ce1b1a8>
search.count     # => 42
search.empty?    # => false
search.pluck(:name)  # => ["Chair", "Table", ...]

Iterating with blocks

search = ProductSearch.search(category: 'furniture')

search.each do |product|
  puts product.name
end

search.map(&:name)  # => ["Chair", "Table", ...]

Error when method doesn’t exist

search = ProductSearch.search(category: 'furniture')
search.non_existent_method
# => Lupa::ResultMethodNotImplementedError: The resulting scope does not respond to non_existent_method method.

Chaining delegated methods

search = ProductSearch.search(category: 'furniture')
search.limit(10).offset(20).to_a
# All these methods are delegated to the results

Parameters:

  • method_sym (Symbol)

    the method name to delegate

  • arguments (Array)

    arguments to pass to the delegated method

  • block (Proc)

    block to pass to the delegated method

Returns:

  • (Object)

    the return value of the delegated method

Raises:

Since:

  • 0.1.0



630
631
632
633
634
635
636
# File 'lib/lupa/search.rb', line 630

def method_missing(method_sym, *arguments, &block)
  if results.respond_to?(method_sym)
    results.send(method_sym, *arguments, &block)
  else
    raise Lupa::ResultMethodNotImplementedError, "The resulting scope does not respond to #{method_sym} method."
  end
end

Instance Attribute Details

#scopeObject (readonly)

Note:

This returns the original scope, not the filtered results. Use ‘results` to get the filtered scope after search methods are applied.

Returns the original scope object passed to the search class. This is the base scope that all search methods will operate on.

Examples:

Getting the scope

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category: search_attributes[:category])
    end
  end
end

products = Product.where(active: true)
search = ProductSearch.new(products).search(category: 'furniture')
search.scope  # => #<Product::ActiveRecord_Relation [...]> (original products scope)

With arrays

numbers = [1, 2, 3, 4, 5]
search = NumberSearch.new(numbers).search(even: true)
search.scope  # => [1, 2, 3, 4, 5] (original array)

Returns:

  • (Object)

    the original scope object (e.g., ActiveRecord::Relation, Array)

Since:

  • 0.1.0



181
182
183
# File 'lib/lupa/search.rb', line 181

def scope
  @scope
end

#search_attributesHash (readonly)

Returns all search attributes including default search attributes. All keys are automatically symbolized and blank values are removed.

Examples:

Basic usage

class ProductSearch < Lupa::Search
  class Scope
    def name
      scope.where(name: search_attributes[:name])
    end
  end
end

search = ProductSearch.new(Product.all).search('name' => 'chair')
search.search_attributes  # => { name: 'chair' }

With default search attributes

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end

    def in_stock
      scope.where(in_stock: search_attributes[:in_stock])
    end
  end

  def default_search_attributes
    { in_stock: true }
  end
end

search = ProductSearch.new(Product.all).search(category: 'furniture')
search.search_attributes  # => { category: 'furniture', in_stock: true }

Blank values are removed

search = ProductSearch.new(Product.all).search(name: 'chair', category: '')
search.search_attributes  # => { name: 'chair' }

Nested hash attributes

search = ProductSearch.new(Product.all).search(
  created_between: { start_date: '2023-01-01', end_date: '2023-12-31' }
)
search.search_attributes
# => { created_between: { start_date: '2023-01-01', end_date: '2023-12-31' } }

Returns:

  • (Hash)

    the search attributes hash with symbolized keys

Since:

  • 0.1.0



230
231
232
# File 'lib/lupa/search.rb', line 230

def search_attributes
  @search_attributes
end

Class Method Details

.search(attributes) ⇒ Lupa::Search

Note:

If you need to pass a custom scope, use ‘ProductSearch.new(scope).search(attributes)` instead

Class method to create a new search instance and perform a search in one call.

This is a convenience method that creates a new instance without a scope parameter and then calls the instance ‘search` method. This only works if you’ve defined a default scope in your ‘initialize` method.

Examples:

With default scope defined

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end

    def name
      scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
    end
  end

  def initialize(scope = Product.where(active: true))
    @scope = scope
  end
end

# Can use the class method
search = ProductSearch.search(category: 'furniture', name: 'chair')
search.results  # => filtered products

Without default scope (will raise error)

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end
  end
  # No default scope in initialize
end

ProductSearch.search(category: 'furniture')
# => Lupa::DefaultScopeError: You need to define a default scope in order to user search class method.

Chaining with results

products = ProductSearch.search(category: 'furniture').results
products.each { |p| puts p.name }

Using delegation

# These all work because of method delegation to results
ProductSearch.search(category: 'furniture').first
ProductSearch.search(category: 'furniture').count
ProductSearch.search(category: 'furniture').each { |p| puts p.name }

Parameters:

  • attributes (Hash)

    the search parameters to apply

Returns:

  • (Lupa::Search)

    the search instance with applied search attributes

Raises:

Since:

  • 0.1.0



499
500
501
502
503
# File 'lib/lupa/search.rb', line 499

def self.search(attributes)
  new.search(attributes)
rescue ArgumentError
  raise Lupa::DefaultScopeError, "You need to define a default scope in order to user search class method."
end

Instance Method Details

#default_search_attributesHash

Note:

This method must return a Hash or a Lupa::DefaultSearchAttributesError will be raised

Returns default search attributes that should always be applied.

Override this method in your search class to define attributes that should always be included in the search, regardless of what’s passed to the ‘search` method. Default attributes can be overridden by explicitly passing them in search params.

Examples:

Defining default search attributes

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end

    def in_stock
      scope.where(in_stock: search_attributes[:in_stock])
    end
  end

  def default_search_attributes
    { in_stock: true }
  end
end

search = ProductSearch.new(Product.all).search(category: 'furniture')
search.default_search_attributes  # => { in_stock: true }
search.search_attributes          # => { category: 'furniture', in_stock: true }

Overriding default attributes

class ProductSearch < Lupa::Search
  class Scope
    def status
      scope.where(status: search_attributes[:status])
    end
  end

  def default_search_attributes
    { status: 'active' }
  end
end

# Using default
search = ProductSearch.new(Product.all).search({})
search.search_attributes  # => { status: 'active' }

# Overriding default
search = ProductSearch.new(Product.all).search(status: 'archived')
search.search_attributes  # => { status: 'archived' }

Using with conditional defaults

class ProductSearch < Lupa::Search
  class Scope
    def visibility
      scope.where(visibility: search_attributes[:visibility])
    end
  end

  def initialize(scope = Product.all, current_user: nil)
    @scope = scope
    @current_user = current_user
  end

  def default_search_attributes
    return { visibility: 'public' } unless @current_user&.admin?
    {}
  end
end

Returns:

  • (Hash)

    a hash of default search attributes (empty hash by default)

Raises:

Since:

  • 0.1.0



352
353
354
# File 'lib/lupa/search.rb', line 352

def default_search_attributes
  {}
end

#resultsObject

Note:

Methods in your Scope class should return either the modified scope or nil (if no filtering should be applied). Returning nil prevents the scope from being updated for that particular search method.

Returns the search results after applying all search methods.

This method executes the search by calling each method defined in the Scope class that corresponds to a key in search_attributes. The methods are called in the order they appear in the search_attributes hash. Results are memoized, so calling this method multiple times won’t re-execute the search.

Examples:

Basic usage

class ProductSearch < Lupa::Search
  class Scope
    def category
      scope.where(category_id: search_attributes[:category])
    end

    def name
      scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
    end
  end
end

search = ProductSearch.new(Product.all).search(category: 'furniture', name: 'chair')
search.results  # => #<Product::ActiveRecord_Relation:0x007ffda11b7d48>

Results are memoized

search = ProductSearch.new(Product.all).search(category: 'furniture')
results1 = search.results  # Executes the search
results2 = search.results  # Returns cached results (same object)
results1.object_id == results2.object_id  # => true

With arrays

class NumberSearch < Lupa::Search
  class Scope
    def even
      return scope unless search_attributes[:even]
      scope.select(&:even?)
    end

    def greater_than
      return scope unless search_attributes[:greater_than]
      scope.select { |n| n > search_attributes[:greater_than] }
    end
  end
end

search = NumberSearch.new([1, 2, 3, 4, 5, 6]).search(even: true, greater_than: 2)
search.results  # => [4, 6]

Scope methods returning nil are handled gracefully

class ProductSearch < Lupa::Search
  class Scope
    def optional_filter
      # If condition not met, return nil - scope won't be updated
      return nil unless search_attributes[:optional_filter]
      scope.where(some_field: search_attributes[:optional_filter])
    end
  end
end

Returns:

  • (Object)

    the filtered scope (e.g., ActiveRecord::Relation, Array)

Raises:

Since:

  • 0.1.0



570
571
572
# File 'lib/lupa/search.rb', line 570

def results
  @results ||= run
end

#search(attributes) ⇒ self

Note:

Search methods are not executed until you call ‘results` or a delegated method

Performs the search with the given attributes.

This method processes the search attributes (symbolizing keys, merging with defaults, removing blank values), validates that all attribute keys have corresponding methods in the Scope class, and returns self for method chaining.

Examples:

Basic usage

class ProductSearch < Lupa::Search
  class Scope
    def name
      scope.where('name LIKE ?', "%#{search_attributes[:name]}%")
    end

    def category
      scope.where(category_id: search_attributes[:category])
    end
  end
end

search = ProductSearch.new(Product.all).search(name: 'chair', category: '23')
# Returns the search instance for further operations

Method chaining

products = ProductSearch.new(Product.all)
  .search(name: 'chair', category: '23')
  .results

Accessing results through delegation

search = ProductSearch.new(Product.all).search(name: 'chair')
search.first     # Delegates to results.first
search.count     # Delegates to results.count
search.each { |p| puts p.name }  # Delegates to results.each

String keys are symbolized

search = ProductSearch.new(Product.all).search('name' => 'chair', 'category' => '23')
search.search_attributes  # => { name: 'chair', category: '23' }

Blank values are removed

search = ProductSearch.new(Product.all).search(name: 'chair', category: '', price: nil)
search.search_attributes  # => { name: 'chair' }

Nested hash attributes

class ProductSearch < Lupa::Search
  class Scope
    def created_between
      start_date = search_attributes[:created_between][:start_date]
      end_date = search_attributes[:created_between][:end_date]
      scope.where(created_at: start_date..end_date)
    end
  end
end

search = ProductSearch.new(Product.all).search(
  created_between: { start_date: '2023-01-01', end_date: '2023-12-31' }
)

Error handling - invalid attributes type

ProductSearch.new(Product.all).search("not a hash")
# => Lupa::SearchAttributesError: Your search params needs to be a hash.

Error handling - undefined scope method

ProductSearch.new(Product.all).search(undefined_attribute: 'value')
# => Lupa::ScopeMethodNotImplementedError: undefined_attribute is not defined on your ProductSearch::Scope class.

Parameters:

  • attributes (Hash)

    the search parameters to apply

Returns:

  • (self)

    returns the search instance for method chaining

Raises:

Since:

  • 0.1.0



430
431
432
433
434
435
436
437
# File 'lib/lupa/search.rb', line 430

def search(attributes)
  raise Lupa::SearchAttributesError, "Your search params needs to be a hash." unless attributes.respond_to?(:keys)

  set_search_attributes(attributes)
  set_scope_class
  check_method_definitions
  self
end