class InvoicesController < ApplicationController
def index
invoices = InvoicesQuery.new(current_account.invoices, filter_params).call
@invoices = invoices.page(params[:page]).per(25)
render :index
end
private
def filter_params
params.permit(:status, :customer_id, :from, :to, :q)
end
end
class InvoicesQuery
FILTERS = %i[by_status by_customer by_date_range by_search].freeze
def initialize(relation = Invoice.all, params = {})
@relation = relation
@params = params
end
def call
FILTERS.reduce(@relation) do |scope, filter|
send(filter, scope)
end
end
private
def by_status(scope)
value = @params[:status]
return scope if value.blank?
return scope unless Invoice.statuses.key?(value)
scope.where(status: value)
end
def by_customer(scope)
value = @params[:customer_id]
return scope if value.blank?
scope.where(customer_id: value)
end
def by_date_range(scope)
from = parse_date(@params[:from])
to = parse_date(@params[:to])
scope = scope.issued_after(from) if from
scope = scope.issued_before(to) if to
scope
end
def by_search(scope)
term = @params[:q]
return scope if term.blank?
pattern = "%#{Invoice.sanitize_sql_like(term)}%"
scope.where("invoices.number ILIKE :p OR invoices.memo ILIKE :p", p: pattern)
end
def parse_date(raw)
Date.iso8601(raw)
rescue ArgumentError, TypeError
nil
end
end
class Invoice < ApplicationRecord
belongs_to :account
belongs_to :customer
enum status: { draft: 0, sent: 1, paid: 2, void: 3 }
scope :issued_after, ->(date) { where("invoices.issued_on >= ?", date) }
scope :issued_before, ->(date) { where("invoices.issued_on <= ?", date) }
validates :number, presence: true, uniqueness: { scope: :account_id }
end
This snippet shows the query object pattern, a common way to move complex, conditional filtering logic out of a Rails controller and into a dedicated, testable class. The problem it solves is the sprawl that happens when a controller tries to translate a bag of request params into a chain of where clauses: nested if statements, leaked SQL, and untestable actions. A query object encapsulates that translation in one place.
In InvoicesController, the action stays deliberately thin. It hands the permitted params to InvoicesQuery and renders whatever relation comes back. Because the query object returns an ActiveRecord::Relation rather than a materialized array, the controller can still layer pagination on top with page and per, and Rails will fold everything into a single SQL query. The filter_params method uses strong parameters to whitelist only the keys the query understands, which prevents arbitrary filtering and keeps the surface area small.
The core idea lives in InvoicesQuery. It is initialized with a base relation (defaulting to Invoice.all) and the params hash. The call method seeds a local scope and folds each filter over it with reduce, so every filter receives the current scope and returns a possibly-narrowed scope. Each private method — by_status, by_customer, by_date_range, by_search — is a pure transformation that ignores blank params via the guard return scope if value.blank?. This is what makes the filters composable: order-independent, individually testable, and safe to add to without touching the others. by_search uses ILIKE with a bound parameter and sanitize_sql_like to defend against injection and wildcard abuse, while by_status intersects against a known set so unknown values are silently dropped rather than raising.
The trade-off is a little indirection for a lot of clarity: filtering rules gain a home, controllers shrink, and each rule can be unit-tested in isolation. The Invoice model tab shows the named scopes the query leans on, keeping the raw SQL fragments close to the data they describe. A pitfall to watch is applying distinct when joins fan out rows; here the search stays on the invoices table, so it is unnecessary. Reach for this pattern whenever an index or report screen accumulates more than two or three optional 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.