Skip to content
ReferenceRust API

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_, and not_in compare values or test membership.
  • Ordered types also provide gt, ge, lt, and le.
  • Text columns provide contains, starts_with, ends_with, and contains_insensitive.
  • Nullable columns provide is_null and is_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. Like count(), it applies filters, not pagination: an earlier limit(0) or offset(...) does not change its answer. It does not load entities or eager relations. Middleware receives QueryOperation::Exists and QueryOutcome::Exists(bool).
  • sum(field), average(field), minimum(field), maximum(field), and aggregate(...) 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), and offset(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.

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:

MethodReturnsCost
page(n, size)OffsetPage with an exact total and total_pagesCounts the match set, then reads the window
slice(n, size)OffsetSlice with has_more and no totalReads the window plus one lookahead row
page_by_<key>(size, cursor)Page with an opaque next cursorReads the window plus one lookahead row, and never offsets
page_after(keys, size, after)Page whose next holds the last row's key valuesSame 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) and maximum(name, field) add outputs. The keys appear under their column names.
  • having_gt, having_ge, having_lt, having_le, having_eq and having_ne keep groups by an output's value. order_by(name), order_by_desc(name), limit and offset order 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 any Deserialize type by output name. An integer sum reads as an integer on every database and average as f64.
  • An output name that is neither a key nor an aggregate, a name used twice, or a row order_by, limit or offset before group_by is EntityError::InvalidGrouping. Order and page the groups after group_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 a format! argument. Any SqlValue binds: strings, integers, floats, booleans, UUIDs, timestamps, JSON, bytes, and Option of each. Write {{ and }} for literal braces. A value cannot become SQL text, so there is no injection to guard against.

  • Statements compose from fragments. push appends one sql! fragment to another and RawSql::join joins 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 Deserialize type: integers and floats as numbers, exact numerics as integers when they have no fraction (so SUM of an integer column reads as i64 on every database) and otherwise the nearest f64, timestamps as DateTime (a MySQL DATETIME is read as UTC, the zone Rent writes), JSON columns as their document, and bytes as Vec<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 is EntityError::RawDecode.

  • Only a system handle runs raw SQL. The tenant guard, policies, hooks and interceptors cannot see inside a hand-written statement, so query_raw and execute_raw return EntityError::RawSqlRequiresSystem on any other handle. Call .system() deliberately and filter the tenant in the statement. Extension clients follow the same rule: bind them to client.system() (or tx.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.