Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Attributes Reference

Complete guide to all attributes supported by entity-derive.

Entity-Level Attributes

Applied to the struct with #[entity(...)]:

#[derive(Entity)]
#[entity(
    table = "users",
    schema = "core",
    sql = "full",
    dialect = "postgres",
    uuid = "v7",
    soft_delete,
    returning = "full",
    error = "AppError",
    events,
    hooks,
    commands,
    transactions
)]
pub struct User { /* ... */ }

Quick Reference

AttributeRequiredDefaultDescription
tableYesDatabase table name
schemaNo"public"Database schema
sqlNo"full"SQL generation level
dialectNo"postgres"Database dialect
uuidNo"v7"UUID version for ID generation
soft_deleteNofalseEnable soft delete
returningNo"full"RETURNING clause mode
upsert(...)NoGenerate INSERT ... ON CONFLICT upsert method
api(guard = "...")NoEnforced FromRequestParts guard in generated handlers
errorNosqlx::ErrorCustom error type
eventsNofalseGenerate lifecycle events
hooksNofalseGenerate lifecycle hooks trait
commandsNofalseEnable CQRS command pattern
transactionsNofalseGenerate transaction repository adapter

table (required)

Database table name.

#[entity(table = "users")]           // → FROM users
#[entity(table = "user_profiles")]   // → FROM user_profiles

schema (optional)

Database schema. Default: "public".

#[entity(table = "users")]                    // → FROM public.users
#[entity(table = "users", schema = "core")]   // → FROM core.users
#[entity(table = "users", schema = "auth")]   // → FROM auth.users

sql (optional)

SQL generation level. Default: "full".

ValueRepository TraitPgPool ImplementationUse Case
"full"YesYesStandard CRUD entities
"trait"YesNoCustom queries (joins, CTEs)
"none"NoNoDTOs only, no database
#[entity(table = "users", sql = "full")]   // Full automation (default)
#[entity(table = "users", sql = "trait")]  // Only trait, implement SQL yourself
#[entity(table = "users", sql = "none")]   // No database layer at all

dialect (optional)

Database dialect for SQL generation. Default: "postgres".

DialectAliasesClient TypeStatus
"postgres""pg", "postgresql"sqlx::PgPoolStable
"clickhouse""ch"clickhouse::ClientPlanned
"mongodb""mongo"mongodb::ClientPlanned

uuid (optional)

UUID version for auto-generated primary keys. Default: "v7".

VersionMethodProperties
"v7"Uuid::now_v7()Time-ordered, sortable (recommended)
"v4"Uuid::new_v4()Random, widely compatible
#[entity(table = "users", uuid = "v7")]     // Time-ordered (default)
#[entity(table = "sessions", uuid = "v4")]  // Random UUID

Why UUID v7?

  • Time-ordered: natural sorting by creation time
  • Better database index performance
  • No coordination required (unlike sequences)
  • Globally unique across distributed systems

soft_delete (optional)

Enable soft delete to mark records as deleted instead of removing them.

#[derive(Entity)]
#[entity(table = "documents", soft_delete)]
pub struct Document {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    pub title: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,  // Required field
}

Generated methods:

  • delete() — Sets deleted_at = NOW() instead of DELETE
  • hard_delete() — Permanently removes the record
  • restore() — Sets deleted_at = NULL
  • find_by_id() / list() — Automatically filter deleted records
  • find_by_id_with_deleted() / list_with_deleted() — Include deleted records

returning (optional)

Control what data is fetched back after INSERT/UPDATE. Default: "full".

ModeSQL ClauseUse Case
"full"RETURNING *Get all fields including DB-generated (default)
"id"RETURNING idConfirm insert, return pre-built entity
"none"(no RETURNING)Fire-and-forget, fastest option
"col1, col2"RETURNING col1, col2Return specific columns
#[entity(table = "logs", returning = "none")]              // Fastest
#[entity(table = "users", returning = "full")]             // Get DB-generated values
#[entity(table = "events", returning = "id, created_at")]  // Custom columns

events(outbox) (optional)

Durable event delivery via a transactional outbox. Plain events only generates the enum; with streams, NOTIFY is fire-and-forget. events(outbox) makes every generated write insert the serialized event into the entity_outbox table in the same transaction, and the OutboxDrainer runtime (entity-core, feature outbox) delivers rows with FOR UPDATE SKIP LOCKED, exponential backoff and parking after max_attempts. At-least-once — handlers must be idempotent. Composes with streams.

#[derive(Entity, Serialize, Deserialize)]
#[entity(table = "orders", events(outbox), migrations)]
pub struct Order { /* ... */ }

sqlx::query(Order::MIGRATION_OUTBOX).execute(&pool).await?;

struct Notifier;

#[async_trait::async_trait]
impl entity_core::outbox::OutboxHandler for Notifier {
    type Error = anyhow::Error;
    async fn handle(&self, row: &OutboxRow) -> Result<(), Self::Error> {
        deliver(&row.entity, &row.payload).await
    }
}

entity_core::outbox::OutboxDrainer::new(pool, Notifier).run().await;

upsert(...) (optional)

Generate an upsert repository method backed by INSERT ... ON CONFLICT.

#[derive(Entity)]
#[entity(table = "users", upsert(conflict = "email"))]
pub struct User {
    #[id]
    pub id: Uuid,

    #[field(create, response)]
    #[column(unique)]
    pub email: String,

    #[field(create, update, response)]
    pub name: String,
}
OptionRequiredDefaultDescription
conflictYesComma-separated conflict target columns
actionNo"update""update" (DO UPDATE) or "nothing" (DO NOTHING)

Generated:

  • action = "update"async fn upsert(&self, dto: CreateUserRequest) -> Result<User, Error> — overwrites the non-conflict #[field(update)] columns (DO UPDATE SET col = EXCLUDED.col) and returns the persisted row; columns not marked updatable keep their stored values on conflict
  • action = "nothing"async fn upsert(&self, dto: CreateUserRequest) -> Result<Option<User>, Error> — keeps the existing row untouched; None means a conflicting row already existed

Compile-time validation:

  • conflict columns must exist and carry a uniqueness guarantee (#[id], #[column(unique)] or a matching unique_index(...))
  • requires returning = "full" (the default)
  • action = "update" needs at least one non-conflict #[field(update)] column

With streams enabled, upsert publishes a Created notification for every row it returns.

api(guard = "...") (optional)

Enforce authentication in generated handlers. security = "..." only documents auth in OpenAPI; guard injects an axum FromRequestParts extractor as a leading argument of every generated CRUD and command handler, so a failed extraction rejects the request before the handler body runs.

pub struct RequireAuth;

impl<S: Send + Sync> FromRequestParts<S> for RequireAuth {
    type Rejection = StatusCode;
    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
        parts.headers.contains_key("authorization")
            .then_some(Self)
            .ok_or(StatusCode::UNAUTHORIZED)
    }
}

#[derive(Entity)]
#[entity(table = "users", api(tag = "Users", handlers, guard = "RequireAuth", guard(list = "none")))]
pub struct User { /* ... */ }

Per-operation overrides: guard(create = "Admin", list = "none", ...) with operations create, get, update, delete, list, commands; the literal "none" disables the guard. Commands listed in public = [...] never receive a guard.

error (optional)

Custom error type for repository. Default: sqlx::Error.

#[derive(Debug)]
pub enum AppError {
    Database(sqlx::Error),
    NotFound,
    Validation(String),
}

impl std::error::Error for AppError {}
impl std::fmt::Display for AppError { /* ... */ }

// Required: convert from sqlx::Error
impl From<sqlx::Error> for AppError {
    fn from(err: sqlx::Error) -> Self {
        AppError::Database(err)
    }
}

#[derive(Entity)]
#[entity(table = "users", error = "AppError")]
pub struct User { /* ... */ }

// Generated repository uses AppError:
// impl UserRepository for PgPool {
//     type Error = AppError;
//     ...
// }

events (optional)

Generate lifecycle events enum. See [[Events]] for details.

#[entity(table = "orders", events)]

Generated:

pub enum OrderEvent {
    Created(Order),
    Updated { id: Uuid, changes: UpdateOrderRequest },
    Deleted(Uuid),
}

hooks (optional)

Generate lifecycle hooks trait. See [[Hooks]] for details.

#[entity(table = "users", hooks)]

Generated:

#[async_trait]
pub trait UserHooks: Send + Sync {
    type Error: std::error::Error + Send + Sync;

    async fn before_create(&self, dto: &mut CreateUserRequest) -> Result<(), Self::Error>;
    async fn after_create(&self, entity: &User) -> Result<(), Self::Error>;
    async fn before_update(&self, id: &Uuid, dto: &mut UpdateUserRequest) -> Result<(), Self::Error>;
    async fn after_update(&self, entity: &User) -> Result<(), Self::Error>;
    async fn before_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
    async fn after_delete(&self, id: &Uuid) -> Result<(), Self::Error>;
}

commands (optional)

Enable CQRS command pattern. See [[Commands]] for details.

#[entity(table = "users", commands)]
#[command(Register)]
#[command(Deactivate, requires_id)]

transactions (optional)

Generate transaction repository adapter for type-safe multi-entity transactions.

#[derive(Entity)]
#[entity(table = "accounts", transactions)]
pub struct Account {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    pub balance: i64,
}

Generated:

  • AccountTransactionRepo<'t> — Repository adapter for transaction context
  • TransactionWithAccount trait with with_accounts() method

Usage:

use entity_core::prelude::*;

async fn transfer(pool: &PgPool, from: Uuid, to: Uuid, amount: i64) -> Result<(), AppError> {
    Transaction::new(pool)
        .with_accounts()
        .run(|mut ctx| async move {
            let from_acc = ctx.accounts().find_by_id(from).await?
                .ok_or(AppError::NotFound)?;

            ctx.accounts().update(from, UpdateAccountRequest {
                balance: Some(from_acc.balance - amount),
            }).await?;

            ctx.accounts().update(to, UpdateAccountRequest {
                balance: Some(to_acc.balance + amount),
            }).await?;

            Ok(())
        })
        .await
}

Transaction methods:

  • create(dto) — Create entity within transaction
  • find_by_id(id) — Find entity by ID
  • update(id, dto) — Update entity
  • delete(id) — Delete entity (respects soft_delete)
  • list(limit, offset) — List entities

Features:

  • Automatic rollback on error or panic
  • Type-safe builder pattern
  • Full CRUD support within transaction

Field-Level Attributes

Applied to individual fields.

#[id]

Marks the primary key field.

Behavior:

  • Auto-generates UUID (v7 by default, configurable with uuid attribute)
  • Always included in Response DTO
  • Excluded from CreateRequest and UpdateRequest
#[id]
pub id: Uuid,

#[auto]

Marks auto-generated fields (timestamps, sequences).

Behavior:

  • Gets Default::default() in From<CreateRequest>
  • Excluded from CreateRequest and UpdateRequest
  • Can be included in Response with #[field(response)]
#[auto]
#[field(response)]
pub created_at: DateTime<Utc>,

#[field(...)]

Controls DTO inclusion. Combine multiple options:

#[field(create)]                    // Only in CreateRequest
#[field(update)]                    // Only in UpdateRequest
#[field(response)]                  // Only in Response
#[field(create, response)]          // In Create and Response
#[field(create, update, response)]  // In all three
#[field(skip)]                      // Excluded from all DTOs

create

Include field in CreateRequest DTO.

#[field(create)]
pub email: String,

// Generated:
pub struct CreateUserRequest {
    pub email: String,
}

update

Include field in UpdateRequest DTO.

Important: Non-optional fields are automatically wrapped in Option<T> for partial updates.

#[field(update)]
pub name: String,  // Not Option

// Generated:
pub struct UpdateUserRequest {
    pub name: Option<String>,  // Wrapped automatically
}

response

Include field in Response DTO.

#[field(response)]
pub email: String,

// Generated:
pub struct UserResponse {
    pub id: Uuid,        // Always included (has #[id])
    pub email: String,   // Included
}

skip

Exclude field from all DTOs. Use for sensitive data.

#[field(skip)]
pub password_hash: String,

Important: skip overrides all other field options. The field will only exist in:

  • Original entity struct
  • Row struct (for database reads)
  • Insertable struct (for database writes)

#[column(pg_enum = "...")]

Wire a ValueObject Postgres enum into DDL generation.

#[derive(ValueObject, Debug, Clone, Serialize, Deserialize)]
#[value_object(pg_type = "order_status", sqlx)]
pub enum OrderStatus { Pending, Shipped, Delivered }

#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[column(pg_enum = "order_status")]
    pub status: OrderStatus,
}

for ddl in Order::MIGRATION_TYPES {
    sqlx::query(ddl).execute(&pool).await?;
}
sqlx::query(Order::MIGRATION_UP).execute(&pool).await?;
  • Sets the DDL column type (enum fields otherwise fall back to TEXT)
  • Registers the enum’s idempotent PG_CREATE_TYPE DDL in {Entity}::MIGRATION_TYPES — run those before MIGRATION_UP
  • The declared name is checked against the enum’s PG_TYPE constant at compile time; a mismatch fails the build
  • The ValueObject’s opt-in sqlx flag emits sqlx::Type / Encode / Decode impls; omit it if you already derive sqlx::Type

#[column(ci)]

Case-insensitive text column. Generated find_by_{field} / exists_by_{field} compare via LOWER(col) = LOWER($1), and Option<T> fields unwrap to T in the lookup signature (a NULL column never matches a probe).

#[field(create, update, response)]
#[column(unique, ci)]
pub username: Option<String>,

let user = pool.find_by_username(handle).await?;   // handle: String
let taken = pool.exists_by_username(handle).await?;
  • With migrations, unique + ci emits CREATE UNIQUE INDEX {table}_{column}_lower_key ON {table} (LOWER({column})) instead of an inline UNIQUE constraint
  • With typed_constraints, violations of that index resolve to the field like any other unique constraint

#[owner]

Row-level ownership scoping. Marks the column carrying the owning principal’s id; the repository gains scoped methods that never reveal whether a row exists for another owner and respect soft_delete.

#[derive(Entity)]
#[entity(table = "orders")]
pub struct Order {
    #[id]
    pub id: Uuid,

    #[owner]
    pub user_id: Uuid,

    #[field(create, update, response)]
    pub note: String,
}

let mine = pool.list_by_owner(user_id, 20, 0).await?;
let order = pool.find_by_id_scoped(id, user_id).await?;
let updated = pool.update_scoped(id, user_id, patch).await?;
let removed = pool.delete_scoped(id, user_id).await?;

Generated: find_by_id_scoped, list_by_owner, update_scoped (when update fields exist, returns None if the row isn’t theirs), delete_scoped. At most one #[owner] field; combining with #[id] is rejected at compile time.

#[filter] / #[filter(...)]

Generate query filter fields. See [[Filtering]] for details.

#[filter]              // Exact match: WHERE field = $n
#[filter(eq)]          // Same as above
#[filter(like)]        // Pattern match: WHERE field ILIKE $n
#[filter(range)]       // Range: WHERE field >= $n AND field <= $m

#[belongs_to(Entity)]

Foreign key relation. See [[Relations]] for details.

#[belongs_to(User)]
pub user_id: Uuid,

Generated: find_user() method in repository.

#[join(...)] — joined read models

Declares an INNER JOIN contributing columns to a generated {Entity}View read model. Repeatable; the joined columns’ Rust types are part of the declaration (the macro cannot see the foreign table’s schema).

#[derive(Entity)]
#[entity(table = "tickets")]
#[join(airports as origin, on = origin_iata = iata, fields(
    lat as origin_lat: f64,
    lon as origin_lon: f64,
    city as origin_city: String
))]
#[join(airports as dest, on = destination_iata = iata, fields(
    lat as destination_lat: f64,
    lon as destination_lon: f64,
    city as destination_city: String
))]
pub struct Ticket { /* ... */ }

Generated:

  • TicketView — flat struct with every entity column plus the joined columns; derives sqlx::FromRow and serde::Serialize
  • TicketView::SELECT — the canonical SELECT ... FROM ... JOIN ... fragment (no WHERE) for custom filters:
    let rows: Vec<TicketView> = sqlx::query_as(::sqlx::AssertSqlSafe(format!(
        "{} WHERE tickets.verified = true ORDER BY tickets.departs_at ASC",
        TicketView::SELECT
    ))).fetch_all(&pool).await?;
  • TicketView::find_by_id(pool, id) and TicketView::list(pool, limit, offset)

Base-table columns are qualified with the table name; a join column that does not match an entity column fails the build.

#[has_many(Entity)]

One-to-many relation (entity-level). See [[Relations]] for details.

#[has_many(Post)]
pub struct User { /* ... */ }

Generated: find_posts() method in repository.

#[projection(Name: fields)]

Generate partial view struct (entity-level).

#[projection(Public: id, name, avatar)]
#[projection(Admin: id, name, email, role)]
pub struct User { /* ... */ }

Generated:

  • UserPublic { id, name, avatar }
  • UserAdmin { id, name, email, role }
  • From<User> implementations
  • find_by_id_public(), find_by_id_admin() methods

Command Attributes

Applied at entity level with #[command(...)].

Quick Reference

SyntaxEffect
#[command(Name)]Uses all #[field(create)] fields
#[command(Name: field1, field2)]Uses only specified fields (adds requires_id)
#[command(Name, requires_id)]Adds ID field, no other fields
#[command(Name, source = "create")]Explicitly use create fields (default)
#[command(Name, source = "update")]Use update fields (optional, adds requires_id)
#[command(Name, source = "none")]No payload fields
#[command(Name, payload = "Type")]Uses custom payload struct
#[command(Name, result = "Type")]Uses custom result type
#[command(Name, kind = "create")]Hint: creates entity (default)
#[command(Name, kind = "update")]Hint: modifies entity
#[command(Name, kind = "delete")]Hint: removes entity (returns ())
#[command(Name, kind = "custom")]Hint: custom operation

See [[Commands]] for detailed documentation.

Complete Example

#[derive(Entity)]
#[entity(
    table = "posts",
    schema = "blog",
    sql = "full",
    dialect = "postgres",
    uuid = "v7",
    soft_delete,
    returning = "full",
    events,
    hooks,
    commands
)]
#[has_many(Comment)]
#[projection(Summary: id, title, author_id, created_at)]
#[command(Publish)]
#[command(Archive, requires_id)]
pub struct Post {
    #[id]
    pub id: Uuid,

    #[field(create, update, response)]
    #[filter(like)]
    pub title: String,

    #[field(create, update, response)]
    pub content: String,

    #[field(create, response)]
    #[belongs_to(User)]
    #[filter]
    pub author_id: Uuid,

    #[field(update, response)]
    pub published: bool,

    #[field(response)]
    #[filter(range)]
    pub view_count: i64,

    #[field(skip)]
    pub moderation_notes: String,

    #[field(skip)]
    pub deleted_at: Option<DateTime<Utc>>,

    #[auto]
    #[field(response)]
    #[filter(range)]
    pub created_at: DateTime<Utc>,

    #[auto]
    #[field(response)]
    pub updated_at: DateTime<Utc>,
}

Decision Matrix

I want to…Attributes
Auto-generate primary key#[id]
Use random UUIDuuid = "v4" on entity
Use time-ordered UUIDuuid = "v7" (default)
Accept in POST body#[field(create)]
Accept in PATCH body#[field(update)]
Return in API response#[field(response)]
Accept and return#[field(create, update, response)]
Hide from all APIs#[field(skip)]
Auto-generate timestamp#[auto] + #[field(response)]
Read-only (DB managed)#[field(response)] only
Write-only (no return)#[field(create)] only
Custom SQL queriessql = "trait"
DTOs only, no DBsql = "none"
Soft delete recordssoft_delete on entity
Custom error typeerror = "MyError" on entity
Filter by exact value#[filter] on field
Filter by pattern#[filter(like)] on field
Filter by range#[filter(range)] on field
Track entity changesevents on entity
Run code on lifecyclehooks on entity
Use domain commandscommands on entity + #[command(...)]
Use multi-entity transactionstransactions on entity
Define relationship#[belongs_to(Entity)] or #[has_many(Entity)]
Partial entity view#[projection(Name: fields)]

Update DTOs: PATCH semantics

Generated updates are true partial patches: the UPDATE SET clause is built at runtime from the fields actually present, so omitted fields stay untouched. Nullable columns use double-Option (None = leave, Some(None) = SET NULL, Some(Some(v)) = SET v) via entity_core::serde_helpers::double_option.

// {}                   → nothing changes
// {"nickname": null}   → nickname = NULL
// {"nickname": "neo"}  → nickname = 'neo'
let patch: UpdateProfileRequest = serde_json::from_str(body)?;
let profile = pool.update(id, patch).await?;

migrations(...) options

Beyond the plain flag, migrations accepts DDL options: touch_updated_at (shared plpgsql function + per-table BEFORE UPDATE trigger keeping updated_at fresh; requires an updated_at field, checked at compile time), audit (entity_audit_log table + trigger recording to_jsonb(OLD/NEW) diffs) and extensions = "pg_trgm, pgcrypto" (idempotent CREATE EXTENSION). New constants: MIGRATION_TRIGGERS (run after MIGRATION_UP) and MIGRATION_EXTENSIONS (run before).

#[entity(table = "articles", migrations(touch_updated_at, audit, extensions = "pg_trgm"))]
pub struct Article { /* ... */ }

for ddl in Article::MIGRATION_EXTENSIONS { sqlx::query(ddl).execute(&pool).await?; }
sqlx::query(Article::MIGRATION_UP).execute(&pool).await?;
for ddl in Article::MIGRATION_TRIGGERS { sqlx::query(ddl).execute(&pool).await?; }

#[version]

Optimistic locking. Marks an integer column (i16/i32/i64); the Update DTO gains a required expected_version, the generated UPDATE bumps the column and only applies while the stored version still matches — a stale write fails with a conflict error instead of overwriting newer data. DDL defaults to INTEGER NOT NULL DEFAULT 0. Applies to plain, scoped and transactional updates.

#[derive(Entity)]
#[entity(table = "orders", migrations)]
pub struct Order {
    #[id] pub id: Uuid,
    #[field(create, update, response)] pub note: String,
    #[version] #[field(response)] #[auto] pub version: i32,
}

let patch = UpdateOrderRequest { note: Some("v2".into()), expected_version: order.version };
let updated = pool.update(order.id, patch).await?;

typed_constraints (optional)

The macro knows every constraint it creates. With this flag, generated write methods resolve violated constraint names (unique columns, belongs_to foreign keys, column checks, unique_index names) and surface entity_core::ConstraintError { kind, constraint, field } instead of a raw driver error. Requires a custom error type implementing From<ConstraintError>; without the flag nothing changes.

#[entity(table = "users", typed_constraints, error = "AppError")]
pub struct User {
    #[id] pub id: Uuid,
    #[field(create, response)] #[column(unique)] pub email: String,
}

match pool.create(dto).await {
    Err(AppError::Constraint(v)) if v.field == Some("email") => conflict_409(),
    other => other?,
}

#[embed(prefix = "...", fields(...))]

Flatten a value object to prefixed scalar columns. DDL, Row struct, CRUD SQL and dynamic PATCH updates operate on price_amount_cents / price_currency, while DTOs and the entity carry the struct itself. The declared shape is destructured against the real struct at compile time — a renamed, retyped, missing or extra field fails the build. Option<T> parents are not supported yet.

pub struct Money { pub amount_cents: i64, pub currency: String }

#[derive(Entity)]
#[entity(table = "products", migrations)]
pub struct Product {
    #[id] pub id: Uuid,
    #[field(create, update, response)]
    #[embed(prefix = "price_", fields(amount_cents: i64, currency: String))]
    pub price: Money,
}

garde feature (validation backend)

Enables garde::Validate on generated DTOs as a maintained alternative to validator. Field #[validate(...)] rules (length, range, email, url, pattern) are translated to garde syntax; unconstrained fields get garde(skip); Option-wrapped Update DTO fields validate the inner value via inner(...). When both validate and garde are enabled, validate takes precedence.

#[field(create, update, response)]
#[validate(length(min = 3, max = 8))]
pub name: String,

let dto: CreateUserRequest = serde_json::from_str(body)?;
garde::Validate::validate(&dto)?;

constraint(...) (optional, with typed_constraints)

Declare constraints the macro cannot infer — foreign keys over natural keys, custom-named CHECK constraints, indexes from hand-written migrations — so violations resolve to ConstraintError with the declared field. Kinds: unique, foreign_key, check. Custom entries take precedence over derived entries with the same name. Requires typed_constraints.

#[entity(
    table = "orders",
    typed_constraints,
    constraint(name = "orders_currency_fkey", kind = "foreign_key", field = "currency"),
    constraint(name = "orders_window_check", kind = "check"),
)]

Composite lookups from unique_index(...)

Every multi-column unique_index(a, b) declaration generates a lookup pair on the repository:

#[derive(Entity)]
#[entity(table = "kyc_sessions", unique_index(provider, external_id))]
pub struct KycSession {
    #[id] pub id: Uuid,
    #[field(create, response)] pub provider: String,
    #[field(create, response)] pub external_id: String,
}

let session = pool.find_by_provider_and_external_id(provider, external_id).await?;
let taken = pool.exists_by_provider_and_external_id(provider, external_id).await?;
  • find_by_{a}_and_{b}(a, b) -> Option<Entity> and exists_by_{a}_and_{b}(a, b) -> bool
  • Parameter types resolve from the matching entity fields; an index column that does not match any entity column fails the build (same rule as upsert conflict columns)
  • Non-unique index(...) declarations generate no lookups

Transactional upsert

With both transactions and upsert(...), the {Entity}TransactionRepo adapter exposes upsert with the same SQL and action semantics as the pool method, executed on the transaction handle — for flows where the upsert must share atomicity with adjacent statements.

let mut tx = pool.begin().await?;
sqlx::query("UPDATE users SET username = NULL WHERE ...").execute(&mut *tx).await?;
let user = UserTransactionRepo::new(&mut tx).upsert(dto).await?;
tx.commit().await?;