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