This version is in beta. Some features may change before release.

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.

Code
rust
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" }
}
Info

The defaults read Model::FIELDS:

  • title() picks the text column named title or name (case-insensitive), else the first text column.
  • body() is every Text column, minus metadata-flagged non-content ones (slug / url / email validators and choices sets) - so searching "published" won't match every published row.
  • ident() defaults to the primary key. Override it to a natural key (a slug) when the model is routed by that key, so SearchHit.pk carries 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:

Code
rust
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 to to_tsvector). Returning a narrower list keeps noise out of results. Default: every Text column minus slug / url / email validators and choices sets.
  • title() - the single column surfaced as SearchHit.title, and weighted highest in ranking (a title match outranks a body-only match). Default: the column named title or name, else the first text column.
Warning

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:

Code
rust
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:

Code
rust
// a choice column + an FK column + a boolean, combined
fn 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, or category IN (1, 2, 3).
  • Booleans / numbers / dates / nullability - featured = true, price > 0, archived_at IS NULL.
Warning

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:

Code
rust
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.

Pass a tuple of Searchable models (arity 1–6):

Code
rust
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.

ormsearchfull-textts_rankrelevance