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