Full-Text Search¶
Model.search() runs a real full-text query — not a LIKE '%...%' scan —
using whichever full-text engine your database already ships: SQLite's
FTS5, or Postgres's tsvector/GIN indexes. No new dependency, no separate
search service to run and keep in sync.
Setup¶
Declare which columns are searchable on the model:
# app/Models/post.py
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from zeython import Model
class Post(Model):
__tablename__ = "posts"
__searchable__ = ("title", "body")
title: Mapped[str] = mapped_column(String(255))
body: Mapped[str] = mapped_column(Text)
Then create the matching index once, from a migration — Model.search()
queries an index, it doesn't create one for you:
# migrations/versions/xxxx_add_posts_search_index.py
from alembic import op
from zeython.search import create_fts5_index # SQLite
def upgrade() -> None:
create_fts5_index(op, "posts", ["title", "body"])
def downgrade() -> None:
from zeython.search import drop_fts5_index
drop_fts5_index(op, "posts")
That's it — search away:
SQLite: create_fts5_index()¶
Creates an external-content FTS5
virtual table (posts_fts) plus three triggers that keep it in sync with
posts on every INSERT/UPDATE/DELETE — including writes that don't
go through Model at all (a raw INSERT, a script, another process),
since the triggers live in the database itself, not in Python. Backfills
every existing row when the migration runs.
Model.search() queries it with SQLite's MATCH operator, ordered by
FTS5's own built-in rank column (best matches first) — no extra
configuration.
Postgres: create_tsvector_index()¶
Adds a search_vector tsvector column,
GENERATED ALWAYS ... STORED
from the given columns, plus a GIN index on it. Postgres keeps a generated
column in sync automatically on every write — no triggers needed here.
from zeython.search import create_tsvector_index
def upgrade() -> None:
create_tsvector_index(op, "posts", ["title", "body"])
Model.search() queries it with plainto_tsquery()/@@, ordered by
ts_rank(). language defaults to "english" on both the index and the
query side — pass a different one to both create_tsvector_index(...,
language="french") and set Post.__search_language__ = "french" if you
need it, and keep them in sync.
Excluding soft-deleted rows¶
search() excludes soft-deleted rows by default, the same as all()/
find_by() — pass include_deleted=True to include them:
Multi-tenant apps¶
A model that declares a tenant_id column (see Multi-Tenancy)
gets search() scoped to current_tenant_id(), the
same as find/all/find_by/paginate — no separate opt-in needed.
Limiting results¶
query is always treated as plain text¶
Pass through whatever a search box actually receives — a stray quote, a
hyphenated word, a colon, a lone AND — and it's searched for as literal
text, not parsed as a query mini-language:
await Post.search("well-known movie") # matches "well-known" and "movie"
await Post.search("what's new?") # the apostrophe/quote is just text
await Post.search("") # [] -- nothing to search for, no query run
Both engines are safe here for the same reason (each does its own thing to
get there): Postgres's plainto_tsquery() already always treats its
input as plain text; on SQLite, Model.search() quotes each
whitespace-separated term as an FTS5 string literal before it reaches
MATCH, so a raw query string can never be parsed as FTS5's own operator
syntax (AND/OR/NOT, col: filters, unbalanced "/() and crash
the request with a syntax error the way it would unquoted.
What this isn't¶
Model.search() is a thin, honest dispatch over each database's own
full-text SQL — not a search engine. It doesn't give you: relevance
tuning beyond what rank/ts_rank compute, typo tolerance/fuzzy
matching, faceted search, or a shared ranking model across SQLite and
Postgres (each engine ranks its own way). For requirements like those, or
for an index too large for your primary database to carry alongside
everything else, point a dedicated search service (Elasticsearch,
Meilisearch, Typesense) at the same data instead — Model.search()
covers the common case of "let me search this table" without a second
service to run, not every case.
MySQL isn't supported yet (Model.search() raises a clear RuntimeError
naming the dialect) — its own full-text indexes work differently enough
from SQLite/Postgres's that adding a third dispatch branch is deliberately
left for when someone actually needs it, not implemented speculatively.
API reference¶
See zeython.search for the full API, and
Model.search() alongside the rest of Model.