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)
218            .r#where(Filter::Equals("status".into(), FilterValue::String("active".to_string())));
219
220        let (sql, params) = op.build_sql();
221
222        assert!(sql.contains("WHERE"));
223        assert!(sql.contains("status = $1"));
224        assert_eq!(params.len(), 1);
225    }
226
227    #[test]
228    fn test_find_first_with_compound_filter() {
229        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
230            .r#where(Filter::Equals("department".into(), FilterValue::String("engineering".to_string())))
231            .r#where(Filter::Gt("salary".into(), FilterValue::Int(50000)));
232
233        let (sql, params) = op.build_sql();
234
235        assert!(sql.contains("WHERE"));
236        assert!(sql.contains("AND"));
237        assert_eq!(params.len(), 2);
238    }
239
240    #[test]
241    fn test_find_first_with_or_filter() {
242        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
243            .r#where(Filter::or([
244                Filter::Equals("role".into(), FilterValue::String("admin".to_string())),
245                Filter::Equals("role".into(), FilterValue::String("superadmin".to_string())),
246            ]));
247
248        let (sql, params) = op.build_sql();
249
250        assert!(sql.contains("OR"));
251        assert_eq!(params.len(), 2);
252    }
253
254    #[test]
255    fn test_find_first_without_filter() {
256        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine);
257        let (sql, params) = op.build_sql();
258
259        assert!(!sql.contains("WHERE"));
260        assert!(params.is_empty());
261    }
262
263    // ========== Order By Tests ==========
264
265    #[test]
266    fn test_find_first_with_order() {
267        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
268            .r#where(Filter::Gt("age".into(), FilterValue::Int(18)))
269            .order_by(OrderByField::desc("created_at"));
270
271        let (sql, params) = op.build_sql();
272
273        assert!(sql.contains("WHERE"));
274        assert!(sql.contains("ORDER BY created_at DESC"));
275        assert!(sql.contains("LIMIT 1"));
276        assert_eq!(params.len(), 1);
277    }
278
279    #[test]
280    fn test_find_first_with_asc_order() {
281        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
282            .order_by(OrderByField::asc("name"));
283
284        let (sql, _) = op.build_sql();
285
286        assert!(sql.contains("ORDER BY name ASC"));
287    }
288
289    #[test]
290    fn test_find_first_without_order() {
291        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine);
292        let (sql, _) = op.build_sql();
293
294        assert!(!sql.contains("ORDER BY"));
295    }
296
297    #[test]
298    fn test_find_first_order_replaces() {
299        // Later order_by should replace the previous one
300        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
301            .order_by(OrderByField::asc("name"))
302            .order_by(OrderByField::desc("created_at"));
303
304        let (sql, _) = op.build_sql();
305
306        assert!(sql.contains("ORDER BY created_at DESC"));
307        assert!(!sql.contains("ORDER BY name"));
308    }
309
310    // ========== Select Tests ==========
311
312    #[test]
313    fn test_find_first_with_select() {
314        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
315            .select(Select::fields(["id", "email"]));
316
317        let (sql, _) = op.build_sql();
318
319        assert!(sql.contains("SELECT id, email FROM"));
320        assert!(!sql.contains("SELECT *"));
321    }
322
323    #[test]
324    fn test_find_first_select_single_field() {
325        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
326            .select(Select::fields(["count"]));
327
328        let (sql, _) = op.build_sql();
329
330        assert!(sql.contains("SELECT count FROM"));
331    }
332
333    // ========== SQL Structure Tests ==========
334
335    #[test]
336    fn test_find_first_sql_structure() {
337        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
338            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
339            .order_by(OrderByField::desc("created_at"))
340            .select(Select::fields(["id", "name"]));
341
342        let (sql, _) = op.build_sql();
343
344        // Check correct SQL clause ordering
345        let select_pos = sql.find("SELECT").unwrap();
346        let from_pos = sql.find("FROM").unwrap();
347        let where_pos = sql.find("WHERE").unwrap();
348        let order_pos = sql.find("ORDER BY").unwrap();
349        let limit_pos = sql.find("LIMIT 1").unwrap();
350
351        assert!(select_pos < from_pos);
352        assert!(from_pos < where_pos);
353        assert!(where_pos < order_pos);
354        assert!(order_pos < limit_pos);
355    }
356
357    #[test]
358    fn test_find_first_table_name() {
359        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine);
360        let (sql, _) = op.build_sql();
361
362        assert!(sql.contains("test_models"));
363    }
364
365    // ========== Async Execution Tests ==========
366
367    #[tokio::test]
368    async fn test_find_first_exec() {
369        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
370            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
371
372        let result = op.exec().await;
373
374        // MockEngine returns Ok(None)
375        assert!(result.is_ok());
376        assert!(result.unwrap().is_none());
377    }
378
379    #[tokio::test]
380    async fn test_find_first_exec_required() {
381        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
382            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)));
383
384        let result = op.exec_required().await;
385
386        // MockEngine returns not_found error
387        assert!(result.is_err());
388        assert!(result.unwrap_err().is_not_found());
389    }
390
391    // ========== Method Chaining Tests ==========
392
393    #[test]
394    fn test_find_first_full_chain() {
395        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
396            .r#where(Filter::Equals("status".into(), FilterValue::String("active".to_string())))
397            .order_by(OrderByField::desc("created_at"))
398            .select(Select::fields(["id", "name", "email"]));
399
400        let (sql, params) = op.build_sql();
401
402        assert!(sql.contains("SELECT id, name, email FROM"));
403        assert!(sql.contains("WHERE"));
404        assert!(sql.contains("ORDER BY created_at DESC"));
405        assert!(sql.contains("LIMIT 1"));
406        assert_eq!(params.len(), 1);
407    }
408
409    // ========== Edge Cases ==========
410
411    #[test]
412    fn test_find_first_with_like_filter() {
413        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
414            .r#where(Filter::Contains("email".into(), FilterValue::String("@example.com".to_string())));
415
416        let (sql, params) = op.build_sql();
417
418        assert!(sql.contains("LIKE"));
419        assert_eq!(params.len(), 1);
420    }
421
422    #[test]
423    fn test_find_first_with_null_filter() {
424        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
425            .r#where(Filter::IsNull("deleted_at".into()));
426
427        let (sql, params) = op.build_sql();
428
429        assert!(sql.contains("IS NULL"));
430        assert!(params.is_empty());
431    }
432
433    #[test]
434    fn test_find_first_with_not_filter() {
435        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
436            .r#where(Filter::Not(Box::new(Filter::Equals(
437                "status".into(),
438                FilterValue::String("deleted".to_string()),
439            ))));
440
441        let (sql, params) = op.build_sql();
442
443        assert!(sql.contains("NOT"));
444        assert_eq!(params.len(), 1);
445    }
446
447    #[test]
448    fn test_find_first_with_in_filter() {
449        let op = FindFirstOperation::<MockEngine, TestModel>::new(MockEngine)
450            .r#where(Filter::In(
451                "status".into(),
452                vec![
453                    FilterValue::String("pending".to_string()),
454                    FilterValue::String("processing".to_string()),
455                ],
456            ));
457
458        let (sql, params) = op.build_sql();
459
460        assert!(sql.contains("IN"));
461        assert_eq!(params.len(), 2);
462    }
463}
464