cratestack-rusqlite 0.4.13

On-device SQLite backend for the CrateStack ORM. Sync API, bundled SQLite, no tokio, intended for mobile/embedded use via FFI.
Documentation
//! `BatchDelete` — soft-delete-aware DELETE / UPDATE RETURNING with per-id
//! NOT_FOUND.

use std::collections::HashMap;
use std::hash::Hash;

use cratestack_core::{BatchItemError, BatchResponse};
use cratestack_sql::{IntoSqlValue, ModelDescriptor, ModelPrimaryKey, SqlValue};
use rusqlite::params_from_iter;

use crate::{FromRusqliteRow, RusqliteError, RusqliteRuntime, SqlValueParam};

use super::support::{err_item, finalize, ok_item, reject_duplicate_pks, validate_batch_size};

pub struct BatchDelete<'a, M: 'static, PK: 'static> {
    pub(crate) runtime: &'a RusqliteRuntime,
    pub(crate) descriptor: &'static ModelDescriptor<M, PK>,
    pub(crate) ids: Vec<PK>,
}

impl<'a, M: 'static, PK: 'static> BatchDelete<'a, M, PK> {
    pub fn run(self) -> Result<BatchResponse<M>, RusqliteError>
    where
        M: FromRusqliteRow + ModelPrimaryKey<PK>,
        PK: IntoSqlValue + Clone + Eq + Hash,
    {
        validate_batch_size(self.ids.len())?;
        reject_duplicate_pks(&self.ids)?;
        if self.ids.is_empty() {
            return Ok(finalize::<M>(vec![]));
        }

        // Soft-delete becomes an UPDATE-of-deleted_at; hard delete uses
        // DELETE. Either way, RETURNING gives us the deleted rows so we
        // can pair them back to the input order.
        let mut sql = match self.descriptor.soft_delete_column {
            Some(col) => {
                let mut s = format!(
                    "UPDATE {} SET {col} = CURRENT_TIMESTAMP",
                    self.descriptor.table_name
                );
                if let Some(version_col) = self.descriptor.version_column {
                    s.push_str(&format!(", {version_col} = {version_col} + 1"));
                }
                s.push_str(&format!(" WHERE {col} IS NULL AND "));
                s
            }
            None => format!("DELETE FROM {} WHERE ", self.descriptor.table_name),
        };
        sql.push_str(self.descriptor.primary_key);
        sql.push_str(" IN (");
        for index in 0..self.ids.len() {
            if index > 0 {
                sql.push_str(", ");
            }
            sql.push_str(&format!("?{}", index + 1));
        }
        sql.push_str(") RETURNING ");
        sql.push_str(&self.descriptor.select_projection());

        let binds: Vec<SqlValue> = self
            .ids
            .iter()
            .cloned()
            .map(IntoSqlValue::into_sql_value)
            .collect();

        let deleted: Vec<M> = self.runtime.with_connection(|conn| {
            let mut stmt = conn.prepare(&sql)?;
            let bind_iter = binds.iter().map(SqlValueParam);
            let rows = stmt
                .query_map(params_from_iter(bind_iter), |row| M::from_rusqlite_row(row))?
                .collect::<Result<Vec<_>, _>>()?;
            Ok(rows)
        })?;

        let mut by_pk: HashMap<PK, M> = deleted.into_iter().map(|m| (m.primary_key(), m)).collect();
        let results = self
            .ids
            .into_iter()
            .enumerate()
            .map(|(index, id)| match by_pk.remove(&id) {
                Some(record) => ok_item(index, record),
                None => err_item(
                    index,
                    BatchItemError {
                        code: "NOT_FOUND".to_owned(),
                        message: "no row matched".to_owned(),
                    },
                ),
            })
            .collect();
        Ok(finalize(results))
    }
}