Query
Search, filters, scopes, and sorting for a resource's index page. All declared in the definition.
Overview
class PostDefinition < Plutonium::Resource::Definition
search do |scope, query|
scope.where("title ILIKE ?", "%#{query}%")
end
filter :title, with: :text, predicate: :contains
filter :status, with: :select, choices: %w[draft published archived]
filter :published, with: :boolean
filter :created_at, with: :date_range
scope :published
default_scope :published
sort :title
sort :created_at
default_sort :created_at, :desc
endSearch
search defines global free-text search. The block receives the scope and the query string; return a filtered relation.
search do |scope, query|
scope.where("title ILIKE ?", "%#{query}%")
endMulti-field
search do |scope, query|
scope.where(
"title ILIKE :q OR content ILIKE :q OR author_name ILIKE :q",
q: "%#{query}%"
)
endAcross associations
search do |scope, query|
scope.joins(:author).where(
"posts.title ILIKE :q OR users.name ILIKE :q",
q: "%#{query}%"
).distinct
endSplit terms
search do |scope, query|
query.split(/\s+/).reduce(scope) do |s, term|
s.where("title ILIKE ?", "%#{term}%")
end
endSearch powers typeahead too
The same search block drives typeahead lookups on association inputs that target this resource — when you write input :author, … for an association, the dropdown's autocomplete calls the target resource's search block.
Typeahead fallback when there's no search block
A resource without a search block still gets typeahead — the framework runs a case-insensitive LIKE against the first column that exists, in priority order:
- The input's
label_method:option, if it names a real column on the model. - Otherwise the first match from
[name, title, label, slug, display_name, email]. - If none exist, the relation is returned unfiltered (capped).
For large tables, write an explicit search block backed by a trigram or full-text index. The fallback's leading-wildcard LIKE '%q%' can't use a b-tree index and gets slow past a few thousand rows.
Filters
Six built-in filter types. Use the shorthand symbol or the full class name.
| Type | Symbol | Params in URL | Options |
|---|---|---|---|
| Text | :text | query | predicate: |
| Boolean | :boolean | value | true_label:, false_label: |
| Date | :date | value | predicate: |
| Date range | :date_range | from, to | from_label:, to_label: |
| Select | :select | value | choices:, multiple: |
| Association | :association | value | class_name:, multiple: |
Text predicates
:eq, :not_eq, :contains, :not_contains, :starts_with, :ends_with, :matches, :not_matches
filter :title, with: :text, predicate: :contains
filter :status, with: :text, predicate: :eq
filter :title, with: Plutonium::Query::Filters::Text, predicate: :contains # full class formBoolean
filter :active, with: :boolean
filter :published, with: :boolean, true_label: "Published", false_label: "Draft"Date
Predicates: :eq, :not_eq, :lt, :lteq, :gt, :gteq.
filter :created_at, with: :date, predicate: :gteq
filter :due_date, with: :date, predicate: :lt
filter :published_at, with: :date, predicate: :eqDate range
Two inputs (from + to):
filter :created_at, with: :date_range
filter :published_at, with: :date_range,
from_label: "Published from",
to_label: "Published to"Select
filter :status, with: :select, choices: %w[draft published archived]
filter :category, with: :select, choices: -> { Category.pluck(:name) }
filter :tags, with: :select, choices: %w[ruby rails js], multiple: trueAssociation
filter :category, with: :association
filter :author, with: :association, class_name: User
filter :tags, with: :association, class_name: Tag, multiple: trueCustom filter (lambda)
For simple one-offs:
filter :published, with: ->(scope, value) {
value == "true" ? scope.where.not(published_at: nil) : scope.where(published_at: nil)
}Custom filter class
For anything reusable or with multiple inputs:
class PriceRangeFilter < Plutonium::Query::Filter
def apply(scope, min: nil, max: nil)
scope = scope.where("price >= ?", min) if min.present?
scope = scope.where("price <= ?", max) if max.present?
scope
end
def customize_inputs
input :min, as: :number
input :max, as: :number
field :min, placeholder: "Min price..."
field :max, placeholder: "Max price..."
end
end
filter :price, with: PriceRangeFilterScopes
Scopes appear as quick-filter buttons across the top of the table.
class PostDefinition < ResourceDefinition
scope :published # uses Post.published
scope :draft # uses Post.draft
# Inline scope — block runs with the scope as argument
scope(:recent) { |s| s.where('created_at > ?', 1.week.ago) }
scope(:this_month) { |s| s.where(created_at: Time.current.all_month) }
endNamed scopes reference a model scope of the same name. Inline (block) scopes have access to controller context (current_user, current_parent, etc.):
scope(:mine) { |s| s.where(author: current_user) }
scope(:my_team) { |s| s.where(team: current_user.team) }Default scope
default_scope :publishedWhen a default is set:
- It applies on initial page load.
- The default scope button is highlighted (not "All").
- Clicking "All" shows the unscoped collection.
Sorting
sort :title
sort :created_at
sorts :title, :created_at, :view_count # shorthand for several at once
default_sort :created_at, :desc
# Complex default sort with a block
default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }The framework default (no default_sort declared, no user sort) is id DESC.
URL parameters
Query parameters are namespaced under q:
/posts?q[search]=rails
/posts?q[title][query]=widget
/posts?q[status][value]=published
/posts?q[created_at][from]=2024-01-01&q[created_at][to]=2024-12-31
/posts?q[scope]=recent
/posts?q[sort_fields][]=created_at&q[sort_directions][created_at]=descCombined:
/posts?q[search]=rails&q[scope]=published&q[sort_fields][]=created_at&q[sort_directions][created_at]=descCommon patterns
Full-text search with pg_search
# Model
class Post < ResourceRecord
include PgSearch::Model
pg_search_scope :search_content, against: %i[title content]
end
# Definition
search do |scope, query|
scope.search_content(query)
endStatus filter + scopes
filter :status, with: :select, choices: %w[draft published archived]
scope :draft
scope :published
scope :archivedDate-based scopes
# Model
scope :today, -> { where(created_at: Time.current.all_day) }
scope :this_week, -> { where(created_at: Time.current.all_week) }
scope :this_month, -> { where(created_at: Time.current.all_month) }
# Definition
scope :today
scope :this_week
scope :this_monthPerformance
- Add indexes for filtered and sorted columns.
- Use
.distinctwhen joining associations in search to avoid duplicate rows. - Prefer scopes over filters for queries used often (faster, no input parsing).
pg_search/ FTS for complex search — write an explicitsearchblock.LIKE '%q%'can't use a b-tree index — the typeahead fallback and naive search blocks get slow on large tables. Plan a trigram or full-text index when scaling.
Related
- Definition — field/input/display configuration
- Actions — custom and bulk actions
- Behavior › Policy —
relation_scope(filters records to what the user can see) - Tenancy › Entity scoping — multi-tenant filtering
