Skip to main content

cratestack_sqlx/query/write/
upsert.rs

1//! `INSERT … ON CONFLICT (<pk>) DO UPDATE …`, but with the
2//! create/update distinction made *before* the SQL runs (via a
3//! `SELECT … FOR UPDATE` probe inside the same transaction) so we can:
4//!
5//!   * pick the right policy slot (both must allow at call time)
6//!   * emit the correct ModelEventKind (Created vs Updated)
7//!   * capture an audit `before` snapshot only on the update branch
8//!
9//! The upsert is always transactional regardless of whether the model
10//! emits events or has `@@audit`. One extra round-trip for the
11//! SELECT, in exchange for clean event/audit semantics. Upsert is not
12//! a hot read path — callers who need raw insert/update throughput
13//! should use `.create()` / `.update()` directly.
14
15use cratestack_core::{CratestackContext, CratestackError};
16
17use crate::audit::{RunInTxOutcome, dispatch_audit_sink};
18use crate::{
19    ConflictTarget, ModelDescriptor, SqlxRuntime, UpsertModelInput, cratestack_error_from_sqlx,
20    sqlx,
21};
22
23use super::upsert_do_nothing::UpsertRecordDoNothing;
24use super::upsert_exec::run_upsert_in_tx;
25
26#[derive(Debug, Clone)]
27pub struct UpsertRecord<'a, M: 'static, PK: 'static, I> {
28    pub(crate) runtime: &'a SqlxRuntime,
29    pub(crate) descriptor: &'static ModelDescriptor<M, PK>,
30    pub(crate) input: I,
31    pub(crate) conflict_target: ConflictTarget,
32}
33
34impl<'a, M: 'static, PK: 'static, I> UpsertRecord<'a, M, PK, I>
35where
36    I: UpsertModelInput<M>,
37{
38    /// Choose the conflict target. Defaults to the model's primary
39    /// key; pass [`ConflictTarget::Columns`] to upsert on a composite
40    /// unique key instead. The named columns must form a `UNIQUE`
41    /// constraint/index on the target table.
42    pub fn on_conflict(mut self, target: ConflictTarget) -> Self {
43        self.conflict_target = target;
44        self
45    }
46
47    /// Switch this call to `ON CONFLICT ... DO NOTHING` semantics
48    /// (cratestack#487), independent of `descriptor.upsert_update_columns`:
49    /// on conflict, leave the existing row completely untouched instead
50    /// of merging `upsert_update_columns` into it. This is the
51    /// idempotent-insert shape ledger-style writes need — e.g. a
52    /// cash-in claim that inserts a `PENDING` row and treats a
53    /// conflict as "already in flight" must never let a retry's blank
54    /// values overwrite an existing `COMPLETED` row's `transfer_ref`.
55    ///
56    /// Returns a distinct builder type rather than a flag on
57    /// `UpsertRecord` because the return shape genuinely changes: a
58    /// real `DO NOTHING` returns nothing on conflict, so the caller
59    /// needs `Inserted` vs `Existing` distinguishable in the type
60    /// ([`crate::UpsertOutcome`]) rather than collapsed into a plain
61    /// `M` the way `.run()` returns it today. Encoding that as a
62    /// separate type also means existing `.upsert(..).run(..)` callers
63    /// keep their `Result<M, CratestackError>` signature unchanged — this is
64    /// purely additive, not a behavior change for the DO UPDATE path.
65    pub fn do_nothing(self) -> UpsertRecordDoNothing<'a, M, PK, I> {
66        UpsertRecordDoNothing {
67            runtime: self.runtime,
68            descriptor: self.descriptor,
69            input: self.input,
70            conflict_target: self.conflict_target,
71        }
72    }
73
74    /// Render an approximate SQL preview. The actual upsert wraps a
75    /// `SELECT … FOR UPDATE` around the `INSERT … ON CONFLICT`, but
76    /// this preview returns only the conflict-bearing statement.
77    pub fn preview_sql(&self) -> String {
78        let values = self.input.sql_values();
79        let placeholders = (1..=values.len())
80            .map(|index| format!("${index}"))
81            .collect::<Vec<_>>()
82            .join(", ");
83        let columns = values
84            .iter()
85            .map(|value| value.column)
86            .collect::<Vec<_>>()
87            .join(", ");
88        let update_assignments = self
89            .descriptor
90            .upsert_update_columns
91            .iter()
92            .map(|column| format!("{column} = EXCLUDED.{column}"))
93            .collect::<Vec<_>>()
94            .join(", ");
95        let version_bump = match self.descriptor.version_column {
96            Some(col) => format!(
97                ", {col} = {table}.{col} + 1",
98                table = self.descriptor.table_name,
99                col = col
100            ),
101            None => String::new(),
102        };
103        let conflict_tuple = match self.conflict_target {
104            ConflictTarget::PrimaryKey => self.descriptor.primary_key.to_owned(),
105            ConflictTarget::Columns(cols) => cols.join(", "),
106        };
107
108        format!(
109            "INSERT INTO {table} ({columns}) VALUES ({placeholders}) \
110             ON CONFLICT ({conflict_tuple}) DO UPDATE SET {update_assignments}{version_bump} \
111             RETURNING {projection}",
112            table = self.descriptor.table_name,
113            projection = self.descriptor.select_projection(),
114        )
115    }
116
117    pub async fn run(self, ctx: &CratestackContext) -> Result<M, CratestackError>
118    where
119        for<'r> M: Send + Unpin + sqlx::FromRow<'r, sqlx::postgres::PgRow> + serde::Serialize,
120        PK: Send + sqlx::Type<sqlx::Postgres> + for<'q> sqlx::Encode<'q, sqlx::Postgres>,
121    {
122        let runtime = self.runtime;
123        let mut tx = runtime
124            .pool()
125            .begin()
126            .await
127            .map_err(cratestack_error_from_sqlx)?;
128        let (record, emits_event, audit_event) = run_upsert_in_tx(
129            &mut tx,
130            runtime,
131            self.descriptor,
132            self.input,
133            self.conflict_target,
134            ctx,
135        )
136        .await?;
137        tx.commit().await.map_err(cratestack_error_from_sqlx)?;
138        if emits_event {
139            let _ = runtime.drain_event_outbox().await;
140        }
141        if let Some(event) = &audit_event {
142            dispatch_audit_sink(runtime, std::slice::from_ref(event)).await;
143        }
144        Ok(record)
145    }
146
147    /// Like [`Self::run`] but participates in a caller-supplied
148    /// transaction. The conflict probe runs against `tx`, so the row
149    /// lock is held until the caller commits. Neither the event outbox
150    /// drain nor the `AuditSink` fan-out happens here — see
151    /// `create.rs`'s `run_in_tx` doc comment for the full contract and
152    /// how a caller opts into both after their own commit.
153    pub async fn run_in_tx<'tx>(
154        self,
155        tx: &mut sqlx::Transaction<'tx, sqlx::Postgres>,
156        ctx: &CratestackContext,
157    ) -> Result<RunInTxOutcome<M>, CratestackError>
158    where
159        for<'r> M: Send + Unpin + sqlx::FromRow<'r, sqlx::postgres::PgRow> + serde::Serialize,
160        PK: Send + sqlx::Type<sqlx::Postgres> + for<'q> sqlx::Encode<'q, sqlx::Postgres>,
161    {
162        let (record, _emits_event, audit_event) = run_upsert_in_tx(
163            tx,
164            self.runtime,
165            self.descriptor,
166            self.input,
167            self.conflict_target,
168            ctx,
169        )
170        .await?;
171        Ok(RunInTxOutcome::new(
172            record,
173            audit_event.into_iter().collect(),
174        ))
175    }
176}