Query builders
Start a query from the singular entity method:
let users = client
.user()
.active_eq(true)
.age_ge(18)
.order_by(user::User::created_at)
.limit(50)
.all()
.await?;
Each additional filter is joined with AND. Generated scalar filters include eq, ne, gt, ge, lt, and
le. Nullable fields also receive is_null and is_not_null. Type-specific filters add operations such as string,
collection, and JSON predicates.
Compose model-safe filters
For alternatives or negation, build standalone filters from column tokens. Every column is an associated
constant on its model (User::id, User::email). Its operators match the compact methods:
eq,ne,in_, andnot_incompare values or test membership.- Ordered types also provide
gt,ge,lt, andle. - Text columns provide
contains,starts_with,ends_with, andcontains_insensitive. - Nullable columns provide
is_nullandis_not_null.
A filter retains the model it belongs to:
use generated::user::User;
let users = client
.user()
.filter(User::id.eq(1) | User::id.eq(2))
.all()
.await?;
To compare two columns of the same row, pass the other column's token: eq_field, ne_field, gt_field,
ge_field, lt_field and le_field take a field of the same model and value type, so a mismatch does not compile.
A NULL on either side matches nothing.
// Guilds with a free seat: the count is still below that guild's own limit.
let open = client
.guild()
.filter(Guild::member_count.lt_field(Guild::max_members))
.all()
.await?;
&, |, and ! compose filters, as do .and(other) and .or(other). The generated
UserPredicate::all(filters) and UserPredicate::any(filters) combine collections. An empty all matches
every row and an empty any matches none. The same filters work with generated updates and deletes.
Passing a post filter to a user operation, or mixing models inside a boolean expression, is a compile error.
filter_opt(Some(filter)) applies a filter that may be absent, and search_opt does the same for full-text
terms, so an endpoint with optional query parameters stays one expression:
client
.user()
.filter_opt(request.active.map(|active| User::active.eq(active)))
.order_by((User::created_at.desc(), User::id))
.page(page, size)
.await?;
order_by accepts a token, a token with .asc() or .desc(), or a tuple of them in priority order.
Advanced SQL expressions use filter_raw(expression) explicitly. This is a trusted application-code escape
hatch, also available through Predicate::from_raw and expression-based ordering. Rent applies the outer
query's runtime policies and interceptors and parenthesizes raw filter operands to preserve boolean precedence.
It does not parse arbitrary SQL or add tenant guards to tables referenced inside a raw subquery.
For example, EXISTS (SELECT 1 FROM invoices WHERE tenant_id = 20) can make a tenant-10 query's result depend
on tenant 20's invoices. The application owns authorization and tenant filtering inside that subquery.
Use generated relationship filters when possible: they apply the related entity's policies automatically.
Bind values as parameters and never accept SQL text from an untrusted caller. Raw expressions are not a
sandbox for untrusted SQL. Standalone query_raw and execute_raw statements still require system().
Cardinality
all()returns every matching row.only_or_none()returns zero or one row and still rejects duplicates.only()requires exactly one row and detects both absence and duplicates.count()returns the number of matches.exist()checks for a matching row with a database-side one-row limit. Likecount(), it applies filters, not pagination: an earlierlimit(0)oroffset(...)does not change its answer. It does not load entities or eager relations. Middleware receivesQueryOperation::ExistsandQueryOutcome::Exists(bool).sum(field),average(field),minimum(field),maximum(field), andaggregate(...)compute one database-side aggregate over the filtered rows.group_by(...)computes them per group (see grouping).order_by(field),order_by_desc(field),order_by_expression(...),order_by_expression_desc(...),limit(n), andoffset(n)shape the result window before any of the terminals above run.
String collection filters accept arrays, slices, owned strings, Cow<str>, and iterators:
let names = ["Maya", "Ken"];
let users = client
.user()
.name_in(&names)
.all()
.await?;
The same inputs work for name_not_in, standalone predicates, update/delete filters, and relation closures.
Owned strings move into the query. Borrowed text is copied once, so the query can outlive the input collection.
An empty in filter matches nothing. An empty not_in filter imposes no membership restriction.
Relationships and eager loading
let posts = client
.post()
.with_author(|author| author)
.with_comments(|comments| {
comments
.order_by(comment::Comment::created_at)
.with_author(|author| author)
})
.all()
.await?;
for post in posts {
let author = post.author()?;
let comments = post.comments()?;
// Use the already-loaded values without another database request.
}Calling a relationship accessor that was not loaded returns RelationNotLoaded. This makes accidental N+1 query
patterns visible instead of issuing hidden requests.
A field holding an enum whose variants carry data filters by variant: format_is(TournamentFormatVariant::Swiss) and
format_in([...]) match a variant with or without its data, and an optional field without a value matches none.
Relationship predicates and traversals are generated beside eager loading. has_author() keeps rows whose target
exists. where_comments(|comments| comments.body_contains("typo")) filters by a predicate over the related rows.
query_comments() traverses from the current result set to a CommentQuery over the related rows. select_title()
projects one column into a typed single-field query that supports all(), only(), only_or_none(), and to_sql().
Rent chooses the relation loading plan automatically. A direct to-one relationship can use one joined query, while collections and nested relationships use bounded select-in queries that avoid row multiplication. Override the plan when profiling a particular workload:
use rent::runtime::RelationLoadStrategy;
let joined = client
.post()
.relation_load_strategy(RelationLoadStrategy::Join)
.with_author(|author| author)
.all()
.await?;
let selected = client
.post()
.relation_load_strategy(RelationLoadStrategy::SelectIn)
.with_author(|author| author)
.all()
.await?;
Join is a preference, not permission to change query semantics. Rent falls back to select-in loading for nested,
paginated, ordered collection, and many-to-many shapes that cannot be joined safely.
Every to-many relationship also generates a count. with_comments_count(|comments| comments) counts each loaded
post's comments without loading them, and post.comments_count()? returns the number as a u64. The closure
filters the counted rows, and a count requested without it is RelationNotLoaded, like any other relationship.
See count related rows.
Inspect SQL and query plans
to_sql() applies configured policies and interceptors, renders backend-native placeholders, and returns redacted
bind kinds instead of secrets:
let inspection = client
.user()
.email_eq("maya@example.com")
.to_sql()?;
assert!(!inspection.sql.contains("maya@example.com"));
assert_eq!(inspection.bindings, ["string"]);Use explain() for a backend-native plan statement and explain_analyze() when runtime measurements are needed.
The latter executes a query when the rendered SQL is submitted to PostgreSQL or MySQL. Generated builders only
inspect SELECT statements.
For permission-enabled queries, these methods show the final read statement. Authorization may first fetch relationship evidence and materialize operation-local decisions on the same connection. The preview does not execute those steps, and its temporary-table names can change between calls. Use a trace of the actual operation when investigating its complete database work. Copying the preview alone into another connection does not reproduce permission evaluation.
Projection
let identities = client
.user()
.select((user::User::id, user::User::email))
.all()
.await?;
Single fields and typed tuples return only the selected columns. Use complete models when relationships will be loaded.
Use tuples for small selections you immediately unpack. Tuples support up to sixteen fields. Prefer a named result struct for larger selections or response shapes reused across your application:
use generated::post::Post;
#[derive(serde::Deserialize, rent::Projection)]
#[rent(entity = Post)]
struct PostSummary {
id: i64,
#[rent(field = title)]
heading: String,
}
let summaries: Vec<PostSummary> = client
.post()
.select(PostSummary::projection())
.all()
.await?;#[rent(entity = Post)] identifies the source model. Result fields match model
fields by name. #[rent(field = title)] selects a different source field for a
result field named heading. Storage names declared with @map are resolved
through the generated model. Missing fields and incompatible Rust types are
compile errors. Named results are not limited to sixteen fields.
Named selections are ordinary reads. Filters, ordering, limits, tenant policies,
permissions, and transactions apply in the same way as tuple selections.
Projection does not define a new database model or require an RSL declaration.
Derive serde::Deserialize on the result for portable decoding. Its fields must
deserialize in declaration order, so serde(flatten), serde(skip),
serde(skip_deserializing), and serde(transparent) are rejected. Custom Serde
deserializers use the dynamic path so their transformations are preserved.
See reading selected fields and
production access patterns for runnable examples.
Generated queries and projections select a suitable decoder automatically,
including direct database decoding for eligible UUID, timestamp and JSON values.
Some query shapes use compact JSON instead, including SQLite UUID-only queries
and streams. No decoder setting is required in application code. Nullable fields return
Option<T>, and tuple values follow the order of the selected fields. Registered field codecs, interceptors,
custom Rust types, and SQL storage overrides use the dynamic decoding path so their behavior remains intact.
Policies apply to both paths, including inside transactions.
Call to_sql() on a projection to inspect the actual selected columns. Use only() when exactly one row is
required, or only_or_none() for zero or one. Both reject multiple matches rather than silently taking the first.
Computed values
To return a calculated value beside a stored field, select a Computed<T> in the
same tuple. For example, Maya can request a lowercase heading without loading a
whole post:
use generated::post::Post;
use rent::runtime::{ColumnKind, Computed};
use rent::sql::ast::{column, function};
let heading = Computed::<String>::trusted(
"heading",
function("lower", [column(Post::title.name())]),
ColumnKind::Scalar,
);
let headings: Vec<(i64, String)> = client
.post()
.select((Post::id, heading))
.all()
.await?;T declares the SQL result type. Use Option<T> for a nullable result and choose
its storage representation with ColumnKind. SQL result types are checked by the
decoder, so an incompatible declaration returns a decoding error. Expressions
can use storage names from generated fields, including mapped columns.
For a PostgreSQL extension expression, use Computed::trusted_postgres. It
supports PostgreSQL's native decoders and rejects other backends before execution.
For example, a cosine-distance expression returns f64, while a pg_search BM25
score returns f32. Both can appear alongside ordinary fields in one selection.
Order by the original expression before select, then apply the desired limit
and offset. An output alias does not become a filter or ordering field.
The trusted constructors make the caller responsible for reviewing functions,
operators and SQL result types. Root-row tenant and permission filters remain in
force. They do not authorize data a database function reads internally.
Use reviewed scalar functions appropriate for the caller's authority. Expressions
may reference only the root model's storage columns. Raw SQL, subqueries, window
expressions and unsupported query shapes are rejected; use the explicit trusted
SQL adapter for those shapes.
Aliases must be distinct ASCII identifiers of at most 63 bytes and cannot shadow
root storage columns. Stored fields retain their registered field codecs.
Computed values decode independently using the declared result kind. When codecs
or interceptors require JSON fallback, PostgreSQL extension results use their
stable SQL text form, which the chosen result type must be able to deserialize.
The Projection derive continues to describe stored fields; computed selections
use tuples.
Full-text search
A model declaring @@fulltext generates a typed search
method that matches against its generated vector:
let found = client
.article()
.search("migration")
.all()
.await?;
The terms are parsed with websearch_to_tsquery, so the syntax a search box already teaches people works
unchanged: quoted phrases match as phrases, or widens, and a leading - excludes.
client.article().search("\"cast iron\"").all().await?;
client.article().search("migration or indexing").all().await?;
client.article().search("index -btree").all().await?;Matching uses the configuration the column was built with, so indexes finds Indexing under the default
english configuration. Terms that match nothing return an empty result rather than an error.
search is an ordinary filter, so it composes with the rest of a query:
let page = client
.article()
.search("index or migration")
.id_ge(2)
.page(1, 20)
.await?;
Pagination and streaming
Rent offers three pagination shapes. Choose by what the caller needs, because their costs differ:
| Method | Returns | Cost |
|---|---|---|
page(n, size) | OffsetPage with an exact total and total_pages | Counts the match set, then reads the window |
slice(n, size) | OffsetSlice with has_more and no total | Reads the window plus one lookahead row |
page_by_<key>(size, cursor) | Page with an opaque next cursor | Reads the window plus one lookahead row, and never offsets |
page_after(keys, size, after) | Page whose next holds the last row's key values | Same as page_by_<key>, over an order chosen at run time |
page fits a page-numbered contract, the shape most HTTP APIs expose. Pages are one-indexed:
let page = client
.post()
.order_by_desc(post::Post::created_at)
.page(1, 20)
.await?;
assert_eq!((page.page, page.page_size), (1, 20));
let showing = format!("{} of {}", page.items.len(), page.total);page issues two statements: a count that ignores the window, and the windowed read. They are not atomic
together, so a write committed between them can leave total disagreeing with items. Run the call inside a
transaction when the page and its total must agree.
With permissions enabled, page keeps the count and windowed read on one transaction
connection and binds one database time for both. A grant cannot expire between their
permission evaluations. Each new page operation obtains a fresh time, including inside an
existing transaction. Visibility of concurrent writes still follows that transaction's isolation.
Both statements retain ordinary read permissions, named .can(...) requirements, tenant
scope, and application policies.
slice reads the same window without counting, for a list that only needs a next-page affordance:
let slice = client
.post()
.order_by(post::Post::id)
.slice(3, 20)
.await?;
let show_next_control = slice.has_more;Both reject a zero page with InvalidPageNumber and a zero page size with InvalidPageSize instead of
clamping, and both replace any earlier limit or offset on the query.
Deep offset pages make the database walk past every skipped row. Keyset pagination stays flat regardless of depth, which makes it the better fit for a feed:
let page = client
.post()
.page_by_id(50, after)
.await?;
let next = page.next;page_by_id orders by ascending ID and its cursor contains that ID. Use the corresponding generated
compound cursor when ordering by multiple fields. A conflicting sort or nonzero offset returns
InvalidCursorOrdering. This also applies when ordering is changed after after_id or before_id.
Filters compose normally with cursor pagination. before_id uses descending ID order.
page_by_<key> pages along an order fixed by the schema. When the order is chosen at run time, for example
from an API's sort parameter, pass it as data. EntityField::keyset(descending) turns a generated field into a
KeysetKey, and page_after orders by the keys, reads one lookahead row, and returns the last row's key values
as next:
let keys = [
post::Post::published_at.keyset(true),
post::Post::id.keyset(false),
];
let page = client
.post()
.published_eq(true)
.page_after(&keys, 25, after.as_deref())
.await?;
let next: Option<Vec<serde_json::Value>> = page.next;End the keys with a unique field, so rows that tie on the earlier keys still have one order. Keys may mix
directions. Rent expands the "after" condition key by key instead of comparing row values, which only works when
every key sorts the same way. A key must be an ordered column, which the keyset bounds check at compile time. Boolean keys order false before true when ascending, and true before false when descending. A
nullable column is allowed: rows without a value sort after every row with one, in either direction, on every
database, and a cursor may hold null for that key. page_after refuses a query that already has an order, offset, or generated cursor with
InvalidCursorOrdering, a zero page size with InvalidPageSize, and an after whose values do not fit the keys
with InvalidCursor (CursorError::KeyCount or CursorError::Decode). The values are plain JSON, not a signed
token: wrap them in your own cursor format, and treat a malformed one as a client error.
A generated query also converts into its EntityQuery with .into(), for code that works across models, such as
a shared list endpoint.
For a large export, use a bounded stream so memory does not grow with the result:
use rent::futures_util::TryStreamExt as _;
let mut rows = client
.post()
.order_by(post::Post::id)
.stream(500);
while let Some(post) = rows.try_next().await? {
export(post).await?;
}For a permission-enabled schema, each stream uses one database evaluation time and one connection
across all bounded batches. The model's read rule, tenant scope, application policy, and .can(...)
filters remain in force. Nested eager loads and counts use that same evaluation time. An expiration
that occurs between batches does not change that stream's expiry decision. A new stream samples
fresh time. Database transaction isolation still applies to concurrent data changes.
Acquisition is lazy: an unpolled stream issues no SQL. Completing or dropping an owned stream
releases its read transaction. A stream inside your transaction leaves that transaction open,
including when dropped early. A permission depth or work limit releases temporary decisions before
returning the error and leaves the borrowed transaction usable. Streams do not create a savepoint
around your transaction. After a database error, roll back if the database has aborted it.
A zero row limit still validates viewer claims. stream(0) returns InvalidStreamBatchSize.
Query interceptors prepare once, and result interceptors run per fetched batch.
Grouping
group_by takes one field or a tuple of fields and returns a grouped query. Add aggregates under names you
choose, then filter, order and page the groups by those names:
#[derive(Deserialize)]
struct Standing {
player: String,
points: i64,
games: u64,
}
let table: Vec<Standing> = client
.score()
.season_eq(3)
.group_by(Score::player)
.sum("points", Score::points)
.count("games")
.having_ge("games", 5_i64)
.order_by_desc("points")
.limit(10)
.all()
.await?;
count(name),sum(name, field),average(name, field),minimum(name, field)andmaximum(name, field)add outputs. The keys appear under their column names.having_gt,having_ge,having_lt,having_le,having_eqandhaving_nekeep groups by an output's value.order_by(name),order_by_desc(name),limitandoffsetorder and page the groups. Groups that tie on the requested order come back in key order, so pages are stable.all()decodes each group into anyDeserializetype by output name. An integersumreads as an integer on every database andaverageasf64.- An output name that is neither a key nor an aggregate, a name used twice, or a row
order_by,limitoroffsetbeforegroup_byisEntityError::InvalidGrouping. Order and page the groups aftergroup_by. - The tenant guard, policies and interceptors apply as they do to any query.
to_sql()inspects the grouped statement without running it. It includes the grouping,having, ordering, pagination and configured access restrictions. Bound values are shown only as their types.
For example, inspect a count before running it:
let grouped = client.score().group_by(Score::player).count("games");
let inspection = grouped.to_sql()?;
println!("{}", inspection.sql);Rebind a low-level SQL template
Query infrastructure can place Argument::Parameter(slot) in a SQL AST and keep the
template without storing request values. Clone it, then use try_visit_arguments_mut
to replace every slot before execution:
use rent::sql::{
Dialect,
ast::{Argument, Comparison, Select, TableRef, field_compare},
};
let template = Select::new(Dialect::Sqlite, TableRef::new("posts"), ["id"])
.where_(field_compare("tenant_id", Comparison::Eq, Argument::Parameter(0)));
let bindings = [Argument::from("acme")];
let mut query = template.clone();
query.try_visit_arguments_mut(&mut |argument| {
if let Argument::Parameter(slot) = argument {
*argument = bindings
.get(*slot as usize)
.cloned()
.ok_or("missing SQL binding")?;
}
Ok::<(), &'static str>(())
})?;The visitor reaches typed arguments in nested selects, common table expressions, compound branches, joins, projections and formatted placeholders. It preserves SQL identifiers and text. A visitor error may leave the clone partly bound. Discard it. All database drivers reject unresolved slots. Literal migration rendering rejects them too. Generated query users supply values through the normal typed methods.
The SQL expression builder also supports conditional projections with case_when:
use rent::sql::ast::{Comparison, case_when, column, compare, value};
let visibility = case_when(
[(
compare(column("published"), Comparison::Eq, value(true)),
value("Public"),
)],
value("Draft"),
);
// Attach it with selector.select_expression(visibility, Some("visibility")).The first true condition supplies the result. False and SQL NULL conditions continue to the next
branch. If none matches, the last argument supplies the result. Values remain bound parameters.
An empty branch list evaluates to that last argument.
Nested and recursive SQL queries
The lower-level rent::sql builder can attach common table expressions (CTEs) to a Select
with .with(CommonTableExpression). The CTE stays local to that selector, including inside
exists, in_select, and Select::from_select. Bind parameters retain statement-wide ordering
when a selector is nested in another query or a write predicate.
For example, start with a root node and repeatedly join its children:
use rent::sql::ast::{
Comparison, Select, SetOperator, TableRef, and, compare, field_compare,
};
use rent::sql::query::CommonTableExpression;
let nodes = TableRef::new("nodes").alias("n");
let tree = TableRef::new("tree");
let root = Select::new(dialect, nodes.clone(), ["n.tenant_id", "n.id"])
.where_(and([
field_compare("tenant_id", Comparison::Eq, "acme"),
field_compare("id", Comparison::Eq, 1),
]));
let children = Select::new(dialect, nodes.clone(), ["n.tenant_id", "n.id"])
.join(tree.clone(), and([
compare(
nodes.column("tenant_id"),
Comparison::Eq,
tree.column("tenant_id"),
),
compare(
nodes.column("parent_id"),
Comparison::Eq,
tree.column("id"),
),
]));
let traversal = root.set(SetOperator::Union, children);
let tree = CommonTableExpression::new("tree", traversal)
.columns(["tenant_id", "id"])
.recursive();
let descendants = Select::new(dialect, TableRef::new("tree"), ["id"])
.with(tree)
.where_(field_compare("tenant_id", Comparison::Eq, "acme"));
Pass descendants to in_select(column("id"), descendants) in an outer query that also filters
the tenant. This example carries the complete (tenant_id, id) identity through every recursive
step. UNION removes repeated identities, so the traversal terminates even for a cycle. If you
add a depth or path column, repeated visits become different rows. Add an explicit termination
strategy rather than relying on UNION alone.
Use explicit tenant predicates in the root and recursive steps. Lower-level SQL builders do not apply generated-client permissions or tenant guards automatically.
Raw SQL
query_raw and execute_raw run a statement built with rent::sql! on the client's connection, inside its
transaction when it is a transaction client:
use rent::sql;
let top: Vec<Ranked> = client
.system()
.query_raw(sql!(
"SELECT player, SUM(score) AS total FROM scores
WHERE season = {season} GROUP BY player
ORDER BY total DESC LIMIT {limit}"
))
.await?;
let removed = client
.system()
.execute_raw(sql!("DELETE FROM scores WHERE season = {season}"))
.await?;
-
Values are always parameters.
sql!takes a string literal only, and each{expression}becomes a bound value, borrowed like aformat!argument. AnySqlValuebinds: strings, integers, floats, booleans, UUIDs, timestamps, JSON, bytes, andOptionof each. Write{{and}}for literal braces. A value cannot become SQL text, so there is no injection to guard against. -
Statements compose from fragments.
pushappends onesql!fragment to another andRawSql::joinjoins many with literal separator text, which builds a statement whose shape depends on input without ever formatting a string:let terms = weights .iter() .map(|(metric, weight)| sql!(" WHEN {metric} THEN score * {weight}")); let mut ranked = sql!("SELECT player, SUM(CASE metric"); ranked .push(RawSql::join(terms, "")) .push(sql!(" ELSE 0 END) AS total FROM scores GROUP BY player")); -
Rows decode by column name into any
Deserializetype: integers and floats as numbers, exact numerics as integers when they have no fraction (soSUMof an integer column reads asi64on every database) and otherwise the nearestf64, timestamps asDateTime(a MySQLDATETIMEis read as UTC, the zone Rent writes), JSON columns as their document, and bytes asVec<u8>. A column type with no such form is an error naming it. Cast it in the statement. A row that does not fit the type isEntityError::RawDecode. -
Only a system handle runs raw SQL. The tenant guard, policies, hooks and interceptors cannot see inside a hand-written statement, so
query_rawandexecute_rawreturnEntityError::RawSqlRequiresSystemon any other handle. Call.system()deliberately and filter the tenant in the statement. Extension clients follow the same rule: bind them toclient.system()(ortx.client().system()inside a transaction), or to the pool.
Reusable expressions
Compact filters are preferred for ordinary code. Column tokens are useful when an expression must be stored or combined dynamically:
use generated::post::{Post, PostPredicate};
fn visible_to(viewer_id: i64) -> PostPredicate {
Post::published.eq(true) | Post::author_id.eq(viewer_id)
}
let posts = client
.post()
.filter(visible_to(viewer_id))
.all()
.await?;
Every token operator returns a Predicate for its own model, and those values compose with &, |, and !.
Combining predicates from two different models is a compile error. A model whose fields include an extension
type or a @rent.rustType JSON column also gets a <model>_predicate module for those fields, because their
comparisons can fail and return a Result.
Require a named permission
Access-enabled schemas generate a permission enum for each model:
use generated::post::PostPermission;
let posts = client.post().can(PostPermission::Editor).all().await?;
let count = client.post().can(PostPermission::Editor).count().await?;can adds a requirement to normal read visibility, tenancy, and application policies. It cannot widen
read access. The same requirement survives projections, grouped queries, and existence checks.
An eager relationship or relationship count applies the target model's own read permission and policy. A modeled join also applies the join model's read rule and policy. Parent and nested queries share a transaction connection and one expiry
evaluation time, including when eager-loading a page. Permission-enabled eager loads use batched
queries to preserve independent tenant validation for each model. The same modeled-join rules
apply to query_*, has_*, where_*, and with_*_count relationship methods.
A PostPermission cannot be passed to a user query. Rules are evaluated against database evidence
when the query executes. Unsupported permission query paths return an explicit error. See the
current limitations.
Include permission flags
let rows = client.post().with_permissions().order_by(Post::id).all().await?;
for row in rows {
let can_edit = row.permissions.has(PostPermission::Editor);
println!("{}: edit={can_edit}", row.entity.title);
}with_permissions() returns WithPermissions<Model> rows with typed Granted flags for all
rules declared on that model. Rows and flags share one visibility snapshot. The flags do not filter
rows. Ordinary read, tenancy, application policies, and .can(...) still determine visibility.
Generated filters, cursor paging, offset pages, slices, streams, and eager loads remain
available. Incomplete recursive evaluation is an error, including for empty results. See
permission badges for a runnable example.
Permission-enabled models reserve the storage column name __rent_permissions for the
projected result format. A relationship named permissions is also reserved on these models
because its eager-loading method would collide with with_permissions().
Check a resource permission
use generated::post::PostPermission;
let can_edit = client.post_permissions(&post_id)
.can(PostPermission::Editor)
.await?;The resource accessor accepts the complete generated key and a rule from that model's enum.
It is generated for every model with a permissions block, including those without bracket
assignments. The result is false for missing, hidden, or out-of-tenant resources. Invalid
claims and incomplete recursive evaluation are errors. Ordinary read permission, tenant scope,
and application policies still apply. Replacing a policy predicate cannot remove the key.
The result describes the existing row when checked. A subsequent write separately authorizes
its final values inside the write transaction.
Recursive permission reads
When a generated model's read rule is recursive, the same query API applies it to typed rows,
projections, counts, existence checks, pagination, and eager loads. Tenant scope, query filters,
and application policies select candidate roots before permission filtering and pagination.
Exceeding the declared relationship depth returns PermissionError::EvaluationDepthLimit.
Exhausting bounded evaluation work returns PermissionError::EvaluationWorkLimit.
Neither is an ordinary denied row. limit(0) still validates completeness and subject scope.
See the runnable recursive example.
Named .can(...) filters can also use recursive rules. They share the same candidate roots
and completeness checks as the required read rule. Repeated names are deduplicated.
Field-backed relationship filters and traversals support recursive rules. Related evaluation
is limited to rows reachable from the parent candidates, before result pagination. A child policy
cannot remove that parent link. The same target checks apply through modeled many-to-many
links, including eager collections and counts. Each link retains its own read rule and
tenant scope. Its rule can follow a recursive permission. Parent reachability and the
related query's filters bound candidate links before evaluation. An incomplete link rule
returns an error even when another branch denies access or the result is empty.
Permission-only results
client.post().permissions().all().await? returns Vec<Granted<PostPermission>> for
readable posts, in query order. The generated builder retains filters, named .can() rules,
ordering, .only(), .only_or_none(), pages, slices, keyset cursors and streams. Permission-only
pages retain their ordinary pagination metadata. The evaluator computes the same flags as
with_permissions() and derives cursors before discarding the model from the returned value.
Tenant, read and application restrictions still apply, including validation on empty results.
The current implementation materializes the model internally, so this is an output-shape API
rather than a reduced SQL-payload optimization.
Reverse permission inspection
On a declared access subject's generated query, who_can(ResourcePermission, &ResourceKey)
returns a typed reverse query. Apply subject filters before this method. Use generated fields
with order_by or order_by_desc, and optionally limit or offset, then execute .all().
The result has subjects, unresolved and complete. It preserves the inspecting viewer's
resource and subject read policies. See sharing dialogs
for identity-only evaluation, contextual claim completeness and current recursive limitations.
Permission explanations
client.post_permissions(&post_key).explain(PostPermission::Viewer).await? returns
Option<PermissionExplanation> for the current trusted viewer. None means the keyed resource
is missing or hidden. A readable resource returns the actual allowed decision, separate
actor/optional represented trees, and the schema's delegation mode. A tree node contains
expression, matched, optional rule documentation, and ordered children. Related-rule
nodes reveal no intermediate records. Full keys, tenant claims, application read policies,
expiry and evaluation limits remain active. Recursive rules use a bounded traversal for each
independent claim context. Incomplete traversal returns a depth/work error rather than a partial
tree. See the explanation example and
recursive explanations.
This typed resource helper is distinct from query .explain(), which renders a database plan.
