oxify_storage/
soft_delete.rs1use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39use uuid::Uuid;
40
41use crate::{Result, StorageError};
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SoftDeleteMetadata {
46 pub deleted_by: Option<Uuid>,
48 pub deleted_at: DateTime<Utc>,
50 pub deletion_reason: Option<String>,
52}
53
54impl SoftDeleteMetadata {
55 pub fn new(deleted_by: Uuid, deletion_reason: Option<&str>) -> Self {
65 Self {
66 deleted_by: Some(deleted_by),
67 deleted_at: Utc::now(),
68 deletion_reason: deletion_reason.map(|s| s.to_string()),
69 }
70 }
71
72 pub fn anonymous(deletion_reason: Option<&str>) -> Self {
74 Self {
75 deleted_by: None,
76 deleted_at: Utc::now(),
77 deletion_reason: deletion_reason.map(|s| s.to_string()),
78 }
79 }
80}
81
82pub struct SoftDeleteBuilder {
86 table: String,
87 where_clause: Option<String>,
88 deleted_at_column: String,
89 deleted_by_column: Option<String>,
90 deletion_reason_column: Option<String>,
91 metadata: Option<SoftDeleteMetadata>,
92}
93
94impl SoftDeleteBuilder {
95 pub fn new(table: &str) -> Self {
103 Self {
104 table: table.to_string(),
105 where_clause: None,
106 deleted_at_column: "deleted_at".to_string(),
107 deleted_by_column: None,
108 deletion_reason_column: None,
109 metadata: None,
110 }
111 }
112
113 pub fn where_clause(mut self, clause: &str) -> Self {
122 self.where_clause = Some(clause.to_string());
123 self
124 }
125
126 pub fn deleted_at_column(mut self, column: &str) -> Self {
128 self.deleted_at_column = column.to_string();
129 self
130 }
131
132 pub fn with_deleted_by_column(mut self, column: &str) -> Self {
134 self.deleted_by_column = Some(column.to_string());
135 self
136 }
137
138 pub fn with_deletion_reason_column(mut self, column: &str) -> Self {
140 self.deletion_reason_column = Some(column.to_string());
141 self
142 }
143
144 pub fn with_metadata(mut self, metadata: SoftDeleteMetadata) -> Self {
146 self.metadata = Some(metadata);
147 self
148 }
149
150 fn build_query(&self) -> Result<String> {
152 if self.where_clause.is_none() {
153 return Err(StorageError::ValidationError(
154 "WHERE clause is required for soft delete".to_string(),
155 ));
156 }
157
158 let mut updates = vec![format!("{} = NOW()", self.deleted_at_column)];
159
160 if let Some(ref metadata) = self.metadata {
161 if let Some(ref col) = self.deleted_by_column {
162 if let Some(user_id) = metadata.deleted_by {
163 updates.push(format!("{col} = '{user_id}'"));
164 }
165 }
166
167 if let Some(ref col) = self.deletion_reason_column {
168 if let Some(ref reason) = metadata.deletion_reason {
169 let escaped_reason = reason.replace('\'', "''");
171 updates.push(format!("{col} = '{escaped_reason}'"));
172 }
173 }
174 }
175
176 let update_clause = updates.join(", ");
177 let where_clause = self.where_clause.as_ref().unwrap();
178
179 Ok(format!(
180 "UPDATE {} SET {} WHERE {} AND {} IS NULL",
181 self.table, update_clause, where_clause, self.deleted_at_column
182 ))
183 }
184
185 pub fn soft_delete_query(&self) -> Result<String> {
199 self.build_query()
200 }
201}
202
203pub struct SoftDeleteFilter {
205 deleted_at_column: String,
206}
207
208impl SoftDeleteFilter {
209 pub fn new(deleted_at_column: &str) -> Self {
218 Self {
219 deleted_at_column: deleted_at_column.to_string(),
220 }
221 }
222
223 pub fn not_deleted_clause(&self) -> String {
233 format!("{} IS NULL", self.deleted_at_column)
234 }
235
236 pub fn deleted_clause(&self) -> String {
246 format!("{} IS NOT NULL", self.deleted_at_column)
247 }
248
249 pub fn deleted_between_clause(&self, start: DateTime<Utc>, end: DateTime<Utc>) -> String {
251 format!(
252 "{} BETWEEN '{}' AND '{}'",
253 self.deleted_at_column,
254 start.to_rfc3339(),
255 end.to_rfc3339()
256 )
257 }
258}
259
260impl Default for SoftDeleteFilter {
261 fn default() -> Self {
262 Self::new("deleted_at")
263 }
264}
265
266pub struct SoftDeleteRestorer {
268 table: String,
269 where_clause: Option<String>,
270 deleted_at_column: String,
271 deleted_by_column: Option<String>,
272 deletion_reason_column: Option<String>,
273}
274
275impl SoftDeleteRestorer {
276 pub fn new(table: &str) -> Self {
284 Self {
285 table: table.to_string(),
286 where_clause: None,
287 deleted_at_column: "deleted_at".to_string(),
288 deleted_by_column: None,
289 deletion_reason_column: None,
290 }
291 }
292
293 pub fn where_clause(mut self, clause: &str) -> Self {
295 self.where_clause = Some(clause.to_string());
296 self
297 }
298
299 pub fn deleted_at_column(mut self, column: &str) -> Self {
301 self.deleted_at_column = column.to_string();
302 self
303 }
304
305 pub fn clear_deleted_by_column(mut self, column: &str) -> Self {
307 self.deleted_by_column = Some(column.to_string());
308 self
309 }
310
311 pub fn clear_deletion_reason_column(mut self, column: &str) -> Self {
313 self.deletion_reason_column = Some(column.to_string());
314 self
315 }
316
317 fn build_query(&self) -> Result<String> {
319 if self.where_clause.is_none() {
320 return Err(StorageError::ValidationError(
321 "WHERE clause is required for restore".to_string(),
322 ));
323 }
324
325 let mut updates = vec![format!("{} = NULL", self.deleted_at_column)];
326
327 if let Some(ref col) = self.deleted_by_column {
328 updates.push(format!("{col} = NULL"));
329 }
330
331 if let Some(ref col) = self.deletion_reason_column {
332 updates.push(format!("{col} = NULL"));
333 }
334
335 let update_clause = updates.join(", ");
336 let where_clause = self.where_clause.as_ref().unwrap();
337
338 Ok(format!(
339 "UPDATE {} SET {} WHERE {} AND {} IS NOT NULL",
340 self.table, update_clause, where_clause, self.deleted_at_column
341 ))
342 }
343
344 pub fn restore_query(&self) -> Result<String> {
358 self.build_query()
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn test_soft_delete_metadata_new() {
368 let user_id = Uuid::new_v4();
369 let metadata = SoftDeleteMetadata::new(user_id, Some("Test reason"));
370
371 assert_eq!(metadata.deleted_by, Some(user_id));
372 assert_eq!(metadata.deletion_reason, Some("Test reason".to_string()));
373 }
374
375 #[test]
376 fn test_soft_delete_metadata_anonymous() {
377 let metadata = SoftDeleteMetadata::anonymous(Some("System cleanup"));
378
379 assert_eq!(metadata.deleted_by, None);
380 assert_eq!(metadata.deletion_reason, Some("System cleanup".to_string()));
381 }
382
383 #[test]
384 fn test_soft_delete_builder_basic_query() {
385 let builder = SoftDeleteBuilder::new("workflows").where_clause("id = $1");
386
387 let query = builder.build_query().unwrap();
388 assert!(query.contains("UPDATE workflows"));
389 assert!(query.contains("SET deleted_at = NOW()"));
390 assert!(query.contains("WHERE id = $1"));
391 assert!(query.contains("AND deleted_at IS NULL"));
392 }
393
394 #[test]
395 fn test_soft_delete_builder_no_where_clause() {
396 let builder = SoftDeleteBuilder::new("workflows");
397
398 let result = builder.build_query();
399 assert!(result.is_err());
400 match result {
401 Err(StorageError::ValidationError(msg)) => {
402 assert!(msg.contains("WHERE clause is required"));
403 }
404 _ => panic!("Expected ValidationError"),
405 }
406 }
407
408 #[test]
409 fn test_soft_delete_builder_custom_columns() {
410 let builder = SoftDeleteBuilder::new("workflows")
411 .where_clause("id = $1")
412 .deleted_at_column("removed_at")
413 .with_deleted_by_column("removed_by")
414 .with_deletion_reason_column("removal_reason");
415
416 let query = builder.build_query().unwrap();
417 assert!(query.contains("removed_at = NOW()"));
418 assert!(query.contains("AND removed_at IS NULL"));
419 }
420
421 #[test]
422 fn test_soft_delete_filter_default() {
423 let filter = SoftDeleteFilter::default();
424 assert_eq!(filter.not_deleted_clause(), "deleted_at IS NULL");
425 assert_eq!(filter.deleted_clause(), "deleted_at IS NOT NULL");
426 }
427
428 #[test]
429 fn test_soft_delete_filter_custom_column() {
430 let filter = SoftDeleteFilter::new("removed_at");
431 assert_eq!(filter.not_deleted_clause(), "removed_at IS NULL");
432 assert_eq!(filter.deleted_clause(), "removed_at IS NOT NULL");
433 }
434
435 #[test]
436 fn test_soft_delete_filter_time_range() {
437 let filter = SoftDeleteFilter::default();
438 let start = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
439 .unwrap()
440 .with_timezone(&Utc);
441 let end = DateTime::parse_from_rfc3339("2026-12-31T23:59:59Z")
442 .unwrap()
443 .with_timezone(&Utc);
444
445 let clause = filter.deleted_between_clause(start, end);
446 assert!(clause.contains("deleted_at BETWEEN"));
447 assert!(clause.contains("2026-01-01"));
448 assert!(clause.contains("2026-12-31"));
449 }
450
451 #[test]
452 fn test_soft_delete_restorer_basic_query() {
453 let restorer = SoftDeleteRestorer::new("workflows").where_clause("id = $1");
454
455 let query = restorer.build_query().unwrap();
456 assert!(query.contains("UPDATE workflows"));
457 assert!(query.contains("SET deleted_at = NULL"));
458 assert!(query.contains("WHERE id = $1"));
459 assert!(query.contains("AND deleted_at IS NOT NULL"));
460 }
461
462 #[test]
463 fn test_soft_delete_restorer_with_metadata_columns() {
464 let restorer = SoftDeleteRestorer::new("workflows")
465 .where_clause("id = $1")
466 .clear_deleted_by_column("deleted_by")
467 .clear_deletion_reason_column("deletion_reason");
468
469 let query = restorer.build_query().unwrap();
470 assert!(query.contains("deleted_at = NULL"));
471 assert!(query.contains("deleted_by = NULL"));
472 assert!(query.contains("deletion_reason = NULL"));
473 }
474
475 #[test]
476 fn test_soft_delete_restorer_no_where_clause() {
477 let restorer = SoftDeleteRestorer::new("workflows");
478
479 let result = restorer.build_query();
480 assert!(result.is_err());
481 match result {
482 Err(StorageError::ValidationError(msg)) => {
483 assert!(msg.contains("WHERE clause is required"));
484 }
485 _ => panic!("Expected ValidationError"),
486 }
487 }
488}