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

Registration (Upsert)

Find or insert a row on an alternate unique key, and say what it did.

A source system that re-sends its own content every cycle needs the write to be idempotent, and needs the answer keyed by what it holds rather than by position in the request. crudcrate::upsert does both in one transaction.

Declaring the key

Name the columns the source identifies its rows by:

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, EntityToModels)]
#[sea_orm(table_name = "curves")]
#[crudcrate(api_struct = "Curve", upsert_key(source_system, source_key))]
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    #[crudcrate(primary_key, exclude(create, update), on_create = Uuid::new_v4())]
    pub id: Uuid,

    pub source_system: String,
    pub source_key: String,
    pub slope: f64,
}

The key is an alternate identity, not the primary key. The row keeps its own id, which is what the rest of your API refers to.

A key that is unique only sometimes

Where the unique index backing the key is partial, name its predicate too. The find is narrowed by it, so a row the index does not cover is not the key’s row:

#[crudcrate(
    api_struct = "Episode",
    upsert_key(site_id, parameter_id),
    upsert_where = Column::ResolvedAt.is_null()
)]

against

CREATE UNIQUE INDEX episodes_open_uniq
    ON episodes (site_id, parameter_id) WHERE resolved_at IS NULL;

An open episode registers against itself. Once it is resolved it leaves the index, so the next registration of that slot opens a second episode rather than reopening the closed one, and the history of the first survives.

What you write is a SeaORM expression, the same value OnConflict::target_cond_where takes, so the predicate is the index’s own WHERE clause rather than a syntax of this crate’s. Naming it without an upsert_key is a compile error: there is no find for it to narrow.

Registering a row

use crudcrate::{upsert, UpsertStatus};

let (curve, status) = upsert::<Curve, _>(&db, CurveCreate {
    source_system: "cnet".to_string(),
    source_key: "DOC:plate-7".to_string(),
    slope: 1.5,
}.into()).await?;

match status {
    UpsertStatus::Created => "the key was not stored here before",
    UpsertStatus::Updated => "the key was stored and content differed",
    UpsertStatus::Unchanged => "the key was stored and content already read as sent",
};

The key columns are read from the active model. For keys excluded from create and update requests, convert the create model first, then set the key columns from the authenticated caller before calling upsert. On Unchanged no write happens.

What counts as content

The columns compared and written are the ones the create model can carry, minus the primary key. A field the entity maintains itself is not part of what a source sends, so it is neither compared nor overwritten:

  • created_at keeps the value it was first stored with.
  • A field carrying on_update advances when the row does change, and not on an Unchanged pass.
  • A field the active model leaves unset is not compared and not written.

Reporting a batch

UpsertOutcome reports one item keyed by what the sender holds:

pub struct UpsertOutcome<K, I, S = UpsertStatus> {
    pub key: K,
    pub id: Option<I>,
    pub status: S,
    pub note: Option<String>,
}

status is generic so a caller with refusals of its own (a row whose owner does not exist here, a row it kept against what the source sent) reports them in the same list rather than as failures. BatchResult, which is per-item success-or-error, cannot express an item that was stored and still has something to report.

Caveats

  • Add a unique index on the key columns. Nothing generates one. Without it, two registrations racing on the same key can both find nothing and both insert.
  • A resource declaring no upsert_key refuses the call rather than guessing a key, as does an active model that leaves a key column unset.
  • A partial index needs upsert_where. Without it the find matches rows the index does not cover, so a registration updates a row the index would have let it insert beside.
  • upsert is a function you call, not a generated route. It is for an ingest path you write, not for the public API surface.

See also