ruby 73 lines · 3 tabs

Composable Query Filters with Rails Scopes and a Filter Object

Shared by codesnips Aug 2026
3 tabs
class Product < ApplicationRecord
  scope :by_status, ->(status) {
    status.present? ? where(status: status) : all
  }

  scope :in_category, ->(category_id) {
    category_id.present? ? where(category_id: category_id) : all
  }

  scope :priced_between, ->(min, max) {
    rel = all
    rel = rel.where("price_cents >= ?", (min.to_f * 100).round) if min.present?
    rel = rel.where("price_cents <= ?", (max.to_f * 100).round) if max.present?
    rel
  }

  scope :search, ->(term) {
    return all if term.blank?
    escaped = sanitize_sql_like(term.strip)
    where("name ILIKE :q OR sku ILIKE :q", q: "%#{escaped}%")
  }

  scope :recently_updated, -> { order(updated_at: :desc) }
end
3 files · ruby Explain with highlit

This snippet shows how to build a maintainable search endpoint by separating two concerns: the model owns what a filter means, and a plain Ruby filter object owns how incoming request params map to those filters. The pattern keeps controllers thin, avoids the sprawling if params[:x] chains that usually accumulate on index actions, and makes each individual filter unit-testable in isolation.

In Product model, each filter is expressed as a named scope. Scopes like by_status, priced_between, and search are ordinary class-level query fragments that return an ActiveRecord::Relation, so they compose by chaining. A key detail is that every scope guards against blank input — by_status short-circuits with all when the value is absent, so calling it with nil is harmless and returns the relation unchanged. This is what lets the filter object apply scopes unconditionally without branching. The search scope uses sanitize_sql_like to escape % and _ before an ILIKE, which prevents user-supplied wildcards from turning into accidental full-table scans or injection.

In ProductFilter query object, the filter object is initialized with a base relation and the raw params hash. FILTERS maps each permitted param key to the scope method it should invoke, and results folds over that map with reduce, threading the relation through each scope in turn. Because blank-guarded scopes are no-ops, only the params actually present narrow the result set. price_range demonstrates normalizing a pair of params into the two arguments priced_between expects, and permitted whitelists keys so unexpected params can't reach a scope.

In ProductsController, the index action does almost nothing: it constructs a ProductFilter over Product.all and calls results, then paginates. The controller never references a specific filter, so adding a new one means adding a scope plus one FILTERS entry — the controller stays untouched.

The trade-off is a small amount of indirection versus inline conditionals, which pays off once more than two or three filters exist. A pitfall to watch is scope ordering when filters interact with distinct or joins; keep join-producing scopes idempotent. This approach fits any index or report screen with several optional, combinable filters.


Related snips

Share this code

Here's the card — post it anywhere.

Composable Query Filters with Rails Scopes and a Filter Object — share card
Link copied