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::{
11 Command, CommandKind, ComposeRef, ComposeTarget, Dialect, Element, Template, TemplateSource,
12};
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct ComposedSql {
17 pub sql: String,
19 pub bind_params: Vec<String>,
25}
26
27pub struct Composer {
32 pub dialect: Dialect,
34 pub search_paths: Vec<PathBuf>,
36 pub mock_tables: HashMap<String, MockTable>,
38}
39
40impl Composer {
41 pub fn new(dialect: Dialect) -> Self {
43 Self {
44 dialect,
45 search_paths: vec![],
46 mock_tables: HashMap::new(),
47 }
48 }
49
50 pub fn add_search_path(&mut self, path: PathBuf) {
52 self.search_paths.push(path);
53 }
54
55 pub fn add_mock_table(&mut self, mock: MockTable) {
57 self.mock_tables.insert(mock.name.clone(), mock);
58 }
59
60 pub fn compose(&self, template: &Template) -> Result<ComposedSql> {
62 let mut visited = HashSet::new();
63 if let TemplateSource::File(ref path) = template.source {
64 visited.insert(path.clone());
65 }
66 let slots = HashMap::new();
67 self.compose_inner(template, &slots, &mut visited)
68 }
69
70 pub fn compose_with_values<V>(
86 &self,
87 template: &Template,
88 values: &BTreeMap<String, Vec<V>>,
89 ) -> Result<ComposedSql> {
90 let mut visited = HashSet::new();
91 if let TemplateSource::File(ref path) = template.source {
92 visited.insert(path.clone());
93 }
94 let slots = HashMap::new();
95 self.compose_with_values_inner(template, values, &slots, &mut visited)
96 }
97
98 fn resolve_compose_target(
105 compose_ref: &ComposeRef,
106 slots: &HashMap<String, PathBuf>,
107 ) -> Result<PathBuf> {
108 match &compose_ref.target {
109 ComposeTarget::Path(p) => Ok(p.clone()),
110 ComposeTarget::Slot(name) => slots
111 .get(name)
112 .cloned()
113 .ok_or_else(|| Error::MissingSlot { name: name.clone() }),
114 }
115 }
116
117 fn build_child_slots(compose_ref: &ComposeRef) -> HashMap<String, PathBuf> {
122 compose_ref
123 .slots
124 .iter()
125 .map(|s| (s.name.clone(), s.path.clone()))
126 .collect()
127 }
128
129 fn compose_inner(
132 &self,
133 template: &Template,
134 slots: &HashMap<String, PathBuf>,
135 visited: &mut HashSet<PathBuf>,
136 ) -> Result<ComposedSql> {
137 if self.dialect.supports_numbered_placeholders() {
138 self.compose_inner_numbered(template, slots, visited)
139 } else {
140 self.compose_inner_positional(template, slots, visited)
141 }
142 }
143
144 fn compose_with_values_inner<V>(
145 &self,
146 template: &Template,
147 values: &BTreeMap<String, Vec<V>>,
148 slots: &HashMap<String, PathBuf>,
149 visited: &mut HashSet<PathBuf>,
150 ) -> Result<ComposedSql> {
151 if self.dialect.supports_numbered_placeholders() {
152 self.compose_with_values_numbered(template, values, slots, visited)
153 } else {
154 self.compose_with_values_positional(template, values, slots, visited)
155 }
156 }
157
158 fn collect_bind_names(
167 &self,
168 template: &Template,
169 slots: &HashMap<String, PathBuf>,
170 visited: &mut HashSet<PathBuf>,
171 ) -> Result<BTreeSet<String>> {
172 let mut names = BTreeSet::new();
173
174 for element in &template.elements {
175 match element {
176 Element::Sql(_) => {}
177 Element::Bind(binding) => {
178 names.insert(binding.name.clone());
179 }
180 Element::Compose(compose_ref) => {
181 let path = Self::resolve_compose_target(compose_ref, slots)?;
182 let child_slots = Self::build_child_slots(compose_ref);
183 let sub =
184 self.collect_compose_bind_names(&path, &child_slots, visited)?;
185 names.extend(sub);
186 }
187 Element::Command(command) => {
188 let sub = self.collect_command_bind_names(command, visited)?;
189 names.extend(sub);
190 }
191 }
192 }
193
194 Ok(names)
195 }
196
197 fn collect_compose_bind_names(
199 &self,
200 path: &Path,
201 child_slots: &HashMap<String, PathBuf>,
202 visited: &mut HashSet<PathBuf>,
203 ) -> Result<BTreeSet<String>> {
204 let resolved = self.find_template(path)?;
205
206 if !visited.insert(resolved.clone()) {
207 return Err(Error::CircularReference {
208 path: path.to_path_buf(),
209 });
210 }
211
212 let template = parser::parse_template_file(&resolved)?;
213 let names = self.collect_bind_names(&template, child_slots, visited)?;
214
215 visited.remove(&resolved);
216 Ok(names)
217 }
218
219 fn collect_command_bind_names(
223 &self,
224 command: &Command,
225 visited: &mut HashSet<PathBuf>,
226 ) -> Result<BTreeSet<String>> {
227 let mut names = BTreeSet::new();
228 let empty_slots = HashMap::new();
229 for source in &command.sources {
230 let resolved = self.find_template(source)?;
231 let template = parser::parse_template_file(&resolved)?;
232 let sub = self.collect_bind_names(&template, &empty_slots, visited)?;
233 names.extend(sub);
234 }
235 Ok(names)
236 }
237
238 fn build_index_map(names: &BTreeSet<String>) -> BTreeMap<String, (usize, usize)> {
241 names
242 .iter()
243 .enumerate()
244 .map(|(i, name)| (name.clone(), (i + 1, 1)))
245 .collect()
246 }
247
248 fn build_index_map_with_values<V>(
252 names: &BTreeSet<String>,
253 values: &BTreeMap<String, Vec<V>>,
254 ) -> BTreeMap<String, (usize, usize)> {
255 let mut map = BTreeMap::new();
256 let mut index = 1;
257 for name in names {
258 let count = values.get(name).map(|vs| vs.len()).unwrap_or(1).max(1);
259 map.insert(name.clone(), (index, count));
260 index += count;
261 }
262 map
263 }
264
265 fn compose_inner_numbered(
267 &self,
268 template: &Template,
269 slots: &HashMap<String, PathBuf>,
270 visited: &mut HashSet<PathBuf>,
271 ) -> Result<ComposedSql> {
272 let mut collect_visited = visited.clone();
274 let names = self.collect_bind_names(template, slots, &mut collect_visited)?;
275
276 let index_map = Self::build_index_map(&names);
278 let bind_params: Vec<String> = names.into_iter().collect();
279
280 let mut sql = String::new();
282 self.emit_sql_numbered(template, &index_map, &mut sql, slots, visited)?;
283
284 Ok(ComposedSql { sql, bind_params })
285 }
286
287 fn compose_with_values_numbered<V>(
289 &self,
290 template: &Template,
291 values: &BTreeMap<String, Vec<V>>,
292 slots: &HashMap<String, PathBuf>,
293 visited: &mut HashSet<PathBuf>,
294 ) -> Result<ComposedSql> {
295 let mut collect_visited = visited.clone();
297 let names = self.collect_bind_names(template, slots, &mut collect_visited)?;
298
299 let index_map = Self::build_index_map_with_values(&names, values);
301
302 let mut bind_params = Vec::new();
304 for name in &names {
305 let count = values
306 .get(name.as_str())
307 .map(|vs| vs.len())
308 .unwrap_or(1)
309 .max(1);
310 for _ in 0..count {
311 bind_params.push(name.clone());
312 }
313 }
314
315 let mut sql = String::new();
317 self.emit_sql_numbered(template, &index_map, &mut sql, slots, visited)?;
318
319 Ok(ComposedSql { sql, bind_params })
320 }
321
322 fn emit_sql_numbered(
324 &self,
325 template: &Template,
326 index_map: &BTreeMap<String, (usize, usize)>,
327 sql: &mut String,
328 slots: &HashMap<String, PathBuf>,
329 visited: &mut HashSet<PathBuf>,
330 ) -> Result<()> {
331 for element in &template.elements {
332 match element {
333 Element::Sql(text) => sql.push_str(text),
334 Element::Bind(binding) => {
335 let &(start, count) = &index_map[&binding.name];
336 for i in 0..count {
337 if i > 0 {
338 sql.push_str(", ");
339 }
340 sql.push_str(&self.dialect.placeholder(start + i));
341 }
342 }
343 Element::Compose(compose_ref) => {
344 let path = Self::resolve_compose_target(compose_ref, slots)?;
345 let child_slots = Self::build_child_slots(compose_ref);
346 self.emit_compose_numbered(
347 &path,
348 &child_slots,
349 index_map,
350 sql,
351 visited,
352 )?;
353 }
354 Element::Command(command) => {
355 self.emit_command_numbered(command, index_map, sql, visited)?;
356 }
357 }
358 }
359 Ok(())
360 }
361
362 fn emit_compose_numbered(
364 &self,
365 path: &Path,
366 child_slots: &HashMap<String, PathBuf>,
367 index_map: &BTreeMap<String, (usize, usize)>,
368 sql: &mut String,
369 visited: &mut HashSet<PathBuf>,
370 ) -> Result<()> {
371 let resolved = self.find_template(path)?;
372
373 if !visited.insert(resolved.clone()) {
374 return Err(Error::CircularReference {
375 path: path.to_path_buf(),
376 });
377 }
378
379 let template = parser::parse_template_file(&resolved)?;
380 self.emit_sql_numbered(&template, index_map, sql, child_slots, visited)?;
381
382 visited.remove(&resolved);
383 Ok(())
384 }
385
386 fn emit_command_numbered(
390 &self,
391 command: &Command,
392 index_map: &BTreeMap<String, (usize, usize)>,
393 sql: &mut String,
394 visited: &mut HashSet<PathBuf>,
395 ) -> Result<()> {
396 match command.kind {
397 CommandKind::Union => self.emit_union_numbered(command, index_map, sql, visited),
398 CommandKind::Count => self.emit_count_numbered(command, index_map, sql, visited),
399 }
400 }
401
402 fn emit_union_numbered(
404 &self,
405 command: &Command,
406 index_map: &BTreeMap<String, (usize, usize)>,
407 sql: &mut String,
408 visited: &mut HashSet<PathBuf>,
409 ) -> Result<()> {
410 let union_kw = if command.all {
411 "UNION ALL"
412 } else if command.distinct {
413 "UNION DISTINCT"
414 } else {
415 "UNION"
416 };
417
418 let empty_slots = HashMap::new();
419 for (i, source) in command.sources.iter().enumerate() {
420 if i > 0 {
421 let trimmed = sql.trim_end().len();
422 sql.truncate(trimmed);
423 sql.push_str(&format!("\n{union_kw}\n"));
424 }
425 let resolved = self.find_template(source)?;
426 let template = parser::parse_template_file(&resolved)?;
427 self.emit_sql_numbered(&template, index_map, sql, &empty_slots, visited)?;
428 }
429
430 Ok(())
431 }
432
433 fn emit_count_numbered(
435 &self,
436 command: &Command,
437 index_map: &BTreeMap<String, (usize, usize)>,
438 sql: &mut String,
439 visited: &mut HashSet<PathBuf>,
440 ) -> Result<()> {
441 let columns = match &command.columns {
442 Some(cols) => cols.join(", "),
443 None => "*".to_string(),
444 };
445
446 let count_expr = if command.distinct {
447 format!("COUNT(DISTINCT {columns})")
448 } else {
449 format!("COUNT({columns})")
450 };
451
452 sql.push_str(&format!("SELECT {count_expr} FROM (\n"));
453
454 let empty_slots = HashMap::new();
455 if command.sources.len() > 1 {
456 let union_cmd = Command {
457 kind: CommandKind::Union,
458 distinct: command.distinct,
459 all: command.all,
460 columns: None,
461 sources: command.sources.clone(),
462 };
463 self.emit_union_numbered(&union_cmd, index_map, sql, visited)?;
464 } else {
465 let source = &command.sources[0];
466 let resolved = self.find_template(source)?;
467 let template = parser::parse_template_file(&resolved)?;
468 self.emit_sql_numbered(&template, index_map, sql, &empty_slots, visited)?;
469 }
470
471 sql.push_str("\n) AS _count_sub");
472 Ok(())
473 }
474
475 fn compose_inner_positional(
481 &self,
482 template: &Template,
483 slots: &HashMap<String, PathBuf>,
484 visited: &mut HashSet<PathBuf>,
485 ) -> Result<ComposedSql> {
486 let mut sql = String::new();
487 let mut bind_params = Vec::new();
488
489 for element in &template.elements {
490 match element {
491 Element::Sql(text) => {
492 sql.push_str(text);
493 }
494 Element::Bind(binding) => {
495 let index = bind_params.len() + 1;
496 sql.push_str(&self.dialect.placeholder(index));
497 bind_params.push(binding.name.clone());
498 }
499 Element::Compose(compose_ref) => {
500 let path = Self::resolve_compose_target(compose_ref, slots)?;
501 let child_slots = Self::build_child_slots(compose_ref);
502 let composed =
503 self.resolve_compose_positional(&path, &child_slots, visited)?;
504 sql.push_str(&composed.sql);
505 bind_params.extend(composed.bind_params);
506 }
507 Element::Command(command) => {
508 let composed = self.compose_command(command, visited)?;
509 sql.push_str(&composed.sql);
510 bind_params.extend(composed.bind_params);
511 }
512 }
513 }
514
515 Ok(ComposedSql { sql, bind_params })
516 }
517
518 fn compose_with_values_positional<V>(
519 &self,
520 template: &Template,
521 values: &BTreeMap<String, Vec<V>>,
522 slots: &HashMap<String, PathBuf>,
523 visited: &mut HashSet<PathBuf>,
524 ) -> Result<ComposedSql> {
525 let mut sql = String::new();
526 let mut bind_params = Vec::new();
527
528 for element in &template.elements {
529 match element {
530 Element::Sql(text) => {
531 sql.push_str(text);
532 }
533 Element::Bind(binding) => {
534 let count = values
535 .get(&binding.name)
536 .map(|vs| vs.len())
537 .unwrap_or(1)
538 .max(1);
539
540 for i in 0..count {
541 if i > 0 {
542 sql.push_str(", ");
543 }
544 let index = bind_params.len() + 1;
545 sql.push_str(&self.dialect.placeholder(index));
546 bind_params.push(binding.name.clone());
547 }
548 }
549 Element::Compose(compose_ref) => {
550 let path = Self::resolve_compose_target(compose_ref, slots)?;
551 let child_slots = Self::build_child_slots(compose_ref);
552 let composed = self.resolve_compose_with_values_positional(
553 &path,
554 &child_slots,
555 values,
556 visited,
557 )?;
558 sql.push_str(&composed.sql);
559 bind_params.extend(composed.bind_params);
560 }
561 Element::Command(command) => {
562 let composed = self.compose_command(command, visited)?;
563 sql.push_str(&composed.sql);
564 bind_params.extend(composed.bind_params);
565 }
566 }
567 }
568
569 Ok(ComposedSql { sql, bind_params })
570 }
571
572 fn resolve_compose_positional(
574 &self,
575 path: &Path,
576 child_slots: &HashMap<String, PathBuf>,
577 visited: &mut HashSet<PathBuf>,
578 ) -> Result<ComposedSql> {
579 let resolved = self.find_template(path)?;
580
581 if !visited.insert(resolved.clone()) {
582 return Err(Error::CircularReference {
583 path: path.to_path_buf(),
584 });
585 }
586
587 let template = parser::parse_template_file(&resolved)?;
588 let result = self.compose_inner_positional(&template, child_slots, visited)?;
589
590 visited.remove(&resolved);
591
592 Ok(result)
593 }
594
595 fn resolve_compose_with_values_positional<V>(
597 &self,
598 path: &Path,
599 child_slots: &HashMap<String, PathBuf>,
600 values: &BTreeMap<String, Vec<V>>,
601 visited: &mut HashSet<PathBuf>,
602 ) -> Result<ComposedSql> {
603 let resolved = self.find_template(path)?;
604
605 if !visited.insert(resolved.clone()) {
606 return Err(Error::CircularReference {
607 path: path.to_path_buf(),
608 });
609 }
610
611 let template = parser::parse_template_file(&resolved)?;
612 let result =
613 self.compose_with_values_positional(&template, values, child_slots, visited)?;
614
615 visited.remove(&resolved);
616 Ok(result)
617 }
618
619 fn compose_command(
623 &self,
624 command: &Command,
625 visited: &mut HashSet<PathBuf>,
626 ) -> Result<ComposedSql> {
627 match command.kind {
628 CommandKind::Union => self.compose_union(command, visited),
629 CommandKind::Count => self.compose_count(command, visited),
630 }
631 }
632
633 fn compose_union(
635 &self,
636 command: &Command,
637 visited: &mut HashSet<PathBuf>,
638 ) -> Result<ComposedSql> {
639 let mut parts = Vec::new();
640 let mut all_params = Vec::new();
641 let empty_slots = HashMap::new();
642
643 for source in &command.sources {
644 let resolved = self.find_template(source)?;
645 let template = parser::parse_template_file(&resolved)?;
646 let composed = self.compose_inner(&template, &empty_slots, visited)?;
647
648 parts.push(composed.sql.trim_end().to_string());
649 all_params.extend(composed.bind_params);
650 }
651
652 let union_kw = if command.all {
653 "UNION ALL"
654 } else if command.distinct {
655 "UNION DISTINCT"
656 } else {
657 "UNION"
658 };
659
660 let sql = parts.join(&format!("\n{union_kw}\n"));
661
662 Ok(ComposedSql {
663 sql,
664 bind_params: all_params,
665 })
666 }
667
668 fn compose_count(
670 &self,
671 command: &Command,
672 visited: &mut HashSet<PathBuf>,
673 ) -> Result<ComposedSql> {
674 let columns = match &command.columns {
675 Some(cols) => cols.join(", "),
676 None => "*".to_string(),
677 };
678
679 let empty_slots = HashMap::new();
680
681 let inner = if command.sources.len() > 1 {
683 let union_cmd = Command {
684 kind: CommandKind::Union,
685 distinct: command.distinct,
686 all: command.all,
687 columns: None,
688 sources: command.sources.clone(),
689 };
690 self.compose_union(&union_cmd, visited)?
691 } else {
692 let source = &command.sources[0];
693 let resolved = self.find_template(source)?;
694 let template = parser::parse_template_file(&resolved)?;
695 self.compose_inner(&template, &empty_slots, visited)?
696 };
697
698 let count_expr = if command.distinct {
699 format!("COUNT(DISTINCT {columns})")
700 } else {
701 format!("COUNT({columns})")
702 };
703
704 let sql = format!("SELECT {count_expr} FROM (\n{}\n) AS _count_sub", inner.sql);
705
706 Ok(ComposedSql {
707 sql,
708 bind_params: inner.bind_params,
709 })
710 }
711
712 fn find_template(&self, path: &Path) -> Result<PathBuf> {
716 if path.exists() {
718 return Ok(path.to_path_buf());
719 }
720
721 for search_path in &self.search_paths {
723 let candidate = search_path.join(path);
724 if candidate.exists() {
725 return Ok(candidate);
726 }
727 }
728
729 Err(Error::TemplateNotFound {
730 path: path.to_path_buf(),
731 })
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use crate::types::{Binding, ComposeTarget, Element, SlotAssignment, TemplateSource};
739 use std::io::Write;
740 use tempfile::TempDir;
741
742 #[test]
743 fn test_compose_plain_sql() {
744 let composer = Composer::new(Dialect::Postgres);
745 let template = Template {
746 elements: vec![Element::Sql("SELECT 1".into())],
747 source: TemplateSource::Literal("test".into()),
748 };
749 let result = composer.compose(&template).unwrap();
750 assert_eq!(result.sql, "SELECT 1");
751 assert!(result.bind_params.is_empty());
752 }
753
754 #[test]
755 fn test_compose_with_bindings_postgres() {
756 let composer = Composer::new(Dialect::Postgres);
757 let template = Template {
758 elements: vec![
759 Element::Sql("SELECT * FROM users WHERE id = ".into()),
760 Element::Bind(Binding {
761 name: "user_id".into(),
762 min_values: None,
763 max_values: None,
764 nullable: false,
765 }),
766 Element::Sql(" AND active = ".into()),
767 Element::Bind(Binding {
768 name: "active".into(),
769 min_values: None,
770 max_values: None,
771 nullable: false,
772 }),
773 ],
774 source: TemplateSource::Literal("test".into()),
775 };
776 let result = composer.compose(&template).unwrap();
777 assert_eq!(
779 result.sql,
780 "SELECT * FROM users WHERE id = $2 AND active = $1"
781 );
782 assert_eq!(result.bind_params, vec!["active", "user_id"]);
783 }
784
785 #[test]
786 fn test_compose_with_bindings_mysql() {
787 let composer = Composer::new(Dialect::Mysql);
788 let template = Template {
789 elements: vec![
790 Element::Sql("SELECT * FROM users WHERE id = ".into()),
791 Element::Bind(Binding {
792 name: "user_id".into(),
793 min_values: None,
794 max_values: None,
795 nullable: false,
796 }),
797 Element::Sql(" AND active = ".into()),
798 Element::Bind(Binding {
799 name: "active".into(),
800 min_values: None,
801 max_values: None,
802 nullable: false,
803 }),
804 ],
805 source: TemplateSource::Literal("test".into()),
806 };
807 let result = composer.compose(&template).unwrap();
808 assert_eq!(
810 result.sql,
811 "SELECT * FROM users WHERE id = ? AND active = ?"
812 );
813 assert_eq!(result.bind_params, vec!["user_id", "active"]);
814 }
815
816 #[test]
817 fn test_compose_with_bindings_sqlite() {
818 let composer = Composer::new(Dialect::Sqlite);
819 let template = Template {
820 elements: vec![
821 Element::Sql("SELECT * FROM users WHERE id = ".into()),
822 Element::Bind(Binding {
823 name: "user_id".into(),
824 min_values: None,
825 max_values: None,
826 nullable: false,
827 }),
828 Element::Sql(" AND active = ".into()),
829 Element::Bind(Binding {
830 name: "active".into(),
831 min_values: None,
832 max_values: None,
833 nullable: false,
834 }),
835 ],
836 source: TemplateSource::Literal("test".into()),
837 };
838 let result = composer.compose(&template).unwrap();
839 assert_eq!(
841 result.sql,
842 "SELECT * FROM users WHERE id = ?2 AND active = ?1"
843 );
844 assert_eq!(result.bind_params, vec!["active", "user_id"]);
845 }
846
847 #[test]
848 fn test_dialect_placeholder() {
849 assert_eq!(Dialect::Postgres.placeholder(1), "$1");
850 assert_eq!(Dialect::Postgres.placeholder(10), "$10");
851 assert_eq!(Dialect::Mysql.placeholder(1), "?");
852 assert_eq!(Dialect::Mysql.placeholder(10), "?");
853 assert_eq!(Dialect::Sqlite.placeholder(1), "?1");
854 assert_eq!(Dialect::Sqlite.placeholder(10), "?10");
855 }
856
857 #[test]
858 fn test_compose_with_values_single() {
859 let composer = Composer::new(Dialect::Postgres);
860 let template = Template {
861 elements: vec![
862 Element::Sql("SELECT * FROM users WHERE id = ".into()),
863 Element::Bind(Binding {
864 name: "user_id".into(),
865 min_values: None,
866 max_values: None,
867 nullable: false,
868 }),
869 ],
870 source: TemplateSource::Literal("test".into()),
871 };
872 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("user_id".into(), vec![42])]);
873 let result = composer.compose_with_values(&template, &values).unwrap();
874 assert_eq!(result.sql, "SELECT * FROM users WHERE id = $1");
875 assert_eq!(result.bind_params, vec!["user_id"]);
876 }
877
878 #[test]
879 fn test_compose_with_values_multi_postgres() {
880 let composer = Composer::new(Dialect::Postgres);
881 let template = Template {
882 elements: vec![
883 Element::Sql("SELECT * FROM users WHERE id IN (".into()),
884 Element::Bind(Binding {
885 name: "ids".into(),
886 min_values: Some(1),
887 max_values: None,
888 nullable: false,
889 }),
890 Element::Sql(")".into()),
891 ],
892 source: TemplateSource::Literal("test".into()),
893 };
894 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("ids".into(), vec![10, 20, 30])]);
895 let result = composer.compose_with_values(&template, &values).unwrap();
896 assert_eq!(result.sql, "SELECT * FROM users WHERE id IN ($1, $2, $3)");
897 assert_eq!(result.bind_params, vec!["ids", "ids", "ids"]);
898 }
899
900 #[test]
901 fn test_compose_with_values_multi_mysql() {
902 let composer = Composer::new(Dialect::Mysql);
903 let template = Template {
904 elements: vec![
905 Element::Sql("SELECT * FROM users WHERE id IN (".into()),
906 Element::Bind(Binding {
907 name: "ids".into(),
908 min_values: Some(1),
909 max_values: None,
910 nullable: false,
911 }),
912 Element::Sql(")".into()),
913 ],
914 source: TemplateSource::Literal("test".into()),
915 };
916 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("ids".into(), vec![10, 20, 30])]);
917 let result = composer.compose_with_values(&template, &values).unwrap();
918 assert_eq!(result.sql, "SELECT * FROM users WHERE id IN (?, ?, ?)");
919 assert_eq!(result.bind_params, vec!["ids", "ids", "ids"]);
920 }
921
922 #[test]
923 fn test_compose_with_values_multi_sqlite() {
924 let composer = Composer::new(Dialect::Sqlite);
925 let template = Template {
926 elements: vec![
927 Element::Sql("SELECT * FROM users WHERE id IN (".into()),
928 Element::Bind(Binding {
929 name: "ids".into(),
930 min_values: Some(1),
931 max_values: None,
932 nullable: false,
933 }),
934 Element::Sql(") AND status = ".into()),
935 Element::Bind(Binding {
936 name: "status".into(),
937 min_values: None,
938 max_values: None,
939 nullable: false,
940 }),
941 ],
942 source: TemplateSource::Literal("test".into()),
943 };
944 let values: BTreeMap<String, Vec<i32>> =
945 BTreeMap::from([("ids".into(), vec![10, 20]), ("status".into(), vec![1])]);
946 let result = composer.compose_with_values(&template, &values).unwrap();
947 assert_eq!(
949 result.sql,
950 "SELECT * FROM users WHERE id IN (?1, ?2) AND status = ?3"
951 );
952 assert_eq!(result.bind_params, vec!["ids", "ids", "status"]);
953 }
954
955 #[test]
958 fn test_alphabetical_ordering_postgres() {
959 let composer = Composer::new(Dialect::Postgres);
960 let template = Template {
961 elements: vec![
962 Element::Sql("SELECT ".into()),
963 Element::Bind(Binding {
964 name: "z_param".into(),
965 min_values: None,
966 max_values: None,
967 nullable: false,
968 }),
969 Element::Sql(", ".into()),
970 Element::Bind(Binding {
971 name: "a_param".into(),
972 min_values: None,
973 max_values: None,
974 nullable: false,
975 }),
976 ],
977 source: TemplateSource::Literal("test".into()),
978 };
979 let result = composer.compose(&template).unwrap();
980 assert_eq!(result.sql, "SELECT $2, $1");
982 assert_eq!(result.bind_params, vec!["a_param", "z_param"]);
983 }
984
985 #[test]
986 fn test_alphabetical_ordering_sqlite() {
987 let composer = Composer::new(Dialect::Sqlite);
988 let template = Template {
989 elements: vec![
990 Element::Sql("SELECT ".into()),
991 Element::Bind(Binding {
992 name: "z_param".into(),
993 min_values: None,
994 max_values: None,
995 nullable: false,
996 }),
997 Element::Sql(", ".into()),
998 Element::Bind(Binding {
999 name: "a_param".into(),
1000 min_values: None,
1001 max_values: None,
1002 nullable: false,
1003 }),
1004 ],
1005 source: TemplateSource::Literal("test".into()),
1006 };
1007 let result = composer.compose(&template).unwrap();
1008 assert_eq!(result.sql, "SELECT ?2, ?1");
1009 assert_eq!(result.bind_params, vec!["a_param", "z_param"]);
1010 }
1011
1012 #[test]
1015 fn test_dedup_single_value_postgres() {
1016 let composer = Composer::new(Dialect::Postgres);
1017 let template = Template {
1018 elements: vec![
1019 Element::Sql("WHERE a = ".into()),
1020 Element::Bind(Binding {
1021 name: "x".into(),
1022 min_values: None,
1023 max_values: None,
1024 nullable: false,
1025 }),
1026 Element::Sql(" AND b = ".into()),
1027 Element::Bind(Binding {
1028 name: "x".into(),
1029 min_values: None,
1030 max_values: None,
1031 nullable: false,
1032 }),
1033 ],
1034 source: TemplateSource::Literal("test".into()),
1035 };
1036 let result = composer.compose(&template).unwrap();
1037 assert_eq!(result.sql, "WHERE a = $1 AND b = $1");
1039 assert_eq!(result.bind_params, vec!["x"]);
1040 }
1041
1042 #[test]
1043 fn test_dedup_multi_value_postgres() {
1044 let composer = Composer::new(Dialect::Postgres);
1045 let template = Template {
1046 elements: vec![
1047 Element::Sql("WHERE a IN (".into()),
1048 Element::Bind(Binding {
1049 name: "ids".into(),
1050 min_values: Some(1),
1051 max_values: None,
1052 nullable: false,
1053 }),
1054 Element::Sql(") AND b IN (".into()),
1055 Element::Bind(Binding {
1056 name: "ids".into(),
1057 min_values: Some(1),
1058 max_values: None,
1059 nullable: false,
1060 }),
1061 Element::Sql(")".into()),
1062 ],
1063 source: TemplateSource::Literal("test".into()),
1064 };
1065 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([("ids".into(), vec![10, 20, 30])]);
1066 let result = composer.compose_with_values(&template, &values).unwrap();
1067 assert_eq!(result.sql, "WHERE a IN ($1, $2, $3) AND b IN ($1, $2, $3)");
1069 assert_eq!(result.bind_params, vec!["ids", "ids", "ids"]);
1070 }
1071
1072 #[test]
1073 fn test_mixed_multi_and_single_values() {
1074 let composer = Composer::new(Dialect::Postgres);
1075 let template = Template {
1076 elements: vec![
1077 Element::Sql("WHERE active = ".into()),
1078 Element::Bind(Binding {
1079 name: "active".into(),
1080 min_values: None,
1081 max_values: None,
1082 nullable: false,
1083 }),
1084 Element::Sql(" AND id IN (".into()),
1085 Element::Bind(Binding {
1086 name: "ids".into(),
1087 min_values: Some(1),
1088 max_values: None,
1089 nullable: false,
1090 }),
1091 Element::Sql(") AND user_id = ".into()),
1092 Element::Bind(Binding {
1093 name: "user_id".into(),
1094 min_values: None,
1095 max_values: None,
1096 nullable: false,
1097 }),
1098 ],
1099 source: TemplateSource::Literal("test".into()),
1100 };
1101 let values: BTreeMap<String, Vec<i32>> = BTreeMap::from([
1102 ("active".into(), vec![1]),
1103 ("ids".into(), vec![10, 20, 30]),
1104 ("user_id".into(), vec![42]),
1105 ]);
1106 let result = composer.compose_with_values(&template, &values).unwrap();
1107 assert_eq!(
1109 result.sql,
1110 "WHERE active = $1 AND id IN ($2, $3, $4) AND user_id = $5"
1111 );
1112 assert_eq!(
1113 result.bind_params,
1114 vec!["active", "ids", "ids", "ids", "user_id"]
1115 );
1116 }
1117
1118 #[test]
1119 fn test_mysql_no_dedup() {
1120 let composer = Composer::new(Dialect::Mysql);
1121 let template = Template {
1122 elements: vec![
1123 Element::Sql("WHERE a = ".into()),
1124 Element::Bind(Binding {
1125 name: "x".into(),
1126 min_values: None,
1127 max_values: None,
1128 nullable: false,
1129 }),
1130 Element::Sql(" AND b = ".into()),
1131 Element::Bind(Binding {
1132 name: "x".into(),
1133 min_values: None,
1134 max_values: None,
1135 nullable: false,
1136 }),
1137 ],
1138 source: TemplateSource::Literal("test".into()),
1139 };
1140 let result = composer.compose(&template).unwrap();
1141 assert_eq!(result.sql, "WHERE a = ? AND b = ?");
1143 assert_eq!(result.bind_params, vec!["x", "x"]);
1144 }
1145
1146 #[test]
1147 fn test_supports_numbered_placeholders() {
1148 assert!(Dialect::Postgres.supports_numbered_placeholders());
1149 assert!(Dialect::Sqlite.supports_numbered_placeholders());
1150 assert!(!Dialect::Mysql.supports_numbered_placeholders());
1151 }
1152
1153 fn write_temp_file(dir: &TempDir, name: &str, content: &str) -> PathBuf {
1157 let path = dir.path().join(name);
1158 if let Some(parent) = path.parent() {
1159 std::fs::create_dir_all(parent).unwrap();
1160 }
1161 let mut f = std::fs::File::create(&path).unwrap();
1162 f.write_all(content.as_bytes()).unwrap();
1163 path
1164 }
1165
1166 #[test]
1167 fn test_slot_resolution_numbered() {
1168 let dir = TempDir::new().unwrap();
1169
1170 write_temp_file(
1172 &dir,
1173 "filter.sqlc",
1174 "SELECT part_num FROM parts WHERE color = :bind(color)",
1175 );
1176
1177 write_temp_file(
1179 &dir,
1180 "base.sqlc",
1181 "WITH f AS (\n :compose(@filter)\n)\nSELECT * FROM f",
1182 );
1183
1184 let mut composer = Composer::new(Dialect::Postgres);
1185 composer.add_search_path(dir.path().to_path_buf());
1186
1187 let template = Template {
1189 elements: vec![Element::Compose(ComposeRef {
1190 target: ComposeTarget::Path(PathBuf::from("base.sqlc")),
1191 slots: vec![SlotAssignment {
1192 name: "filter".into(),
1193 path: PathBuf::from("filter.sqlc"),
1194 }],
1195 })],
1196 source: TemplateSource::Literal("test".into()),
1197 };
1198
1199 let result = composer.compose(&template).unwrap();
1200 assert_eq!(
1201 result.sql,
1202 "WITH f AS (\n SELECT part_num FROM parts WHERE color = $1\n)\nSELECT * FROM f"
1203 );
1204 assert_eq!(result.bind_params, vec!["color"]);
1205 }
1206
1207 #[test]
1208 fn test_slot_resolution_positional() {
1209 let dir = TempDir::new().unwrap();
1210
1211 write_temp_file(
1212 &dir,
1213 "filter.sqlc",
1214 "SELECT part_num FROM parts WHERE color = :bind(color)",
1215 );
1216
1217 write_temp_file(
1218 &dir,
1219 "base.sqlc",
1220 "WITH f AS (\n :compose(@filter)\n)\nSELECT * FROM f",
1221 );
1222
1223 let mut composer = Composer::new(Dialect::Mysql);
1224 composer.add_search_path(dir.path().to_path_buf());
1225
1226 let template = Template {
1227 elements: vec![Element::Compose(ComposeRef {
1228 target: ComposeTarget::Path(PathBuf::from("base.sqlc")),
1229 slots: vec![SlotAssignment {
1230 name: "filter".into(),
1231 path: PathBuf::from("filter.sqlc"),
1232 }],
1233 })],
1234 source: TemplateSource::Literal("test".into()),
1235 };
1236
1237 let result = composer.compose(&template).unwrap();
1238 assert_eq!(
1239 result.sql,
1240 "WITH f AS (\n SELECT part_num FROM parts WHERE color = ?\n)\nSELECT * FROM f"
1241 );
1242 assert_eq!(result.bind_params, vec!["color"]);
1243 }
1244
1245 #[test]
1246 fn test_missing_slot_error() {
1247 let dir = TempDir::new().unwrap();
1248
1249 write_temp_file(
1251 &dir,
1252 "base.sqlc",
1253 "WITH f AS (\n :compose(@filter)\n)\nSELECT * FROM f",
1254 );
1255
1256 let mut composer = Composer::new(Dialect::Postgres);
1257 composer.add_search_path(dir.path().to_path_buf());
1258
1259 let template = Template {
1260 elements: vec![Element::Compose(ComposeRef {
1261 target: ComposeTarget::Path(PathBuf::from("base.sqlc")),
1262 slots: vec![], })],
1264 source: TemplateSource::Literal("test".into()),
1265 };
1266
1267 let err = composer.compose(&template).unwrap_err();
1268 match err {
1269 Error::MissingSlot { name } => assert_eq!(name, "filter"),
1270 other => panic!("expected MissingSlot, got {:?}", other),
1271 }
1272 }
1273
1274 #[test]
1275 fn test_slots_not_inherited() {
1276 let dir = TempDir::new().unwrap();
1277
1278 write_temp_file(
1280 &dir,
1281 "c.sqlc",
1282 "SELECT id FROM t WHERE x = :compose(@deep)",
1283 );
1284
1285 write_temp_file(&dir, "b.sqlc", ":compose(c.sqlc)");
1287
1288 let mut composer = Composer::new(Dialect::Postgres);
1291 composer.add_search_path(dir.path().to_path_buf());
1292
1293 let template = Template {
1294 elements: vec![Element::Compose(ComposeRef {
1295 target: ComposeTarget::Path(PathBuf::from("b.sqlc")),
1296 slots: vec![SlotAssignment {
1297 name: "deep".into(),
1298 path: PathBuf::from("filter.sqlc"),
1299 }],
1300 })],
1301 source: TemplateSource::Literal("test".into()),
1302 };
1303
1304 let err = composer.compose(&template).unwrap_err();
1305 match err {
1306 Error::MissingSlot { name } => assert_eq!(name, "deep"),
1307 other => panic!("expected MissingSlot, got {:?}", other),
1308 }
1309 }
1310
1311 #[test]
1312 fn test_explicit_slot_passthrough() {
1313 let dir = TempDir::new().unwrap();
1314
1315 write_temp_file(&dir, "deep.sqlc", ":compose(@deep)");
1317
1318 write_temp_file(&dir, "middle.sqlc", ":compose(deep.sqlc, @deep = @deep)");
1320
1321 write_temp_file(&dir, "leaf.sqlc", "SELECT 1");
1330
1331 write_temp_file(&dir, "deep.sqlc", ":compose(@inner)");
1333
1334 write_temp_file(
1336 &dir,
1337 "middle.sqlc",
1338 ":compose(deep.sqlc, @inner = leaf.sqlc)",
1339 );
1340
1341 let mut composer = Composer::new(Dialect::Postgres);
1342 composer.add_search_path(dir.path().to_path_buf());
1343
1344 let template = Template {
1345 elements: vec![Element::Compose(ComposeRef {
1346 target: ComposeTarget::Path(PathBuf::from("middle.sqlc")),
1347 slots: vec![],
1348 })],
1349 source: TemplateSource::Literal("test".into()),
1350 };
1351
1352 let result = composer.compose(&template).unwrap();
1353 assert_eq!(result.sql, "SELECT 1");
1354 }
1355
1356 #[test]
1357 fn test_slot_circular_reference() {
1358 let dir = TempDir::new().unwrap();
1359
1360 write_temp_file(&dir, "a.sqlc", ":compose(b.sqlc, @slot = a.sqlc)");
1362 write_temp_file(&dir, "b.sqlc", ":compose(@slot)");
1363
1364 let mut composer = Composer::new(Dialect::Postgres);
1365 composer.add_search_path(dir.path().to_path_buf());
1366
1367 let template = parser::parse_template_file(&dir.path().join("a.sqlc")).unwrap();
1368 let err = composer.compose(&template).unwrap_err();
1369 assert!(matches!(err, Error::CircularReference { .. }));
1370 }
1371
1372 #[test]
1373 fn test_slotted_template_with_bind_params() {
1374 let dir = TempDir::new().unwrap();
1375
1376 write_temp_file(
1377 &dir,
1378 "filter.sqlc",
1379 "SELECT id FROM items WHERE color = :bind(color)",
1380 );
1381
1382 write_temp_file(
1383 &dir,
1384 "base.sqlc",
1385 "WITH f AS (\n :compose(@filter)\n)\nSELECT * FROM f WHERE active = :bind(active)",
1386 );
1387
1388 let mut composer = Composer::new(Dialect::Postgres);
1389 composer.add_search_path(dir.path().to_path_buf());
1390
1391 let template = Template {
1392 elements: vec![Element::Compose(ComposeRef {
1393 target: ComposeTarget::Path(PathBuf::from("base.sqlc")),
1394 slots: vec![SlotAssignment {
1395 name: "filter".into(),
1396 path: PathBuf::from("filter.sqlc"),
1397 }],
1398 })],
1399 source: TemplateSource::Literal("test".into()),
1400 };
1401
1402 let result = composer.compose(&template).unwrap();
1403 assert_eq!(
1405 result.sql,
1406 "WITH f AS (\n SELECT id FROM items WHERE color = $2\n)\nSELECT * FROM f WHERE active = $1"
1407 );
1408 assert_eq!(result.bind_params, vec!["active", "color"]);
1409 }
1410
1411 #[test]
1412 fn test_slot_path_resolved_via_search_paths() {
1413 let dir = TempDir::new().unwrap();
1414
1415 write_temp_file(
1417 &dir,
1418 "filters/by_color.sqlc",
1419 "SELECT part_num FROM parts WHERE color = :bind(color)",
1420 );
1421
1422 write_temp_file(
1423 &dir,
1424 "shared/base.sqlc",
1425 "WITH f AS (\n :compose(@filter)\n)\nSELECT * FROM f",
1426 );
1427
1428 let mut composer = Composer::new(Dialect::Postgres);
1429 composer.add_search_path(dir.path().to_path_buf());
1430
1431 let template = Template {
1432 elements: vec![Element::Compose(ComposeRef {
1433 target: ComposeTarget::Path(PathBuf::from("shared/base.sqlc")),
1434 slots: vec![SlotAssignment {
1435 name: "filter".into(),
1436 path: PathBuf::from("filters/by_color.sqlc"),
1437 }],
1438 })],
1439 source: TemplateSource::Literal("test".into()),
1440 };
1441
1442 let result = composer.compose(&template).unwrap();
1443 assert_eq!(
1444 result.sql,
1445 "WITH f AS (\n SELECT part_num FROM parts WHERE color = $1\n)\nSELECT * FROM f"
1446 );
1447 assert_eq!(result.bind_params, vec!["color"]);
1448 }
1449
1450 #[test]
1451 fn test_slot_target_reference() {
1452 let dir = TempDir::new().unwrap();
1454
1455 write_temp_file(&dir, "inner.sqlc", "SELECT 42");
1456
1457 let mut composer = Composer::new(Dialect::Postgres);
1458 composer.add_search_path(dir.path().to_path_buf());
1459
1460 let template = Template {
1462 elements: vec![
1463 Element::Sql("WITH cte AS (\n ".into()),
1464 Element::Compose(ComposeRef {
1465 target: ComposeTarget::Slot("source".into()),
1466 slots: vec![],
1467 }),
1468 Element::Sql("\n)\nSELECT * FROM cte".into()),
1469 ],
1470 source: TemplateSource::Literal("test".into()),
1471 };
1472
1473 let err = composer.compose(&template).unwrap_err();
1475 assert!(matches!(err, Error::MissingSlot { .. }));
1476
1477 let mut visited = HashSet::new();
1479 let mut slots = HashMap::new();
1480 slots.insert("source".into(), PathBuf::from("inner.sqlc"));
1481 let result = composer
1482 .compose_inner(&template, &slots, &mut visited)
1483 .unwrap();
1484 assert_eq!(result.sql, "WITH cte AS (\n SELECT 42\n)\nSELECT * FROM cte");
1485 }
1486
1487 #[test]
1488 fn test_multiple_slots() {
1489 let dir = TempDir::new().unwrap();
1490
1491 write_temp_file(&dir, "source.sqlc", "SELECT id, name FROM items");
1492 write_temp_file(
1493 &dir,
1494 "filter.sqlc",
1495 "SELECT id FROM items WHERE active = :bind(active)",
1496 );
1497
1498 write_temp_file(
1499 &dir,
1500 "base.sqlc",
1501 "WITH src AS (\n :compose(@source)\n),\nf AS (\n :compose(@filter)\n)\nSELECT s.* FROM src s JOIN f ON f.id = s.id",
1502 );
1503
1504 let mut composer = Composer::new(Dialect::Postgres);
1505 composer.add_search_path(dir.path().to_path_buf());
1506
1507 let template = Template {
1508 elements: vec![Element::Compose(ComposeRef {
1509 target: ComposeTarget::Path(PathBuf::from("base.sqlc")),
1510 slots: vec![
1511 SlotAssignment {
1512 name: "source".into(),
1513 path: PathBuf::from("source.sqlc"),
1514 },
1515 SlotAssignment {
1516 name: "filter".into(),
1517 path: PathBuf::from("filter.sqlc"),
1518 },
1519 ],
1520 })],
1521 source: TemplateSource::Literal("test".into()),
1522 };
1523
1524 let result = composer.compose(&template).unwrap();
1525 assert_eq!(
1526 result.sql,
1527 "WITH src AS (\n SELECT id, name FROM items\n),\nf AS (\n SELECT id FROM items WHERE active = $1\n)\nSELECT s.* FROM src s JOIN f ON f.id = s.id"
1528 );
1529 assert_eq!(result.bind_params, vec!["active"]);
1530 }
1531}