rent
Learn by example

17. Upserts and custom IDs

Any scalar ID type supported by Rent can be your primary key, and its field does not have to be named id. This create uses a string key named token without dropping down to SQL:

let key = client
    .create_api_key()
    .token("key_live_ada")
    .label("primary")
    .save()
    .await?;

Use named keys in relationships

The schema can map Rust-facing field names to different SQL column names. Relationship declarations refer to the schema field names:

model ApiKey {
  token    String       @id @map("key_token")
  label    String
  sessions ApiSession[]
}

model ApiSession {
  id        BigInt  @id
  key_token String? @map("key_ref")
  key       ApiKey? @relation(fields: [key_token], references: [token])
}

Connect the entity directly, then load the relationship in either direction:

let session = client
    .create_api_session()
    .id(1)
    .connect_key(&key)
    .save()
    .await?;

let loaded = client
    .api_key()
    .token_eq(&key.token)
    .with_sessions(|sessions| sessions.with_key(|key| key))
    .only()
    .await?;

The executable chapter asserts that the session and its nested key are loaded correctly. For a single unique field other than the primary key, name that field in references instead; for example, a post's author_email can reference User.email when email is unique. connect_author(&user) then copies the email, not the user's primary key. Choose a stable key or configure the appropriate foreign-key update action if the referenced value can change.

After adding these models to your application's schema, generate the client and create the database tables:

rent generate
rent migrate dev --name add_api_sessions
rent migrate drift --check

The migration uses key_token as the referenced SQL column, while application code continues to use token. For an alternate unique key, Rent creates its unique index before adding the foreign key. Join-table columns also inherit the referenced key's database type, including native overrides such as @rent.type(mysql: "varchar(64)") on a string key.

Use compound identities

Compound identities are declared directly in RSL:

model Membership {
  user_id  BigInt
  group_id BigInt
  role     String

  @@id([user_id, group_id])
}

For an upsert, pass the typed field tuple that identifies a conflicting row. One field handles a normal unique constraint; several fields handle a compound unique constraint:

let membership = client
    .upsert_membership((
        membership::fields::USER_ID,
        membership::fields::GROUP_ID,
    ))
    .user_id(user.id)
    .group_id(group.id)
    .role("owner")
    .save()
    .await?;

The database performs one atomic insert-or-update statement. Rent returns the inserted or updated entity and runs the operation through mutation policies and hooks as an upsert. Updates and deletes reuse the generated compound identity without making you reconstruct its fields:

let updated = client
    .update_membership_one(&membership)
    .role("maintainer")
    .save_one()
    .await?;

client.delete_membership_one(&updated).exec().await?;

The tested chapter executes the same compound identity twice, proves that one row remains, updates it, and deletes it. On databases without row-returning inserts, Rent reloads by the complete identity rather than a partial key.

Compound identities retain their field types in generated models, filters, projections, and streamed results. You do not need to stringify a key or decode a JSON object in application code. Rent selects the row-decoding path for the database; field codecs and query interceptors remain active on these models as well.

Load relationships across a compound identity

When user IDs are unique only inside a tenant, include both columns in the identity and its foreign keys:

model TenantUser {
  tenant_id BigInt
  id        BigInt
  name      String
  posts     TenantPost[] @relation("TenantAuthor") @rent.order(field: "id", direction: Desc)

  @@id([tenant_id, id])
}

model TenantPost {
  id        BigInt      @id
  tenant_id BigInt?
  author_id BigInt?
  title     String
  author    TenantUser? @relation("TenantAuthor", fields: [tenant_id, author_id], references: [tenant_id, id])
}

The generated methods are the same as for a single-column relationship. connect_author(&author) assigns both key fields, and eager loading matches the complete pair:

let users = client
    .tenant_user()
    .with_posts(|posts| posts.limit(2).with_author(|author| author))
    .all()
    .await?;

for user in users {
    for post in user.posts()? {
        let author = post.author()?;
        // Each post belongs to this user's complete tenant/user identity.
    }
}

This returns the two highest-ID posts per user, using the relationship's declared descending order. Rent applies the child window in SQL and budgets each complete key tuple when batching. It does not combine independent tenant and user ID lists. If either optional foreign-key component is null, post.author()? is None; an eager-loaded empty collection is an empty slice. Use a transaction with the isolation your application requires when several reads must share a consistent snapshot.

The executable chapter creates the same user ID in two tenants, connects posts through generated builders, loads bounded previews with nested authors, and asserts that the tenant identities never mix.

Page across compound identities

Use the generated cursor for the complete identity. A tenant-local user ID alone does not uniquely order users across tenants:

let first = client
    .tenant_user()
    .page_by_tenant_id_and_id(1, None)
    .await?;

let second = client
    .tenant_user()
    .page_by_tenant_id_and_id(1, first.next.as_ref())
    .await?;

The cursor contains both fields, so users with the same local ID are returned on separate pages. For non-unique indexes, generated cursors append any missing primary-key fields as tie-breakers. Partial unique indexes also need that complete tie-breaker outside their predicate. Nullable unique fields do not get standalone keyset cursors. This chapter exercises the two-tenant page boundary under nextest.

The complete custom-ID and compound-identity model is crates/rent/examples/tutorial_16_upserts_and_custom_ids/schema.rsl. Run cargo run -p rent --example tutorial_16_upserts_and_custom_ids. The same upsert and custom-ID workflow runs under nextest.

On this page