1use 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
16pub 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 includes: SmallVec<[IncludeSpec; 2]>,
44 pub extra_projections: Vec<ScalarProjection>,
47 _model: PhantomData<M>,
48}
49
50impl<E: QueryEngine, M: Model + crate::row::FromRow> FindManyOperation<E, M> {
51 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 pub fn include(mut self, spec: IncludeSpec) -> Self {
73 self.includes.push(spec);
74 self
75 }
76
77 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 pub fn order_by(mut self, order: impl Into<OrderBy>) -> Self {
86 self.order_by = order.into();
87 self
88 }
89
90 pub fn skip(mut self, n: u64) -> Self {
92 self.pagination = self.pagination.skip(n);
93 self
94 }
95
96 pub fn take(mut self, n: u64) -> Self {
98 self.pagination = self.pagination.take(n);
99 self
100 }
101
102 pub fn select(mut self, select: impl Into<Select>) -> Self {
104 self.select = select.into();
105 self
106 }
107
108 pub fn distinct(mut self, columns: impl IntoIterator<Item = impl Into<String>>) -> Self {
110 self.distinct = Some(columns.into_iter().map(Into::into).collect());
111 self
112 }
113
114 pub fn cursor(mut self, cursor: crate::pagination::Cursor) -> Self {
116 self.pagination = self.pagination.cursor(cursor);
117 self
118 }
119
120 pub fn with_where_input<W: crate::inputs::WhereInput<Model = M>>(mut self, w: W) -> Self {
123 let f = w.into_ir();
124 self.filter = self.filter.and_then(f);
125 self
126 }
127
128 pub fn with_include_input<I: crate::inputs::IncludeInput<Model = M>>(mut self, i: I) -> Self {
131 let inc = i.into_ir();
132 for spec in inc.specs() {
133 self.includes.push(spec.clone());
134 }
135 self
136 }
137
138 pub fn with_select_input<S: crate::inputs::SelectInput<Model = M>>(mut self, s: S) -> Self {
140 self.select = s.into_ir();
141 self
142 }
143
144 pub fn with_order_by_input<O: crate::inputs::OrderByInput<Model = M>>(mut self, o: O) -> Self {
146 self.order_by = o.into_ir();
147 self
148 }
149
150 #[doc(hidden)]
153 pub fn filter_for_test(&self) -> &Filter {
154 &self.filter
155 }
156}
157
158impl<E, M> FindManyOperation<E, M>
159where
160 E: QueryEngine + SupportsScalarSubqueryInSelect,
161 M: Model + crate::row::FromRow,
162{
163 pub fn with_scalar_projection(mut self, proj: ScalarProjection) -> Self {
171 self.extra_projections.push(proj);
172 self
173 }
174}
175
176impl<E: QueryEngine, M: Model + crate::row::FromRow> FindManyOperation<E, M> {
177 pub fn build_sql(
179 &self,
180 dialect: &dyn crate::dialect::SqlDialect,
181 ) -> (String, Vec<crate::filter::FilterValue>) {
182 let proj_param_count: usize = self.extra_projections.iter().map(|p| p.params.len()).sum();
186 let (where_sql, where_params) = self.filter.to_sql(proj_param_count, dialect);
187
188 let mut params: Vec<crate::filter::FilterValue> =
189 Vec::with_capacity(proj_param_count + where_params.len());
190
191 let mut sql = String::new();
192
193 sql.push_str("SELECT ");
195 if let Some(ref cols) = self.distinct {
196 sql.push_str("DISTINCT ON (");
197 sql.push_str(&cols.join(", "));
198 sql.push_str(") ");
199 }
200 sql.push_str(&self.select.to_sql());
201
202 let mut proj_offset = 0usize;
204 for proj in &self.extra_projections {
205 sql.push_str(", ");
206 let frag = proj.to_sql(proj_offset, dialect, &mut params);
207 sql.push('(');
208 sql.push_str(&frag);
209 sql.push_str(") AS \"");
210 sql.push_str(proj.alias);
211 sql.push('"');
212 proj_offset += proj.params.len();
213 }
214
215 sql.push_str(" FROM ");
217 sql.push_str(M::TABLE_NAME);
218
219 if !self.filter.is_none() {
221 sql.push_str(" WHERE ");
222 sql.push_str(&where_sql);
223 }
224 params.extend(where_params);
225
226 if !self.order_by.is_empty() {
228 sql.push_str(" ORDER BY ");
229 sql.push_str(&self.order_by.to_sql());
230 }
231
232 let pagination_sql = self.pagination.to_sql();
234 if !pagination_sql.is_empty() {
235 sql.push(' ');
236 sql.push_str(&pagination_sql);
237 }
238
239 (sql, params)
240 }
241
242 pub async fn exec(self) -> QueryResult<Vec<M>>
250 where
251 M: Send + 'static + ModelRelationLoader<E>,
252 {
253 let dialect = self.engine.dialect();
254 let (sql, params) = self.build_sql(dialect);
255 let mut parents = self.engine.query_many::<M>(&sql, params).await?;
256 for spec in &self.includes {
257 <M as ModelRelationLoader<E>>::load_relation(&self.engine, &mut parents, spec).await?;
258 }
259 Ok(parents)
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::error::QueryError;
267 use crate::filter::FilterValue;
268 use crate::pagination::{Cursor, CursorDirection, CursorValue};
269 use crate::types::OrderByField;
270
271 struct TestModel;
272
273 impl Model for TestModel {
274 const MODEL_NAME: &'static str = "TestModel";
275 const TABLE_NAME: &'static str = "test_models";
276 const PRIMARY_KEY: &'static [&'static str] = &["id"];
277 const COLUMNS: &'static [&'static str] = &["id", "name", "email"];
278 }
279
280 impl crate::row::FromRow for TestModel {
281 fn from_row(_row: &impl crate::row::RowRef) -> Result<Self, crate::row::RowError> {
282 Ok(TestModel)
283 }
284 }
285
286 impl crate::traits::ModelRelationLoader<MockEngine> for TestModel {
290 fn load_relation<'a>(
291 _engine: &'a MockEngine,
292 _parents: &'a mut [Self],
293 spec: &'a crate::relations::IncludeSpec,
294 ) -> crate::traits::BoxFuture<'a, QueryResult<()>> {
295 let name = spec.relation_name.clone();
296 Box::pin(async move {
297 Err(QueryError::internal(format!(
298 "unknown relation '{name}' on TestModel (mock)",
299 )))
300 })
301 }
302 }
303
304 #[derive(Clone)]
305 struct MockEngine;
306
307 impl QueryEngine for MockEngine {
308 fn dialect(&self) -> &dyn crate::dialect::SqlDialect {
309 &crate::dialect::Postgres
310 }
311
312 fn query_many<T: Model + crate::row::FromRow + Send + 'static>(
313 &self,
314 _sql: &str,
315 _params: Vec<FilterValue>,
316 ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
317 Box::pin(async { Ok(Vec::new()) })
318 }
319
320 fn query_one<T: Model + crate::row::FromRow + Send + 'static>(
321 &self,
322 _sql: &str,
323 _params: Vec<FilterValue>,
324 ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
325 Box::pin(async { Err(QueryError::not_found("test")) })
326 }
327
328 fn query_optional<T: Model + crate::row::FromRow + Send + 'static>(
329 &self,
330 _sql: &str,
331 _params: Vec<FilterValue>,
332 ) -> crate::traits::BoxFuture<'_, QueryResult<Option<T>>> {
333 Box::pin(async { Ok(None) })
334 }
335
336 fn execute_insert<T: Model + crate::row::FromRow + Send + 'static>(
337 &self,
338 _sql: &str,
339 _params: Vec<FilterValue>,
340 ) -> crate::traits::BoxFuture<'_, QueryResult<T>> {
341 Box::pin(async { Err(QueryError::not_found("test")) })
342 }
343
344 fn execute_update<T: Model + crate::row::FromRow + Send + 'static>(
345 &self,
346 _sql: &str,
347 _params: Vec<FilterValue>,
348 ) -> crate::traits::BoxFuture<'_, QueryResult<Vec<T>>> {
349 Box::pin(async { Ok(Vec::new()) })
350 }
351
352 fn execute_delete(
353 &self,
354 _sql: &str,
355 _params: Vec<FilterValue>,
356 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
357 Box::pin(async { Ok(0) })
358 }
359
360 fn execute_raw(
361 &self,
362 _sql: &str,
363 _params: Vec<FilterValue>,
364 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
365 Box::pin(async { Ok(0) })
366 }
367
368 fn count(
369 &self,
370 _sql: &str,
371 _params: Vec<FilterValue>,
372 ) -> crate::traits::BoxFuture<'_, QueryResult<u64>> {
373 Box::pin(async { Ok(0) })
374 }
375 }
376
377 #[test]
380 fn test_find_many_new() {
381 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
382 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
383
384 assert!(sql.contains("SELECT * FROM test_models"));
385 assert!(params.is_empty());
386 }
387
388 #[test]
389 fn test_find_many_basic() {
390 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
391 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
392
393 assert_eq!(sql, "SELECT * FROM test_models");
394 assert!(params.is_empty());
395 }
396
397 #[test]
400 fn test_find_many_with_filter() {
401 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
402 .r#where(Filter::Equals("name".into(), "Alice".into()));
403
404 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
405
406 assert!(sql.contains("WHERE"));
407 assert!(sql.contains(r#""name" = $1"#));
408 assert_eq!(params.len(), 1);
409 }
410
411 #[test]
412 fn test_find_many_with_compound_filter() {
413 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
414 .r#where(Filter::Equals(
415 "status".into(),
416 FilterValue::String("active".to_string()),
417 ))
418 .r#where(Filter::Gte("age".into(), FilterValue::Int(18)));
419
420 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
421
422 assert!(sql.contains("WHERE"));
423 assert!(sql.contains("AND"));
424 assert_eq!(params.len(), 2);
425 }
426
427 #[test]
428 fn test_find_many_with_or_filter() {
429 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::or([
430 Filter::Equals("role".into(), FilterValue::String("admin".to_string())),
431 Filter::Equals("role".into(), FilterValue::String("moderator".to_string())),
432 ]));
433
434 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
435
436 assert!(sql.contains("OR"));
437 assert_eq!(params.len(), 2);
438 }
439
440 #[test]
441 fn test_find_many_with_in_filter() {
442 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::In(
443 "status".into(),
444 vec![
445 FilterValue::String("pending".to_string()),
446 FilterValue::String("processing".to_string()),
447 ],
448 ));
449
450 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
451
452 assert!(sql.contains("IN"));
453 assert_eq!(params.len(), 2);
454 }
455
456 #[test]
457 fn test_find_many_without_filter() {
458 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
459 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
460
461 assert!(!sql.contains("WHERE"));
462 assert!(params.is_empty());
463 }
464
465 #[test]
468 fn test_find_many_with_order() {
469 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
470 .order_by(OrderByField::desc("created_at"));
471
472 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
473
474 assert!(sql.contains("ORDER BY created_at DESC"));
475 }
476
477 #[test]
478 fn test_find_many_with_asc_order() {
479 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
480 .order_by(OrderByField::asc("name"));
481
482 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
483
484 assert!(sql.contains("ORDER BY name ASC"));
485 }
486
487 #[test]
488 fn test_find_many_without_order() {
489 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
490 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
491
492 assert!(!sql.contains("ORDER BY"));
493 }
494
495 #[test]
496 fn test_find_many_order_replaces() {
497 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
498 .order_by(OrderByField::asc("name"))
499 .order_by(OrderByField::desc("created_at"));
500
501 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
502
503 assert!(sql.contains("ORDER BY created_at DESC"));
504 assert!(!sql.contains("ORDER BY name"));
505 }
506
507 #[test]
510 fn test_find_many_with_pagination() {
511 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
512 .skip(10)
513 .take(20);
514
515 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
516
517 assert!(sql.contains("LIMIT 20"));
518 assert!(sql.contains("OFFSET 10"));
519 }
520
521 #[test]
522 fn test_find_many_with_skip_only() {
523 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).skip(5);
524
525 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
526
527 assert!(sql.contains("OFFSET 5"));
528 }
529
530 #[test]
531 fn test_find_many_with_take_only() {
532 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).take(100);
533
534 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
535
536 assert!(sql.contains("LIMIT 100"));
537 }
538
539 #[test]
540 fn test_find_many_with_cursor() {
541 let cursor = Cursor::new("id", CursorValue::Int(100), CursorDirection::After);
542 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
543 .cursor(cursor)
544 .take(10);
545
546 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
547
548 assert!(sql.contains("LIMIT 10"));
550 }
551
552 #[test]
555 fn test_find_many_with_select() {
556 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
557 .select(Select::fields(["id", "name"]));
558
559 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
560
561 assert!(sql.contains("SELECT id, name FROM"));
562 assert!(!sql.contains("SELECT *"));
563 }
564
565 #[test]
566 fn test_find_many_select_single_field() {
567 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
568 .select(Select::fields(["id"]));
569
570 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
571
572 assert!(sql.contains("SELECT id FROM"));
573 }
574
575 #[test]
576 fn test_find_many_select_all() {
577 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).select(Select::All);
578
579 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
580
581 assert!(sql.contains("SELECT * FROM"));
582 }
583
584 #[test]
590 fn find_many_emits_explicit_column_list_when_select_narrows() {
591 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
592 .select(Select::fields(["id", "email"]));
593 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
594 assert!(
595 sql.contains("SELECT id, email FROM") && !sql.contains("SELECT *"),
596 "expected narrow select list, got: {sql}"
597 );
598 }
599
600 #[test]
605 fn find_many_emits_star_when_no_select() {
606 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
607 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
608 assert!(sql.contains("SELECT *"), "expected SELECT *, got: {sql}");
609 }
610
611 #[test]
614 fn test_find_many_with_distinct() {
615 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).distinct(["category"]);
616
617 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
618
619 assert!(sql.contains("DISTINCT ON (category)"));
620 }
621
622 #[test]
623 fn test_find_many_with_multiple_distinct() {
624 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
625 .distinct(["category", "status"]);
626
627 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
628
629 assert!(sql.contains("DISTINCT ON (category, status)"));
630 }
631
632 #[test]
633 fn test_find_many_without_distinct() {
634 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
635 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
636
637 assert!(!sql.contains("DISTINCT"));
638 }
639
640 #[test]
643 fn test_find_many_sql_structure() {
644 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
645 .r#where(Filter::Equals("id".into(), FilterValue::Int(1)))
646 .order_by(OrderByField::desc("created_at"))
647 .skip(10)
648 .take(20)
649 .select(Select::fields(["id", "name"]));
650
651 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
652
653 let select_pos = sql.find("SELECT").unwrap();
655 let from_pos = sql.find("FROM").unwrap();
656 let where_pos = sql.find("WHERE").unwrap();
657 let order_pos = sql.find("ORDER BY").unwrap();
658 let limit_pos = sql.find("LIMIT").unwrap();
659 let offset_pos = sql.find("OFFSET").unwrap();
660
661 assert!(select_pos < from_pos);
662 assert!(from_pos < where_pos);
663 assert!(where_pos < order_pos);
664 assert!(order_pos < limit_pos);
665 assert!(limit_pos < offset_pos);
666 }
667
668 #[test]
669 fn test_find_many_table_name() {
670 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
671 let (sql, _) = op.build_sql(&crate::dialect::Postgres);
672
673 assert!(sql.contains("test_models"));
674 }
675
676 #[tokio::test]
679 async fn test_find_many_exec() {
680 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(
681 Filter::Equals("status".into(), FilterValue::String("active".to_string())),
682 );
683
684 let result = op.exec().await;
685
686 assert!(result.is_ok());
687 assert!(result.unwrap().is_empty()); }
689
690 #[tokio::test]
691 async fn test_find_many_exec_no_filter() {
692 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine);
693
694 let result = op.exec().await;
695
696 assert!(result.is_ok());
697 }
698
699 #[test]
702 fn test_find_many_full_chain() {
703 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
704 .r#where(Filter::Equals(
705 "status".into(),
706 FilterValue::String("active".to_string()),
707 ))
708 .order_by(OrderByField::desc("created_at"))
709 .skip(10)
710 .take(20)
711 .select(Select::fields(["id", "name", "email"]))
712 .distinct(["category"]);
713
714 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
715
716 assert!(sql.contains("DISTINCT ON (category)"));
717 assert!(sql.contains("SELECT"));
718 assert!(sql.contains("WHERE"));
719 assert!(sql.contains("ORDER BY created_at DESC"));
720 assert!(sql.contains("LIMIT 20"));
721 assert!(sql.contains("OFFSET 10"));
722 assert_eq!(params.len(), 1);
723 }
724
725 #[test]
728 fn test_find_many_with_like_filter() {
729 let op =
730 FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Contains(
731 "email".into(),
732 FilterValue::String("@example.com".to_string()),
733 ));
734
735 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
736
737 assert!(sql.contains("LIKE"));
738 assert_eq!(params.len(), 1);
739 }
740
741 #[test]
742 fn test_find_many_with_null_filter() {
743 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
744 .r#where(Filter::IsNull("deleted_at".into()));
745
746 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
747
748 assert!(sql.contains("IS NULL"));
749 assert!(params.is_empty());
750 }
751
752 #[test]
753 fn test_find_many_with_not_filter() {
754 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine).r#where(Filter::Not(
755 Box::new(Filter::Equals(
756 "status".into(),
757 FilterValue::String("deleted".to_string()),
758 )),
759 ));
760
761 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
762
763 assert!(sql.contains("NOT"));
764 assert_eq!(params.len(), 1);
765 }
766
767 #[test]
768 fn test_find_many_with_between_equivalent() {
769 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
770 .r#where(Filter::Gte("age".into(), FilterValue::Int(18)))
771 .r#where(Filter::Lte("age".into(), FilterValue::Int(65)));
772
773 let (sql, params) = op.build_sql(&crate::dialect::Postgres);
774
775 assert!(sql.contains("AND"));
776 assert_eq!(params.len(), 2);
777 }
778
779 #[test]
782 fn builds_mysql_placeholders() {
783 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
784 .r#where(Filter::Equals("name".into(), "a".into()));
785 let (sql, _) = op.build_sql(&crate::dialect::Mysql);
786 assert!(
787 sql.contains("?") && !sql.contains("$1"),
788 "expected ? placeholders, got: {sql}"
789 );
790 }
791
792 #[test]
793 fn builds_mssql_placeholders() {
794 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
795 .r#where(Filter::Equals("name".into(), "a".into()));
796 let (sql, _) = op.build_sql(&crate::dialect::Mssql);
797 assert!(sql.contains("@P1"), "expected @P1 placeholders, got: {sql}");
798 }
799
800 #[test]
801 fn builds_sqlite_placeholders() {
802 let op = FindManyOperation::<MockEngine, TestModel>::new(MockEngine)
803 .r#where(Filter::Equals("name".into(), "a".into()));
804 let (sql, _) = op.build_sql(&crate::dialect::Sqlite);
805 assert!(sql.contains("?1"), "expected ?1 placeholders, got: {sql}");
806 }
807}