Skip to main content

alibi_sqlx/
model.rs

1//! Column-level write staging for `SQLx` models.
2//!
3//! An [`ActiveRow`] records, per physical column, whether a write sets it,
4//! leaves a loaded value unchanged, or omits it. Inserts write every present
5//! column; updates write only set columns, so concurrent changes to other
6//! columns survive. Both return the stored row.
7
8use crate::error::record_not_inserted;
9use crate::pool::{Engine, Exec, SqlxRow};
10use crate::sql::{Sql, select};
11use crate::value::{ColumnKind, SqlValue};
12use alibi_core::error::{AuthError, AuthResult};
13
14/// One column's write state.
15#[derive(Clone, Debug, PartialEq)]
16pub enum ActiveValue {
17    /// Written by the next insert or update.
18    Set(SqlValue),
19    /// Loaded from the database and not written by an update.
20    Unchanged(SqlValue),
21    /// Omitted, so the database default applies on insert.
22    NotSet,
23}
24
25impl ActiveValue {
26    #[must_use]
27    pub const fn is_set(&self) -> bool {
28        matches!(self, Self::Set(_))
29    }
30
31    #[must_use]
32    pub const fn is_not_set(&self) -> bool {
33        matches!(self, Self::NotSet)
34    }
35
36    /// The staged or loaded value.
37    #[must_use]
38    pub const fn value(&self) -> Option<&SqlValue> {
39        match self {
40            Self::Set(value) | Self::Unchanged(value) => Some(value),
41            Self::NotSet => None,
42        }
43    }
44
45    #[must_use]
46    pub fn into_value(self) -> Option<SqlValue> {
47        match self {
48            Self::Set(value) | Self::Unchanged(value) => Some(value),
49            Self::NotSet => None,
50        }
51    }
52}
53
54/// Per-column write state of one model row, in model column order.
55#[derive(Clone, Debug, Default, PartialEq)]
56pub struct ActiveRow {
57    columns: Vec<(&'static str, ActiveValue)>,
58}
59
60impl ActiveRow {
61    #[must_use]
62    pub const fn new() -> Self {
63        Self {
64            columns: Vec::new(),
65        }
66    }
67
68    fn slot(&mut self, column: &'static str) -> &mut ActiveValue {
69        let index = self
70            .columns
71            .iter()
72            .position(|(name, _)| *name == column)
73            .unwrap_or_else(|| {
74                self.columns.push((column, ActiveValue::NotSet));
75                self.columns.len() - 1
76            });
77        // The index was found or pushed above.
78        #[expect(
79            clippy::indexing_slicing,
80            reason = "the slot index was located or appended immediately above"
81        )]
82        &mut self.columns[index].1
83    }
84
85    /// Stage a written value.
86    pub fn set(&mut self, column: &'static str, value: impl Into<SqlValue>) {
87        *self.slot(column) = ActiveValue::Set(value.into());
88    }
89
90    /// Record a loaded value which an update leaves unchanged.
91    pub fn unchanged(&mut self, column: &'static str, value: impl Into<SqlValue>) {
92        *self.slot(column) = ActiveValue::Unchanged(value.into());
93    }
94
95    /// Declare a column omitted from inserts.
96    pub fn not_set(&mut self, column: &'static str) {
97        *self.slot(column) = ActiveValue::NotSet;
98    }
99
100    #[must_use]
101    pub fn get(&self, column: &str) -> Option<&ActiveValue> {
102        self.columns
103            .iter()
104            .find(|(name, _)| *name == column)
105            .map(|(_, value)| value)
106    }
107
108    /// Remove a column's state, leaving it omitted.
109    pub fn take(&mut self, column: &str) -> ActiveValue {
110        self.columns
111            .iter_mut()
112            .find(|(name, _)| *name == column)
113            .map_or(ActiveValue::NotSet, |(_, value)| {
114                std::mem::replace(value, ActiveValue::NotSet)
115            })
116    }
117
118    pub(crate) fn present(&self) -> impl Iterator<Item = (&'static str, &SqlValue)> {
119        self.columns
120            .iter()
121            .filter_map(|(name, value)| value.value().map(|value| (*name, value)))
122    }
123
124    pub(crate) fn changed(&self) -> impl Iterator<Item = (&'static str, &SqlValue)> {
125        self.columns.iter().filter_map(|(name, value)| match value {
126            ActiveValue::Set(value) => Some((*name, value)),
127            ActiveValue::Unchanged(_) | ActiveValue::NotSet => None,
128        })
129    }
130}
131
132/// A physical model column.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub struct ColumnDef {
135    pub name: &'static str,
136    pub kind: ColumnKind,
137}
138
139/// A table-backed `SQLx` model.
140pub trait SqlxModel: SqlxRow + Clone + Send + Sync + 'static {
141    /// Physical table name.
142    const TABLE: &'static str;
143    /// Physical columns in model field order.
144    const COLUMNS: &'static [ColumnDef];
145    /// The names of [`COLUMNS`](Self::COLUMNS), for select and returning lists.
146    const COLUMN_NAMES: &'static [&'static str];
147    /// Rust field names paired with their physical columns.
148    const FIELD_COLUMNS: &'static [(&'static str, &'static str)] = &[];
149    /// Physical primary-key column.
150    const PRIMARY_KEY: &'static str;
151    /// Provider verification column whose SQLite storage may retain raw scalars.
152    /// Other model roles and handwritten models retain their existing projection.
153    const PROVIDER_VERIFICATION_COLUMN: Option<&'static str> = None;
154
155    /// Every column as an unchanged value.
156    fn into_active(self) -> ActiveRow;
157
158    /// Materialize a model without a database write. Omitted optional fields
159    /// become `None`; other omitted fields fail.
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if a required field is omitted or has another type.
164    fn from_active(active: ActiveRow) -> AuthResult<Self>;
165
166    /// Apply this physical column's binding override without changing its Rust
167    /// String representation. Ordinary TEXT/VARCHAR columns remain unchanged.
168    fn column_value(column: &str, value: SqlValue) -> SqlValue {
169        match (Self::column_kind(column), value) {
170            (ColumnKind::BpChar, SqlValue::Text(value)) => SqlValue::BpChar(value),
171            (ColumnKind::Text, SqlValue::Json(value)) => {
172                SqlValue::Text(value.map(|value| value.to_string()))
173            }
174            (_, value) => value,
175        }
176    }
177
178    /// Bind the auth UTC clock using this model column's timestamp wire type.
179    fn timestamp_value(column: &str, value: chrono::DateTime<chrono::Utc>) -> SqlValue {
180        match Self::column_kind(column) {
181            ColumnKind::NaiveTimestamp => value.naive_utc().into(),
182            _ => value.into(),
183        }
184    }
185
186    #[must_use]
187    fn column_kind(column: &str) -> ColumnKind {
188        Self::COLUMNS
189            .iter()
190            .find(|definition| definition.name == column)
191            .map_or(ColumnKind::Other, |definition| definition.kind)
192    }
193}
194
195fn projection<M: SqlxModel>(sql: &mut Sql, qualified: bool) {
196    for (index, column) in M::COLUMN_NAMES.iter().enumerate() {
197        if index > 0 {
198            sql.push(", ");
199        }
200        let raw_verification =
201            sql.engine() == Engine::Sqlite && M::PROVIDER_VERIFICATION_COLUMN == Some(*column);
202        let emit_column = |sql: &mut Sql| {
203            if qualified {
204                sql.column(M::TABLE, column);
205            } else {
206                sql.ident(column);
207            }
208        };
209        if raw_verification {
210            // Match the published SQLite adapter's numeric boolean conversion:
211            // only 1 is true. Strings remain raw in adapter output; the typed
212            // Rust accessor uses their truthiness without changing the column.
213            sql.push("CASE WHEN typeof(");
214            emit_column(sql);
215            sql.push(") IN ('integer', 'real') THEN ");
216            emit_column(sql);
217            sql.push(" = 1 WHEN ");
218            emit_column(sql);
219            sql.push(" IS NULL THEN NULL ELSE ");
220            emit_column(sql);
221            sql.push(" <> '' END AS ");
222            sql.ident(column);
223        } else if sql.engine() == Engine::Postgres && M::column_kind(column) == ColumnKind::BigInt {
224            sql.push("CAST(");
225            emit_column(sql);
226            sql.push(" AS BIGINT) AS ");
227            sql.ident(column);
228        } else {
229            emit_column(sql);
230        }
231    }
232}
233
234pub(crate) fn select_model<M: SqlxModel>(exec: Exec<'_>) -> Sql {
235    if exec.engine() == Engine::Sqlite && M::PROVIDER_VERIFICATION_COLUMN.is_none() {
236        return select(exec.engine(), M::TABLE, M::COLUMN_NAMES);
237    }
238    let mut sql = Sql::new(exec.engine());
239    sql.push("SELECT ");
240    projection::<M>(&mut sql, true);
241    sql.push(" FROM ");
242    sql.ident(M::TABLE);
243    sql
244}
245
246pub(crate) fn returning<M: SqlxModel>(sql: &mut Sql) {
247    sql.push(" RETURNING ");
248    projection::<M>(sql, false);
249}
250
251pub(crate) fn bind_model_value<M: SqlxModel>(sql: &mut Sql, column: &str, value: SqlValue) {
252    if sql.engine() == Engine::Postgres && M::column_kind(column) == ColumnKind::Number {
253        let text = match value {
254            SqlValue::Double(value) => SqlValue::Text(value.map(|value| value.to_string())),
255            SqlValue::BigInt(value) => SqlValue::Text(value.map(|value| value.to_string())),
256            SqlValue::Int(value) => SqlValue::Text(value.map(|value| value.to_string())),
257            value => value,
258        };
259        sql.push("CAST(");
260        sql.bind(text);
261        sql.push(" AS INTEGER)");
262        return;
263    }
264    let cast = sql.engine() == Engine::Postgres
265        && M::PROVIDER_VERIFICATION_COLUMN == Some(column)
266        && matches!(&value, SqlValue::Text(_));
267    if cast {
268        sql.push("CAST(");
269    }
270    sql.bind(M::column_value(column, value));
271    if cast {
272        sql.push(" AS BOOLEAN)");
273    }
274}
275
276/// `INSERT ... RETURNING` every present column.
277pub(crate) async fn insert<M: SqlxModel>(exec: Exec<'_>, active: &ActiveRow) -> AuthResult<M> {
278    let mut sql = Sql::new(exec.engine());
279    sql.push("INSERT INTO ");
280    sql.ident(M::TABLE);
281    sql.push(" (");
282    let present = active.present().collect::<Vec<_>>();
283    sql.column_list(&present.iter().map(|(name, _)| *name).collect::<Vec<_>>());
284    sql.push(") VALUES (");
285    for (index, (column, value)) in present.into_iter().enumerate() {
286        if index > 0 {
287            sql.push(", ");
288        }
289        bind_model_value::<M>(&mut sql, column, value.clone());
290    }
291    sql.push(")");
292    returning::<M>(&mut sql);
293    exec.fetch_optional::<M>(sql)
294        .await?
295        .ok_or_else(record_not_inserted)
296}
297
298/// `UPDATE ... RETURNING` the set columns of the row with this primary key.
299/// `None` means no row matched.
300pub(crate) async fn update<M: SqlxModel>(
301    exec: Exec<'_>,
302    active: &ActiveRow,
303) -> AuthResult<Option<M>> {
304    let key = active
305        .get(M::PRIMARY_KEY)
306        .and_then(ActiveValue::value)
307        .cloned()
308        .ok_or_else(|| AuthError::internal("model update requires its primary key"))?;
309    if active.changed().next().is_none() {
310        // Nothing to write: return the stored row, as an unchanged model update does.
311        let mut sql = by_id::<M>(exec, key);
312        limit_one(&mut sql);
313        return exec.fetch_optional::<M>(sql).await;
314    }
315    let mut sql = Sql::new(exec.engine());
316    sql.push("UPDATE ");
317    sql.ident(M::TABLE);
318    sql.push(" SET ");
319    for (index, (column, value)) in active.changed().enumerate() {
320        if index > 0 {
321            sql.push(", ");
322        }
323        sql.ident(column);
324        sql.push(" = ");
325        bind_model_value::<M>(&mut sql, column, value.clone());
326    }
327    sql.push(" WHERE ");
328    sql.compare_model::<M>(M::TABLE, M::PRIMARY_KEY, " = ", key);
329    returning::<M>(&mut sql);
330    exec.fetch_optional::<M>(sql).await
331}
332
333/// `SELECT ... WHERE "t"."pk" = ?`.
334pub(crate) fn by_id<M: SqlxModel>(exec: Exec<'_>, id: impl Into<SqlValue>) -> Sql {
335    let mut sql = select_model::<M>(exec);
336    sql.push(" WHERE ");
337    sql.compare_model::<M>(M::TABLE, M::PRIMARY_KEY, " = ", id);
338    sql
339}
340
341/// Append ` LIMIT 1`, as a single-row model lookup does.
342pub(crate) fn limit_one(sql: &mut Sql) {
343    sql.push(" LIMIT 1");
344}
345
346/// `DELETE FROM "t" WHERE "t"."pk" = ?`.
347pub(crate) fn delete_by_id<M: SqlxModel>(exec: Exec<'_>, id: impl Into<SqlValue>) -> Sql {
348    let mut sql = Sql::new(exec.engine());
349    sql.push("DELETE FROM ");
350    sql.ident(M::TABLE);
351    sql.push(" WHERE ");
352    sql.compare_model::<M>(M::TABLE, M::PRIMARY_KEY, " = ", id);
353    sql
354}