Queries¶
Reading is done with a builder that starts from the entity and ends with a call that runs the statement. This page explains how the builder is put together, what SQL each method adds, how results come back, and how to go beyond the typed API when you need to.
The shape of a query¶
Entity::find() returns a Select typed by the entity. Every method on
it adds a clause and returns the builder, until one of the terminal
methods executes it:
| Terminal | SQL | Result |
|---|---|---|
one(db) |
adds LIMIT 1 |
Option<Model> |
all(db) |
as built | Vec<Model> |
count(db) |
wraps the query in SELECT COUNT(*) FROM (...) |
u64 |
exists(db) |
wraps the query in SELECT EXISTS (...) |
bool |
stream(db) |
as built | a stream of Models |
paginate(db, size) |
adds LIMIT ... OFFSET ... per page |
a Paginator |
cursor_by([columns]) |
adds a key boundary and ORDER BY per page |
a Cursor |
The builder knows the entity's Column enum, so a filter on a column that
does not exist is a compile error. Every column reference is rendered
qualified, as "cake"."price", which is what keeps a query unambiguous
once it joins another table that has a column of the same name.
Finding rows¶
/// Finds by key, by `LIKE`, and with a composed `OR` condition.
async fn find_one_and_filter(db: &Database) -> Result<(), DbErr> {
let by_id = Cake::find_by_id(1).one(db).await?;
println!("find one by primary key: {by_id:?}");
let by_name = Cake::find()
.filter(cake::Column::Name.contains("chocolate"))
.one(db)
.await?;
println!("find one by name: {by_name:?}");
let cond = Condition::any()
.add(cake::Column::GlutenFree.eq(true))
.add(cake::Column::Price.gt(10));
let cakes = Cake::find()
.filter(cond)
.order_by_desc(cake::Column::Price)
.all(db)
.await?;
println!("gluten free or pricey, most expensive first:");
for cake in cakes {
println!(" {cake:?}");
}
Ok(())
}
find_by_id is the shortcut for a lookup by primary key; it takes a
value, or a tuple for a composite key. filter takes anything that
converts into a condition: a single comparison such as
Column::Name.contains("chocolate"), which renders
"cake"."name" LIKE '%chocolate%', or a Condition that groups several.
Several filter calls are combined with AND. To build an OR, start
from Condition::any() and add each branch; Condition::all() is the
explicit AND form, and the two nest freely. The example above finds the
cakes that are gluten free or cost more than ten.
The operators¶
ColumnTrait provides the comparison builders on every Column:
| Method | SQL |
|---|---|
eq, ne, gt, gte, lt, lte |
=, <>, >, >=, <, <= |
between(a, b), not_between(a, b) |
BETWEEN a AND b |
like, not_like |
LIKE, with the pattern you give |
starts_with, ends_with, contains |
LIKE 'x%' ESCAPE '\' and friends: %, _ and \ in your text match themselves |
is_null, is_not_null |
IS NULL, IS NOT NULL |
is_in(iter), is_not_in(iter) |
IN (...), NOT IN (...) |
in_subquery(select), not_in_subquery(select) |
IN (SELECT ...), NOT IN (SELECT ...) |
eq_col(other) |
= other.column, to compare two columns across a join |
matches(query) |
Full-text MATCH, for columns with a Turso FTS index |
max, min, sum, avg, count |
The aggregate expression, for projections |
Values are bound as parameters, never interpolated, so a user-supplied string in a filter is safe.
Ordering, limiting, grouping¶
order_by_asc(column), order_by_desc(column) and
order_by(column, Order) add ORDER BY clauses in the order you call
them. limit(n) and offset(n) map directly to SQL; distinct() adds
DISTINCT; group_by(column) and having(condition) are there for
aggregate queries. count deliberately drops ordering, limit and offset
from the inner query: they do not change the total and would only slow
it down.
Pagination and streaming¶
Two tools cover large result sets. The paginator is for pages a user browses; the stream is for processing every row without holding them all in memory.
/// Pages through fruits two at a time, then streams them one row at a time.
async fn paginate_and_stream(db: &Database) -> Result<(), DbErr> {
let paginator = Fruit::find()
.order_by_asc(fruit::Column::Id)
.paginate(db, 2);
let (items, pages) = paginator.num_items_and_pages().await?;
println!("fruits: {items} items over {pages} pages");
for page in 0..pages {
println!(" page {page}: {:?}", paginator.fetch_page(page).await?);
}
println!("stream of fruit names:");
let mut stream = Fruit::find()
.order_by_asc(fruit::Column::Name)
.stream(db)
.await?;
while let Some(fruit) = stream.try_next().await? {
println!(" {}", fruit.name);
}
Ok(())
}
paginate(db, page_size) returns a Paginator over the query. Pages are
counted from zero: fetch_page(0) is the first one. num_items runs a
COUNT(*), num_pages divides it by the page size, and
num_items_and_pages does both in one call so that a listing endpoint
can answer with the total and the current page from one query.
for_each_page walks every page in turn and stops when a page comes back
short or when your closure returns false.
stream(db) executes the statement and yields models as the engine
produces them. The stream borrows a pooled connection for its whole life,
so drop it when you are done. It is Unpin, which is why the
while let Some(x) = stream.try_next().await? loop works without pinning;
try_next comes from futures_util::TryStreamExt.
Keyset pagination¶
Offset pagination re-reads every row before the page and shifts when rows are inserted ahead of it. For an API that hands out "the next page after this one", a cursor is the better fit: it remembers the key of the last row seen and asks for the rows after it, which is an index seek that stays stable under concurrent writes.
/// Pages through posts by key, two at a time, the way an API with
/// `?after=<id>` would.
async fn cursor(db: &Database) -> Result<(), DbErr> {
let mut after: Option<i32> = None;
loop {
let mut page = post::Entity::find().cursor_by([post::Column::Id]).first(2);
if let Some(id) = after {
page = page.after(id);
}
let rows = page.all(db).await?;
println!("page: {:?}", rows.iter().map(|p| p.id).collect::<Vec<_>>());
match rows.last() {
Some(last) if rows.len() == 2 => after = Some(last.id),
_ => return Ok(()),
}
}
}
cursor_by([columns]) names the key, most significant column first, so
[Column::CreatedAt, Column::Id] orders by date with the id as a
tie-breaker. after(key) and before(key) set the boundaries, with a
tuple for a composite key; first(n) takes the page from the start of
the range and last(n) from the end, both returned in key order; desc()
flips the direction. A composite key is compared lexicographically, with
the expanded a > ? OR (a = ? AND b > ?) form, so the statement only
uses operators every SQLite build accepts.
Custom projections¶
Not every query returns table rows. To select a few columns or an
aggregate, switch the builder to a custom shape with select_only, add
columns and expressions, and decode into any struct that derives
FromQueryResult:
/// Aggregates per bakery decoded into a plain struct.
#[derive(Debug, FromQueryResult)]
struct CakeStats {
/// Number of cakes.
count: i64,
/// Highest price.
#[turso(column_name = "max_price")]
most_expensive: f64,
}
/// Selects only computed columns and decodes them by name.
async fn project_into_struct(db: &Database) -> Result<(), DbErr> {
let stats = Cake::find()
.select_only()
.expr_as(Func::count_star(), "count")
.expr_as(cake::Column::Price.max(), "max_price")
.into_model::<CakeStats>()
.one(db)
.await?;
if let Some(CakeStats {
count,
most_expensive,
}) = stats
{
println!("{count} cakes, the most expensive at {most_expensive}");
}
Ok(())
}
select_only clears the entity's column list; column, column_as and
expr_as(expression, alias) add output columns; into_model::<T>() tells
the builder to decode rows into T by column name. The struct's field
names must match the aliases, or carry #[turso(column_name = "...")]
when they do not, as most_expensive does for max_price.
Func::count_star() and the aggregate methods on columns are the usual
ingredients.
Three other shapes avoid the struct altogether:
/// Reads posts as a partial model, as tuples and as JSON, and probes with `exists`.
async fn projections(db: &Database) -> Result<(), DbErr> {
let headlines = post::Entity::find()
.filter(post::Column::Status.eq(post::Status::Published))
.into_partial_model::<Headline>()
.all(db)
.await?;
for h in &headlines {
println!("headline {}: {:?} / {:?}", h.id, h.text, h.draft_text);
}
let pairs = post::Entity::find()
.select_only()
.column(post::Column::Id)
.column_as(post::Column::Title, "title")
.into_tuple::<(i32, String)>()
.all(db)
.await?;
println!("pairs: {pairs:?}");
let json = post::Entity::find().into_json().one(db).await?;
println!("as json: {json:?}");
let any_draft = post::Entity::find()
.filter(post::Column::Status.eq(post::Status::Draft))
.exists(db)
.await?;
println!("any draft: {any_draft}");
Ok(())
}
| Call | You get |
|---|---|
into_partial_model::<P>() |
A struct deriving DerivePartialModel, which selects its own columns; see entities. |
into_tuple::<(A, B)>() |
Tuples decoded by position in the select list, after select_only. |
into_json() |
serde_json::Value objects keyed by column name, behind the with-json feature. |
exists(db) |
Whether at least one row matches, without reading any. |
Writing many rows at once¶
insert, update and delete on an active model work one row at a
time. The entity offers bulk forms:
/// A multi-row insert with `RETURNING`, a bulk update and a closure transaction.
async fn bulk_and_transaction(db: &Database) -> Result<(), DbErr> {
let bakery = Bakery::find().one(db).await?.expect("seeded above");
let cakes = Cake::insert_many([
cake::ActiveModel {
name: Set("Chocolate Forest".to_owned()),
price: Set(8.5),
gluten_free: Set(false),
bakery_id: Set(bakery.id),
..Default::default()
},
cake::ActiveModel {
name: Set("Lemon Drizzle".to_owned()),
price: Set(6.0),
gluten_free: Set(true),
bakery_id: Set(bakery.id),
..Default::default()
},
])
.exec_with_returning(db)
.await?;
println!("inserted {} cakes", cakes.len());
let fruits = [
("Blueberry", Some(1)),
("Raspberry", Some(1)),
("Strawberry", Some(2)),
("Apple", None),
("Cherry", None),
];
let inserted = Fruit::insert_many(fruits.map(|(name, cake_id)| fruit::ActiveModel {
name: Set(name.to_owned()),
cake_id: Set(cake_id),
..Default::default()
}))
.exec(db)
.await?;
println!("inserted {inserted} fruits");
let bumped = Cake::update_many()
.col(cake::Column::Price, 7.0)
.filter(cake::Column::GlutenFree.eq(true))
.exec(db)
.await?;
println!("bulk update touched {} rows", bumped.rows_affected);
// The closure form commits on `Ok` and rolls back on `Err`. Here the
// insert is undone on purpose, so the fruit count is unchanged after.
let attempted: Result<(), DbErr> = db
.transaction(|txn| {
Box::pin(async move {
fruit::ActiveModel {
name: Set("Ghost".to_owned()),
..Default::default()
}
.insert(txn)
.await?;
Err(DbErr::Custom("abort on purpose".to_owned()))
})
})
.await;
println!("transaction result: {attempted:?}");
println!("fruits after rollback: {}", Fruit::find().count(db).await?);
Ok(())
}
| Call | SQL | Returns |
|---|---|---|
Entity::insert_many(iter).exec(db) |
One multi-row INSERT |
The number of rows |
Entity::insert_many(iter).exec_with_returning(db) |
The same with RETURNING * |
The stored models |
Entity::update_many().col(column, value).filter(...).exec(db) |
UPDATE ... SET ... WHERE ... |
rows_affected |
Entity::update_many().col(...).filter(...).exec_with_returning(db) |
The same with RETURNING * |
The changed models |
Entity::delete_many().filter(...).exec(db) |
DELETE ... WHERE ... |
rows_affected |
Entity::delete_many().filter(...).exec_with_returning(db) |
The same with RETURNING * |
The removed models |
A bulk insert takes any iterator of active models; each one contributes
its Set fields. The statement has one column list, the union of what
the models set, and SQLite cannot ask for the default of a single cell:
a model that leaves one of those columns unset contributes the default
the entity declares for it with default_value, or NULL when it
declares none. Without a filter, update_many and delete_many touch
the whole table, which is occasionally what you want and is worth a
second look otherwise. Entity::delete(active_model) has the same
exec_with_returning, for the row as it was just before removal.
Writing from a request body goes through DeriveIntoActiveModel or
ActiveModel::from_json; both are on the
entities page.
/// Creates posts from a request struct and from a JSON body, and deletes
/// one with `RETURNING`.
async fn requests(db: &Database) -> Result<(), DbErr> {
let created = CreatePost {
author_id: 2,
title: "From a request".into(),
editor_id: None,
}
.into_active_model()
.insert(db)
.await?;
println!(
"created {:?} with status {:?}",
created.title, created.status
);
let from_json = post::ActiveModel::from_json(serde_json::json!({
"author_id": 1,
"title": "From JSON",
"status": "live"
}))?
.insert(db)
.await?;
println!(
"created {:?} with status {:?}",
from_json.title, from_json.status
);
let removed = post::Entity::delete_many()
.filter(post::Column::Title.starts_with("From "))
.exec_with_returning(db)
.await?;
println!("removed {} post(s)", removed.len());
Ok(())
}
Handling errors¶
Every call returns Result<T, DbErr>. Most variants describe a misuse
you can fix at development time; the ones a running application has to
handle come from the database itself and are classified by the driver, so
that a handler matches on structure rather than on an error message.
| Variant | Meaning |
|---|---|
DbErr::Driver(e) |
An engine or pool error. e.kind() tells busy from constraint from I/O; e.constraint() names the violated constraint kind. |
DbErr::RecordNotFound(what) |
A lookup that had to return a row returned none. |
DbErr::RecordNotInserted, RecordNotUpdated |
A write produced no row, for example under ON CONFLICT DO NOTHING or because the key matched nothing. |
DbErr::AttrNotSet(field) |
A try_into_model met a field that is NotSet. |
DbErr::PrimaryKeyNotSet |
An update or delete without a key. |
DbErr::Type(msg), Json(msg) |
A value could not be decoded as the requested type. |
DbErr::Migration(msg) |
A migration refused to run or to revert. |
DbErr::Custom(msg) |
Reserved for application code and hooks. |
Three predicates on DbErr cover what a request handler usually needs,
and avoid digging into the driver error:
match post.insert(&db).await {
Ok(model) => /* 201 Created */,
Err(e) if e.is_unique_violation() => /* 409 Conflict */,
Err(e) if e.is_foreign_key_violation() => /* 422 Unprocessable */,
Err(e) if e.is_busy() => /* the lock did not clear in time: retry */,
Err(e) => /* 500 */,
}
The web examples map them to HTTP statuses in one place, in
the api crate of each.
Beyond the typed builder¶
The typed API covers the common cases; it is not a wall.
Select::as_query()andquery_mut()expose the underlyingturso_sql::Select, where any clause the SQL builder supports can be added, including Turso's vector functions.Entity::find().from_raw_sql(statement)runs a hand-writtenStatementand decodes its rows into the model;Selector::<T>::from_statement(statement)does the same for anyFromQueryResulttype.db.execute,db.query_oneanddb.query_alltake aStatementand return rawRows that decode on access withrow.get::<T>("column").
Whichever route you take, parameters stay bound and rows decode with the same rules as the entity API.