class Article < ApplicationRecord
include PgSearch::Model
pg_search_scope :full_text_search,
against: { title: 'A', body: 'B' },
using: {
tsearch: {
prefix: true,
negation: true,
dictionary: 'english',
tsvector_column: nil
},
trigram: {
threshold: 0.3,
word_similarity: true
}
}
scope :published, -> { where.not(published_at: nil) }
def self.ranked_search(query)
full_text_search(query).with_pg_search_rank.reorder('pg_search_rank DESC')
end
end
class AddFullTextSearchToArticles < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def up
enable_extension 'pg_trgm' unless extension_enabled?('pg_trgm')
execute <<~SQL
CREATE INDEX CONCURRENTLY IF NOT EXISTS index_articles_on_fts
ON articles
USING gin (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(body, ''))
)
SQL
end
def down
execute 'DROP INDEX CONCURRENTLY IF EXISTS index_articles_on_fts'
end
end
class ArticleSearch
include ActiveModel::Model
include ActiveModel::Attributes
MAX_PER_PAGE = 50
attribute :query, :string
attribute :published_only, :boolean, default: false
attribute :page, :integer, default: 1
attribute :per_page, :integer, default: 20
validates :query, length: { maximum: 200 }
def results
return Article.none if invalid?
return Article.none if query.blank?
@results ||= begin
relation = Article.full_text_search(query)
relation = relation.published if published_only
relation.limit(bounded_per_page).offset(offset)
end
end
def bounded_per_page
per_page.clamp(1, MAX_PER_PAGE)
end
def offset
(page.clamp(1, Float::INFINITY).to_i - 1) * bounded_per_page
end
end
class ArticlesController < ApplicationController
def search
@search = ArticleSearch.new(search_params)
if @search.invalid?
render json: { errors: @search.errors.full_messages }, status: :unprocessable_entity
return
end
render json: {
query: @search.query,
page: @search.page,
per_page: @search.bounded_per_page,
results: @search.results.as_json(only: %i[id title published_at])
}
end
private
def search_params
params.permit(:query, :published_only, :page, :per_page)
end
end
This snippet shows how a search feature is layered in a Rails app so the controller stays thin, the query logic lives on the model, and the messy business of parsing user input is isolated in a plain Ruby form object. The pattern separates three concerns that are frequently tangled together: SQL search configuration, request-parameter coercion, and HTTP concerns.
In Article model, pg_search_scope builds a class method named full_text_search backed by Postgres tsvector/tsquery. It searches the title and body columns with a weighted, prefix-and-negation-aware tsearch config, and layers a trigram fallback so slight misspellings still match. Weighting title as 'A' and body as 'B' biases ranking toward titles. A published scope is kept separate so search can be composed with other constraints, and ranked_search demonstrates that composition — pg_search exposes pg_search_rank for ordering by relevance.
The migration in enable full-text index matters as much as the scope: pg_search on a large table is unusable without an index. It enables the pg_trgm extension for trigram matching and adds a GIN index over a to_tsvector expression covering both columns, so Postgres can satisfy the search with an index scan instead of a full table scan.
ArticleSearch form object is the heart of the design. It includes ActiveModel::Model so it validates and behaves like a form without being a database record. It coerces page and per_page, clamps per_page to a sane ceiling to prevent abusive requests, and validates the query length. Its results method blanks-checks the query, applies full_text_search, chains published when asked, and paginates — returning Article.none when invalid so callers never branch on nil. Memoizing @results keeps repeated calls cheap.
In ArticlesController, search simply instantiates the form from search_params, then renders JSON. Because the form owns validation and defaults, the action has no query logic and no parameter juggling. This structure makes the search unit-testable in isolation, keeps SQL concerns on the model, and gives one obvious place to add filters later without bloating the controller.
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.