1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::path::{Path, PathBuf};
6
7use crate::error::{Error, Result};
8use crate::mock::MockTable;
9use crate::parser;
10use crate::types::{Command, CommandKind, Dialect, Element, Template, TemplateSource};
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct ComposedSql {
15 pub sql: String,
17 pub bind_params: Vec<String>,
23}
24
25pub struct Composer {
30 pub dialect: Dialect,
32 pub search_paths: Vec<PathBuf>,
34 pub mock_tables: HashMap<String, MockTable>,
36}
37
38impl Composer {
39 pub fn new(dialect: Dialect) -> Self {
41 Self {
42 dialect,
43 search_paths: vec![],
44 mock_tables: HashMap::new(),
45 }
46 }
47
48 pub fn add_search_path(&mut self, path: PathBuf) {
50 self.search_paths.push(path);
51 }
52
53 pub fn add_mock_table(&mut self, mock: MockTable) {
55 self.mock_tables.insert(mock.name.clone(), mock);
56 }
57
58 pub fn compose(&self, template: &Template) -> Result<ComposedSql> {
60 let mut visited = HashSet::new();
61 if let TemplateSource::File(ref path) = template.source {
62 visited.insert(path.clone());
63 }
64 self.compose_inner(template, &mut visited)
65 }
66
67 pub fn compose_with_values<V>(
83 &self,
84 template: &Template,
85 values: &BTreeMap<String, Vec<V>>,
86 ) -> Result<ComposedSql> {
87 let mut visited = HashSet::new();
88 if let TemplateSource::File(ref path) = template.source {
89 visited.insert(path.clone());
90 }
91 self.compose_with_values_inner(template, values, &mut visited)
92 }
93
94 fn compose_inner(
97 &self,
98 template: &Template,
99 visited: &mut HashSet<PathBuf>,
100 ) -> Result<ComposedSql> {
101 if self.dialect.supports_numbered_placeholders() {
102 self.compose_inner_numbered(template, visited)
103 } else {
104 self.compose_inner_positional(template, visited)
105 }
106 }
107
108 fn compose_with_values_inner<V>(
109 &self,
110 template: &Template,
111 values: &BTreeMap<String, Vec<V>>,
112 visited: &mut HashSet<PathBuf>,
113 ) -> Result<ComposedSql> {
114 if self.dialect.supports_numbered_placeholders() {
115 self.compose_with_values_numbered(template, values, visited)
116 } else {
117 self.compose_with_values_positional(template, values, visited)
118 }
119 }
120
121 fn collect_bind_names(
130 &self,
131 template: &Template,
132 visited: &mut HashSet<PathBuf>,
133 ) -> Result<BTreeSet<String>> {
134 let mut names = BTreeSet::new();
135
136 for element in &template.elements {
137 match element {
138 Element::Sql(_) => {}
139 Element::Bind(binding) => {
140 names.insert(binding.name.clone());
141 }
142 Element::Compose(compose_ref) => {
143 let sub = self.collect_compose_bind_names(&compose_ref.path, visited)?;
144 names.extend(sub);
145 }
146 Element::Command(command) => {
147 let sub = self.collect_command_bind_names(command, visited)?;
148 names.extend(sub);
149 }
150 }
151 }
152
153 Ok(names)
154 }
155
156 fn collect_compose_bind_names(
158 &self,
159 path: &Path,
160 visited: &mut HashSet<PathBuf>,
161 ) -> Result<BTreeSet<String>> {
162 let resolved = self.find_template(path)?;
163
164 if !visited.insert(resolved.clone()) {
165 return Err(Error::CircularReference {
166 path: path.to_path_buf(),
167 });
168 }
169
170 let template = parser::parse_template_file(&resolved)?;
171 let names = self.collect_bind_names(&template, visited)?;
172
173 visited.remove(&resolved);
174 Ok(names)
175 }
176
177 fn collect_command_bind_names(
179 &self,
180 command: &Command,
181 visited: &mut HashSet<PathBuf>,
182 ) -> Result<BTreeSet<String>> {
183 let mut names = BTreeSet::new();
184 for source in &command.sources {
185 let resolved = self.find_template(source)?;
186 let template = parser::parse_template_file(&resolved)?;
187 let sub = self.collect_bind_names(&template, visited)?;
188 names.extend(sub);
189 }
190 Ok(names)
191 }
192
193 fn build_index_map(names: &BTreeSet<String>) -> BTreeMap<String, (usize, usize)> {
196 names
197 .iter()
198 .enumerate()
199 .map(|(i, name)| (name.clone(), (i + 1, 1)))
200 .collect()
201 }
202
203 fn build_index_map_with_values<V>(
207 names: &BTreeSet<String>,
208 values: &BTreeMap<String, Vec<V>>,
209 ) -> BTreeMap<String, (usize, usize)> {
210 let mut map = BTreeMap::new();
211 let mut index = 1;
212 for name in names {
213 let count = values.get(name).map(|vs| vs.len()).unwrap_or(1).max(1);
214 map.insert(name.clone(), (index, count));
215 index += count;
216 }
217 map
218 }
219
220 fn compose_inner_numbered(
222 &self,
223 template: &Template,
224 visited: &mut HashSet<PathBuf>,
225 ) -> Result<ComposedSql> {
226 let mut collect_visited = visited.clone();
228 let names = self.collect_bind_names(template, &mut collect_visited)?;
229
230 let index_map = Self::build_index_map(&names);
232 let bind_params: Vec<String> = names.into_iter().collect();
233
234 let mut sql = String::new();
236 self.emit_sql_numbered(template, &index_map, &mut sql, visited)?;
237
238 Ok(ComposedSql { sql, bind_params })
239 }
240
241 fn compose_with_values_numbered<V>(
243 &self,
244 template: &Template,
245 values: &BTreeMap<String, Vec<V>>,
246 visited: &mut HashSet<PathBuf>,
247 ) -> Result<ComposedSql> {
248 let mut collect_visited = visited.clone();
250 let names = self.collect_bind_names(template, &mut collect_visited)?;
251
252 let index_map = Self::build_index_map_with_values(&names, values);
254
255 let mut bind_params = Vec::new();
257 for name in &names {
258 let count = values
259 .get(name.as_str())
260 .map(|vs| vs.len())
261 .unwrap_or(1)
262 .max(1);
263 for _ in 0..count {
264 bind_params.push(name.clone());
265 }
266 }
267
268 let mut sql = String::new();
270 self.emit_sql_numbered(template, &index_map, &mut sql, visited)?;
271
272 Ok(ComposedSql { sql, bind_params })
273 }
274
275 fn emit_sql_numbered(
277 &self,
278 template: &Template,
279 index_map: &BTreeMap<String, (usize, usize)>,
280 sql: &mut String,
281 visited: &mut HashSet<PathBuf>,
282 ) -> Result<()> {
283 for element in &template.elements {
284 match element {
285 Element::Sql(text) => sql.push_str(text),
286 Element::Bind(binding) => {
287 let &(start, count) = &index_map[&binding.name];
288 for i in 0..count {
289 if i > 0 {
290 sql.push_str(", ");
291 }
292 sql.push_str(&self.dialect.placeholder(start + i));
293 }
294 }
295 Element::Compose(compose_ref) => {
296 self.emit_compose_numbered(&compose_ref.path, index_map, sql, visited)?;
297 }
298 Element::Command(command) => {
299 self.emit_command_numbered(command, index_map, sql, visited)?;
300 }
301 }
302 }
303 Ok(())
304 }
305
306 fn emit_compose_numbered(
308 &self,
309 path: &Path,
310 index_map: &BTreeMap<String, (usize, usize)>,
311 sql: &mut String,
312 visited: &mut HashSet<PathBuf>,
313 ) -> Result<()> {
314 let resolved = self.find_template(path)?;
315
316 if !visited.insert(resolved.clone()) {
317 return Err(Error::CircularReference {
318 path: path.to_path_buf(),
319 });
320 }
321
322 let template = parser::parse_template_file(&resolved)?;
323 self.emit_sql_numbered(&template, index_map, sql, visited)?;
324
325 visited.remove(&resolved);
326 Ok(())
327 }
328
329 fn emit_command_numbered(
331 &self,
332 command: &Command,
333 index_map: &BTreeMap<String, (usize, usize)>,
334 sql: &mut String,
335 visited: &mut HashSet<PathBuf>,
336 ) -> Result<()> {
337 match command.kind {
338 CommandKind::Union => self.emit_union_numbered(command, index_map, sql, visited),
339 CommandKind::Count => self.emit_count_numbered(command, index_map, sql, visited),
340 }
341 }
342
343 fn emit_union_numbered(
345 &self,
346 command: &Command,
347 index_map: &BTreeMap<String, (usize, usize)>,
348 sql: &mut String,
349 visited: &mut HashSet<PathBuf>,
350 ) -> Result<()> {
351 let union_kw = if command.all {
352 "UNION ALL"
353 } else if command.distinct {
354 "UNION DISTINCT"
355 } else {
356 "UNION"
357 };
358
359 for (i, source) in command.sources.iter().enumerate() {
360 if i > 0 {
361 sql.push_str(&format!("\n{union_kw}\n"));
362 }
363 let resolved = self.find_template(source)?;
364 let template = parser::parse_template_file(&resolved)?;
365 self.emit_sql_numbered(&template, index_map, sql, visited)?;
366 }
367
368 Ok(())
369 }
370
371 fn emit_count_numbered(
373 &self,
374 command: &Command,
375 index_map: &BTreeMap<String, (usize, usize)>,
376 sql: &mut String,
377 visited: &mut HashSet<PathBuf>,
378 ) -> Result<()> {
379 let columns = match &command.columns {
380 Some(cols) => cols.join(", "),
381 None => "*".to_string(),
382 };
383
384 let count_expr = if command.distinct {
385 format!("COUNT(DISTINCT {columns})")
386 } else {
387 format!("COUNT({columns})")
388 };
389
390 sql.push_str(&format!("SELECT {count_expr} FROM (\n"));
391
392 if command.sources.len() > 1 {
393 let union_cmd = Command {
394 kind: CommandKind::Union,
395 distinct: command.distinct,
396 all: command.all,
397 columns: None,
398 sources: command.sources.clone(),
399 };
400 self.emit_union_numbered(&union_cmd, index_map, sql, visited)?;
401 } else {
402 let source = &command.sources[0];
403 let resolved = self.find_template(source)?;
404 let template = parser::parse_template_file(&resolved)?;
405 self.emit_sql_numbered(&template, index_map, sql, visited)?;
406 }
407
408 sql.push_str("\n) AS _count_sub");
409 Ok(())
410 }
411
412 fn compose_inner_positional(
418 &self,
419 template: &Template,
420 visited: &mut HashSet<PathBuf>,
421 ) -> Result<ComposedSql> {
422 let mut sql = String::new();
423 let mut bind_params = Vec::new();
424
425 for element in &template.elements {
426 match element {
427 Element::Sql(text) => {
428 sql.push_str(text);
429 }
430 Element::Bind(binding) => {
431 let index = bind_params.len() + 1;
432 sql.push_str(&self.dialect.placeholder(index));
433 bind_params.push(binding.name.clone());
434 }
435 Element::Compose(compose_ref) => {
436 let composed = self.resolve_compose(&compose_ref.path, visited)?;
437 sql.push_str(&composed.sql);
438 bind_params.extend(composed.bind_params);
439 }
440 Element::Command(command) => {
441 let composed = self.compose_command(command, visited)?;
442 sql.push_str(&composed.sql);
443 bind_params.extend(composed.bind_params);
444 }
445 }
446 }
447
448 Ok(ComposedSql { sql, bind_params })
449 }
450
451 fn compose_with_values_positional<V>(
452 &self,
453 template: &Template,
454 values: &BTreeMap<String, Vec<V>>,
455 visited: &mut HashSet<PathBuf>,
456 ) -> Result<ComposedSql> {
457 let mut sql = String::new();
458 let mut bind_params = Vec::new();
459
460 for element in &template.elements {
461 match element {
462 Element::Sql(text) => {
463 sql.push_str(text);
464 }
465 Element::Bind(binding) => {
466 let count = values
467 .get(&binding.name)
468 .map(|vs| vs.len())
469 .unwrap_or(1)
470 .max(1);
471
472 for i in 0..count {
473 if i > 0 {
474 sql.push_str(", ");
475 }
476 let index = bind_params.len() + 1;
477 sql.push_str(&self.dialect.placeholder(index));
478 bind_params.push(binding.name.clone());
479 }
480 }
481 Element::Compose(compose_ref) => {
482 let composed =
483 self.resolve_compose_with_values(&compose_ref.path, values, visited)?;
484 sql.push_str(&composed.sql);
485 bind_params.extend(composed.bind_params);
486 }
487 Element::Command(command) => {
488 let composed = self.compose_command(command, visited)?;
489 sql.push_str(&composed.sql);
490 bind_params.extend(composed.bind_params);
491 }
492 }
493 }
494
495 Ok(ComposedSql { sql, bind_params })
496 }
497
498 fn resolve_compose(&self, path: &Path, visited: &mut HashSet<PathBuf>) -> Result<ComposedSql> {
500 let resolved = self.find_template(path)?;
501
502 if !visited.insert(resolved.clone()) {
503 return Err(Error::CircularReference {
504 path: path.to_path_buf(),
505 });
506 }
507
508 let template = parser::parse_template_file(&resolved)?;
509 let result = self.compose_inner(&template, visited)?;
510
511 visited.remove(&resolved);
512
513 Ok(result)
514 }
515
516 fn resolve_compose_with_values<V>(
518 &self,
519 path: &Path,
520 values: &BTreeMap<String, Vec<V>>,
521 visited: &mut HashSet<PathBuf>,
522 ) -> Result<ComposedSql> {
523 let resolved = self.find_template(path)?;
524
525 if !visited.insert(resolved.clone()) {
526 return Err(Error::CircularReference {
527 path: path.to_path_buf(),
528 });
529 }
530
531 let template = parser::parse_template_file(&resolved)?;
532 let result = self.compose_with_values_inner(&template, values, visited)?;
533
534 visited.remove(&resolved);
535 Ok(result)
536 }
537
538 fn compose_command(
540 &self,
541 command: &Command,
542 visited: &mut HashSet<PathBuf>,
543 ) -> Result<ComposedSql> {
544 match command.kind {
545 CommandKind::Union => self.compose_union(command, visited),
546 CommandKind::Count => self.compose_count(command, visited),
547 }
548 }
549
550 fn compose_union(
552 &self,
553 command: &Command,
554 visited: &mut HashSet<PathBuf>,
555 ) -> Result<ComposedSql> {
556 let mut parts = Vec::new();
557 let mut all_params = Vec::new();
558
559 for source in &command.sources {
560 let resolved = self.find_template(source)?;
561 let template = parser::parse_template_file(&resolved)?;
562 let composed = self.compose_inner(&template, visited)?;
563
564 parts.push(composed.sql);
565 all_params.extend(composed.bind_params);
566 }
567
568 let union_kw = if command.all {
569 "UNION ALL"
570 } else if command.distinct {
571 "UNION DISTINCT"
572 } else {
573 "UNION"
574 };
575
576 let sql = parts.join(&format!("\n{union_kw}\n"));
577
578 Ok(ComposedSql {
579 sql,
580 bind_params: all_params,
581 })
582 }
583
584 fn compose_count(
586 &self,
587 command: &Command,
588 visited: &mut HashSet<PathBuf>,
589 ) -> Result<ComposedSql> {
590 let columns = match &command.columns {
591 Some(cols) => cols.join(", "),
592 None => "*".to_string(),
593 };
594
595 let inner = if command.sources.len() > 1 {
597 let union_cmd = Command {
598 kind: CommandKind::Union,
599 distinct: command.distinct,
600 all: command.all,
601 columns: None,
602 sources: command.sources.clone(),
603 };
604 self.compose_union(&union_cmd, visited)?
605 } else {
606 let source = &command.sources[0];
607 let resolved = self.find_template(source)?;
608 let template = parser::parse_template_file(&resolved)?;
609 self.compose_inner(&template, visited)?
610 };
611
612 let count_expr = if command.distinct {
613 format!("COUNT(DISTINCT {columns})")
614 } else {
615 format!("COUNT({columns})")
616 };
617
618 let sql = format!("SELECT {count_expr} FROM (\n{}\n) AS _count_sub", inner.sql);
619
620 Ok(ComposedSql {
621 sql,
622 bind_params: inner.bind_params,
623 })
624 }
625
626 fn find_template(&self, path: &Path) -> Result<PathBuf> {
630 if path.exists() {
632 return Ok(path.to_path_buf());
633 }
634
635 for search_path in &self.search_paths {
637 let candidate = search_path.join(path);
638 if candidate.exists() {
639 return Ok(candidate);
640 }
641 }
642
643 Err(Error::TemplateNotFound {
644 path: path.to_path_buf(),
645 })
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use crate::types::{Binding, Element, TemplateSource};
653
654 #[test]
655 fn test_compose_plain_sql() {
656 let composer = Composer::new(Dialect::Postgres);
657 let template = Template {
658 elements: vec![Element::Sql("SELECT 1".into())],
659 source: TemplateSource::Literal("test".into()),
660 };
661 let result = composer.compose(&template).unwrap();
662 assert_eq!(result.sql, "SELECT 1");
663 assert!(result.bind_params.is_empty());
664 }
665
666 #[test]
667 fn test_compose_with_bindings_postgres() {
668 let composer = Composer::new(Dialect::Postgres);
669 let template = Template {
670 elements: vec![
671 Element::Sql("SELECT * FROM users WHERE id = ".into()),
672 Element::Bind(Binding {
673 name: "user_id".into(),
674 min_values: None,
675 max_values: None,
676 nullable: false,
677 }),
678 Element::Sql(" AND active = ".into()),
679 Element::Bind(Binding {
680 name: "active".into(),
681 min_values: None,
682 max_values: None,
683 nullable: false,
684 }),
685 ],
686 source: TemplateSource::Literal("test".into()),
687 };
688 let result = composer.compose(&template).unwrap();
689 assert_eq!(
691 result.sql,
692 "SELECT * FROM users WHERE id = $2 AND active = $1"
693 );
694 assert_eq!(result.bind_params, vec!["active", "user_id"]);
695 }
696
697 #[test]
698 fn test_compose_with_bindings_mysql() {
699 let composer = Composer::new(Dialect::Mysql);
700 let template = Template {
701 elements: vec![
702 Element::Sql("SELECT * FROM users WHERE id = ".into()),
703 Element::Bind(Binding {
704 name: "user_id".into(),
705 min_values: None,
706 max_values: None,
707 nullable: false,
708 }),
709 Element::Sql(" AND active = ".into()),
710 Element::Bind(Binding {
711 name: "active".into(),
712 min_values: None,
713 max_values: None,
714 nullable: false,
715 }),
716 ],
717 source: TemplateSource::Literal("test".into()),
718 };
719 let result = composer.compose(&template).unwrap();
720 assert_eq!(
722 result.sql,
723 "SELECT * FROM users WHERE id = ? AND active = ?"
724 );
725 assert_eq!(result.bind_params, vec!["user_id", "active"]);
726 }
727
728 #[test]
729 fn test_compose_with_bindings_sqlite() {
730 let composer = Composer::new(Dialect::Sqlite);
731 let template = Template {
732 elements: vec![
733 Element::Sql("SELECT * FROM users WHERE id = ".into()),
734 Element::Bind(Binding {
735 name: "user_id".into(),
736 min_values: None,
737 max_values: None,
738 nullable: false,
739 }),
740 Element::Sql(" AND active = ".into()),
741 Element::Bind(Binding {
742 name: "active".into(),
743 min_values: None,
744 max_values: None,
745 nullable: false,
746 }),
747 ],
748 source: TemplateSource::Literal("test".into()),
749 };
750 let result = composer.compose(&template).unwrap();
751 assert_eq!(
753 result.sql,
754 "SELECT * FROM users WHERE id = ?2 AND active = ?1"
755 );
756 assert_eq!(result.bind_params, vec!["active", "user_id"]);
757 }
758
759 #[test]
760 fn test_dialect_placeholder() {
761 assert_eq!(Dialect::Postgres.placeholder(1), "$1");
762 assert_eq!(Dialect::Postgres.placeholder(10), "$10");
763 assert_eq!(Dialect::Mysql.placeholder(1), "?");
764 assert_eq!(Dialect::Mysql.placeholder(10), "?");
765 assert_eq!(Dialect::Sqlite.placeholder(1), "?1");
766 assert_eq!(Dialect::Sqlite.placeholder(10), "?10");
767 }
768
769 #[test]
770 fn test_compose_with_values_single() {
771 let composer = Composer::new(Dialect::Postgres);
772 let template = Template {
773 elements: vec![
774 Element::Sql("SELECT * FROM users WHERE id = ".into()),
775 Element::Bind(Binding {
776 name: "user_id".into(),
777 min_values: None,
778 max_values: None,
779 nullable: false,
780 }),
781 ],
782 source: TemplateSource::Literal("test".into()),
783 };
784 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("user_id".into(), vec![42])]);
785 let result = composer.compose_with_values(&template, &values).unwrap();
786 assert_eq!(result.sql, "SELECT * FROM users WHERE id = $1");
787 assert_eq!(result.bind_params, vec!["user_id"]);
788 }
789
790 #[test]
791 fn test_compose_with_values_multi_postgres() {
792 let composer = Composer::new(Dialect::Postgres);
793 let template = Template {
794 elements: vec![
795 Element::Sql("SELECT * FROM users WHERE id IN (".into()),
796 Element::Bind(Binding {
797 name: "ids".into(),
798 min_values: Some(1),
799 max_values: None,
800 nullable: false,
801 }),
802 Element::Sql(")".into()),
803 ],
804 source: TemplateSource::Literal("test".into()),
805 };
806 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("ids".into(), vec![10, 20, 30])]);
807 let result = composer.compose_with_values(&template, &values).unwrap();
808 assert_eq!(result.sql, "SELECT * FROM users WHERE id IN ($1, $2, $3)");
809 assert_eq!(result.bind_params, vec!["ids", "ids", "ids"]);
810 }
811
812 #[test]
813 fn test_compose_with_values_multi_mysql() {
814 let composer = Composer::new(Dialect::Mysql);
815 let template = Template {
816 elements: vec![
817 Element::Sql("SELECT * FROM users WHERE id IN (".into()),
818 Element::Bind(Binding {
819 name: "ids".into(),
820 min_values: Some(1),
821 max_values: None,
822 nullable: false,
823 }),
824 Element::Sql(")".into()),
825 ],
826 source: TemplateSource::Literal("test".into()),
827 };
828 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("ids".into(), vec![10, 20, 30])]);
829 let result = composer.compose_with_values(&template, &values).unwrap();
830 assert_eq!(result.sql, "SELECT * FROM users WHERE id IN (?, ?, ?)");
831 assert_eq!(result.bind_params, vec!["ids", "ids", "ids"]);
832 }
833
834 #[test]
835 fn test_compose_with_values_multi_sqlite() {
836 let composer = Composer::new(Dialect::Sqlite);
837 let template = Template {
838 elements: vec![
839 Element::Sql("SELECT * FROM users WHERE id IN (".into()),
840 Element::Bind(Binding {
841 name: "ids".into(),
842 min_values: Some(1),
843 max_values: None,
844 nullable: false,
845 }),
846 Element::Sql(") AND status = ".into()),
847 Element::Bind(Binding {
848 name: "status".into(),
849 min_values: None,
850 max_values: None,
851 nullable: false,
852 }),
853 ],
854 source: TemplateSource::Literal("test".into()),
855 };
856 let values: BTreeMap<String, Vec<i32>> =
857 BTreeMap::from([("ids".into(), vec![10, 20]), ("status".into(), vec![1])]);
858 let result = composer.compose_with_values(&template, &values).unwrap();
859 assert_eq!(
861 result.sql,
862 "SELECT * FROM users WHERE id IN (?1, ?2) AND status = ?3"
863 );
864 assert_eq!(result.bind_params, vec!["ids", "ids", "status"]);
865 }
866
867 #[test]
870 fn test_alphabetical_ordering_postgres() {
871 let composer = Composer::new(Dialect::Postgres);
872 let template = Template {
873 elements: vec![
874 Element::Sql("SELECT ".into()),
875 Element::Bind(Binding {
876 name: "z_param".into(),
877 min_values: None,
878 max_values: None,
879 nullable: false,
880 }),
881 Element::Sql(", ".into()),
882 Element::Bind(Binding {
883 name: "a_param".into(),
884 min_values: None,
885 max_values: None,
886 nullable: false,
887 }),
888 ],
889 source: TemplateSource::Literal("test".into()),
890 };
891 let result = composer.compose(&template).unwrap();
892 assert_eq!(result.sql, "SELECT $2, $1");
894 assert_eq!(result.bind_params, vec!["a_param", "z_param"]);
895 }
896
897 #[test]
898 fn test_alphabetical_ordering_sqlite() {
899 let composer = Composer::new(Dialect::Sqlite);
900 let template = Template {
901 elements: vec![
902 Element::Sql("SELECT ".into()),
903 Element::Bind(Binding {
904 name: "z_param".into(),
905 min_values: None,
906 max_values: None,
907 nullable: false,
908 }),
909 Element::Sql(", ".into()),
910 Element::Bind(Binding {
911 name: "a_param".into(),
912 min_values: None,
913 max_values: None,
914 nullable: false,
915 }),
916 ],
917 source: TemplateSource::Literal("test".into()),
918 };
919 let result = composer.compose(&template).unwrap();
920 assert_eq!(result.sql, "SELECT ?2, ?1");
921 assert_eq!(result.bind_params, vec!["a_param", "z_param"]);
922 }
923
924 #[test]
927 fn test_dedup_single_value_postgres() {
928 let composer = Composer::new(Dialect::Postgres);
929 let template = Template {
930 elements: vec![
931 Element::Sql("WHERE a = ".into()),
932 Element::Bind(Binding {
933 name: "x".into(),
934 min_values: None,
935 max_values: None,
936 nullable: false,
937 }),
938 Element::Sql(" AND b = ".into()),
939 Element::Bind(Binding {
940 name: "x".into(),
941 min_values: None,
942 max_values: None,
943 nullable: false,
944 }),
945 ],
946 source: TemplateSource::Literal("test".into()),
947 };
948 let result = composer.compose(&template).unwrap();
949 assert_eq!(result.sql, "WHERE a = $1 AND b = $1");
951 assert_eq!(result.bind_params, vec!["x"]);
952 }
953
954 #[test]
955 fn test_dedup_multi_value_postgres() {
956 let composer = Composer::new(Dialect::Postgres);
957 let template = Template {
958 elements: vec![
959 Element::Sql("WHERE a IN (".into()),
960 Element::Bind(Binding {
961 name: "ids".into(),
962 min_values: Some(1),
963 max_values: None,
964 nullable: false,
965 }),
966 Element::Sql(") AND b IN (".into()),
967 Element::Bind(Binding {
968 name: "ids".into(),
969 min_values: Some(1),
970 max_values: None,
971 nullable: false,
972 }),
973 Element::Sql(")".into()),
974 ],
975 source: TemplateSource::Literal("test".into()),
976 };
977 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("ids".into(), vec![10, 20, 30])]);
978 let result = composer.compose_with_values(&template, &values).unwrap();
979 assert_eq!(result.sql, "WHERE a IN ($1, $2, $3) AND b IN ($1, $2, $3)");
981 assert_eq!(result.bind_params, vec!["ids", "ids", "ids"]);
982 }
983
984 #[test]
985 fn test_mixed_multi_and_single_values() {
986 let composer = Composer::new(Dialect::Postgres);
987 let template = Template {
988 elements: vec![
989 Element::Sql("WHERE active = ".into()),
990 Element::Bind(Binding {
991 name: "active".into(),
992 min_values: None,
993 max_values: None,
994 nullable: false,
995 }),
996 Element::Sql(" AND id IN (".into()),
997 Element::Bind(Binding {
998 name: "ids".into(),
999 min_values: Some(1),
1000 max_values: None,
1001 nullable: false,
1002 }),
1003 Element::Sql(") AND user_id = ".into()),
1004 Element::Bind(Binding {
1005 name: "user_id".into(),
1006 min_values: None,
1007 max_values: None,
1008 nullable: false,
1009 }),
1010 ],
1011 source: TemplateSource::Literal("test".into()),
1012 };
1013 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([
1014 ("active".into(), vec![1]),
1015 ("ids".into(), vec![10, 20, 30]),
1016 ("user_id".into(), vec![42]),
1017 ]);
1018 let result = composer.compose_with_values(&template, &values).unwrap();
1019 assert_eq!(
1021 result.sql,
1022 "WHERE active = $1 AND id IN ($2, $3, $4) AND user_id = $5"
1023 );
1024 assert_eq!(
1025 result.bind_params,
1026 vec!["active", "ids", "ids", "ids", "user_id"]
1027 );
1028 }
1029
1030 #[test]
1031 fn test_mysql_no_dedup() {
1032 let composer = Composer::new(Dialect::Mysql);
1033 let template = Template {
1034 elements: vec![
1035 Element::Sql("WHERE a = ".into()),
1036 Element::Bind(Binding {
1037 name: "x".into(),
1038 min_values: None,
1039 max_values: None,
1040 nullable: false,
1041 }),
1042 Element::Sql(" AND b = ".into()),
1043 Element::Bind(Binding {
1044 name: "x".into(),
1045 min_values: None,
1046 max_values: None,
1047 nullable: false,
1048 }),
1049 ],
1050 source: TemplateSource::Literal("test".into()),
1051 };
1052 let result = composer.compose(&template).unwrap();
1053 assert_eq!(result.sql, "WHERE a = ? AND b = ?");
1055 assert_eq!(result.bind_params, vec!["x", "x"]);
1056 }
1057
1058 #[test]
1059 fn test_supports_numbered_placeholders() {
1060 assert!(Dialect::Postgres.supports_numbered_placeholders());
1061 assert!(Dialect::Sqlite.supports_numbered_placeholders());
1062 assert!(!Dialect::Mysql.supports_numbered_placeholders());
1063 }
1064}