Cross-model search
Search several models at once and get one relevance-ranked list of hits.
Cross-model search
Search::across searches every text column of each opted-in model and returns one list ordered by relevance. On Postgres it ranks with ts_rank (computed inline at query time - nothing is stored), and on SQLite it degrades to a weighted LIKE. One call replaces hand-merging a query per model in Rust.
The surface lives in umbral::orm: Searchable, Search, and SearchHit.
Opt a model in
A marker impl is enough - the searchable columns, the title, and the routing key are read from the model's metadata. Override a default only when you need to.
use umbral::prelude::*; impl umbral::orm::Searchable for Plugin { fn kind() -> &'static str { "plugin" } // result tag; default = the table name fn ident() -> &'static str { "slug" } // becomes SearchHit.pk (the routing key)} impl umbral::orm::Searchable for BlogPost { fn kind() -> &'static str { "blog" } fn ident() -> &'static str { "slug" }}The defaults read Model::FIELDS:
title()picks the text column namedtitleorname(case-insensitive), else the first text column.body()is everyTextcolumn, minus metadata-flagged non-content ones (slug / url / email validators andchoicessets) - so searching"published"won't match every published row.ident()defaults to the primary key. Override it to a natural key (aslug) when the model is routed by that key, soSearchHit.pkcarries the right value for the URL.
Choose which fields are searched
The defaults cover most models, but you can name the columns explicitly - to narrow the search to a few meaningful fields, or to pick a title the auto-detection wouldn't. Each method returns the SQL column name(s) as plain strings:
impl umbral::orm::Searchable for Plugin { fn kind() -> &'static str { "plugin" } fn ident() -> &'static str { "slug" } // Search ONLY these columns, instead of every text column. fn body() -> Vec<&'static str> { vec!["name", "short_description"] } // The column whose value is shown as the result's title. fn title() -> &'static str { "name" }}body()- the columns concatenated into the searchable text (and, on Postgres, fed toto_tsvector). Returning a narrower list keeps noise out of results. Default: everyTextcolumn minus slug / url / email validators andchoicessets.title()- the single column surfaced asSearchHit.title, and weighted highest in ranking (a title match outranks a body-only match). Default: the column namedtitleorname, else the first text column.
These are the SQL column names passed as strings, not Rust column constants. For umbral models the column name is the struct field name - the short_description field is the "short_description" column - so use the field name verbatim.
Restrict which rows are searchable
By default every row of an opted-in model is searchable. Override filter_sql() to scope it - typically to a published/approved state - so drafts or unmoderated rows never surface:
impl umbral::orm::Searchable for Plugin { fn kind() -> &'static str { "plugin" } fn ident() -> &'static str { "slug" } fn filter_sql() -> Option<&'static str> { Some("moderation = 'approved'") }}The string is a static SQL boolean ANDed into the search WHERE. Soft-delete is handled for you: a #[umbral(soft_delete)] model automatically excludes deleted_at IS NULL, so filter_sql is only for business rules.
Any column, and compound conditions
It is not limited to choice fields. The fragment is raw SQL, so it can reference any column and combine conditions with AND / OR / IN / parentheses:
// a choice column + an FK column + a boolean, combinedfn filter_sql() -> Option<&'static str> { Some("status = 'published' AND category = 3 AND featured = true")}- Choice columns - compared to the stored string:
status = 'published'. - FK columns - compared to the parent's id (umbral's FK column is the field name):
author = 5, orcategory IN (1, 2, 3). - Booleans / numbers / dates / nullability -
featured = true,price > 0,archived_at IS NULL.
Write SQL that's valid on every backend you run - the same fragment is spliced into both the Postgres and SQLite query. Stick to portable expressions (the examples above are); a Postgres-only function would break SQLite.
What it can't do: per-request values
Because filter_sql() returns a &'static str, every value in it must be a constant you write at compile time. It cannot carry a runtime value - there is no safe way to put "the logged-in user's id" or any request data into it (that string is interpolated into the SQL verbatim, so request data there would be a SQL-injection hole).
So filter_sql answers "which rows of this model are ever searchable" (published, approved), not "search only this author's rows for this request". For a per-request filter on a single model, skip Search::across and use a normal QuerySet, where values are safely bound:
BlogPost::objects() .filter(blog_post::STATUS.eq("published")) .filter(blog_post::AUTHOR.eq(author_id)) // a runtime value, bound safely .filter(Q::or(blog_post::TITLE.contains(q), blog_post::BODY.contains(q))) .fetch().await?Search::across is for the cross-model, ranked case; a single model filtered by a runtime value is plain QuerySet territory.
Run a search
Pass a tuple of Searchable models (arity 1–6):
let hits = umbral::orm::Search::across::<(Plugin, BlogPost)>("redis cache", 10).await?;for hit in hits { // hit.kind - "plugin" / "blog" (route on this) // hit.pk - the ident value as text (e.g. the slug) // hit.title - the title column's value // hit.snippet - leading slice of the body // hit.rank - f64, descending}hits is a Vec<SearchHit> ordered by descending rank; a title match outranks a body-only match. A blank or whitespace-only query returns an empty list without touching the database.
The query dispatches on the ambient pool, so the same call works on Postgres and SQLite with no code change.
See the design rationale in docs/superpowers/specs/2026-06-15-cross-model-search-design.md.