prax_query/operations/
find_first.rs

1//! FindFirst operation for querying the first matching record.
2
3use std::marker::PhantomData;
4
5use crate::error::QueryResult;
6use crate::filter::Filter;
7use crate::traits::{Model, QueryEngine};
8use crate::types::{OrderBy, Select};
9
10/// A query operation that finds the first record matching the filter.
11///
12/// Unlike `FindUnique`, this doesn't require a unique constraint.
13///
14/// # Example
15///
16/// ```rust,ignore
17/// let user = client
18///     .user()
19///     .find_first()
20///     .r#where(user::email::contains("@example.com"))
21///     .order_by(user::created_at::desc())
22///     .exec()
23///     .await?;
24/// ```
25pub struct FindFirstOperation<E: QueryEngine, M: Model> {
26    engine: E,
27    filter: Filter,
28    order_by: OrderBy,
29    select: Select,
30    _model: PhantomData<M>,
31}
32
33impl<E: QueryEngine, M: Model> FindFirstOperation<E, M> {
34    /// Create a new FindFirst operation.
35    pub fn new(engine: E) -> Self {
36        Self {
37            engine,
38            filter: Filter::None,
39            order_by: OrderBy::none(),
40            select: Select::All,
41            _model: PhantomData,
42        }
43    }
44
45    /// Add a filter condition.
46    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
47        let new_filter = filter.into();
48        self.filter = self.filter.and_then(new_filter);
49        self
50    }
51
52    /// Set the order by clause.
53    pub fn order_by(mut self, order: impl Into<OrderBy>) -> Self {
54        self.order_by = order.into();
55        self
56    }
57
58    /// Select specific fields.
59    pub fn select(mut self, select: impl Into<Select>) -> Self {
60        self.select = select.into();
61        self
62    }
63
64    /// Build the SQL query.
65    pub fn build_sql(&self) -> (String, Vec<crate::filter::FilterValue>) {
66        let (where_sql, params) = self.filter.to_sql(0);
67
68        let mut sql = String::new();
69
70        // SELECT clause
71        sql.push_str("SELECT ");
72        sql.push_str(&self.select.to_sql());
73
74        // FROM clause
75        sql.push_str(" FROM ");
76        sql.push_str(M::TABLE_NAME);
77
78        // WHERE clause
79        if !self.filter.is_none() {
80            sql.push_str(" WHERE ");
81            sql.push_str(&where_sql);
82        }
83
84        // ORDER BY clause
85        if !self.order_by.is_empty() {
86            sql.push_str(" ORDER BY ");
87            sql.push_str(&self.order_by.to_sql());
88        }
89
90        // LIMIT 1
91        sql.push_str(" LIMIT 1");
92
93        (sql, params)
94    }
95
96    /// Execute the query and return an optional result.
97    pub async fn exec(self) -> QueryResult<Option<M>>
98    where
99        M: Send + 'static,
100    {
101        let (sql, params) = self.build_sql();
102        self.engine.query_optional::<M>(&sql, params).await
103    }
104
105    /// Execute the query and error if not found.
106    pub async fn exec_required(self) -> QueryResult<M>
107    where
108        M: Send + 'static,
109    {
110        let (sql, params) = self.build_sql();
111        self.engine.query_one::<M>(&sql, params).await
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::error::QueryError;
119    use crate::filter::FilterValue;
120    use crate::types::OrderByField;
121
122    #[derive(Debug)]
123    struct TestModel;
124
125    impl Model for TestModel {
126        const MODEL_NAME: &'static str = "TestModel";
127        const TABLE_NAME: &'static str = "test_models";
128        const PRIMARY_KEY: &'static [&'static str] = &["id"];
129        const COLUMNS: &'static [&'static str] = &["id", "name", "email"];
130    }
131
132    #[derive(Clone)]
133    struct MockEngine;
134
135    impl QueryEngine for MockEngine {
136        fn query_many<T: Model + Send + 'static>(
137            &self,
138            _sql: &str,
139            _params: Vec<FilterValue>,
140        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
141            Box::pin(async { Ok(Vec::new()) })
142        }
143
144        fn query_one<T: Model + Send + 'static>(
145            &self,
146            _sql: &str,
147            _params: Vec<FilterValue>,
148        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
149            Box::pin(async { Err(QueryError::not_found("test")) })
150        }
151
152        fn query_optional<T: Model + Send + 'static>(
153            &self,
154            _sql: &str,
155            _params: Vec<FilterValue>,
156        ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
157            Box::pin(async { Ok(None) })
158        }
159
160        fn execute_insert<T: Model + Send + 'static>(
161            &self,
162            _sql: &str,
163            _params: Vec<FilterValue>,
164        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
165            Box::pin(async { Err(QueryError::not_found("test")) })
166        }
167
168        fn execute_update<T: Model + Send + 'static>(
169            &self,
170            _sql: &str,
171            _params: Vec<FilterValue>,
172        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
173            Box::pin(async { Ok(Vec::new()) })
174        }
175
176        fn execute_delete(
177            &self,
178            _sql: &str,
179            _params: Vec<FilterValue>,
180        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
181            Box::pin(async { Ok(0) })
182        }
183
184        fn execute_raw(
185            &self,
186            _sql: &str,
187            _params: Vec<FilterValue>,
188        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
189            Box::pin(async { Ok(0) })
190        }
191
192        fn count(
193            &self,
194            _sql: &str,
195            _params: Vec<FilterValue>,
196        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
197            Box::pin(async { Ok(0) })
198        }
199    }
200
201    // ========== Construction Tests ==========
202
203    #[test]
204    fn test_find_first_new() {
205        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine);
206        let (sql, params) = op.build_sql();
207
208        assert!(sql.contains("SELECT * FROM test_models"));
209        assert!(sql.contains("LIMIT 1"));
210        assert!(params.is_empty());
211    }
212
213    // ========== Filter Tests ==========
214
215    #[test]
216    fn test_find_first_with_filter() {
217        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine).r#where(
218            Filter::Equals("status".into(), FilterValue::String("active".to_string())),
219        );
220
221        let (sql, params) = op.build_sql();
222
223        assert!(sql.contains("WHERE"));
224        assert!(sql.contains("status = $1"));
225        assert_eq!(params.len(), 1);
226    }
227
228    #[test]
229    fn test_find_first_with_compound_filter() {
230        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
231            .r#where(Filter::Equals(
232                "department".into(),
233                FilterValue::String("engineering".to_string()),
234            ))
235            .r#where(Filter::Gt("salary".into(), FilterValue::Int(50000)));
236
237        let (sql, params) = op.build_sql();
238
239        assert!(sql.contains("WHERE"));
240        assert!(sql.contains("AND"));
241        assert_eq!(params.len(), 2);
242    }
243
244    #[test]
245    fn test_find_first_with_or_filter() {
246        let op =
247            FindFirstOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::or([
248                Filter::Equals("role".into(), FilterValue::String("admin".to_string())),
249                Filter::Equals("role".into(), FilterValue::String("superadmin".to_string())),
250            ]));
251
252        let (sql, params) = op.build_sql();
253
254        assert!(sql.contains("OR"));
255        assert_eq!(params.len(), 2);
256    }
257
258    #[test]
259    fn test_find_first_without_filter() {
260        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine);
261        let (sql, params) = op.build_sql();
262
263        assert!(!sql.contains("WHERE"));
264        assert!(params.is_empty());
265    }
266
267    // ========== Order By Tests ==========
268
269    #[test]
270    fn test_find_first_with_order() {
271        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
272            .r#where(Filter::Gt("age".into(), FilterValue::Int(18)))
273            .order_by(OrderByField::desc("created_at"));
274
275        let (sql, params) = op.build_sql();
276
277        assert!(sql.contains("WHERE"));
278        assert!(sql.contains("ORDER BY created_at DESC"));
279        assert!(sql.contains("LIMIT 1"));
280        assert_eq!(params.len(), 1);
281    }
282
283    #[test]
284    fn test_find_first_with_asc_order() {
285        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
286            .order_by(OrderByField::asc("name"));
287
288        let (sql, _) = op.build_sql();
289
290        assert!(sql.contains("ORDER BY name ASC"));
291    }
292
293    #[test]
294    fn test_find_first_without_order() {
295        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine);
296        let (sql, _) = op.build_sql();
297
298        assert!(!sql.contains("ORDER BY"));
299    }
300
301    #[test]
302    fn test_find_first_order_replaces() {
303        // Later order_by should replace the previous one
304        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
305            .order_by(OrderByField::asc("name"))
306            .order_by(OrderByField::desc("created_at"));
307
308        let (sql, _) = op.build_sql();
309
310        assert!(sql.contains("ORDER BY created_at DESC"));
311        assert!(!sql.contains("ORDER BY name"));
312    }
313
314    // ========== Select Tests ==========
315
316    #[test]
317    fn test_find_first_with_select() {
318        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
319            .select(Select::fields(["id", "email"]));
320
321        let (sql, _) = op.build_sql();
322
323        assert!(sql.contains("SELECT id, email FROM"));
324        assert!(!sql.contains("SELECT *"));
325    }
326
327    #[test]
328    fn test_find_first_select_single_field() {
329        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
330            .select(Select::fields(["count"]));
331
332        let (sql, _) = op.build_sql();
333
334        assert!(sql.contains("SELECT count FROM"));
335    }
336
337    // ========== SQL Structure Tests ==========
338
339    #[test]
340    fn test_find_first_sql_structure() {
341        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
342            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
343            .order_by(OrderByField::desc("created_at"))
344            .select(Select::fields(["id", "name"]));
345
346        let (sql, _) = op.build_sql();
347
348        // Check correct SQL clause ordering
349        let select_pos = sql.find("SELECT").unwrap();
350        let from_pos = sql.find("FROM").unwrap();
351        let where_pos = sql.find("WHERE").unwrap();
352        let order_pos = sql.find("ORDER BY").unwrap();
353        let limit_pos = sql.find("LIMIT 1").unwrap();
354
355        assert!(select_pos < from_pos);
356        assert!(from_pos < where_pos);
357        assert!(where_pos < order_pos);
358        assert!(order_pos < limit_pos);
359    }
360
361    #[test]
362    fn test_find_first_table_name() {
363        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine);
364        let (sql, _) = op.build_sql();
365
366        assert!(sql.contains("test_models"));
367    }
368
369    // ========== Async Execution Tests ==========
370
371    #[tokio::test]
372    async fn test_find_first_exec() {
373        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
374            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
375
376        let result = op.exec().await;
377
378        // MockEngine returns Ok(None)
379        assert!(result.is_ok());
380        assert!(result.unwrap().is_none());
381    }
382
383    #[tokio::test]
384    async fn test_find_first_exec_required() {
385        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
386            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
387
388        let result = op.exec_required().await;
389
390        // MockEngine returns not_found error
391        assert!(result.is_err());
392        assert!(result.unwrap_err().is_not_found());
393    }
394
395    // ========== Method Chaining Tests ==========
396
397    #[test]
398    fn test_find_first_full_chain() {
399        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
400            .r#where(Filter::Equals(
401                "status".into(),
402                FilterValue::String("active".to_string()),
403            ))
404            .order_by(OrderByField::desc("created_at"))
405            .select(Select::fields(["id", "name", "email"]));
406
407        let (sql, params) = op.build_sql();
408
409        assert!(sql.contains("SELECT id, name, email FROM"));
410        assert!(sql.contains("WHERE"));
411        assert!(sql.contains("ORDER BY created_at DESC"));
412        assert!(sql.contains("LIMIT 1"));
413        assert_eq!(params.len(), 1);
414    }
415
416    // ========== Edge Cases ==========
417
418    #[test]
419    fn test_find_first_with_like_filter() {
420        let op =
421            FindFirstOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Contains(
422                "email".into(),
423                FilterValue::String("@example.com".to_string()),
424            ));
425
426        let (sql, params) = op.build_sql();
427
428        assert!(sql.contains("LIKE"));
429        assert_eq!(params.len(), 1);
430    }
431
432    #[test]
433    fn test_find_first_with_null_filter() {
434        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
435            .r#where(Filter::IsNull("deleted_at".into()));
436
437        let (sql, params) = op.build_sql();
438
439        assert!(sql.contains("IS NULL"));
440        assert!(params.is_empty());
441    }
442
443    #[test]
444    fn test_find_first_with_not_filter() {
445        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Not(
446            Box::new(Filter::Equals(
447                "status".into(),
448                FilterValue::String("deleted".to_string()),
449            )),
450        ));
451
452        let (sql, params) = op.build_sql();
453
454        assert!(sql.contains("NOT"));
455        assert_eq!(params.len(), 1);
456    }
457
458    #[test]
459    fn test_find_first_with_in_filter() {
460        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::In(
461            "status".into(),
462            vec![
463                FilterValue::String("pending".to_string()),
464                FilterValue::String("processing".to_string()),
465            ],
466        ));
467
468        let (sql, params) = op.build_sql();
469
470        assert!(sql.contains("IN"));
471        assert_eq!(params.len(), 2);
472    }
473}