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
class ProductFilter
FILTERS = {
status: :by_status,
category_id: :in_category,
q: :search
}.freeze
PERMITTED = (FILTERS.keys + [:min_price, :max_price]).freeze
def initialize(relation, params)
@relation = relation
@params = permitted(params)
end
def results
scoped = FILTERS.reduce(@relation) do |rel, (param, scope)|
rel.public_send(scope, @params[param])
end
price_range(scoped).recently_updated
end
private
def price_range(rel)
rel.priced_between(@params[:min_price], @params[:max_price])
end
def permitted(params)
params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params
params.symbolize_keys.slice(*PERMITTED)
end
end
class ProductsController < ApplicationController
def index
filter = ProductFilter.new(Product.all, filter_params)
@pagy, @products = pagy(filter.results, items: 25)
respond_to do |format|
format.html
format.json { render json: @products }
end
end
private
def filter_params
params.permit(:status, :category_id, :q, :min_price, :max_price)
end
end
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
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.