Skip to main content

prax_query/operations/
find_many.rs

1//! FindMany operation for querying multiple records.
2
3use std::marker::PhantomData;
4
5use smallvec::SmallVec;
6
7use crate::capabilities::SupportsScalarSubqueryInSelect;
8use crate::error::QueryResult;
9use crate::filter::Filter;
10use crate::pagination::Pagination;
11use crate::projection::ScalarProjection;
12use crate::relations::IncludeSpec;
13use crate::traits::{Model, ModelRelationLoader, QueryEngine};
14use crate::types::{OrderBy, Select};
15
16/// A query operation that finds multiple records.
17///
18/// # Example
19///
20/// ```rust,ignore
21/// let users = client
22///     .user()
23///     .find_many()
24///     .r#where(user::email::contains("@example.com"))
25///     .order_by(user::created_at::desc())
26///     .skip(0)
27///     .take(10)
28///     .exec()
29///     .await?;
30/// ```
31pub struct FindManyOperation<E: QueryEngine, M: Model> {
32    engine: E,
33    filter: Filter,
34    order_by: OrderBy,
35    pagination: Pagination,
36    select: Select,
37    distinct: Option<Vec<String>>,
38    /// Relations to eager-load after the main query returns. Each
39    /// spec drives one follow-up SELECT via the model's
40    /// [`ModelRelationLoader`] impl. Inlined for up to two specs
41    /// (the typical 0-2 case) to avoid a heap allocation on the hot
42    /// builder path.
43    includes: SmallVec<[IncludeSpec; 2]>,
44    /// Extra scalar-subquery columns appended to the SELECT clause.
45    /// Used by relation-aggregate virtual fields (`@count`, `@sum`, …).
46    pub extra_projections: Vec<ScalarProjection>,
47    _model: PhantomData<M>,
48}
49
50impl<E: QueryEngine, M: Model + crate::row::FromRow> FindManyOperation<E, M> {
51    /// Create a new FindMany operation.
52    pub fn new(engine: E) -> Self {
53        Self {
54            engine,
55            filter: Filter::None,
56            order_by: OrderBy::none(),
57            pagination: Pagination::new(),
58            select: Select::All,
59            distinct: None,
60            includes: SmallVec::new(),
61            extra_projections: Vec::new(),
62            _model: PhantomData,
63        }
64    }
65
66    /// Eager-load a relation alongside the main query.
67    ///
68    /// Each `.include()` call appends one follow-up SELECT that
69    /// fetches the target rows for every parent returned by this
70    /// find. Children get stitched onto the parent slice by the
71    /// [`ModelRelationLoader`] impl emitted by `#[derive(Model)]`.
72    pub fn include(mut self, spec: IncludeSpec) -> Self {
73        self.includes.push(spec);
74        self
75    }
76
77    /// Add a filter condition.
78    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
79        let new_filter = filter.into();
80        self.filter = self.filter.and_then(new_filter);
81        self
82    }
83
84    /// Set the order by clause.
85    pub fn order_by(mut self, order: impl Into<OrderBy>) -> Self {
86        self.order_by = order.into();
87        self
88    }
89
90    /// Skip a number of records.
91    pub fn skip(mut self, n: u64) -> Self {
92        self.pagination = self.pagination.skip(n);
93        self
94    }
95
96    /// Take a limited number of records.
97    pub fn take(mut self, n: u64) -> Self {
98        self.pagination = self.pagination.take(n);
99        self
100    }
101
102    /// Select specific fields.
103    pub fn select(mut self, select: impl Into<Select>) -> Self {
104        self.select = select.into();
105        self
106    }
107
108    /// Make the query distinct.
109    ///
110    /// Emits `SELECT DISTINCT ON (cols)` on dialects where
111    /// [`crate::dialect::SqlDialect::supports_distinct_on`] holds
112    /// (Postgres). Dialects without support (MySQL, SQLite, MSSQL) fall
113    /// back to plain `SELECT DISTINCT` — note the semantics change:
114    /// plain `DISTINCT` deduplicates whole rows, not per-column groups.
115    pub fn distinct(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
116        self.distinct = Some(columns.into_iter().map(Into::into).collect());
117        self
118    }
119
120    /// Set cursor for cursor-based pagination.
121    ///
122    /// Emits a keyset predicate (`"col" > $n` / `"col" < $n`) AND-composed
123    /// with any filter. Keyset pagination is only deterministic with a
124    /// matching row order, so when no explicit [`order_by`](Self::order_by)
125    /// is set the query falls back to ordering by the cursor column in the
126    /// pagination direction (`After` → `ASC`, `Before` → `DESC`).
127    pub fn cursor(mut self, cursor: crate::pagination::Cursor) -> Self {
128        self.pagination = self.pagination.cursor(cursor);
129        self
130    }
131
132    /// Apply a typed `WhereInput`. AND-composes with any previously set
133    /// filter — same semantics as calling `.r#where(...)` again.
134    pub fn with_where_input<W: crate::inputs::WhereInput<Model = M>>(mut self, w: W) -> Self {
135        let f = w.into_ir();
136        self.filter = self.filter.and_then(f);
137        self
138    }
139
140    /// Apply a typed `IncludeInput`. Merges into any previously set
141    /// includes (later wins on conflicting relation names).
142    pub fn with_include_input<I: crate::inputs::IncludeInput<Model = M>>(mut self, i: I) -> Self {
143        let inc = i.into_ir();
144        for spec in inc.specs() {
145            self.includes.push(spec.clone());
146        }
147        self
148    }
149
150    /// Apply a typed `SelectInput`.
151    pub fn with_select_input<S: crate::inputs::SelectInput<Model = M>>(mut self, s: S) -> Self {
152        self.select = s.into_ir();
153        self
154    }
155
156    /// Apply a typed `OrderByInput` (replaces current).
157    pub fn with_order_by_input<O: crate::inputs::OrderByInput<Model = M>>(mut self, o: O) -> Self {
158        self.order_by = o.into_ir();
159        self
160    }
161
162    /// Doc-hidden accessor for the current filter — needed for unit
163    /// tests that don't have a running engine to issue queries against.
164    #[doc(hidden)]
165    pub fn filter_for_test(&self) -> &Filter {
166        &self.filter
167    }
168}
169
170impl<E, M> FindManyOperation<E, M>
171where
172    E: QueryEngine + SupportsScalarSubqueryInSelect,
173    M: Model + crate::row::FromRow,
174{
175    /// Append a scalar-subquery projection to the SELECT list.
176    ///
177    /// Available only on engines that implement
178    /// [`SupportsScalarSubqueryInSelect`]. SQL backends (Postgres, MySQL,
179    /// SQLite, MSSQL, DuckDB, SQLx) all satisfy this bound; MongoDB,
180    /// ScyllaDB, and Cassandra do not — calling this method on those
181    /// engines is a **compile-time error**.
182    pub fn with_scalar_projection(mut self, proj: ScalarProjection) -> Self {
183        self.extra_projections.push(proj);
184        self
185    }
186}
187
188impl<E: QueryEngine, M: Model + crate::row::FromRow> FindManyOperation<E, M> {
189    /// Build the SQL query.
190    pub fn build_sql(
191        &self,
192        dialect: &dyn crate::dialect::SqlDialect,
193    ) -> (String, Vec<crate::filter::FilterValue>) {
194        // Projection params come first; WHERE params are offset by the
195        // number of params already consumed by the extra projections so
196        // that all dialect placeholders form a single contiguous sequence.
197        let proj_param_count: usize = self.extra_projections.iter().map(|p| p.params.len()).sum();
198        let (where_sql, where_params) = self.filter.to_sql(proj_param_count, dialect);
199
200        let cursor = self.pagination.cursor.as_ref();
201        let mut params: Vec<crate::filter::FilterValue> = Vec::with_capacity(
202            proj_param_count + where_params.len() + usize::from(cursor.is_some()),
203        );
204
205        // Pre-size for the fixed clauses plus the filter fragment; the
206        // ORDER BY / LIMIT / DISTINCT tail grows amortized on top.
207        let mut sql = String::with_capacity(64 + M::TABLE_NAME.len() + where_sql.len());
208
209        // SELECT clause
210        sql.push_str("SELECT ");
211        if let Some(ref cols) = self.distinct {
212            if dialect.supports_distinct_on() {
213                sql.push_str("DISTINCT ON (");
214                sql.push_str(&cols.join(", "));
215                sql.push_str(") ");
216            } else {
217                // `DISTINCT ON` is Postgres-only; degrade to plain
218                // DISTINCT (whole-row dedup) on dialects without support.
219                sql.push_str("DISTINCT ");
220            }
221        }
222        self.select.write_sql(&mut sql);
223
224        // Extra scalar-subquery projections
225        let mut proj_offset = 0usize;
226        for proj in &self.extra_projections {
227            sql.push_str(", ");
228            let frag = proj.to_sql(proj_offset, dialect, &mut params);
229            sql.push('(');
230            sql.push_str(&frag);
231            sql.push_str(") AS \"");
232            sql.push_str(proj.alias);
233            sql.push('"');
234            proj_offset += proj.params.len();
235        }
236
237        // FROM clause
238        sql.push_str(" FROM ");
239        sql.push_str(M::TABLE_NAME);
240
241        // WHERE clause — a stored cursor AND-composes a keyset predicate
242        // (`"col" > $n` / `"col" < $n`) after the filter; its value binds
243        // last so the placeholder sequence stays dense.
244        if !self.filter.is_none() || cursor.is_some() {
245            sql.push_str(" WHERE ");
246            let mut conjunct = false;
247            if !self.filter.is_none() {
248                sql.push_str(&where_sql);
249                conjunct = true;
250            }
251            if let Some(cursor) = cursor {
252                if conjunct {
253                    sql.push_str(" AND ");
254                }
255                sql.push_str(&dialect.quote_ident(&cursor.column));
256                sql.push(' ');
257                sql.push_str(cursor.operator());
258                sql.push(' ');
259                sql.push_str(&dialect.placeholder(proj_param_count + where_params.len() + 1));
260            }
261        }
262        params.extend(where_params);
263        if let Some(cursor) = cursor {
264            params.push(match &cursor.value {
265                crate::pagination::CursorValue::Int(v) => crate::filter::FilterValue::Int(*v),
266                crate::pagination::CursorValue::String(s) => {
267                    crate::filter::FilterValue::String(s.clone())
268                }
269            });
270        }
271
272        // ORDER BY clause. A stored cursor with no explicit `order_by`
273        // falls back to ordering by the cursor column in the pagination
274        // direction — without a deterministic row order the keyset
275        // predicate can skip or re-see rows between pages.
276        if !self.order_by.is_empty() {
277            sql.push_str(" ORDER BY ");
278            self.order_by.write_sql(&mut sql);
279        } else if let Some(cursor) = cursor {
280            sql.push_str(" ORDER BY ");
281            sql.push_str(&dialect.quote_ident(&cursor.column));
282            sql.push_str(match cursor.direction {
283                crate::pagination::CursorDirection::After => " ASC",
284                crate::pagination::CursorDirection::Before => " DESC",
285            });
286        }
287
288        // LIMIT/OFFSET clause (the cursor predicate lives in WHERE).
289        if self.pagination.take.is_some() || self.pagination.skip.is_some() {
290            sql.push(' ');
291            self.pagination.write_sql(&mut sql);
292        }
293
294        (sql, params)
295    }
296
297    /// Execute the query.
298    ///
299    /// After the main SELECT hydrates the parent rows, any pending
300    /// `.include()` specs are dispatched through
301    /// [`ModelRelationLoader::load_relation`] which issues one
302    /// additional SELECT per relation and stitches the children onto
303    /// the parent slice.
304    pub async fn exec(self) -> QueryResult<Vec<M>>
305    where
306        M: Send + 'static + ModelRelationLoader<E>,
307    {
308        let dialect = self.engine.dialect();
309        let (sql, params) = self.build_sql(dialect);
310        let mut parents = self.engine.query_many::<M>(&sql, params).await?;
311        for spec in &self.includes {
312            <M as ModelRelationLoader<E>>::load_relation(&self.engine, &mut parents, spec).await?;
313        }
314        Ok(parents)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::error::QueryError;
322    use crate::filter::FilterValue;
323    use crate::pagination::{Cursor, CursorDirection, CursorValue};
324    use crate::types::OrderByField;
325
326    struct TestModel;
327
328    impl Model for TestModel {
329        const MODEL_NAME: &'static str = "TestModel";
330        const TABLE_NAME: &'static str = "test_models";
331        const PRIMARY_KEY: &'static [&'static str] = &["id"];
332        const COLUMNS: &'static [&'static str] = &["id", "name", "email"];
333    }
334
335    impl crate::row::FromRow for TestModel {
336        fn from_row(_row: &impl crate::row::RowRef) -> Result<Self, crate::row::RowError> {
337            Ok(TestModel)
338        }
339    }
340
341    // Minimal `ModelRelationLoader` impl for the mock — real models
342    // get one from codegen. Errors on any include name (the tests
343    // never register an include).
344    impl crate::traits::ModelRelationLoader<MockEngine> for TestModel {
345        fn load_relation<'a>(
346            _engine: &'a MockEngine,
347            _parents: &'a mut [Self],
348            spec: &'a crate::relations::IncludeSpec,
349        ) -> crate::traits::BoxFuture<'a, QueryResult<()>> {
350            let name = spec.relation_name.clone();
351            Box::pin(async move {
352                Err(QueryError::internal(format!(
353                    "unknown relation '{name}' on TestModel (mock)",
354                )))
355            })
356        }
357    }
358
359    #[derive(Clone)]
360    struct MockEngine;
361
362    impl QueryEngine for MockEngine {
363        fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
364            &crate::dialect::Postgres
365        }
366
367        fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
368            &self,
369            _sql: &str,
370            _params: Vec<FilterValue>,
371        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
372            Box::pin(async { Ok(Vec::new()) })
373        }
374
375        fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
376            &self,
377            _sql: &str,
378            _params: Vec<FilterValue>,
379        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
380            Box::pin(async { Err(QueryError::not_found("test")) })
381        }
382
383        fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
384            &self,
385            _sql: &str,
386            _params: Vec<FilterValue>,
387        ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
388            Box::pin(async { Ok(None) })
389        }
390
391        fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
392            &self,
393            _sql: &str,
394            _params: Vec<FilterValue>,
395        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
396            Box::pin(async { Err(QueryError::not_found("test")) })
397        }
398
399        fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
400            &self,
401            _sql: &str,
402            _params: Vec<FilterValue>,
403        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
404            Box::pin(async { Ok(Vec::new()) })
405        }
406
407        fn execute_delete(
408            &self,
409            _sql: &str,
410            _params: Vec<FilterValue>,
411        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
412            Box::pin(async { Ok(0) })
413        }
414
415        fn execute_raw(
416            &self,
417            _sql: &str,
418            _params: Vec<FilterValue>,
419        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
420            Box::pin(async { Ok(0) })
421        }
422
423        fn count(
424            &self,
425            _sql: &str,
426            _params: Vec<FilterValue>,
427        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
428            Box::pin(async { Ok(0) })
429        }
430    }
431
432    // ========== Construction Tests ==========
433
434    #[test]
435    fn test_find_many_new() {
436        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
437        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
438
439        assert!(sql.contains("SELECT * FROM test_models"));
440        assert!(params.is_empty());
441    }
442
443    #[test]
444    fn test_find_many_basic() {
445        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
446        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
447
448        assert_eq!(sql, "SELECT * FROM test_models");
449        assert!(params.is_empty());
450    }
451
452    // ========== Filter Tests ==========
453
454    #[test]
455    fn test_find_many_with_filter() {
456        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
457            .r#where(Filter::Equals("name".into(), "Alice".into()));
458
459        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
460
461        assert!(sql.contains("WHERE"));
462        assert!(sql.contains(r#""name" = $1"#));
463        assert_eq!(params.len(), 1);
464    }
465
466    #[test]
467    fn test_find_many_with_compound_filter() {
468        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
469            .r#where(Filter::Equals(
470                "status".into(),
471                FilterValue::String("active".to_string()),
472            ))
473            .r#where(Filter::Gte("age".into(), FilterValue::Int(18)));
474
475        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
476
477        assert!(sql.contains("WHERE"));
478        assert!(sql.contains("AND"));
479        assert_eq!(params.len(), 2);
480    }
481
482    #[test]
483    fn test_find_many_with_or_filter() {
484        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::or([
485            Filter::Equals("role".into(), FilterValue::String("admin".to_string())),
486            Filter::Equals("role".into(), FilterValue::String("moderator".to_string())),
487        ]));
488
489        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
490
491        assert!(sql.contains("OR"));
492        assert_eq!(params.len(), 2);
493    }
494
495    #[test]
496    fn test_find_many_with_in_filter() {
497        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::In(
498            "status".into(),
499            vec![
500                FilterValue::String("pending".to_string()),
501                FilterValue::String("processing".to_string()),
502            ],
503        ));
504
505        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
506
507        assert!(sql.contains("IN"));
508        assert_eq!(params.len(), 2);
509    }
510
511    #[test]
512    fn test_find_many_without_filter() {
513        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
514        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
515
516        assert!(!sql.contains("WHERE"));
517        assert!(params.is_empty());
518    }
519
520    // ========== Order By Tests ==========
521
522    #[test]
523    fn test_find_many_with_order() {
524        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
525            .order_by(OrderByField::desc("created_at"));
526
527        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
528
529        assert!(sql.contains("ORDER BY created_at DESC"));
530    }
531
532    #[test]
533    fn test_find_many_with_asc_order() {
534        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
535            .order_by(OrderByField::asc("name"));
536
537        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
538
539        assert!(sql.contains("ORDER BY name ASC"));
540    }
541
542    #[test]
543    fn test_find_many_without_order() {
544        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
545        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
546
547        assert!(!sql.contains("ORDER BY"));
548    }
549
550    #[test]
551    fn test_find_many_order_replaces() {
552        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
553            .order_by(OrderByField::asc("name"))
554            .order_by(OrderByField::desc("created_at"));
555
556        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
557
558        assert!(sql.contains("ORDER BY created_at DESC"));
559        assert!(!sql.contains("ORDER BY name"));
560    }
561
562    // ========== Pagination Tests ==========
563
564    #[test]
565    fn test_find_many_with_pagination() {
566        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
567            .skip(10)
568            .take(20);
569
570        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
571
572        assert!(sql.contains("LIMIT 20"));
573        assert!(sql.contains("OFFSET 10"));
574    }
575
576    #[test]
577    fn test_find_many_with_skip_only() {
578        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).skip(5);
579
580        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
581
582        assert!(sql.contains("OFFSET 5"));
583    }
584
585    #[test]
586    fn test_find_many_with_take_only() {
587        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).take(100);
588
589        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
590
591        assert!(sql.contains("LIMIT 100"));
592    }
593
594    #[test]
595    fn test_find_many_with_cursor() {
596        let cursor = Cursor::new("id", CursorValue::Int(100), CursorDirection::After);
597        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
598            .cursor(cursor)
599            .take(10);
600
601        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
602
603        // Cursor pagination emits a keyset predicate and binds the value.
604        assert!(sql.contains("LIMIT 10"));
605        assert!(sql.contains(r#"WHERE "id" > $1"#), "got: {sql}");
606        assert_eq!(params, vec![FilterValue::Int(100)]);
607    }
608
609    #[test]
610    fn test_find_many_cursor_exact_sql() {
611        let cursor = Cursor::new("id", CursorValue::Int(100), CursorDirection::After);
612        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
613            .r#where(Filter::Equals("name".into(), "a".into()))
614            .cursor(cursor)
615            .take(10);
616
617        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
618
619        assert_eq!(
620            sql,
621            r#"SELECT * FROM test_models WHERE "name" = $1 AND "id" > $2 ORDER BY "id" ASC LIMIT 10"#
622        );
623        assert_eq!(
624            params,
625            vec![FilterValue::String("a".to_string()), FilterValue::Int(100)]
626        );
627    }
628
629    /// A cursor with no explicit `order_by` falls back to ordering by the
630    /// cursor column in the pagination direction — keyset pagination is
631    /// only deterministic with a matching row order.
632    #[test]
633    fn test_find_many_cursor_orders_by_cursor_column() {
634        let after = Cursor::new("id", CursorValue::Int(100), CursorDirection::After);
635        let (sql, _) = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
636            .cursor(after)
637            .build_sql(&crate::dialect::Postgres);
638        assert!(
639            sql.contains(r#"ORDER BY "id" ASC"#),
640            "After cursor must order ASC, got: {sql}"
641        );
642
643        let before = Cursor::new("id", CursorValue::Int(100), CursorDirection::Before);
644        let (sql, _) = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
645            .cursor(before)
646            .build_sql(&crate::dialect::Postgres);
647        assert!(
648            sql.contains(r#"WHERE "id" < $1"#) && sql.contains(r#"ORDER BY "id" DESC"#),
649            "Before cursor must order DESC, got: {sql}"
650        );
651    }
652
653    /// An explicit `order_by` wins over the cursor-derived fallback.
654    #[test]
655    fn test_find_many_cursor_explicit_order_by_wins() {
656        let cursor = Cursor::new("id", CursorValue::Int(100), CursorDirection::After);
657        let (sql, _) = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
658            .cursor(cursor)
659            .order_by(OrderByField::desc("created_at"))
660            .build_sql(&crate::dialect::Postgres);
661
662        assert!(sql.contains("ORDER BY created_at DESC"), "got: {sql}");
663        assert!(!sql.contains(r#"ORDER BY "id""#), "got: {sql}");
664    }
665
666    // ========== Select Tests ==========
667
668    #[test]
669    fn test_find_many_with_select() {
670        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
671            .select(Select::fields(["id", "name"]));
672
673        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
674
675        assert!(sql.contains("SELECT id, name FROM"));
676        assert!(!sql.contains("SELECT *"));
677    }
678
679    #[test]
680    fn test_find_many_select_single_field() {
681        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
682            .select(Select::fields(["id"]));
683
684        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
685
686        assert!(sql.contains("SELECT id FROM"));
687    }
688
689    #[test]
690    fn test_find_many_select_all() {
691        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).select(Select::All);
692
693        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
694
695        assert!(sql.contains("SELECT * FROM"));
696    }
697
698    /// Task 28 regression test: a narrow `Select::fields` list must turn
699    /// the emitted `SELECT *` into an explicit column list so wide models
700    /// don't waste bandwidth. The projection still hydrates as the full
701    /// struct, so callers are responsible for covering every non-`Option`
702    /// field — see the CHANGELOG migration note.
703    #[test]
704    fn find_many_emits_explicit_column_list_when_select_narrows() {
705        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
706            .select(Select::fields(["id", "email"]));
707        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
708        assert!(
709            sql.contains("SELECT id, email FROM") && !sql.contains("SELECT *"),
710            "expected narrow select list, got: {sql}"
711        );
712    }
713
714    /// Counterpart to the narrowing test: with no `.select(...)` call,
715    /// the default `Select::All` must still emit `SELECT *`. Guards
716    /// against a regression where a future refactor of the default
717    /// value silently drops back to an empty column list.
718    #[test]
719    fn find_many_emits_star_when_no_select() {
720        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
721        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
722        assert!(sql.contains("SELECT *"), "expected SELECT *, got: {sql}");
723    }
724
725    // ========== Distinct Tests ==========
726
727    #[test]
728    fn test_find_many_with_distinct() {
729        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).distinct(["category"]);
730
731        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
732
733        assert!(sql.contains("DISTINCT ON (category)"));
734    }
735
736    #[test]
737    fn test_find_many_with_multiple_distinct() {
738        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
739            .distinct(["category", "status"]);
740
741        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
742
743        assert!(sql.contains("DISTINCT ON (category, status)"));
744    }
745    #[test]
746    fn test_find_many_without_distinct() {
747        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
748
749        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
750
751        assert!(!sql.contains("DISTINCT"));
752    }
753
754    #[test]
755    fn test_find_many_distinct_falls_back_without_distinct_on_support() {
756        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).distinct(["category"]);
757
758        // MySQL has no `DISTINCT ON`; expect plain DISTINCT instead of
759        // emitting syntax the backend would reject.
760        let (sql, _) = op.build_sql(&crate::dialect::Mysql);
761
762        assert!(sql.contains("SELECT DISTINCT "), "got: {sql}");
763        assert!(!sql.contains("DISTINCT ON"), "got: {sql}");
764    }
765
766    // ========== SQL Structure Tests ==========
767
768    #[test]
769    fn test_find_many_sql_structure() {
770        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
771            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
772            .order_by(OrderByField::desc("created_at"))
773            .skip(10)
774            .take(20)
775            .select(Select::fields(["id", "name"]));
776
777        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
778
779        // Check correct SQL clause ordering
780        let select_pos = sql.find("SELECT").unwrap();
781        let from_pos = sql.find("FROM").unwrap();
782        let where_pos = sql.find("WHERE").unwrap();
783        let order_pos = sql.find("ORDER BY").unwrap();
784        let limit_pos = sql.find("LIMIT").unwrap();
785        let offset_pos = sql.find("OFFSET").unwrap();
786
787        assert!(select_pos < from_pos);
788        assert!(from_pos < where_pos);
789        assert!(where_pos < order_pos);
790        assert!(order_pos < limit_pos);
791        assert!(limit_pos < offset_pos);
792    }
793
794    #[test]
795    fn test_find_many_table_name() {
796        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
797        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
798
799        assert!(sql.contains("test_models"));
800    }
801
802    // ========== Async Execution Tests ==========
803
804    #[tokio::test]
805    async fn test_find_many_exec() {
806        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(
807            Filter::Equals("status".into(), FilterValue::String("active".to_string())),
808        );
809
810        let result = op.exec().await;
811
812        assert!(result.is_ok());
813        assert!(result.unwrap().is_empty()); // MockEngine returns empty vec
814    }
815
816    #[tokio::test]
817    async fn test_find_many_exec_no_filter() {
818        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
819
820        let result = op.exec().await;
821
822        assert!(result.is_ok());
823    }
824
825    // ========== Method Chaining Tests ==========
826
827    #[test]
828    fn test_find_many_full_chain() {
829        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
830            .r#where(Filter::Equals(
831                "status".into(),
832                FilterValue::String("active".to_string()),
833            ))
834            .order_by(OrderByField::desc("created_at"))
835            .skip(10)
836            .take(20)
837            .select(Select::fields(["id", "name", "email"]))
838            .distinct(["category"]);
839
840        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
841
842        assert!(sql.contains("DISTINCT ON (category)"));
843        assert!(sql.contains("SELECT"));
844        assert!(sql.contains("WHERE"));
845        assert!(sql.contains("ORDER BY created_at DESC"));
846        assert!(sql.contains("LIMIT 20"));
847        assert!(sql.contains("OFFSET 10"));
848        assert_eq!(params.len(), 1);
849    }
850
851    // ========== Edge Cases ==========
852
853    #[test]
854    fn test_find_many_with_like_filter() {
855        let op =
856            FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Contains(
857                "email".into(),
858                FilterValue::String("@example.com".to_string()),
859            ));
860
861        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
862
863        assert!(sql.contains("LIKE"));
864        assert_eq!(params.len(), 1);
865    }
866
867    #[test]
868    fn test_find_many_with_null_filter() {
869        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
870            .r#where(Filter::IsNull("deleted_at".into()));
871
872        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
873
874        assert!(sql.contains("IS NULL"));
875        assert!(params.is_empty());
876    }
877
878    #[test]
879    fn test_find_many_with_not_filter() {
880        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Not(
881            Box::new(Filter::Equals(
882                "status".into(),
883                FilterValue::String("deleted".to_string()),
884            )),
885        ));
886
887        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
888
889        assert!(sql.contains("NOT"));
890        assert_eq!(params.len(), 1);
891    }
892
893    #[test]
894    fn test_find_many_with_between_equivalent() {
895        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
896            .r#where(Filter::Gte("age".into(), FilterValue::Int(18)))
897            .r#where(Filter::Lte("age".into(), FilterValue::Int(65)));
898
899        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
900
901        assert!(sql.contains("AND"));
902        assert_eq!(params.len(), 2);
903    }
904
905    // ========== Cross-Dialect Tests ==========
906
907    #[test]
908    fn builds_mysql_placeholders() {
909        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
910            .r#where(Filter::Equals("name".into(), "a".into()));
911        let (sql, _) = op.build_sql(&crate::dialect::Mysql);
912        assert!(
913            sql.contains("?") && !sql.contains("$1"),
914            "expected ? placeholders, got: {sql}"
915        );
916    }
917
918    #[test]
919    fn builds_mssql_placeholders() {
920        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
921            .r#where(Filter::Equals("name".into(), "a".into()));
922        let (sql, _) = op.build_sql(&crate::dialect::Mssql);
923        assert!(sql.contains("@P1"), "expected @P1 placeholders, got: {sql}");
924    }
925
926    #[test]
927    fn builds_sqlite_placeholders() {
928        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
929            .r#where(Filter::Equals("name".into(), "a".into()));
930        let (sql, _) = op.build_sql(&crate::dialect::Sqlite);
931        assert!(sql.contains("?1"), "expected ?1 placeholders, got: {sql}");
932    }
933}