1use crate::SoftDeleteEntity;
2use sql_orm_core::{ColumnMetadata, ColumnValue, EntityMetadata, OrmError, SqlValue};
3use std::collections::BTreeSet;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum SoftDeleteOperation {
8 Delete,
10}
11
12#[derive(Debug, Clone, Default, PartialEq)]
13pub struct SoftDeleteRequestValues {
19 values: Vec<ColumnValue>,
20}
21
22impl SoftDeleteRequestValues {
23 pub fn new(values: Vec<ColumnValue>) -> Self {
25 Self { values }
26 }
27
28 pub fn values(&self) -> &[ColumnValue] {
30 &self.values
31 }
32}
33
34#[derive(Debug, Clone, Copy)]
35pub struct SoftDeleteContext<'a> {
37 pub entity: &'static EntityMetadata,
39 pub operation: SoftDeleteOperation,
41 pub request_values: Option<&'a SoftDeleteRequestValues>,
43}
44
45pub trait SoftDeleteProvider: Send + Sync {
47 fn apply(
49 &self,
50 context: SoftDeleteContext<'_>,
51 changes: &mut Vec<ColumnValue>,
52 ) -> Result<(), OrmError>;
53}
54
55pub trait SoftDeleteValues {
57 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}