Mutation builders
Builder setters only collect values. The database request begins at save(), save_one(), or exec().
let post = client
.create_post()
.title("A typed database client")
.body("Built in Rust")
.connect_author(&author)
.save()
.await?;
The setters do not cause multiple round trips. PostgreSQL and SQLite return the model from the insert request. MySQL and MariaDB reload an auto-identified row when the server cannot return its complete shape.
create_post_from(NewPost { .. }) takes the generated typed input for the required fields, so a missing value
fails at compile time. Nested writes use create_author(...), create_tags(...), connect_tags_entities(...),
set_tags(...), and sync_tags_ids(...). on_conflict(target) turns the create into an upsert. upsert_post(target)
is the same builder with the conflict target preset, and the _raw forms of both take raw column names.
Update one row
let post = client
.update_post_one(&post)
.title("A faster typed database client")
.save_one()
.await?;
An entity-targeted update carries its ID. If the schema has a version field, it also compares the old version and
increments it atomically. update_post().one(id) targets one row by ID without an entity value, and
expect_version(v) on either form checks a specific version. delete_post_one(&post) and delete_post().one(id)
are the matching one-row deletes. On a versioned model the entity form checks the version too. overwrite() on a
versioned update or delete drops the comparison for a deliberate last-write-wins, while still advancing the version. Many-to-many links can also be written by ID with add_post_tags(post_id, tag_id),
remove_post_tags(...), connect_post_tags(...), and disconnect_post_tags(...).
Additional filters on a single-row update are conditions for making the change. For example,
.one(id).published_eq(false).published(true).save_one() returns NotFound if the post is
already published. It does not return the unchanged post as a successful update. The same
condition protects relationship changes made through that builder. A matching update can
change the field used in its condition, and setting an existing value still succeeds.
With RSL permissions enabled, a single-row update or delete first requires ordinary read
visibility, then the operation's write rule. A recursive read rule participates in both
the visibility check and the actual guarded write. An invisible or missing row returns
NotFound. An incomplete recursive walk returns its depth/work error and changes no row.
A visible row without write authority returns PermissionError::Denied.
An additional caller filter on .one(id), such as .rev_eq(expected), returns
NotFound when it excludes the target. It remains part of the atomic write
predicate. Built-in expect_version retains its separate permission-first
OptimisticLock behavior. A manually filtered revision does not become a built-in
version field.
A delete rule may follow recursive permissions, such as delete = post.viewer. Bulk deletes
select authorized matching rows. Single-row deletes keep the visibility behavior above. A depth
or work-limit error rejects the complete operation. After-hook failures and outer transaction
rollback restore deleted rows. Recursive update rules use the same bounded evaluator. Changed permission dependencies still
require final create-rule validation, including recursive rules such as create = parent.editor.
The final check retains pre-write permission facts for every changed row. A batch cannot
manufacture its own authority. See reparenting
and parent-authorized creates. Both single and bulk creates support these parent rules.
If a changed dependency fails the final row’s create rule, the update or upsert returns
PermissionError::Denied { permission: "create", .. } and rolls back. The create check runs
before the final relationship-visibility check. Both must pass before the write can commit.
For versioned single-row deletes, Rent checks visibility and delete authority before comparing
the expected version. An authorized stale value returns OptimisticLock. An unauthorized
visible row returns PermissionError::Denied. The complete identity and expected version
remain independent constraints on the actual delete, including when a policy replaces its filter.
See the inherited-delete example.
Use a filtered builder for set-based updates:
let changed = client
.update_post()
.author_id_eq(author.id)
.published_eq(false)
.published(true)
.save()
.await?;
Partial updates
Every settable field has a setter, title(value), and a nullable one also has clear_title(), which stores
SQL NULL. When the caller decides which columns change, describe the change as a patch instead of a chain of
conditionals. Every model gets a generated <Model>Patch with one Change per mutable column, Keep by
default, and both the create and the update builder accept it through apply:
use generated::post_patch::PostPatch;
use rent::runtime::Change::{self, Set};
let post = client
.update_post_one(&post)
.apply(PostPatch {
title: Set("Renamed".to_owned()),
summary: Change::clear(),
..Default::default()
})
.save_one()
.await?;
A nullable column's field is Change<Option<T>>, so Set(None) (or Change::clear()) writes NULL. A required
column's is Change<T> and cannot.
When the optional columns arrive in a struct of your own, derive rent::Patch on it. Each Option<T> field
keeps the column when None and sets it when Some, an Option<Option<T>> field can also clear a nullable
column, #[rent(skip)] leaves a field out, and #[rent(with = "path")] converts a present value first. A
field the model does not have, or whose type does not match the column, fails to compile at that field:
#[derive(Deserialize, rent::Patch)]
#[rent(model = Post)]
struct UpdatePostRequest {
title: Option<String>,
body: Option<String>,
summary: Option<Option<String>>,
}
let post = client
.update_post_one(&post)
.apply(request)
.save_one()
.await?;
On a create, a kept column takes its schema default or NULL. On an update, a kept column is untouched.
An update that sets no field writes nothing, @updatedAt included, but still behaves like an update: policies and
hooks run, save() reports the rows it matched, and save_one() returns the row as it stands. So save() returning
0 always means no row matched. A versioned compare-and-swap (expect_version, or update_<entity>_one on a model
with a version field) is the exception: it counts as a change and advances the version even with no other field set,
so concurrent editors holding the old version are refused.
Atomic updates
When concurrent writers only add to a counter or a list, let the database compute the new value from the stored one. Both writes land, where reading the row, changing it in Rust, and saving it would keep only the later one:
let post = client
.update_post_one(&post)
.increment_views(1)
.push_tags("rust".to_owned())
.save_one()
.await?;
client.update_product().id_eq(product_id).decrement_stock(2).save().await?;Every required numeric field gets increment_<field>(by) and decrement_<field>(by), except identities, foreign
keys, tenant_id, version fields, and fields with a custom Rust type. Every required list field (String[],
Json[], enum lists, composite lists) gets push_<field>(element), which appends after the elements already
stored. Calls on one field compose in order, so .increment_views(1).increment_views(2) adds three.
An atomic change and a plain assignment to the same field in one update is rejected with
EntityError::InvalidAtomicChange, as is an atomic change to a field with a registered field codec, whose stored
form the database cannot compute from. Hooks and policies see each change through Mutation::added,
Mutation::subtracted, and Mutation::appended. The resulting value exists only in the database.
Delete
let removed = client
.delete_comment()
.post_id_eq(post.id)
.exec()
.await?;
Database foreign keys determine cascade, restrict, and SET NULL behavior.
Upsert
With an access-enabled schema, Rent authorizes the branch that actually runs: create for a
new row, normal read visibility plus update for an existing row. keep_existing() also needs
that existing-row access. Creator stamping applies to insertion. An update preserves the stored
creator and complete identity. Policies and hooks see Operation::Upsert. The guarded operation
uses one transaction (or savepoint), and a failed hook or final-row check rolls it back. A racing
insert can return a uniqueness error. Hooks are not automatically replayed. See
permission upserts.
let membership = client
.upsert_membership(membership::unique::user_id_group_id)
.user_id(user.id)
.group_id(group.id)
.role("owner")
.save()
.await?;
The conflict target is one of the model's declared unique keys. The generated <model>::unique module has one
constant per primary key and full unique constraint, named after its fields (id, email, user_id_group_id), so a
target that matches no constraint does not compile. On a tenant-scoped model the per-tenant uniques include the
tenant field, and so do their keys (tenant_id_email). _raw forms take column names and are not checked.
A mode: insensitive unique gets the same typed key. For example, upserting "MAYA" by a case-insensitive
name key matches the existing "Maya" row. keep_existing() preserves that row's stored spelling.
on_conflict_update(...) applies only its explicit changes. Compound keys and partial unique conditions
retain these rules. See comparison semantics for provider
and collation requirements. If an exact and an insensitive unique share the same fields, the insensitive
key has an _insensitive suffix.
Soft delete: upsert against a partial unique
A partial unique index is unique only among the rows its condition matches. With soft delete, that makes a name unique among live rows, so deleting a row frees its name to be taken again:
model Handle {
id BigInt @id @rent.manual
name String
owner String
deleted_at DateTime?
@@unique([name], where: "deleted_at IS NULL")
}A partial unique gets a key like any other (handle::unique::name), and an upsert against it repeats the condition, so
it matches the live row and ignores deleted ones:
let handle = client
.upsert_handle(handle::unique::name)
.id(next_id)
.name("maya")
.owner("ken")
.on_conflict_update(|update| update.owner("ken"))
.save()
.await?;
While maya is live, the upsert updates it in place. Once it is deleted, the same call creates a new maya, and the
deleted row stays as it was. When a full unique covers the same fields, the partial key is named <fields>_partial.
MySQL and MariaDB have no partial indexes, so a where: unique is a schema error there (RSL241).
An upsert preserves the existing row's primary key, including every component of a compound key. A supplied
or automatically generated candidate ID is used only if the insert creates a new row. This lets you upsert by
a unique email or slug without breaking relationships to the existing row. Other incoming values, including
evaluated client defaults, update the matching row. Omitted database-defaulted fields keep their current values.
Fields declared @rent.immutable retain their existing values on conflict, even when a different insert value
is supplied. A version field advances atomically on conflict. Newly inserted records use their create default.
Update differently on conflict
By default a conflict writes the create values to the existing row. on_conflict_update writes different changes
instead: the create values build a new row, and the update changes an existing one. It takes the model's update
builder, so setters, atomic changes and @updatedAt work as they do in an update:
let counter = client
.upsert_page_view(page_view::unique::tenant_id_path)
.path("/pricing")
.views(1)
.on_conflict_update(|update| update.increment_views(1))
.save()
.await?;
A new row gets views = 1. An existing one keeps its other columns and adds one. The update carries values only: a
filter, an expected version or a relationship write in it is refused, and so is a change to the row's identity or an
immutable field.
Keep the existing row
keep_existing inserts when the target is free and otherwise returns the existing row unchanged, an insert-if-absent
that always hands back the row:
let tag = client
.upsert_tag(tag::unique::tenant_id_name)
.name("release")
.keep_existing()
.save()
.await?;
On a tenant-scoped model neither form reaches another tenant's row: a conflict with one is refused as a policy denial.
An upsert is an unconditional insert-or-update, not a stale-write check. Use update_post_one(&post) when you
need compare-and-swap behavior. Generated filtered updates also advance version fields for every matched row,
so earlier entity snapshots become stale after a bulk edit. Empty updates without an explicit expected version
remain no-ops. Raw SQL writers must maintain the version themselves. An exhausted version counter rejects the
statement instead of wrapping or silently switching to floating-point storage. Database rejection leaves the
row unchanged, and a failed set-based statement rolls back all of that statement's row changes.
Bulk create
let created = client
.create_post_many([
client
.create_post()
.title("First")
.connect_author(&user),
client
.create_post()
.title("Second")
.connect_author(&user),
])
.save()
.await?;
Rent groups compatible rows, bounds statement parameters and payload size, and wraps chunked execution in one transaction. A failure in a later chunk rolls back earlier chunks.
If only the count matters, skip returning and decoding the inserted models:
let result = client
.create_post_many(creates)
.exec()
.await?;
assert_eq!(result.inserted, expected);With access enabled, both bulk-create methods check every candidate against the model's
create rule, tenant scope, and application policy. Creators are stamped for every row. A denied
candidate, conflicting key, forbidden trigger change, or rejected after hook rolls back the entire
batch. save() retains input order. exec() omits model decoding but keeps all authorization checks.
The batch has one database evaluation time and one before/after hook pair. See the
permission example.
Recursive rules such as create = parent.editor apply to both terminals. Each row must be
authorized through evidence that predates the batch. Newly inserted rows cannot authorize
later ones. Before hooks can change candidate values, and final stored rows are checked
again before commit. Omitted auto-increment keys are supported when the rule and application
policy do not require the unallocated key. Returned rows retain input order. A rollback may
leave gaps in the database sequence. See native-key children
and inherited bulk creates.
PostgreSQL ingestion jobs can use the generated native CSV COPY builder:
let result = client
.copy_user_csv(csv_bytes)
.header(true)
.exec()
.await?;
COPY follows generated column order and is intentionally rejected when field codecs, application policies, or mutation hooks need to inspect individual rows. Permission-enabled handles also reject COPY. Trusted imports must use an explicit system handle.
Nested relationship writes are also transactional. Use explicit foreign-key setters when no related create is
needed. Use generated connect_*, create_*, and relationship mutation methods for typed relationship changes.
Relationship mutations
To-one setters accept either a loaded entity or only its ID:
let draft = client
.create_post()
.title("Draft")
.connect_author(&author)
.save()
.await?;
let imported = client
.create_post()
.title("Imported")
.connect_author_id(author_id)
.save()
.await?;
Collection setters distinguish replacement from incremental changes:
let post = client
.update_post_one(&post)
.set_tags([&rust, &databases])
.save_one()
.await?;
let post = client
.update_post_one(&post)
.connect_tags([new_tag_id])
.disconnect_tags([old_tag_id])
.save_one()
.await?;
set_tags and sync_tags_ids clear and rebuild the join rows inside the same transaction as scalar changes.
With optional RSL permissions enabled, join mutations require read and update authority on the
source and readable target rows in the same tenant. Modeled joins also run their own create or
delete rules and hooks. A forbidden target or failed hook rolls back the replacement and its
scalar changes. save_one() returns the authorized parent from that transaction after child writes
and final-value validation. Its after-write hook receives the entity result once. A child failure,
invalid final parent, decode failure, or rejected hook rolls back the parent and every edge.
Nested creates and joins share one permission evaluation time. See the runnable
relationship permissions example.
Assign and revoke permission grants
Access-enabled schemas expose only the assignments declared with brackets in RSL. Given
editor = [User] on Post:
client.post_permissions(&post_id)
.grant_editor(User::subject(user_id.clone()))
.expires_at(deadline)
.save().await?;
client.post_permissions(&post_id)
.revoke_editor(User::subject(user_id))
.await?;
An omitted expiry clears any existing expiry. Repeating a grant or revoke is idempotent, after
authorization. Both endpoints must be readable, and the resource's manage_grants rule must allow
the caller. Complete keys and tenant boundaries are checked inside the write transaction.
manage_grants may use a bounded recursive rule, such as editor = [User] or parent.editor.
The same grant/revoke methods apply. See the
inherited grant example.
Rules accepting Team#member also accept the generated Team::member_subject(team_id) value.
Other subject types fail at compile time. See permissions for creator
stamping, hooks, isolation, and currently unsupported mutation paths.
Fields declared with @rent.generated(...) are database-owned and have no create setter.
Permission-enabled inserts evaluate their expressions from final inputs inside the guarded SQL
statement, then validate the stored row before commit. This applies to individual creates, bulk
creates, and an upsert's insert branch. Hooks cannot assign computed fields. See
computed permission values
for create-authorization requirements.
Modeled many-to-many relationships may use compound endpoint identities. Generated connect,
disconnect, and replacement methods accept the generated ID structs with every key field. Shared
physical columns, such as tenant_id, must agree between both endpoints. Permission-enabled
helpers validate the full keys and the join model's create/delete rules in the operation's transaction.
See compound permission relationships
for complete key requirements and the compound-identity tutorial.
