Skip to main content

prax_query/operations/
find_many.rs

1//! FindMany operation for querying multiple records.
2
3use std::marker::PhantomData;
4
5use crate::error::QueryResult;
6use crate::filter::Filter;
7use crate::pagination::Pagination;
8use crate::relations::IncludeSpec;
9use crate::traits::{Model, ModelRelationLoader, QueryEngine};
10use crate::types::{OrderBy, Select};
11
12/// A query operation that finds multiple records.
13///
14/// # Example
15///
16/// ```rust,ignore
17/// let users = client
18///     .user()
19///     .find_many()
20///     .r#where(user::email::contains("@example.com"))
21///     .order_by(user::created_at::desc())
22///     .skip(0)
23///     .take(10)
24///     .exec()
25///     .await?;
26/// ```
27pub struct FindManyOperation<E: QueryEngine, M: Model> {
28    engine: E,
29    filter: Filter,
30    order_by: OrderBy,
31    pagination: Pagination,
32    select: Select,
33    distinct: Option<Vec<String>>,
34    /// Relations to eager-load after the main query returns. Each
35    /// spec drives one follow-up SELECT via the model's
36    /// [`ModelRelationLoader`] impl.
37    includes: Vec<IncludeSpec>,
38    _model: PhantomData<M>,
39}
40
41impl<E: QueryEngine, M: Model + crate::row::FromRow> FindManyOperation<E, M> {
42    /// Create a new FindMany operation.
43    pub fn new(engine: E) -> Self {
44        Self {
45            engine,
46            filter: Filter::None,
47            order_by: OrderBy::none(),
48            pagination: Pagination::new(),
49            select: Select::All,
50            distinct: None,
51            includes: Vec::new(),
52            _model: PhantomData,
53        }
54    }
55
56    /// Eager-load a relation alongside the main query.
57    ///
58    /// Each `.include()` call appends one follow-up SELECT that
59    /// fetches the target rows for every parent returned by this
60    /// find. Children get stitched onto the parent slice by the
61    /// [`ModelRelationLoader`] impl emitted by `#[derive(Model)]`.
62    pub fn include(mut self, spec: IncludeSpec) -> Self {
63        self.includes.push(spec);
64        self
65    }
66
67    /// Add a filter condition.
68    pub fn r#where(mut self, filter: impl Into<Filter>) -> Self {
69        let new_filter = filter.into();
70        self.filter = self.filter.and_then(new_filter);
71        self
72    }
73
74    /// Set the order by clause.
75    pub fn order_by(mut self, order: impl Into<OrderBy>) -> Self {
76        self.order_by = order.into();
77        self
78    }
79
80    /// Skip a number of records.
81    pub fn skip(mut self, n: u64) -> Self {
82        self.pagination = self.pagination.skip(n);
83        self
84    }
85
86    /// Take a limited number of records.
87    pub fn take(mut self, n: u64) -> Self {
88        self.pagination = self.pagination.take(n);
89        self
90    }
91
92    /// Select specific fields.
93    pub fn select(mut self, select: impl Into<Select>) -> Self {
94        self.select = select.into();
95        self
96    }
97
98    /// Make the query distinct.
99    pub fn distinct(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
100        self.distinct = Some(columns.into_iter().map(Into::into).collect());
101        self
102    }
103
104    /// Set cursor for cursor-based pagination.
105    pub fn cursor(mut self, cursor: crate::pagination::Cursor) -> Self {
106        self.pagination = self.pagination.cursor(cursor);
107        self
108    }
109
110    /// Build the SQL query.
111    pub fn build_sql(
112        &self,
113        dialect: &dyn crate::dialect::SqlDialect,
114    ) -> (String, Vec<crate::filter::FilterValue>) {
115        let (where_sql, params) = self.filter.to_sql(0, dialect);
116
117        let mut sql = String::new();
118
119        // SELECT clause
120        sql.push_str("SELECT ");
121        if let Some(ref cols) = self.distinct {
122            sql.push_str("DISTINCT ON (");
123            sql.push_str(&cols.join(", "));
124            sql.push_str(") ");
125        }
126        sql.push_str(&self.select.to_sql());
127
128        // FROM clause
129        sql.push_str(" FROM ");
130        sql.push_str(M::TABLE_NAME);
131
132        // WHERE clause
133        if !self.filter.is_none() {
134            sql.push_str(" WHERE ");
135            sql.push_str(&where_sql);
136        }
137
138        // ORDER BY clause
139        if !self.order_by.is_empty() {
140            sql.push_str(" ORDER BY ");
141            sql.push_str(&self.order_by.to_sql());
142        }
143
144        // LIMIT/OFFSET clause
145        let pagination_sql = self.pagination.to_sql();
146        if !pagination_sql.is_empty() {
147            sql.push(' ');
148            sql.push_str(&pagination_sql);
149        }
150
151        (sql, params)
152    }
153
154    /// Execute the query.
155    ///
156    /// After the main SELECT hydrates the parent rows, any pending
157    /// `.include()` specs are dispatched through
158    /// [`ModelRelationLoader::load_relation`] which issues one
159    /// additional SELECT per relation and stitches the children onto
160    /// the parent slice.
161    pub async fn exec(self) -> QueryResult<Vec<M>>
162    where
163        M: Send + 'static + ModelRelationLoader<E>,
164    {
165        let dialect = self.engine.dialect();
166        let (sql, params) = self.build_sql(dialect);
167        let mut parents = self.engine.query_many::<M>(&sql, params).await?;
168        for spec in &self.includes {
169            <M as ModelRelationLoader<E>>::load_relation(&self.engine, &mut parents, spec).await?;
170        }
171        Ok(parents)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::error::QueryError;
179    use crate::filter::FilterValue;
180    use crate::pagination::{Cursor, CursorDirection, CursorValue};
181    use crate::types::OrderByField;
182
183    struct TestModel;
184
185    impl Model for TestModel {
186        const MODEL_NAME: &'static str = "TestModel";
187        const TABLE_NAME: &'static str = "test_models";
188        const PRIMARY_KEY: &'static [&'static str] = &["id"];
189        const COLUMNS: &'static [&'static str] = &["id", "name", "email"];
190    }
191
192    impl crate::row::FromRow for TestModel {
193        fn from_row(_row: &impl crate::row::RowRef) -> Result<Self, crate::row::RowError> {
194            Ok(TestModel)
195        }
196    }
197
198    // Minimal `ModelRelationLoader` impl for the mock — real models
199    // get one from codegen. Errors on any include name (the tests
200    // never register an include).
201    impl crate::traits::ModelRelationLoader<MockEngine> for TestModel {
202        fn load_relation<'a>(
203            _engine: &'a MockEngine,
204            _parents: &'a mut [Self],
205            spec: &'a crate::relations::IncludeSpec,
206        ) -> crate::traits::BoxFuture<'a, QueryResult<()>> {
207            let name = spec.relation_name.clone();
208            Box::pin(async move {
209                Err(QueryError::internal(format!(
210                    "unknown relation '{name}' on TestModel (mock)",
211                )))
212            })
213        }
214    }
215
216    #[derive(Clone)]
217    struct MockEngine;
218
219    impl QueryEngine for MockEngine {
220        fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
221            &crate::dialect::Postgres
222        }
223
224        fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
225            &self,
226            _sql: &str,
227            _params: Vec<FilterValue>,
228        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
229            Box::pin(async { Ok(Vec::new()) })
230        }
231
232        fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
233            &self,
234            _sql: &str,
235            _params: Vec<FilterValue>,
236        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
237            Box::pin(async { Err(QueryError::not_found("test")) })
238        }
239
240        fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
241            &self,
242            _sql: &str,
243            _params: Vec<FilterValue>,
244        ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
245            Box::pin(async { Ok(None) })
246        }
247
248        fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
249            &self,
250            _sql: &str,
251            _params: Vec<FilterValue>,
252        ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
253            Box::pin(async { Err(QueryError::not_found("test")) })
254        }
255
256        fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
257            &self,
258            _sql: &str,
259            _params: Vec<FilterValue>,
260        ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
261            Box::pin(async { Ok(Vec::new()) })
262        }
263
264        fn execute_delete(
265            &self,
266            _sql: &str,
267            _params: Vec<FilterValue>,
268        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
269            Box::pin(async { Ok(0) })
270        }
271
272        fn execute_raw(
273            &self,
274            _sql: &str,
275            _params: Vec<FilterValue>,
276        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
277            Box::pin(async { Ok(0) })
278        }
279
280        fn count(
281            &self,
282            _sql: &str,
283            _params: Vec<FilterValue>,
284        ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
285            Box::pin(async { Ok(0) })
286        }
287    }
288
289    // ========== Construction Tests ==========
290
291    #[test]
292    fn test_find_many_new() {
293        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
294        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
295
296        assert!(sql.contains("SELECT * FROM test_models"));
297        assert!(params.is_empty());
298    }
299
300    #[test]
301    fn test_find_many_basic() {
302        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
303        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
304
305        assert_eq!(sql, "SELECT * FROM test_models");
306        assert!(params.is_empty());
307    }
308
309    // ========== Filter Tests ==========
310
311    #[test]
312    fn test_find_many_with_filter() {
313        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
314            .r#where(Filter::Equals("name".into(), "Alice".into()));
315
316        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
317
318        assert!(sql.contains("WHERE"));
319        assert!(sql.contains(r#""name" = $1"#));
320        assert_eq!(params.len(), 1);
321    }
322
323    #[test]
324    fn test_find_many_with_compound_filter() {
325        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
326            .r#where(Filter::Equals(
327                "status".into(),
328                FilterValue::String("active".to_string()),
329            ))
330            .r#where(Filter::Gte("age".into(), FilterValue::Int(18)));
331
332        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
333
334        assert!(sql.contains("WHERE"));
335        assert!(sql.contains("AND"));
336        assert_eq!(params.len(), 2);
337    }
338
339    #[test]
340    fn test_find_many_with_or_filter() {
341        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::or([
342            Filter::Equals("role".into(), FilterValue::String("admin".to_string())),
343            Filter::Equals("role".into(), FilterValue::String("moderator".to_string())),
344        ]));
345
346        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
347
348        assert!(sql.contains("OR"));
349        assert_eq!(params.len(), 2);
350    }
351
352    #[test]
353    fn test_find_many_with_in_filter() {
354        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::In(
355            "status".into(),
356            vec![
357                FilterValue::String("pending".to_string()),
358                FilterValue::String("processing".to_string()),
359            ],
360        ));
361
362        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
363
364        assert!(sql.contains("IN"));
365        assert_eq!(params.len(), 2);
366    }
367
368    #[test]
369    fn test_find_many_without_filter() {
370        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
371        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
372
373        assert!(!sql.contains("WHERE"));
374        assert!(params.is_empty());
375    }
376
377    // ========== Order By Tests ==========
378
379    #[test]
380    fn test_find_many_with_order() {
381        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
382            .order_by(OrderByField::desc("created_at"));
383
384        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
385
386        assert!(sql.contains("ORDER BY created_at DESC"));
387    }
388
389    #[test]
390    fn test_find_many_with_asc_order() {
391        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
392            .order_by(OrderByField::asc("name"));
393
394        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
395
396        assert!(sql.contains("ORDER BY name ASC"));
397    }
398
399    #[test]
400    fn test_find_many_without_order() {
401        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
402        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
403
404        assert!(!sql.contains("ORDER BY"));
405    }
406
407    #[test]
408    fn test_find_many_order_replaces() {
409        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
410            .order_by(OrderByField::asc("name"))
411            .order_by(OrderByField::desc("created_at"));
412
413        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
414
415        assert!(sql.contains("ORDER BY created_at DESC"));
416        assert!(!sql.contains("ORDER BY name"));
417    }
418
419    // ========== Pagination Tests ==========
420
421    #[test]
422    fn test_find_many_with_pagination() {
423        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
424            .skip(10)
425            .take(20);
426
427        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
428
429        assert!(sql.contains("LIMIT 20"));
430        assert!(sql.contains("OFFSET 10"));
431    }
432
433    #[test]
434    fn test_find_many_with_skip_only() {
435        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).skip(5);
436
437        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
438
439        assert!(sql.contains("OFFSET 5"));
440    }
441
442    #[test]
443    fn test_find_many_with_take_only() {
444        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).take(100);
445
446        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
447
448        assert!(sql.contains("LIMIT 100"));
449    }
450
451    #[test]
452    fn test_find_many_with_cursor() {
453        let cursor = Cursor::new("id", CursorValue::Int(100), CursorDirection::After);
454        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
455            .cursor(cursor)
456            .take(10);
457
458        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
459
460        // Cursor pagination should add some cursor-based filtering
461        assert!(sql.contains("LIMIT 10"));
462    }
463
464    // ========== Select Tests ==========
465
466    #[test]
467    fn test_find_many_with_select() {
468        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
469            .select(Select::fields(["id", "name"]));
470
471        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
472
473        assert!(sql.contains("SELECT id, name FROM"));
474        assert!(!sql.contains("SELECT *"));
475    }
476
477    #[test]
478    fn test_find_many_select_single_field() {
479        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
480            .select(Select::fields(["id"]));
481
482        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
483
484        assert!(sql.contains("SELECT id FROM"));
485    }
486
487    #[test]
488    fn test_find_many_select_all() {
489        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).select(Select::All);
490
491        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
492
493        assert!(sql.contains("SELECT * FROM"));
494    }
495
496    /// Task 28 regression test: a narrow `Select::fields` list must turn
497    /// the emitted `SELECT *` into an explicit column list so wide models
498    /// don't waste bandwidth. The projection still hydrates as the full
499    /// struct, so callers are responsible for covering every non-`Option`
500    /// field — see the CHANGELOG migration note.
501    #[test]
502    fn find_many_emits_explicit_column_list_when_select_narrows() {
503        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
504            .select(Select::fields(["id", "email"]));
505        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
506        assert!(
507            sql.contains("SELECT id, email FROM") && !sql.contains("SELECT *"),
508            "expected narrow select list, got: {sql}"
509        );
510    }
511
512    /// Counterpart to the narrowing test: with no `.select(...)` call,
513    /// the default `Select::All` must still emit `SELECT *`. Guards
514    /// against a regression where a future refactor of the default
515    /// value silently drops back to an empty column list.
516    #[test]
517    fn find_many_emits_star_when_no_select() {
518        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
519        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
520        assert!(sql.contains("SELECT *"), "expected SELECT *, got: {sql}");
521    }
522
523    // ========== Distinct Tests ==========
524
525    #[test]
526    fn test_find_many_with_distinct() {
527        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).distinct(["category"]);
528
529        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
530
531        assert!(sql.contains("DISTINCT ON (category)"));
532    }
533
534    #[test]
535    fn test_find_many_with_multiple_distinct() {
536        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
537            .distinct(["category", "status"]);
538
539        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
540
541        assert!(sql.contains("DISTINCT ON (category, status)"));
542    }
543
544    #[test]
545    fn test_find_many_without_distinct() {
546        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
547        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
548
549        assert!(!sql.contains("DISTINCT"));
550    }
551
552    // ========== SQL Structure Tests ==========
553
554    #[test]
555    fn test_find_many_sql_structure() {
556        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
557            .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
558            .order_by(OrderByField::desc("created_at"))
559            .skip(10)
560            .take(20)
561            .select(Select::fields(["id", "name"]));
562
563        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
564
565        // Check correct SQL clause ordering
566        let select_pos = sql.find("SELECT").unwrap();
567        let from_pos = sql.find("FROM").unwrap();
568        let where_pos = sql.find("WHERE").unwrap();
569        let order_pos = sql.find("ORDER BY").unwrap();
570        let limit_pos = sql.find("LIMIT").unwrap();
571        let offset_pos = sql.find("OFFSET").unwrap();
572
573        assert!(select_pos < from_pos);
574        assert!(from_pos < where_pos);
575        assert!(where_pos < order_pos);
576        assert!(order_pos < limit_pos);
577        assert!(limit_pos < offset_pos);
578    }
579
580    #[test]
581    fn test_find_many_table_name() {
582        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
583        let (sql, _) = op.build_sql(&crate::dialect::Postgres);
584
585        assert!(sql.contains("test_models"));
586    }
587
588    // ========== Async Execution Tests ==========
589
590    #[tokio::test]
591    async fn test_find_many_exec() {
592        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(
593            Filter::Equals("status".into(), FilterValue::String("active".to_string())),
594        );
595
596        let result = op.exec().await;
597
598        assert!(result.is_ok());
599        assert!(result.unwrap().is_empty()); // MockEngine returns empty vec
600    }
601
602    #[tokio::test]
603    async fn test_find_many_exec_no_filter() {
604        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
605
606        let result = op.exec().await;
607
608        assert!(result.is_ok());
609    }
610
611    // ========== Method Chaining Tests ==========
612
613    #[test]
614    fn test_find_many_full_chain() {
615        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
616            .r#where(Filter::Equals(
617                "status".into(),
618                FilterValue::String("active".to_string()),
619            ))
620            .order_by(OrderByField::desc("created_at"))
621            .skip(10)
622            .take(20)
623            .select(Select::fields(["id", "name", "email"]))
624            .distinct(["category"]);
625
626        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
627
628        assert!(sql.contains("DISTINCT ON (category)"));
629        assert!(sql.contains("SELECT"));
630        assert!(sql.contains("WHERE"));
631        assert!(sql.contains("ORDER BY created_at DESC"));
632        assert!(sql.contains("LIMIT 20"));
633        assert!(sql.contains("OFFSET 10"));
634        assert_eq!(params.len(), 1);
635    }
636
637    // ========== Edge Cases ==========
638
639    #[test]
640    fn test_find_many_with_like_filter() {
641        let op =
642            FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Contains(
643                "email".into(),
644                FilterValue::String("@example.com".to_string()),
645            ));
646
647        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
648
649        assert!(sql.contains("LIKE"));
650        assert_eq!(params.len(), 1);
651    }
652
653    #[test]
654    fn test_find_many_with_null_filter() {
655        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
656            .r#where(Filter::IsNull("deleted_at".into()));
657
658        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
659
660        assert!(sql.contains("IS NULL"));
661        assert!(params.is_empty());
662    }
663
664    #[test]
665    fn test_find_many_with_not_filter() {
666        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Not(
667            Box::new(Filter::Equals(
668                "status".into(),
669                FilterValue::String("deleted".to_string()),
670            )),
671        ));
672
673        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
674
675        assert!(sql.contains("NOT"));
676        assert_eq!(params.len(), 1);
677    }
678
679    #[test]
680    fn test_find_many_with_between_equivalent() {
681        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
682            .r#where(Filter::Gte("age".into(), FilterValue::Int(18)))
683            .r#where(Filter::Lte("age".into(), FilterValue::Int(65)));
684
685        let (sql, params) = op.build_sql(&crate::dialect::Postgres);
686
687        assert!(sql.contains("AND"));
688        assert_eq!(params.len(), 2);
689    }
690
691    // ========== Cross-Dialect Tests ==========
692
693    #[test]
694    fn builds_mysql_placeholders() {
695        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
696            .r#where(Filter::Equals("name".into(), "a".into()));
697        let (sql, _) = op.build_sql(&crate::dialect::Mysql);
698        assert!(
699            sql.contains("?") && !sql.contains("$1"),
700            "expected ? placeholders, got: {sql}"
701        );
702    }
703
704    #[test]
705    fn builds_mssql_placeholders() {
706        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
707            .r#where(Filter::Equals("name".into(), "a".into()));
708        let (sql, _) = op.build_sql(&crate::dialect::Mssql);
709        assert!(sql.contains("@P1"), "expected @P1 placeholders, got: {sql}");
710    }
711
712    #[test]
713    fn builds_sqlite_placeholders() {
714        let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
715            .r#where(Filter::Equals("name".into(), "a".into()));
716        let (sql, _) = op.build_sql(&crate::dialect::Sqlite);
717        assert!(sql.contains("?1"), "expected ?1 placeholders, got: {sql}");
718    }
719}