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