Skip to main content

sql_composer/
composer.rs

1//! The composer transforms parsed templates into final SQL with dialect-specific
2//! placeholders and resolved compose references.
3
4use 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/// The result of composing a template: final SQL and ordered bind parameter names.
15#[derive(Debug, Clone, PartialEq)]
16pub struct ComposedSql {
17    /// The final SQL string with dialect-specific placeholders.
18    pub sql: String,
19    /// Ordered list of bind parameter names corresponding to placeholders.
20    ///
21    /// For numbered dialects (Postgres, SQLite), names are in alphabetical order
22    /// with duplicates removed. For positional dialects (MySQL), names are in
23    /// document order.
24    pub bind_params: Vec<String>,
25}
26
27/// Composes parsed templates into final SQL.
28///
29/// Handles dialect-specific placeholder generation, compose reference resolution,
30/// and mock table substitution.
31pub struct Composer {
32    /// The target database dialect for placeholder syntax.
33    pub dialect: Dialect,
34    /// Directories to search for template files referenced by `:compose()`.
35    pub search_paths: Vec<PathBuf>,
36    /// Mock tables for test data substitution.
37    pub mock_tables: HashMap<String, MockTable>,
38}
39
40impl Composer {
41    /// Create a new composer with the given dialect.
42    pub fn new(dialect: Dialect) -> Self {
43        Self {
44            dialect,
45            search_paths: vec![],
46            mock_tables: HashMap::new(),
47        }
48    }
49
50    /// Add a search path for resolving compose references.
51    pub fn add_search_path(&mut self, path: PathBuf) {
52        self.search_paths.push(path);
53    }
54
55    /// Register a mock table for test data substitution.
56    pub fn add_mock_table(&mut self, mock: MockTable) {
57        self.mock_tables.insert(mock.name.clone(), mock);
58    }
59
60    /// Compose a template into final SQL with placeholders.
61    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    /// Compose a template with value counts, expanding multi-value bindings
71    /// into multiple placeholders.
72    ///
73    /// When a `:bind(name)` has multiple values in the map, this method emits
74    /// one placeholder per value (e.g. `$1, $2, $3` for 3 values), and repeats
75    /// the bind name in `bind_params` for each. This enables `IN` clauses:
76    ///
77    /// ```text
78    /// SELECT * FROM users WHERE id IN (:bind(ids))
79    /// -- with ids=[10, 20, 30] becomes:
80    /// SELECT * FROM users WHERE id IN ($1, $2, $3)
81    /// -- bind_params = ["ids", "ids", "ids"]
82    /// ```
83    ///
84    /// For bindings with only one value, behavior is identical to [`Composer::compose()`].
85    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    // ── Slot helpers ─────────────────────────────────────────────────
99
100    /// Resolve a compose target to a concrete file path.
101    ///
102    /// `ComposeTarget::Path` returns the path directly.
103    /// `ComposeTarget::Slot` looks up the slot name in the provided slots map.
104    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    /// Build the child slot map from a compose reference's slot assignments.
118    ///
119    /// These are the ONLY slots the child template sees — parent slots are
120    /// NOT inherited.
121    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    // ── Dispatch ──────────────────────────────────────────────────────
130
131    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    // ── Numbered path (Postgres, SQLite) ──────────────────────────────
159    //
160    // Two-pass approach:
161    //   Pass 1 — collect all unique bind names (BTreeSet gives alphabetical order)
162    //   Allocate — assign 1-based indices from the sorted names
163    //   Pass 2 — emit SQL using the global index map (same name → same $N)
164
165    /// Pass 1: Recursively collect unique bind names from a template tree.
166    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    /// Collect bind names from a compose reference's resolved template.
198    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    /// Collect bind names from all sources in a command.
220    ///
221    /// Command sources are standalone templates — they get empty slots.
222    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    /// Build an index map for `compose` (single-value bindings).
239    /// Each name maps to `(1-based-index, 1)`.
240    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    /// Build an index map for `compose_with_values` (multi-value bindings).
249    /// Each name maps to `(start_index, count)` where count comes from the
250    /// values map (defaults to 1 if absent).
251    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    /// Two-pass compose for numbered dialects (single-value).
266    fn compose_inner_numbered(
267        &self,
268        template: &Template,
269        slots: &HashMap<String, PathBuf>,
270        visited: &mut HashSet<PathBuf>,
271    ) -> Result<ComposedSql> {
272        // Pass 1: collect
273        let mut collect_visited = visited.clone();
274        let names = self.collect_bind_names(template, slots, &mut collect_visited)?;
275
276        // Allocate
277        let index_map = Self::build_index_map(&names);
278        let bind_params: Vec<String> = names.into_iter().collect();
279
280        // Pass 2: emit
281        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    /// Two-pass compose for numbered dialects (multi-value).
288    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        // Pass 1: collect
296        let mut collect_visited = visited.clone();
297        let names = self.collect_bind_names(template, slots, &mut collect_visited)?;
298
299        // Allocate with value counts
300        let index_map = Self::build_index_map_with_values(&names, values);
301
302        // Build bind_params: each name repeated by its value count, alphabetical
303        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        // Pass 2: emit
316        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    /// Pass 2: Emit SQL for a template using the global index map.
323    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    /// Emit SQL for a compose reference using the global index map.
363    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    /// Emit SQL for a command (union/count) using the global index map.
387    ///
388    /// Command sources are standalone templates — they get empty slots.
389    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    /// Emit SQL for a UNION command using the global index map.
403    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    /// Emit SQL for a COUNT command using the global index map.
434    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    // ── Positional path (MySQL) ───────────────────────────────────────
476    //
477    // Document-order placeholders with bare `?`. No reindexing needed
478    // since MySQL placeholders carry no index number.
479
480    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    /// Resolve a compose reference by finding and parsing the template file (positional).
573    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    /// Resolve a compose reference with value-aware expansion (positional).
596    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    /// Compose a command (count/union) into SQL (positional path).
620    ///
621    /// Command sources are standalone templates — they get empty slots.
622    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    /// Compose a UNION command (positional path).
634    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    /// Compose a COUNT command (positional path).
669    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        // If multiple sources, wrap a union first
682        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    // ── Shared helpers ────────────────────────────────────────────────
713
714    /// Find a template file on the search paths.
715    fn find_template(&self, path: &Path) -> Result<PathBuf> {
716        // Try the path directly first
717        if path.exists() {
718            return Ok(path.to_path_buf());
719        }
720
721        // Search on each search path
722        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        // Alphabetical: active=$1, user_id=$2
778        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        // MySQL: document order, bare ?
809        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        // Alphabetical: active=?1, user_id=?2
840        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        // Alphabetical: ids=(1,2), status=(3,1) → ids=?1,?2  status=?3
948        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    // ── Alphabetical ordering tests ───────────────────────────────────
956
957    #[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        // a_param=$1 (alphabetically first), z_param=$2
981        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    // ── Dedup tests ───────────────────────────────────────────────────
1013
1014    #[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        // Both :bind(x) emit $1, bind_params has one entry
1038        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        // Both emit $1, $2, $3 — same placeholders
1068        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        // Alphabetical: active(1)=$1, ids(3)=$2,$3,$4, user_id(1)=$5
1108        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        // MySQL: document order, no dedup, bare ?
1142        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    // ── Slot tests ────────────────────────────────────────────────────
1154
1155    /// Helper: write a temp file and return its path.
1156    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        // Filter template: standalone query
1171        write_temp_file(
1172            &dir,
1173            "filter.sqlc",
1174            "SELECT part_num FROM parts WHERE color = :bind(color)",
1175        );
1176
1177        // Base template with @filter slot
1178        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        // Caller template: fills the @filter slot
1188        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        // Template uses @filter but caller doesn't provide it
1250        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![], // no slots provided
1263            })],
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        // C uses @deep slot — should NOT inherit from A→B
1279        write_temp_file(
1280            &dir,
1281            "c.sqlc",
1282            "SELECT id FROM t WHERE x = :compose(@deep)",
1283        );
1284
1285        // B composes C without passing any slots
1286        write_temp_file(&dir, "b.sqlc", ":compose(c.sqlc)");
1287
1288        // A composes B with @deep slot
1289        // But B doesn't pass @deep to C, so C should fail
1290        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        // Deep template uses @deep slot
1316        write_temp_file(&dir, "deep.sqlc", ":compose(@deep)");
1317
1318        // Middle template explicitly passes @deep through
1319        write_temp_file(&dir, "middle.sqlc", ":compose(deep.sqlc, @deep = @deep)");
1320
1321        // Hmm, @deep = @deep isn't valid — slot values are file paths.
1322        // Instead, the middle template must know the concrete path:
1323        // :compose(deep.sqlc, @deep = leaf.sqlc)
1324        // OR middle itself takes a slot and passes it.
1325        // But slots are file paths, not slot references.
1326        // Let me redesign this test.
1327
1328        // leaf.sqlc — the concrete content
1329        write_temp_file(&dir, "leaf.sqlc", "SELECT 1");
1330
1331        // deep.sqlc — uses @inner slot
1332        write_temp_file(&dir, "deep.sqlc", ":compose(@inner)");
1333
1334        // middle.sqlc — composes deep.sqlc, passes @inner = leaf.sqlc
1335        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        // a.sqlc composes b.sqlc with @slot = a.sqlc (circular)
1361        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        // Alphabetical: active=$1, color=$2
1404        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        // Put the filter in a subdirectory
1416        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        // The target itself is a slot reference: :compose(@slot)
1453        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        // Template with a slot reference as the compose target
1461        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        // Without providing the slot, should fail
1474        let err = composer.compose(&template).unwrap_err();
1475        assert!(matches!(err, Error::MissingSlot { .. }));
1476
1477        // Now compose with slots provided via internal API
1478        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}