Skip to main content

sql_orm/
soft_delete_runtime.rs

1use crate::SoftDeleteEntity;
2use sql_orm_core::{ColumnMetadata, ColumnValue, EntityMetadata, OrmError, SqlValue};
3use std::collections::BTreeSet;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6/// Persistence operation currently requesting soft-delete values.
7pub enum SoftDeleteOperation {
8    /// Logical delete path for an existing row.
9    Delete,
10}
11
12#[derive(Debug, Clone, Default, PartialEq)]
13/// Low-level request-scoped soft-delete values.
14///
15/// Most applications can use `with_soft_delete_values(SoftDelete { ... })`
16/// with a struct derived from `SoftDeleteFields`. This type remains available
17/// for callers that need to construct `ColumnValue`s manually.
18pub struct SoftDeleteRequestValues {
19    values: Vec<ColumnValue>,
20}
21
22impl SoftDeleteRequestValues {
23    /// Creates request-scoped soft-delete values from explicit column values.
24    pub fn new(values: Vec<ColumnValue>) -> Self {
25        Self { values }
26    }
27
28    /// Returns the raw soft-delete column values.
29    pub fn values(&self) -> &[ColumnValue] {
30        &self.values
31    }
32}
33
34#[derive(Debug, Clone, Copy)]
35/// Context passed to a `SoftDeleteProvider`.
36pub struct SoftDeleteContext<'a> {
37    /// Entity metadata for the row being logically deleted.
38    pub entity: &'static EntityMetadata,
39    /// Persistence operation requesting values.
40    pub operation: SoftDeleteOperation,
41    /// Request values already configured on the shared connection.
42    pub request_values: Option<&'a SoftDeleteRequestValues>,
43}
44
45/// Runtime provider that mutates soft-delete changes before execution.
46pub trait SoftDeleteProvider: Send + Sync {
47    /// Adds or adjusts soft-delete column values for the current context.
48    fn apply(
49        &self,
50        context: SoftDeleteContext<'_>,
51        changes: &mut Vec<ColumnValue>,
52    ) -> Result<(), OrmError>;
53}
54
55/// Typed soft-delete value conversion generated by `#[derive(SoftDeleteFields)]`.
56pub trait SoftDeleteValues {
57    /// Consumes the typed soft-delete struct and returns physical column values.
58    fn soft_delete_values(self) -> Vec<ColumnValue>;
59}
60
61#[allow(dead_code)]
62pub(crate) fn apply_soft_delete_values<E: SoftDeleteEntity>(
63    operation: SoftDeleteOperation,
64    values: Vec<ColumnValue>,
65    soft_delete_provider: Option<&dyn SoftDeleteProvider>,
66    request_values: Option<&SoftDeleteRequestValues>,
67) -> Result<Vec<ColumnValue>, OrmError> {
68    validate_no_duplicate_columns(&values)?;
69
70    let Some(policy) = E::soft_delete_policy() else {
71        return Ok(values);
72    };
73
74    let mut values = values;
75    let mut seen = values
76        .iter()
77        .map(|value| value.column_name)
78        .collect::<BTreeSet<_>>();
79
80    if let Some(request_values) = request_values {
81        validate_no_duplicate_columns(request_values.values())?;
82        append_missing_soft_delete_values(
83            policy.columns,
84            &mut values,
85            &mut seen,
86            request_values.values(),
87        )?;
88    }
89
90    let context = SoftDeleteContext {
91        entity: E::metadata(),
92        operation,
93        request_values,
94    };
95
96    if let Some(provider) = soft_delete_provider {
97        provider.apply(context, &mut values)?;
98    }
99
100    validate_no_duplicate_columns(&values)?;
101
102    for value in &values {
103        let Some(column) = policy
104            .columns
105            .iter()
106            .find(|column| column.column_name == value.column_name)
107        else {
108            continue;
109        };
110
111        validate_soft_delete_column_value(column, &value.value)?;
112    }
113
114    for column in policy.columns {
115        if !column.updatable {
116            continue;
117        }
118
119        if column.default_sql.is_some() {
120            continue;
121        }
122
123        if column.nullable {
124            continue;
125        }
126
127        if !values
128            .iter()
129            .any(|value| value.column_name == column.column_name)
130        {
131            return Err(OrmError::new(format!(
132                "soft_delete requires a runtime value for non-nullable column `{}`",
133                column.column_name
134            )));
135        }
136    }
137
138    Ok(values)
139}
140
141#[allow(dead_code)]
142fn append_missing_soft_delete_values(
143    columns: &'static [ColumnMetadata],
144    resolved: &mut Vec<ColumnValue>,
145    seen: &mut BTreeSet<&'static str>,
146    values: &[ColumnValue],
147) -> Result<(), OrmError> {
148    for value in values {
149        let Some(column) = columns
150            .iter()
151            .find(|column| column.column_name == value.column_name)
152        else {
153            continue;
154        };
155
156        validate_soft_delete_column_value(column, &value.value)?;
157
158        if seen.insert(value.column_name) {
159            resolved.push(value.clone());
160        }
161    }
162
163    Ok(())
164}
165
166#[allow(dead_code)]
167fn validate_no_duplicate_columns(values: &[ColumnValue]) -> Result<(), OrmError> {
168    let mut seen = BTreeSet::new();
169
170    for value in values {
171        if !seen.insert(value.column_name) {
172            return Err(OrmError::new(format!(
173                "duplicate column `{}` in soft_delete values",
174                value.column_name
175            )));
176        }
177    }
178
179    Ok(())
180}
181
182#[allow(dead_code)]
183fn validate_soft_delete_column_value(
184    column: &ColumnMetadata,
185    value: &SqlValue,
186) -> Result<(), OrmError> {
187    if !column.updatable {
188        return Err(OrmError::new(format!(
189            "soft_delete column `{}` is not updatable",
190            column.column_name
191        )));
192    }
193
194    if value.is_null() && !column.nullable {
195        return Err(OrmError::new(format!(
196            "soft_delete column `{}` is not nullable",
197            column.column_name
198        )));
199    }
200
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::{
207        SoftDeleteContext, SoftDeleteOperation, SoftDeleteProvider, SoftDeleteRequestValues,
208        apply_soft_delete_values,
209    };
210    use crate::SoftDeleteEntity;
211    use sql_orm_core::{
212        ColumnMetadata, ColumnValue, Entity, EntityMetadata, EntityPolicyMetadata, OrmError,
213        PrimaryKeyMetadata, SqlServerType, SqlValue,
214    };
215
216    struct TestSoftDeleteEntity;
217
218    static TEST_ENTITY_COLUMNS: [ColumnMetadata; 2] = [
219        ColumnMetadata {
220            rust_field: "id",
221            column_name: "id",
222            renamed_from: None,
223            sql_type: SqlServerType::BigInt,
224            nullable: false,
225            primary_key: true,
226            identity: None,
227            default_sql: None,
228            computed_sql: None,
229            rowversion: false,
230            insertable: false,
231            updatable: false,
232            max_length: None,
233            precision: None,
234            scale: None,
235        },
236        ColumnMetadata {
237            rust_field: "deleted_at",
238            column_name: "deleted_at",
239            renamed_from: None,
240            sql_type: SqlServerType::DateTime2,
241            nullable: false,
242            primary_key: false,
243            identity: None,
244            default_sql: None,
245            computed_sql: None,
246            rowversion: false,
247            insertable: false,
248            updatable: true,
249            max_length: None,
250            precision: None,
251            scale: None,
252        },
253    ];
254
255    static TEST_ENTITY_METADATA: EntityMetadata = EntityMetadata {
256        rust_name: "TestSoftDeleteEntity",
257        schema: "dbo",
258        table: "test_soft_delete_entities",
259        renamed_from: None,
260        columns: &TEST_ENTITY_COLUMNS,
261        primary_key: PrimaryKeyMetadata::new(None, &["id"]),
262        indexes: &[],
263        foreign_keys: &[],
264        navigations: &[],
265    };
266
267    static TEST_SOFT_DELETE_COLUMNS: [ColumnMetadata; 2] = [
268        ColumnMetadata {
269            rust_field: "deleted_at",
270            column_name: "deleted_at",
271            renamed_from: None,
272            sql_type: SqlServerType::DateTime2,
273            nullable: false,
274            primary_key: false,
275            identity: None,
276            default_sql: None,
277            computed_sql: None,
278            rowversion: false,
279            insertable: false,
280            updatable: true,
281            max_length: None,
282            precision: None,
283            scale: None,
284        },
285        ColumnMetadata {
286            rust_field: "deleted_by",
287            column_name: "deleted_by",
288            renamed_from: None,
289            sql_type: SqlServerType::NVarChar,
290            nullable: true,
291            primary_key: false,
292            identity: None,
293            default_sql: None,
294            computed_sql: None,
295            rowversion: false,
296            insertable: false,
297            updatable: true,
298            max_length: Some(120),
299            precision: None,
300            scale: None,
301        },
302    ];
303
304    impl Entity for TestSoftDeleteEntity {
305        fn metadata() -> &'static EntityMetadata {
306            &TEST_ENTITY_METADATA
307        }
308    }
309
310    impl SoftDeleteEntity for TestSoftDeleteEntity {
311        fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
312            Some(EntityPolicyMetadata::new(
313                "soft_delete",
314                &TEST_SOFT_DELETE_COLUMNS,
315            ))
316        }
317    }
318
319    struct TestSoftDeleteProvider;
320
321    impl SoftDeleteProvider for TestSoftDeleteProvider {
322        fn apply(
323            &self,
324            context: SoftDeleteContext<'_>,
325            changes: &mut Vec<ColumnValue>,
326        ) -> Result<(), OrmError> {
327            assert_eq!(context.entity.rust_name, "TestSoftDeleteEntity");
328            assert_eq!(context.operation, SoftDeleteOperation::Delete);
329            assert!(context.request_values.is_some());
330
331            changes.push(ColumnValue::new(
332                "deleted_at",
333                SqlValue::String("2026-04-25T00:00:00".to_string()),
334            ));
335            Ok(())
336        }
337    }
338
339    #[test]
340    fn apply_soft_delete_values_returns_input_for_entities_without_policy() {
341        struct PlainEntity;
342
343        impl Entity for PlainEntity {
344            fn metadata() -> &'static EntityMetadata {
345                &TEST_ENTITY_METADATA
346            }
347        }
348
349        impl SoftDeleteEntity for PlainEntity {
350            fn soft_delete_policy() -> Option<EntityPolicyMetadata> {
351                None
352            }
353        }
354
355        let values = vec![ColumnValue::new(
356            "status",
357            SqlValue::String("x".to_string()),
358        )];
359
360        let result = apply_soft_delete_values::<PlainEntity>(
361            SoftDeleteOperation::Delete,
362            values.clone(),
363            None,
364            None,
365        )
366        .expect("plain entity should pass through");
367
368        assert_eq!(result, values);
369    }
370
371    #[test]
372    fn apply_soft_delete_values_applies_provider_and_validates_required_columns() {
373        let request_values =
374            SoftDeleteRequestValues::new(vec![ColumnValue::new("deleted_by", SqlValue::Null)]);
375
376        let values = apply_soft_delete_values::<TestSoftDeleteEntity>(
377            SoftDeleteOperation::Delete,
378            vec![],
379            Some(&TestSoftDeleteProvider),
380            Some(&request_values),
381        )
382        .expect("provider should populate required soft delete columns");
383
384        assert_eq!(values.len(), 2);
385        assert_eq!(values[0].column_name, "deleted_by");
386        assert_eq!(values[1].column_name, "deleted_at");
387    }
388
389    #[test]
390    fn apply_soft_delete_values_uses_request_values_without_provider() {
391        let request_values = SoftDeleteRequestValues::new(vec![ColumnValue::new(
392            "deleted_at",
393            SqlValue::String("2026-04-28T00:00:00".to_string()),
394        )]);
395
396        let values = apply_soft_delete_values::<TestSoftDeleteEntity>(
397            SoftDeleteOperation::Delete,
398            vec![],
399            None,
400            Some(&request_values),
401        )
402        .expect("request values should populate soft delete columns");
403
404        assert_eq!(values.len(), 1);
405        assert_eq!(values[0].column_name, "deleted_at");
406    }
407
408    #[test]
409    fn apply_soft_delete_values_rejects_duplicate_columns() {
410        let error = apply_soft_delete_values::<TestSoftDeleteEntity>(
411            SoftDeleteOperation::Delete,
412            vec![
413                ColumnValue::new("deleted_at", SqlValue::String("first".to_string())),
414                ColumnValue::new("deleted_at", SqlValue::String("second".to_string())),
415            ],
416            None,
417            None,
418        )
419        .unwrap_err();
420
421        assert_eq!(
422            error,
423            OrmError::new("duplicate column `deleted_at` in soft_delete values")
424        );
425    }
426
427    #[test]
428    fn apply_soft_delete_values_rejects_missing_required_column_without_default() {
429        let error = apply_soft_delete_values::<TestSoftDeleteEntity>(
430            SoftDeleteOperation::Delete,
431            vec![ColumnValue::new("deleted_by", SqlValue::Null)],
432            None,
433            None,
434        )
435        .unwrap_err();
436
437        assert_eq!(
438            error,
439            OrmError::new(
440                "soft_delete requires a runtime value for non-nullable column `deleted_at`"
441            )
442        );
443    }
444}