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            (_, value) => value,
172        }
173    }
174
175    /// Bind the auth UTC clock using this model column's timestamp wire type.
176    fn timestamp_value(column: &str, value: chrono::DateTime<chrono::Utc>) -> SqlValue {
177        match Self::column_kind(column) {
178            ColumnKind::NaiveTimestamp => value.naive_utc().into(),
179            _ => value.into(),
180        }
181    }
182
183    #[must_use]
184    fn column_kind(column: &str) -> ColumnKind {
185        Self::COLUMNS
186            .iter()
187            .find(|definition| definition.name == column)
188            .map_or(ColumnKind::Other, |definition| definition.kind)
189    }
190}
191
192fn projection<M: SqlxModel>(sql: &mut Sql, qualified: bool) {
193    for (index, column) in M::COLUMN_NAMES.iter().enumerate() {
194        if index > 0 {
195            sql.push(", ");
196        }
197        let raw_verification =
198            sql.engine() == Engine::Sqlite && M::PROVIDER_VERIFICATION_COLUMN == Some(*column);
199        let emit_column = |sql: &mut Sql| {
200            if qualified {
201                sql.column(M::TABLE, column);
202            } else {
203                sql.ident(column);
204            }
205        };
206        if raw_verification {
207            // Match the published SQLite adapter's numeric boolean conversion:
208            // only 1 is true. Strings remain raw in adapter output; the typed
209            // Rust accessor uses their truthiness without changing the column.
210            sql.push("CASE WHEN typeof(");
211            emit_column(sql);
212            sql.push(") IN ('integer', 'real') THEN ");
213            emit_column(sql);
214            sql.push(" = 1 WHEN ");
215            emit_column(sql);
216            sql.push(" IS NULL THEN NULL ELSE ");
217            emit_column(sql);
218            sql.push(" <> '' END AS ");
219            sql.ident(column);
220        } else {
221            emit_column(sql);
222        }
223    }
224}
225
226pub(crate) fn select_model<M: SqlxModel>(exec: Exec<'_>) -> Sql {
227    if exec.engine() != Engine::Sqlite || M::PROVIDER_VERIFICATION_COLUMN.is_none() {
228        return select(exec.engine(), M::TABLE, M::COLUMN_NAMES);
229    }
230    let mut sql = Sql::new(exec.engine());
231    sql.push("SELECT ");
232    projection::<M>(&mut sql, true);
233    sql.push(" FROM ");
234    sql.ident(M::TABLE);
235    sql
236}
237
238pub(crate) fn returning<M: SqlxModel>(sql: &mut Sql) {
239    sql.push(" RETURNING ");
240    projection::<M>(sql, false);
241}
242
243fn bind_model_value<M: SqlxModel>(sql: &mut Sql, column: &str, value: SqlValue) {
244    let cast = sql.engine() == Engine::Postgres
245        && M::PROVIDER_VERIFICATION_COLUMN == Some(column)
246        && matches!(&value, SqlValue::Text(_));
247    if cast {
248        sql.push("CAST(");
249    }
250    sql.bind(M::column_value(column, value));
251    if cast {
252        sql.push(" AS BOOLEAN)");
253    }
254}
255
256/// `INSERT ... RETURNING` every present column.
257pub(crate) async fn insert<M: SqlxModel>(exec: Exec<'_>, active: &ActiveRow) -> AuthResult<M> {
258    let mut sql = Sql::new(exec.engine());
259    sql.push("INSERT INTO ");
260    sql.ident(M::TABLE);
261    sql.push(" (");
262    let present = active.present().collect::<Vec<_>>();
263    sql.column_list(&present.iter().map(|(name, _)| *name).collect::<Vec<_>>());
264    sql.push(") VALUES (");
265    for (index, (column, value)) in present.into_iter().enumerate() {
266        if index > 0 {
267            sql.push(", ");
268        }
269        bind_model_value::<M>(&mut sql, column, value.clone());
270    }
271    sql.push(")");
272    returning::<M>(&mut sql);
273    exec.fetch_optional::<M>(sql)
274        .await?
275        .ok_or_else(record_not_inserted)
276}
277
278/// `UPDATE ... RETURNING` the set columns of the row with this primary key.
279/// `None` means no row matched.
280pub(crate) async fn update<M: SqlxModel>(
281    exec: Exec<'_>,
282    active: &ActiveRow,
283) -> AuthResult<Option<M>> {
284    let key = active
285        .get(M::PRIMARY_KEY)
286        .and_then(ActiveValue::value)
287        .cloned()
288        .ok_or_else(|| AuthError::internal("model update requires its primary key"))?;
289    if active.changed().next().is_none() {
290        // Nothing to write: return the stored row, as an unchanged model update does.
291        let mut sql = by_id::<M>(exec, key);
292        limit_one(&mut sql);
293        return exec.fetch_optional::<M>(sql).await;
294    }
295    let mut sql = Sql::new(exec.engine());
296    sql.push("UPDATE ");
297    sql.ident(M::TABLE);
298    sql.push(" SET ");
299    for (index, (column, value)) in active.changed().enumerate() {
300        if index > 0 {
301            sql.push(", ");
302        }
303        sql.ident(column);
304        sql.push(" = ");
305        bind_model_value::<M>(&mut sql, column, value.clone());
306    }
307    sql.push(" WHERE ");
308    sql.compare_model::<M>(M::TABLE, M::PRIMARY_KEY, " = ", key);
309    returning::<M>(&mut sql);
310    exec.fetch_optional::<M>(sql).await
311}
312
313/// `SELECT ... WHERE "t"."pk" = ?`.
314pub(crate) fn by_id<M: SqlxModel>(exec: Exec<'_>, id: impl Into<SqlValue>) -> Sql {
315    let mut sql = select_model::<M>(exec);
316    sql.push(" WHERE ");
317    sql.compare_model::<M>(M::TABLE, M::PRIMARY_KEY, " = ", id);
318    sql
319}
320
321/// Append ` LIMIT 1`, as a single-row model lookup does.
322pub(crate) fn limit_one(sql: &mut Sql) {
323    sql.push(" LIMIT 1");
324}
325
326/// `DELETE FROM "t" WHERE "t"."pk" = ?`.
327pub(crate) fn delete_by_id<M: SqlxModel>(exec: Exec<'_>, id: impl Into<SqlValue>) -> Sql {
328    let mut sql = Sql::new(exec.engine());
329    sql.push("DELETE FROM ");
330    sql.ident(M::TABLE);
331    sql.push(" WHERE ");
332    sql.compare_model::<M>(M::TABLE, M::PRIMARY_KEY, " = ", id);
333    sql
334}