ruby 98 lines · 4 tabs

Full-Text Search Endpoint in Rails with pg_search Scope and a Search Form Object

Shared by codesnips Aug 2026
4 tabs
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
4 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Full-Text Search Endpoint in Rails with pg_search Scope and a Search Form Object — share card
Link copied