Skip to main content

prax_query/operations/
delete.rs

1//! Delete operation for removing records.
2
3use std::marker::PhantomData;
4
5use crate::error::QueryResult;
6use crate::filter::{Filter, FilterValue};
7use crate::traits::{Model, QueryEngine};
8use crate::types::Select;
9
10/// A delete operation for removing records.
11///
12/// # Example
13///
14/// ```rust,ignore
15/// let deleted = client
16///     .user()
17///     .delete()
18///     .r#where(user::id::equals(1))
19///     .exec()
20///     .await?;
21/// ```
22pub struct DeleteOperation<E: QueryEngine, M: Model> {
23    engine: E,
24    filter: Filter,
25    select: Select,
26    _model: PhantomData<M>,
27}
28
29impl<E: QueryEngine, M: Model + crate::row::FromRow> DeleteOperation<E, M> {
30    /// Create a new Delete operation.
31    pub fn new(engine: E) -> Self {
32        Self {
33            engine,
34            filter: Filter::None,
35            select: Select::All,
36            _model: PhantomData,
37        }
38    }
39
40    /// Add a filter condition.
41    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
42        let new_filter = filter.into();
43        self.filter = self.filter.and_then(new_filter);
44        self
45    }
46
47    /// Select specific fields to return from deleted records.
48    pub fn select(mut self, select: impl Into<Select>) -> Self {
49        self.select = select.into();
50        self
51    }
52
53    /// Build the SQL query.
54    pub fn build_sql(
55        &self,
56        dialect: &dyn crate::dialect::SqlDialect,
57    ) -> (String, Vec<FilterValue>) {
58        let (where_sql, params) = self.filter.to_sql(0, dialect);
59
60        let mut sql = String::new();
61
62        // DELETE FROM clause
63        sql.push_str("DELETE FROM ");
64        sql.push_str(M::TABLE_NAME);
65
66        // WHERE clause
67        if !self.filter.is_none() {
68            sql.push_str(" WHERE ");
69            sql.push_str(&where_sql);
70        }
71
72        // RETURNING clause
73        sql.push_str(&dialect.returning_clause(&self.select.to_sql()));
74
75        (sql, params)
76    }
77
78    /// Build SQL without RETURNING (for count).
79    fn build_sql_count(
80        &self,
81        dialect: &dyn crate::dialect::SqlDialect,
82    ) -> (String, Vec<FilterValue>) {
83        let (where_sql, params) = self.filter.to_sql(0, dialect);
84
85        let mut sql = String::new();
86
87        sql.push_str("DELETE FROM ");
88        sql.push_str(M::TABLE_NAME);
89
90        if !self.filter.is_none() {
91            sql.push_str(" WHERE ");
92            sql.push_str(&where_sql);
93        }
94
95        (sql, params)
96    }
97
98    /// Execute the delete and return deleted records.
99    pub async fn exec(self) -> QueryResult<Vec<M>>
100    where
101        M: Send + 'static,
102    {
103        let dialect = self.engine.dialect();
104        let (sql, params) = self.build_sql(dialect);
105        self.engine.execute_update::<M>(&sql, params).await
106    }
107
108    /// Execute the delete and return the count of deleted records.
109    pub async fn exec_count(self) -> QueryResult<u64> {
110        let dialect = self.engine.dialect();
111        let (sql, params) = self.build_sql_count(dialect);
112        self.engine.execute_delete(&sql, params).await
113    }
114
115    /// Apply a typed `WhereUniqueInput`. Overwrites any previously set
116    /// filter — delete operations are intentionally precise.
117    pub fn with_where_input<W: crate::inputs::WhereUniqueInput<Model = M>>(mut self, w: W) -> Self {
118        self.filter = w.into_ir();
119        self
120    }
121
122    /// Apply a typed `SelectInput`.
123    pub fn with_select_input<S: crate::inputs::SelectInput<Model = M>>(mut self, s: S) -> Self {
124        self.select = s.into_ir();
125        self
126    }
127
128    /// Doc-hidden accessor for the current filter.
129    #[doc(hidden)]
130    pub fn filter_for_test(&self) -> &Filter {
131        &self.filter
132    }
133}
134
135/// Delete many records at once.
136pub struct DeleteManyOperation<E: QueryEngine, M: Model> {
137    engine: E,
138    filter: Filter,
139    _model: PhantomData<M>,
140}
141
142impl<E: QueryEngine, M: Model> DeleteManyOperation<E, M> {
143    /// Create a new DeleteMany operation.
144    pub fn new(engine: E) -> Self {
145        Self {
146            engine,
147            filter: Filter::None,
148            _model: PhantomData,
149        }
150    }
151
152    /// Add a filter condition.
153    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
154        let new_filter = filter.into();
155        self.filter = self.filter.and_then(new_filter);
156        self
157    }
158
159    /// Build the SQL query.
160    pub fn build_sql(
161        &self,
162        dialect: &dyn crate::dialect::SqlDialect,
163    ) -> (String, Vec<FilterValue>) {
164        let (where_sql, params) = self.filter.to_sql(0, dialect);
165
166        let mut sql = String::new();
167
168        sql.push_str("DELETE FROM ");
169        sql.push_str(M::TABLE_NAME);
170
171        if !self.filter.is_none() {
172            sql.push_str(" WHERE ");
173            sql.push_str(&where_sql);
174        }
175
176        (sql, params)
177    }
178
179    /// Execute the delete and return the count of deleted records.
180    pub async fn exec(self) -> QueryResult<u64> {
181        let dialect = self.engine.dialect();
182        let (sql, params) = self.build_sql(dialect);
183        self.engine.execute_delete(&sql, params).await
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::error::QueryError;
191
192    struct TestModel;
193
194    impl Model for TestModel {
195        const MODEL_NAME: &'static str = "TestModel";
196        const TABLE_NAME: &'static str = "test_models";
197        const PRIMARY_KEY: &'static [&'static str] = &["id"];
198        const COLUMNS: &'static [&'static str] = &["id", "name", "email"];
199    }
200
201    impl crate::row::FromRow for TestModel {
202        fn from_row(_row: &impl crate::row::RowRef) -> Result<Self, crate::row::RowError> {
203            Ok(TestModel)
204        }
205    }
206
207    #[derive(Clone)]
208    struct MockEngine {
209        delete_count: u64,
210    }
211
212    impl MockEngine {
213        fn new() -> Self {
214            Self { delete_count: 0 }
215        }
216
217        fn with_count(count: u64) -> Self {
218            Self {
219                delete_count: count,
220            }
221        }
222    }
223
224    impl QueryEngine for MockEngine {
225        fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
226            &crate::dialect::Postgres
227        }
228
229        fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
230            &self,
231            _sql: &str,
232            _params: Vec<FilterValue>,
233        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
234            Box::pin(async { Ok(Vec::new()) })
235        }
236
237        fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
238            &self,
239            _sql: &str,
240            _params: Vec<FilterValue>,
241        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
242            Box::pin(async { Err(QueryError::not_found("test")) })
243        }
244
245        fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
246            &self,
247            _sql: &str,
248            _params: Vec<FilterValue>,
249        ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
250            Box::pin(async { Ok(None) })
251        }
252
253        fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
254            &self,
255            _sql: &str,
256            _params: Vec<FilterValue>,
257        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
258            Box::pin(async { Err(QueryError::not_found("test")) })
259        }
260
261        fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
262            &self,
263            _sql: &str,
264            _params: Vec<FilterValue>,
265        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
266            Box::pin(async { Ok(Vec::new()) })
267        }
268
269        fn execute_delete(
270            &self,
271            _sql: &str,
272            _params: Vec<FilterValue>,
273        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
274            let count = self.delete_count;
275            Box::pin(async move { Ok(count) })
276        }
277
278        fn execute_raw(
279            &self,
280            _sql: &str,
281            _params: Vec<FilterValue>,
282        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
283            Box::pin(async { Ok(0) })
284        }
285
286        fn count(
287            &self,
288            _sql: &str,
289            _params: Vec<FilterValue>,
290        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
291            Box::pin(async { Ok(0) })
292        }
293    }
294
295    // ========== DeleteOperation Tests ==========
296
297    #[test]
298    fn test_delete_new() {
299        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new());
300        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
301
302        assert!(sql.contains("DELETE FROM test_models"));
303        assert!(sql.contains("RETURNING *"));
304        assert!(params.is_empty());
305    }
306
307    #[test]
308    fn test_delete_with_filter() {
309        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
310            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
311
312        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
313
314        assert!(sql.contains("DELETE FROM test_models"));
315        assert!(sql.contains("WHERE"));
316        assert!(sql.contains(r#""id" = $1"#));
317        assert!(sql.contains("RETURNING *"));
318        assert_eq!(params.len(), 1);
319    }
320
321    #[test]
322    fn test_delete_with_select() {
323        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
324            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
325            .select(Select::fields(["id", "name"]));
326
327        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
328
329        assert!(sql.contains("RETURNING id, name"));
330        assert!(!sql.contains("RETURNING *"));
331    }
332
333    #[test]
334    fn test_delete_with_compound_filter() {
335        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
336            .r#where(Filter::Equals(
337                "status".into(),
338                FilterValue::String("deleted".to_string()),
339            ))
340            .r#where(Filter::Lt(
341                "updated_at".into(),
342                FilterValue::String("2024-01-01".to_string()),
343            ));
344
345        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
346
347        assert!(sql.contains("WHERE"));
348        assert!(sql.contains("AND"));
349        assert_eq!(params.len(), 2);
350    }
351
352    #[test]
353    fn test_delete_without_filter() {
354        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new());
355        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
356
357        assert!(!sql.contains("WHERE"));
358        assert!(sql.contains("DELETE FROM test_models"));
359    }
360
361    #[test]
362    fn test_delete_build_sql_count() {
363        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
364            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
365
366        let (sql, params) = op.build_sql_count(&crate::dialect::Postgres);
367
368        assert!(sql.contains("DELETE FROM test_models"));
369        assert!(sql.contains("WHERE"));
370        assert!(!sql.contains("RETURNING")); // No RETURNING for count
371        assert_eq!(params.len(), 1);
372    }
373
374    #[test]
375    fn test_delete_with_or_filter() {
376        let op =
377            DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new()).r#where(Filter::or([
378                Filter::Equals("status".into(), FilterValue::String("deleted".to_string())),
379                Filter::Equals("status".into(), FilterValue::String("archived".to_string())),
380            ]));
381
382        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
383
384        assert!(sql.contains("OR"));
385        assert_eq!(params.len(), 2);
386    }
387
388    #[test]
389    fn test_delete_with_in_filter() {
390        let op =
391            DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new()).r#where(Filter::In(
392                "id".into(),
393                vec![
394                    FilterValue::Int(1),
395                    FilterValue::Int(2),
396                    FilterValue::Int(3),
397                ],
398            ));
399
400        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
401
402        assert!(sql.contains("IN"));
403        assert_eq!(params.len(), 3);
404    }
405
406    #[tokio::test]
407    async fn test_delete_exec() {
408        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
409            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
410
411        let result = op.exec().await;
412        assert!(result.is_ok());
413    }
414
415    #[tokio::test]
416    async fn test_delete_exec_count() {
417        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::with_count(5))
418            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
419
420        let result = op.exec_count().await;
421        assert!(result.is_ok());
422        assert_eq!(result.unwrap(), 5);
423    }
424
425    // ========== DeleteManyOperation Tests ==========
426
427    #[test]
428    fn test_delete_many_new() {
429        let op = DeleteManyOperation::<MockEngine, TestModel>::new(MockEngine::new());
430        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
431
432        assert!(sql.contains("DELETE FROM test_models"));
433        assert!(!sql.contains("RETURNING"));
434        assert!(params.is_empty());
435    }
436
437    #[test]
438    fn test_delete_many() {
439        let op = DeleteManyOperation::<MockEngine, TestModel>::new(MockEngine::new()).r#where(
440            Filter::In("id".into(), vec![FilterValue::Int(1), FilterValue::Int(2)]),
441        );
442
443        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
444
445        assert!(sql.contains("DELETE FROM test_models"));
446        assert!(sql.contains("IN"));
447        assert!(!sql.contains("RETURNING"));
448        assert_eq!(params.len(), 2);
449    }
450
451    #[test]
452    fn test_delete_many_with_compound_filter() {
453        let op = DeleteManyOperation::<MockEngine, TestModel>::new(MockEngine::new())
454            .r#where(Filter::Equals("tenant_id".into(), FilterValue::Int(1)))
455            .r#where(Filter::Equals("deleted".into(), FilterValue::Bool(true)));
456
457        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
458
459        assert!(sql.contains("WHERE"));
460        assert!(sql.contains("AND"));
461        assert_eq!(params.len(), 2);
462    }
463
464    #[test]
465    fn test_delete_many_without_filter() {
466        let op = DeleteManyOperation::<MockEngine, TestModel>::new(MockEngine::new());
467        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
468
469        assert!(!sql.contains("WHERE"));
470    }
471
472    #[test]
473    fn test_delete_many_with_not_in_filter() {
474        let op = DeleteManyOperation::<MockEngine, TestModel>::new(MockEngine::new()).r#where(
475            Filter::NotIn(
476                "status".into(),
477                vec![
478                    FilterValue::String("active".to_string()),
479                    FilterValue::String("pending".to_string()),
480                ],
481            ),
482        );
483
484        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
485
486        assert!(sql.contains("NOT IN"));
487        assert_eq!(params.len(), 2);
488    }
489
490    #[tokio::test]
491    async fn test_delete_many_exec() {
492        let op =
493            DeleteManyOperation::<MockEngine, TestModel>::new(MockEngine::with_count(10)).r#where(
494                Filter::Equals("status".into(), FilterValue::String("deleted".to_string())),
495            );
496
497        let result = op.exec().await;
498        assert!(result.is_ok());
499        assert_eq!(result.unwrap(), 10);
500    }
501
502    // ========== SQL Structure Tests ==========
503
504    #[test]
505    fn test_delete_sql_structure() {
506        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
507            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
508            .select(Select::fields(["id"]));
509
510        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
511
512        let delete_pos = sql.find("DELETE FROM").unwrap();
513        let where_pos = sql.find("WHERE").unwrap();
514        let returning_pos = sql.find("RETURNING").unwrap();
515
516        assert!(delete_pos < where_pos);
517        assert!(where_pos < returning_pos);
518    }
519
520    #[test]
521    fn test_delete_with_null_check() {
522        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
523            .r#where(Filter::IsNull("deleted_at".into()));
524
525        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
526
527        assert!(sql.contains("IS NULL"));
528        assert!(params.is_empty()); // IS NULL doesn't have params
529    }
530
531    #[test]
532    fn test_delete_with_not_null_check() {
533        let op = DeleteOperation::<MockEngine, TestModel>::new(MockEngine::new())
534            .r#where(Filter::IsNotNull("email".into()));
535
536        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
537
538        assert!(sql.contains("IS NOT NULL"));
539        assert!(params.is_empty());
540    }
541}