Skip to content
ReferenceRust API

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.