Skip to main content

scythe_codegen/backends/
mod.rs

1pub(crate) mod csharp_microsoft_sqlite;
2pub(crate) mod csharp_mysqlconnector;
3pub(crate) mod csharp_npgsql;
4pub(crate) mod csharp_oracle;
5pub(crate) mod csharp_snowflake;
6pub(crate) mod csharp_sqlclient;
7pub(crate) mod elixir_common;
8pub(crate) mod elixir_ecto;
9pub(crate) mod elixir_exqlite;
10pub(crate) mod elixir_jamdb;
11pub(crate) mod elixir_myxql;
12pub(crate) mod elixir_postgrex;
13pub(crate) mod elixir_tds;
14pub(crate) mod go_common;
15pub(crate) mod go_database_sql;
16pub(crate) mod go_godror;
17pub(crate) mod go_gosnowflake;
18pub(crate) mod go_pgx;
19pub(crate) mod java_jdbc;
20pub(crate) mod java_r2dbc;
21pub(crate) mod jvm_common;
22pub(crate) mod kotlin_exposed;
23pub(crate) mod kotlin_jdbc;
24pub(crate) mod kotlin_r2dbc;
25pub(crate) mod php_amphp;
26pub(crate) mod php_common;
27pub(crate) mod php_pdo;
28pub(crate) mod python_aiomysql;
29pub(crate) mod python_aiosqlite;
30pub(crate) mod python_asyncpg;
31pub(crate) mod python_common;
32pub(crate) mod python_duckdb;
33pub(crate) mod python_oracledb;
34pub(crate) mod python_psycopg3;
35pub(crate) mod python_pyodbc;
36pub(crate) mod python_snowflake;
37pub(crate) mod ruby_mysql2;
38pub(crate) mod ruby_oci8;
39pub(crate) mod ruby_pg;
40pub(crate) mod ruby_rbs;
41pub(crate) mod ruby_sqlite3;
42pub(crate) mod ruby_tiny_tds;
43pub(crate) mod ruby_trilogy;
44pub(crate) mod rust_sibyl;
45pub(crate) mod rust_tiberius;
46pub(crate) mod sqlx;
47pub(crate) mod tokio_postgres;
48pub(crate) mod typescript_better_sqlite3;
49pub(crate) mod typescript_common;
50pub(crate) mod typescript_duckdb;
51pub(crate) mod typescript_kysely;
52pub(crate) mod typescript_mssql;
53pub(crate) mod typescript_mysql2;
54pub(crate) mod typescript_node_sqlite;
55pub(crate) mod typescript_oracledb;
56pub(crate) mod typescript_pg;
57pub(crate) mod typescript_postgres;
58pub(crate) mod typescript_snowflake;
59pub(crate) mod typescript_wasm_sqlite;
60
61use scythe_backend::manifest::BackendManifest;
62use scythe_backend::naming::NamingConfig;
63use scythe_core::SqlDialect;
64use scythe_core::analyzer::AnalyzedParam;
65use scythe_core::errors::{ErrorCode, ScytheError};
66
67use crate::backend_trait::{CodegenBackend, ResolvedParam};
68
69/// Parse a backend's compiled-in manifest.
70///
71/// Manifest selection is a pure function of (backend, engine). There is
72/// deliberately no filesystem lookup here: generated output must not depend
73/// on the process working directory (#82).
74pub(crate) fn parse_manifest(manifest_toml: &str) -> Result<BackendManifest, ScytheError> {
75    toml::from_str(manifest_toml).map_err(|e| ScytheError::new(ErrorCode::InternalError, format!("manifest: {e}")))
76}
77
78/// Validate and apply the `field_case` backend option, writing it into a
79/// backend's `NamingConfig`.
80///
81/// Shared by the five driver-level Java and Kotlin backends (`java-jdbc`,
82/// `java-r2dbc`, `kotlin-jdbc`, `kotlin-r2dbc`, `kotlin-exposed`). Unlike the
83/// TypeScript backends' `field_case` (see `typescript_common::TsFieldCase`),
84/// none of these five need a companion runtime remap: every row they build
85/// comes from an explicit `ResultSet`/`Row` getter call keyed by the raw SQL
86/// column name (`ResolvedColumn::name`), with `ResolvedColumn::field_name`
87/// used only as the emitted record/data-class property name -- see e.g.
88/// `java_jdbc::col_rs_expr`, which reads `rs.getX(col.name)` and assigns it
89/// positionally into a constructor whose declared parameter is `col.field_name`.
90/// Renaming the property therefore never changes what key the driver is
91/// asked to look up. `resolve::resolve_columns` is what performs the actual
92/// rename (and rejects same-query collisions) once this sets
93/// `NamingConfig.field_case`.
94pub(crate) fn apply_field_case_option(
95    naming: &mut NamingConfig,
96    backend_name: &str,
97    value: &str,
98) -> Result<(), ScytheError> {
99    match value {
100        "snake_case" | "camelCase" => {
101            naming.field_case = value.to_string();
102            Ok(())
103        }
104        other => Err(ScytheError::new(
105            ErrorCode::InvalidConfig,
106            format!("{backend_name}: invalid field_case '{other}' (expected 'snake_case' or 'camelCase')"),
107        )),
108    }
109}
110
111/// Whether a backend built for `engine` may emit nested-aggregate structs.
112///
113/// The four opted-in backends (`rust-sqlx`, `rust-tokio-postgres`, `go-pgx`,
114/// `python-psycopg3`) all also serve `redshift`, and `rust-sqlx` serves
115/// MySQL/MariaDB/SQLite besides. `json_agg` and `row_to_json` are PostgreSQL
116/// functions; Redshift has neither. The analyzer already declines to infer a
117/// nested type for a Redshift catalog, but the backend cannot rely on that
118/// alone — it also selects a *different manifest* per engine, and the
119/// Redshift manifests deliberately do not declare the `json_nested`
120/// container. Opting in there would resolve the column against a container
121/// the manifest never defines.
122///
123/// Kept as one predicate so all four backends answer the question the same
124/// way instead of each spelling out its own engine list.
125pub(crate) fn engine_supports_nested_aggregates(engine: &str) -> bool {
126    matches!(engine, "postgresql" | "postgres" | "pg")
127}
128
129/// The ADO.NET accessor a `csharp-*` backend must use for a column whose
130/// neutral type its driver-specific accessor table does not name.
131///
132/// `DbDataReader.GetValue` is declared to return `object`, so a record field
133/// the manifest declared as anything else -- `List<{T}>` for an `array`,
134/// `byte[]` for `bytes`, a composite record, `{T}` for `json_typed` -- does
135/// not bind to it. The compiler rejects the constructor call outright:
136///
137/// ```text
138/// error CS1503: Argument 2: cannot convert from 'object' to
139/// 'System.Collections.Generic.List<string>'
140/// ```
141///
142/// `DbDataReader.GetFieldValue<T>` is the typed path every ADO.NET provider
143/// inherits, and it returns `T` by signature. Naming the manifest's own
144/// declared type as `T` therefore makes the reader agree with the declaration
145/// by construction, rather than by a second hand-maintained table that can
146/// drift away from the manifest -- which is precisely how this defect
147/// (issue #155) survived in five backends at once.
148///
149/// The typed path is not merely a compile-time trick: verified against a live
150/// PostgreSQL 17 server through Npgsql 8, `GetFieldValue<List<string>>` reads
151/// a `text[]`, `GetFieldValue<TimeSpan>` an `interval`,
152/// `GetFieldValue<System.Net.IPAddress>` an `inet` and
153/// `GetFieldValue<byte[]>` a `bytea`. Degrading the *manifest* to whatever
154/// `GetValue`/`GetString` happens to return would throw away types the driver
155/// can genuinely produce.
156pub(crate) fn csharp_typed_reader_method(lang_type: &str) -> String {
157    format!("GetFieldValue<{lang_type}>")
158}
159
160/// A classified span of SQL text produced by [`tokenize_sql`].
161///
162/// One lexer pass drives both comment-stripping (`clean_sql`,
163/// `clean_sql_oneline`) and placeholder-rewriting (`rewrite_pg_placeholders`)
164/// -- see #186 and board #148, where two independent character scanners each got
165/// string/comment handling wrong in a different way. `Code` spans are the
166/// only spans either consumer may rewrite; every other span is opaque SQL
167/// text that must be reproduced byte-for-byte (module the caller's own
168/// whitespace joining).
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170enum SqlSpanKind {
171    /// Ordinary SQL: identifiers, keywords, operators, placeholders.
172    Code,
173    /// A `'...'` string literal, with `''` as the escape for an embedded quote.
174    SingleQuoted,
175    /// A `"..."` quoted identifier, with `""` as the escape for an embedded quote.
176    DoubleQuoted,
177    /// A PostgreSQL `$$...$$` or `$tag$...$tag$` dollar-quoted string.
178    DollarQuoted,
179    /// A MySQL/MariaDB `` `...` `` quoted identifier, with `` `` `` as the
180    /// escape for an embedded backtick. Only ever produced when the caller's
181    /// dialect is [`SqlDialect::MySQL`] (board #148 item 2).
182    Backtick,
183    /// An MSSQL `[...]` delimited identifier, with `]]` as the escape for an
184    /// embedded `]`. Only ever produced when the caller's dialect is
185    /// [`SqlDialect::MsSql`] (board #148 item 4) -- gating on dialect is what keeps
186    /// this from misreading PostgreSQL array-subscript syntax (`arr[1]`).
187    Bracketed,
188    /// A `-- ...` comment, running to end of line (the newline itself is not
189    /// part of the span).
190    LineComment,
191    /// A `/* ... */` comment. PostgreSQL nests these, so this tracks nesting
192    /// depth; an unterminated comment consumes to end of input.
193    BlockComment,
194}
195
196/// Tokenize `sql` into a sequence of classified spans whose text
197/// concatenates back to exactly `sql`.
198///
199/// This is the single source of truth for "is this character part of a
200/// string/identifier/comment, or is it live SQL". `dialect` gates the lexical
201/// forms that cannot be told apart from the text alone without knowing the
202/// target engine: MySQL/MariaDB backtick identifiers and `#` line comments
203/// (only under [`SqlDialect::MySQL`]), and MSSQL `[bracketed]` identifiers
204/// (only under [`SqlDialect::MsSql`]) -- `#` in particular collides with
205/// PostgreSQL's `#>`/`#>>` JSON operators, and `[`/`]` collide with
206/// PostgreSQL array-subscript syntax, so neither can be recognized
207/// dialect-blind (board #148).
208fn tokenize_sql(sql: &str, dialect: SqlDialect) -> Vec<(SqlSpanKind, String)> {
209    let chars: Vec<char> = sql.chars().collect();
210    let len = chars.len();
211    let mut spans: Vec<(SqlSpanKind, String)> = Vec::new();
212    let mut code_buf = String::new();
213    let mut i = 0;
214
215    while i < len {
216        let c = chars[i];
217
218        if c == '-' && i + 1 < len && chars[i + 1] == '-' {
219            flush_code_buf(&mut code_buf, &mut spans);
220            let start = i;
221            i += 2;
222            while i < len && chars[i] != '\n' {
223                i += 1;
224            }
225            spans.push((SqlSpanKind::LineComment, chars[start..i].iter().collect()));
226            continue;
227        }
228
229        if c == '/' && i + 1 < len && chars[i + 1] == '*' {
230            flush_code_buf(&mut code_buf, &mut spans);
231            let start = i;
232            i += 2;
233            let mut depth = 1u32;
234            while i < len && depth > 0 {
235                if chars[i] == '/' && i + 1 < len && chars[i + 1] == '*' {
236                    depth += 1;
237                    i += 2;
238                } else if chars[i] == '*' && i + 1 < len && chars[i + 1] == '/' {
239                    depth -= 1;
240                    i += 2;
241                } else {
242                    i += 1;
243                }
244            }
245            spans.push((SqlSpanKind::BlockComment, chars[start..i].iter().collect()));
246            continue;
247        }
248
249        if c == '\'' {
250            flush_code_buf(&mut code_buf, &mut spans);
251            let start = i;
252            i += 1;
253            while i < len {
254                if chars[i] == '\'' {
255                    if i + 1 < len && chars[i + 1] == '\'' {
256                        i += 2;
257                        continue;
258                    }
259                    i += 1;
260                    break;
261                }
262                i += 1;
263            }
264            spans.push((SqlSpanKind::SingleQuoted, chars[start..i].iter().collect()));
265            continue;
266        }
267
268        if c == '"' {
269            flush_code_buf(&mut code_buf, &mut spans);
270            let start = i;
271            i += 1;
272            while i < len {
273                if chars[i] == '"' {
274                    if i + 1 < len && chars[i + 1] == '"' {
275                        i += 2;
276                        continue;
277                    }
278                    i += 1;
279                    break;
280                }
281                i += 1;
282            }
283            spans.push((SqlSpanKind::DoubleQuoted, chars[start..i].iter().collect()));
284            continue;
285        }
286
287        if c == '`' && dialect == SqlDialect::MySQL {
288            flush_code_buf(&mut code_buf, &mut spans);
289            let start = i;
290            i += 1;
291            while i < len {
292                if chars[i] == '`' {
293                    if i + 1 < len && chars[i + 1] == '`' {
294                        i += 2;
295                        continue;
296                    }
297                    i += 1;
298                    break;
299                }
300                i += 1;
301            }
302            spans.push((SqlSpanKind::Backtick, chars[start..i].iter().collect()));
303            continue;
304        }
305
306        if c == '#' && dialect == SqlDialect::MySQL {
307            flush_code_buf(&mut code_buf, &mut spans);
308            let start = i;
309            while i < len && chars[i] != '\n' {
310                i += 1;
311            }
312            spans.push((SqlSpanKind::LineComment, chars[start..i].iter().collect()));
313            continue;
314        }
315
316        if c == '[' && dialect == SqlDialect::MsSql {
317            flush_code_buf(&mut code_buf, &mut spans);
318            let start = i;
319            i += 1;
320            while i < len {
321                if chars[i] == ']' {
322                    if i + 1 < len && chars[i + 1] == ']' {
323                        i += 2;
324                        continue;
325                    }
326                    i += 1;
327                    break;
328                }
329                i += 1;
330            }
331            spans.push((SqlSpanKind::Bracketed, chars[start..i].iter().collect()));
332            continue;
333        }
334
335        if c == '$'
336            && let Some(open_end) = match_dollar_quote_open(&chars, i)
337        {
338            flush_code_buf(&mut code_buf, &mut spans);
339            let start = i;
340            let delim: Vec<char> = chars[i..open_end].to_vec();
341            let mut j = open_end;
342            let mut found = false;
343            while j < len {
344                if chars[j] == '$' && j + delim.len() <= len && chars[j..j + delim.len()] == delim[..] {
345                    j += delim.len();
346                    found = true;
347                    break;
348                }
349                j += 1;
350            }
351            if !found {
352                j = len;
353            }
354            spans.push((SqlSpanKind::DollarQuoted, chars[start..j].iter().collect()));
355            i = j;
356            continue;
357        }
358
359        code_buf.push(c);
360        i += 1;
361    }
362
363    flush_code_buf(&mut code_buf, &mut spans);
364    spans
365}
366
367fn flush_code_buf(code_buf: &mut String, spans: &mut Vec<(SqlSpanKind, String)>) {
368    if !code_buf.is_empty() {
369        spans.push((SqlSpanKind::Code, std::mem::take(code_buf)));
370    }
371}
372
373/// If `chars[i]` (a `$`) opens a dollar-quote delimiter (`$$` or `$tag$`),
374/// return the index just past the opening delimiter. A tag follows SQL
375/// identifier rules (must not start with a digit), which is exactly what
376/// keeps this from misfiring on a `$1`-style placeholder.
377fn match_dollar_quote_open(chars: &[char], i: usize) -> Option<usize> {
378    let mut j = i + 1;
379    if j < chars.len() && chars[j] == '$' {
380        return Some(j + 1);
381    }
382    let tag_start = j;
383    while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
384        j += 1;
385    }
386    if j > tag_start && j < chars.len() && chars[j] == '$' && !chars[tag_start].is_ascii_digit() {
387        return Some(j + 1);
388    }
389    None
390}
391
392/// Strip SQL comments, trailing semicolons, and excess whitespace.
393/// Preserves newlines between lines.
394///
395/// Dialect-blind: kept for the call sites this multi-agent change did not
396/// reach (see [`clean_sql_dialect`] for the dialect-aware replacement, which
397/// every owned call site now uses). Passing `SqlDialect::PostgreSQL` into
398/// [`tokenize_sql`] here gates off the MySQL backtick/`#` and MSSQL bracket
399/// handling, reproducing this function's pre-board #148 behaviour exactly.
400pub(crate) fn clean_sql(sql: &str) -> String {
401    clean_sql_lines(sql, SqlDialect::PostgreSQL)
402        .join("\n")
403        .trim()
404        .trim_end_matches(';')
405        .trim()
406        .to_string()
407}
408
409/// Like clean_sql but joins lines with spaces (for languages that embed SQL inline).
410/// Dialect-blind; see [`clean_sql`]'s doc comment.
411pub(crate) fn clean_sql_oneline(sql: &str) -> String {
412    clean_sql_lines(sql, SqlDialect::PostgreSQL)
413        .join(" ")
414        .trim()
415        .trim_end_matches(';')
416        .trim()
417        .to_string()
418}
419
420/// Dialect-aware [`clean_sql`]: recognizes MySQL backtick identifiers/`#`
421/// comments and MSSQL `[bracketed]` identifiers per `dialect` (board #148).
422///
423/// ~keep No production caller yet: the four backends migrated to the
424/// dialect-aware pipeline all embed SQL as a single line and so reach for
425/// [`clean_sql_oneline_dialect`]. The multiline form is what the ~44
426/// still-unmigrated backends will need, and its tests below pin the MySQL
427/// backtick / `#`-comment and MSSQL bracket behaviour that only this
428/// signature exercises. `expect`, not `allow`, deliberately: it becomes a
429/// hard error the moment a caller appears, so the attribute cannot outlive
430/// the reason for it.
431#[cfg_attr(
432    not(test),
433    expect(dead_code, reason = "staged board #148 migration; see doc comment")
434)]
435pub(crate) fn clean_sql_dialect(sql: &str, dialect: SqlDialect) -> String {
436    clean_sql_lines(sql, dialect)
437        .join("\n")
438        .trim()
439        .trim_end_matches(';')
440        .trim()
441        .to_string()
442}
443
444/// Dialect-aware [`clean_sql_oneline`]; see [`clean_sql_dialect`].
445pub(crate) fn clean_sql_oneline_dialect(sql: &str, dialect: SqlDialect) -> String {
446    clean_sql_lines(sql, dialect)
447        .join(" ")
448        .trim()
449        .trim_end_matches(';')
450        .trim()
451        .to_string()
452}
453
454/// Split `sql` into lines with every comment (`--` and `/* */`, string- and
455/// dollar-quote-aware) removed, dropping any line whose content was entirely
456/// a comment.
457///
458/// A line is dropped only when a comment is what emptied it -- a line that
459/// was already blank in the source (no comment token touched it) is kept,
460/// matching the previous behaviour of the naive `starts_with("--")` filter
461/// for every case that filter got right. This is what lets a mid-line
462/// trailing comment (board #148) be stripped without deleting the rest of the
463/// query, while a `-- @name Foo` header line still disappears instead of
464/// leaving a blank line behind (every generated fixture depends on that).
465fn clean_sql_lines(sql: &str, dialect: SqlDialect) -> Vec<String> {
466    let normalized = sql.replace("\r\n", "\n");
467    let spans = tokenize_sql(&normalized, dialect);
468
469    let mut lines: Vec<String> = vec![String::new()];
470    let mut touched_by_comment: Vec<bool> = vec![false];
471
472    for (kind, text) in &spans {
473        let is_comment = matches!(kind, SqlSpanKind::LineComment | SqlSpanKind::BlockComment);
474        for ch in text.chars() {
475            if ch == '\n' {
476                if is_comment {
477                    *touched_by_comment.last_mut().expect("at least one line") = true;
478                }
479                lines.push(String::new());
480                touched_by_comment.push(false);
481            } else if is_comment {
482                *touched_by_comment.last_mut().expect("at least one line") = true;
483            } else {
484                lines.last_mut().expect("at least one line").push(ch);
485            }
486        }
487    }
488
489    lines
490        .into_iter()
491        .zip(touched_by_comment)
492        .filter(|(kept, touched)| !(*touched && kept.trim().is_empty()))
493        .map(|(kept, _)| kept)
494        .collect()
495}
496
497/// Rewrite SQL for optional parameters.
498///
499/// For each optional param, finds `column = $N` (or `column <> $N`, `column != $N`)
500/// and rewrites to `($N IS NULL OR column = $N)`. This allows callers to pass NULL
501/// to skip a filter condition at runtime.
502///
503/// This operates on the raw SQL before any backend-specific placeholder rewriting.
504pub(crate) fn rewrite_optional_params(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
505    if optional_params.is_empty() {
506        return sql.to_string();
507    }
508
509    let mut result = sql.to_string();
510
511    for opt_name in optional_params {
512        let Some(param) = params.iter().find(|p| p.name == *opt_name) else {
513            continue;
514        };
515        let placeholder = format!("${}", param.position);
516
517        for op in &[
518            ">=",
519            "<=",
520            "<>",
521            "!=",
522            ">",
523            "<",
524            "=",
525            "NOT ILIKE",
526            "not ilike",
527            "NOT LIKE",
528            "not like",
529            "ILIKE",
530            "ilike",
531            "LIKE",
532            "like",
533        ] {
534            result = rewrite_comparison(&result, &placeholder, op);
535        }
536    }
537
538    result
539}
540
541/// Rewrite a single `column <op> $N` pattern to `($N IS NULL OR column <op> $N)`.
542/// Handles both `column <op> $N` and `$N <op> column` orderings.
543fn rewrite_comparison(sql: &str, placeholder: &str, op: &str) -> String {
544    let mut result = String::with_capacity(sql.len() + 32);
545    let chars: Vec<char> = sql.chars().collect();
546    let len = chars.len();
547    let mut i = 0;
548
549    while i < len {
550        if let Some((_start, col, end)) = try_match_col_op_ph(&chars, i, op, placeholder) {
551            result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
552            i = end;
553            continue;
554        }
555
556        if let Some((end, col)) = try_match_ph_op_col(&chars, i, op, placeholder) {
557            result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
558            i = end;
559            continue;
560        }
561
562        result.push(chars[i]);
563        i += 1;
564    }
565
566    result
567}
568
569/// Keywords that `is_ident_char` would happily scan as a column name but
570/// that can never actually be one in this position. Without this check, a
571/// compound operator like `NOT LIKE` gets misread: the scanner captures
572/// `NOT` as "the column" and matches `LIKE` as "the operator", producing
573/// `col NOT ($N IS NULL OR LIKE $N)` -- invalid SQL that silently drops the
574/// real column (#186 item 3). This also guards against a second rewrite
575/// pass (e.g. plain `LIKE` running after `NOT LIKE` already matched) seeing
576/// the literal word `NOT` it just emitted and matching on that.
577fn is_reserved_non_column_keyword(ident: &str) -> bool {
578    matches!(
579        ident.to_ascii_uppercase().as_str(),
580        "NOT" | "AND" | "OR" | "IS" | "NULL"
581    )
582}
583
584/// Match `op`, split on whitespace, against `chars` starting at `j`,
585/// tolerating any amount of whitespace between words of a compound operator
586/// (`NOT LIKE`, `NOT ILIKE`). Single-word operators degenerate to an exact
587/// literal match, same as before. Returns the index just past the match.
588fn match_op_tokens(chars: &[char], mut j: usize, op: &str) -> Option<usize> {
589    for (idx, word) in op.split_whitespace().enumerate() {
590        if idx > 0 {
591            let ws_start = j;
592            while j < chars.len() && chars[j].is_whitespace() {
593                j += 1;
594            }
595            if j == ws_start {
596                return None;
597            }
598        }
599        let word_chars: Vec<char> = word.chars().collect();
600        if j + word_chars.len() > chars.len() {
601            return None;
602        }
603        for (k, wc) in word_chars.iter().enumerate() {
604            if chars[j + k] != *wc {
605                return None;
606            }
607        }
608        j += word_chars.len();
609    }
610    Some(j)
611}
612
613/// Try to match `identifier <ws>* <op> <ws>* placeholder` starting at position `i`.
614/// Returns `(match_start, column_name, match_end)` if found.
615fn try_match_col_op_ph(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String, usize)> {
616    if !is_ident_char(chars[i]) {
617        return None;
618    }
619    if i > 0 && is_ident_char(chars[i - 1]) {
620        return None;
621    }
622
623    let ident_start = i;
624    let mut j = i;
625    while j < chars.len() && is_ident_char(chars[j]) {
626        j += 1;
627    }
628    let ident: String = chars[ident_start..j].iter().collect();
629    if is_reserved_non_column_keyword(&ident) {
630        return None;
631    }
632
633    while j < chars.len() && chars[j].is_whitespace() {
634        j += 1;
635    }
636
637    j = match_op_tokens(chars, j, op)?;
638
639    while j < chars.len() && chars[j].is_whitespace() {
640        j += 1;
641    }
642
643    let ph_chars: Vec<char> = placeholder.chars().collect();
644    if j + ph_chars.len() > chars.len() {
645        return None;
646    }
647    for (k, pc) in ph_chars.iter().enumerate() {
648        if chars[j + k] != *pc {
649            return None;
650        }
651    }
652    j += ph_chars.len();
653
654    if j < chars.len() && chars[j].is_ascii_digit() {
655        return None;
656    }
657
658    Some((i, ident, j))
659}
660
661/// Try to match `placeholder <ws>* <op> <ws>* identifier` starting at position `i`.
662/// Returns `(match_end, column_name)` if found.
663fn try_match_ph_op_col(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String)> {
664    let ph_chars: Vec<char> = placeholder.chars().collect();
665    if i + ph_chars.len() > chars.len() {
666        return None;
667    }
668
669    if i > 0 && (chars[i - 1] == '$' || chars[i - 1].is_ascii_digit()) {
670        return None;
671    }
672
673    for (k, pc) in ph_chars.iter().enumerate() {
674        if chars[i + k] != *pc {
675            return None;
676        }
677    }
678    let mut j = i + ph_chars.len();
679
680    if j < chars.len() && chars[j].is_ascii_digit() {
681        return None;
682    }
683
684    while j < chars.len() && chars[j].is_whitespace() {
685        j += 1;
686    }
687
688    j = match_op_tokens(chars, j, op)?;
689
690    while j < chars.len() && chars[j].is_whitespace() {
691        j += 1;
692    }
693
694    if j >= chars.len() || !is_ident_char(chars[j]) {
695        return None;
696    }
697    let ident_start = j;
698    while j < chars.len() && is_ident_char(chars[j]) {
699        j += 1;
700    }
701    let ident: String = chars[ident_start..j].iter().collect();
702
703    if is_reserved_non_column_keyword(&ident) {
704        return None;
705    }
706
707    Some((j, ident))
708}
709
710/// Clean SQL and apply optional parameter rewriting.
711/// Dialect-blind; see [`clean_sql`]'s doc comment.
712pub(crate) fn clean_sql_with_optional(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
713    let cleaned = clean_sql(sql);
714    rewrite_optional_params(&cleaned, optional_params, params)
715}
716
717/// Clean SQL (oneline) and apply optional parameter rewriting.
718/// Dialect-blind; see [`clean_sql`]'s doc comment.
719pub(crate) fn clean_sql_oneline_with_optional(
720    sql: &str,
721    optional_params: &[String],
722    params: &[AnalyzedParam],
723) -> String {
724    let cleaned = clean_sql_oneline(sql);
725    rewrite_optional_params(&cleaned, optional_params, params)
726}
727
728/// Dialect-aware [`clean_sql_oneline_with_optional`]; see [`clean_sql_dialect`].
729pub(crate) fn clean_sql_oneline_with_optional_dialect(
730    sql: &str,
731    dialect: SqlDialect,
732    optional_params: &[String],
733    params: &[AnalyzedParam],
734) -> String {
735    let cleaned = clean_sql_oneline_dialect(sql, dialect);
736    rewrite_optional_params(&cleaned, optional_params, params)
737}
738
739fn is_ident_char(c: char) -> bool {
740    c.is_alphanumeric() || c == '_' || c == '.'
741}
742
743/// Rewrite SQL placeholders (`$N` or `?`) to a target format.
744///
745/// Skips placeholders inside single-/double-quoted and dollar-quoted spans,
746/// and inside comments (see [`tokenize_sql`]). The `formatter` closure
747/// receives the parameter number (1-based) and returns the replacement.
748///
749/// Dialect-blind, kept for the call sites this multi-agent change did not
750/// reach: whether a bare `?` is a placeholder is decided by scanning the code
751/// spans for a `$`-followed-by-digit occurrence, and treating a bare `?` as a
752/// placeholder only when none exists. That heuristic has a known gap --
753/// [`rewrite_placeholders_indexed`]'s doc comment explains why, and is what
754/// every owned call site now uses instead.
755///
756/// `?|`, `?&`, `?-`, `?-|`, `?||`, and `@?` are multi-character PostgreSQL
757/// operators (JSONB key/path existence, geometric comparisons) that are
758/// **never** valid placeholder syntax in any dialect this crate targets, so
759/// [`rewrite_code_span_placeholders`] recognizes and skips them unconditionally
760/// -- independent of the `$N` heuristic above, and correct even for a
761/// zero-placeholder query that has no `$N` to anchor that heuristic on.
762pub(crate) fn rewrite_pg_placeholders(sql: &str, formatter: impl Fn(u32) -> String) -> String {
763    let spans = tokenize_sql(sql, SqlDialect::PostgreSQL);
764    let uses_dollar_placeholders = spans
765        .iter()
766        .any(|(kind, text)| *kind == SqlSpanKind::Code && code_span_has_dollar_number(text));
767
768    let mut result = String::with_capacity(sql.len());
769    let mut positional_counter: u32 = 0;
770    let mut occurrences: Vec<u32> = Vec::new();
771    for (kind, text) in &spans {
772        if *kind == SqlSpanKind::Code {
773            rewrite_code_span_placeholders(
774                text,
775                !uses_dollar_placeholders,
776                &formatter,
777                &mut positional_counter,
778                &mut result,
779                &mut occurrences,
780            );
781        } else {
782            result.push_str(text);
783        }
784    }
785    result
786}
787
788/// Whether a bare `?` can ever be a placeholder under `dialect`, decided from
789/// the dialect alone -- replacing the `$N`-occurrence heuristic in
790/// [`rewrite_pg_placeholders`] (board #148 item 1, #186).
791///
792/// Verified against sqlparser 0.62's own tokenizer (`src/tokenizer.rs`,
793/// the `'?' if self.dialect.supports_geometric_types()` arm, with the
794/// source comment "Postgres uses ? for jsonb operators, not prepared
795/// statements"): `PostgreSqlDialect` is the only builtin dialect that
796/// overrides `supports_geometric_types()` to `true` (`dialect/postgresql.rs`),
797/// so it is the only dialect whose tokenizer never emits `Token::Placeholder`
798/// for a lone `?` -- it always emits `Token::Question` or one of the
799/// JSONB/geometric operator tokens instead. A PostgreSQL query containing
800/// `data ? 'active'` therefore never held a placeholder in the first place,
801/// with or without another `$N` elsewhere in the query, and no
802/// parameter-count heuristic is needed to tell the two apart. Every other
803/// dialect this crate targets (MySQL, SQLite, MsSql, Oracle, Snowflake) uses
804/// bare `?` as its native positional placeholder syntax and has no
805/// JSONB-style operator that collides with it.
806fn dialect_allows_bare_question_mark_placeholder(dialect: SqlDialect) -> bool {
807    !matches!(dialect, SqlDialect::PostgreSQL)
808}
809
810/// Dialect-aware rewrite of SQL placeholders (`$N` or bare `?`), returning
811/// both the rewritten SQL and, for each placeholder occurrence rewritten (in
812/// textual order, including repeats), the `$N`/`?N` position number it
813/// resolved to.
814///
815/// Unlike [`rewrite_pg_placeholders`], whether a bare `?` is a placeholder is
816/// decided from `dialect` alone via
817/// [`dialect_allows_bare_question_mark_placeholder`], never from whether the
818/// query happens to also contain a `$N`.
819///
820/// The returned position list is what lets a caller bind per SQL-text
821/// occurrence instead of per declared parameter (GH #149): `$2 ... $1`
822/// binds out of declaration order, a repeated `$1` produces two occurrences
823/// of position `1`, and an `@optional` rewrite (`($1 IS NULL OR col = $1)`)
824/// likewise produces two occurrences of the same position -- all three need
825/// one bind per occurrence, not one bind per unique parameter, or the
826/// generated code either swaps arguments or throws a parameter-count
827/// mismatch at runtime.
828pub(crate) fn rewrite_placeholders_indexed(
829    sql: &str,
830    dialect: SqlDialect,
831    formatter: impl Fn(u32) -> String,
832) -> (String, Vec<u32>) {
833    let spans = tokenize_sql(sql, dialect);
834    let bare_question_is_placeholder = dialect_allows_bare_question_mark_placeholder(dialect);
835
836    let mut result = String::with_capacity(sql.len());
837    let mut positional_counter: u32 = 0;
838    let mut occurrences: Vec<u32> = Vec::new();
839    for (kind, text) in &spans {
840        if *kind == SqlSpanKind::Code {
841            rewrite_code_span_placeholders(
842                text,
843                bare_question_is_placeholder,
844                &formatter,
845                &mut positional_counter,
846                &mut result,
847                &mut occurrences,
848            );
849        } else {
850            result.push_str(text);
851        }
852    }
853    (result, occurrences)
854}
855
856/// Resolve the [`ResolvedParam`] that a [`rewrite_placeholders_indexed`]
857/// occurrence position refers to.
858///
859/// `resolve::resolve_params` builds `resolved` from `analyzed_params` in the
860/// same order (one output per input, positionally), so the index into one
861/// slice is the index into the other; `analyzed_params[i].position` is the
862/// actual `$N` the analyzer resolved parameter `i` to. This is what lets a
863/// backend bind an occurrence's own position back to a concrete parameter
864/// instead of assuming SQL-declaration order matches bind order (#149): a
865/// repeated `$1`, an out-of-order `$2 ... $1`, and an `@optional` rewrite all
866/// resolve correctly through this lookup because it is driven by the
867/// occurrence's own position, never by loop index.
868///
869/// Panics if `position` is not among `analyzed_params` -- every position
870/// [`rewrite_placeholders_indexed`] returns came from the same SQL text the
871/// analyzer walked to build `analyzed_params`, so a miss here means the two
872/// disagree about what that SQL contains, which is a scythe bug, not
873/// something a caller can recover from.
874pub(crate) fn resolved_param_for_position<'a>(
875    analyzed_params: &[AnalyzedParam],
876    resolved: &'a [ResolvedParam],
877    position: u32,
878) -> &'a ResolvedParam {
879    let idx = analyzed_params
880        .iter()
881        .position(|p| p.position == i64::from(position))
882        .unwrap_or_else(|| panic!("no analyzed parameter at position {position}; analyzed_params={analyzed_params:?}"));
883    &resolved[idx]
884}
885
886/// Whether `text` (a `Code`-span substring, guaranteed free of strings and
887/// comments) contains a `$` immediately followed by an ASCII digit.
888fn code_span_has_dollar_number(text: &str) -> bool {
889    let chars: Vec<char> = text.chars().collect();
890    (0..chars.len()).any(|i| chars[i] == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()))
891}
892
893/// Multi-character PostgreSQL operators built on `?` or `@?` that are never
894/// placeholder syntax in any dialect this crate targets -- JSONB key-array
895/// existence (`?|`, `?&`) and jsonpath existence (`@?`), plus the geometric
896/// comparison operators (`?-`, `?-|`, `?||`). Longer operators are listed
897/// before the shorter operators they prefix (`?-|` before `?-`, `?||` before
898/// `?|`) so the greedy match in [`match_unambiguous_operator`] picks the
899/// longest one, not "the first two characters".
900const UNAMBIGUOUS_OPERATORS: [&str; 6] = ["?-|", "?||", "?|", "?&", "?-", "@?"];
901
902/// If `chars[i..]` starts with one of [`UNAMBIGUOUS_OPERATORS`], return its
903/// length so the caller can copy it through untouched.
904fn match_unambiguous_operator(chars: &[char], i: usize) -> Option<usize> {
905    UNAMBIGUOUS_OPERATORS.iter().find_map(|op| {
906        let op_chars: Vec<char> = op.chars().collect();
907        let end = i + op_chars.len();
908        (end <= chars.len() && chars[i..end] == op_chars[..]).then_some(op_chars.len())
909    })
910}
911
912/// Rewrite `$N` and (when `bare_question_is_placeholder` is true) bare `?`
913/// placeholders within a single `Code` span, appending the result to `out`,
914/// advancing `counter` for each `?` consumed, and recording each rewritten
915/// occurrence's resolved position (in output order) into `occurrences`.
916///
917/// Shared by [`rewrite_pg_placeholders`] (which computes
918/// `bare_question_is_placeholder` from the `$N`-occurrence heuristic and
919/// discards `occurrences`) and [`rewrite_placeholders_indexed`] (which
920/// computes it from the dialect and returns `occurrences`) -- the actual
921/// rewriting is one implementation; only how the two callers decide whether a
922/// bare `?` counts differs. The multi-character operators in
923/// [`UNAMBIGUOUS_OPERATORS`] have no such ambiguity and are always passed
924/// through, regardless of `bare_question_is_placeholder`.
925fn rewrite_code_span_placeholders(
926    text: &str,
927    bare_question_is_placeholder: bool,
928    formatter: &impl Fn(u32) -> String,
929    counter: &mut u32,
930    out: &mut String,
931    occurrences: &mut Vec<u32>,
932) {
933    let chars: Vec<char> = text.chars().collect();
934    let len = chars.len();
935    let mut i = 0;
936    while i < len {
937        if let Some(op_len) = match_unambiguous_operator(&chars, i) {
938            out.extend(&chars[i..i + op_len]);
939            i += op_len;
940            continue;
941        }
942
943        let ch = chars[i];
944        if ch == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
945            let mut j = i + 1;
946            let mut num_str = String::new();
947            while j < len && chars[j].is_ascii_digit() {
948                num_str.push(chars[j]);
949                j += 1;
950            }
951            let num: u32 = num_str.parse().unwrap_or(0);
952            out.push_str(&formatter(num));
953            occurrences.push(num);
954            i = j;
955        } else if ch == '?' && bare_question_is_placeholder && !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
956            *counter += 1;
957            out.push_str(&formatter(*counter));
958            occurrences.push(*counter);
959            i += 1;
960        } else {
961            out.push(ch);
962            i += 1;
963        }
964    }
965}
966
967/// Get a backend by name and database engine.
968///
969/// The `engine` parameter (e.g., "postgresql", "mysql", "sqlite") determines
970/// which manifest is loaded for type mappings. PG-only backends reject non-PG engines.
971pub fn get_backend(name: &str, engine: &str) -> Result<Box<dyn CodegenBackend>, ScytheError> {
972    let canonical_engine = normalize_engine(engine);
973    let backend: Box<dyn CodegenBackend> = match name {
974        "rust-sqlx" | "sqlx" | "rust" => Box::new(sqlx::SqlxBackend::new(canonical_engine)?),
975        "rust-tokio-postgres" | "tokio-postgres" => {
976            Box::new(tokio_postgres::TokioPostgresBackend::new(canonical_engine)?)
977        }
978        "python-psycopg3" | "python" => Box::new(python_psycopg3::PythonPsycopg3Backend::new(canonical_engine)?),
979        "python-asyncpg" => Box::new(python_asyncpg::PythonAsyncpgBackend::new(canonical_engine)?),
980        "python-aiomysql" => Box::new(python_aiomysql::PythonAiomysqlBackend::new(canonical_engine)?),
981        "python-aiosqlite" => Box::new(python_aiosqlite::PythonAiosqliteBackend::new(canonical_engine)?),
982        "python-duckdb" => Box::new(python_duckdb::PythonDuckdbBackend::new(canonical_engine)?),
983        "typescript-postgres" | "ts" | "typescript" => {
984            Box::new(typescript_postgres::TypescriptPostgresBackend::new(canonical_engine)?)
985        }
986        "javascript-postgres" => Box::new(typescript_postgres::TypescriptPostgresBackend::new_js(
987            canonical_engine,
988        )?),
989        "typescript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new(canonical_engine)?),
990        "javascript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new_js(canonical_engine)?),
991        "typescript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new(canonical_engine)?),
992        "javascript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new_js(canonical_engine)?),
993        "typescript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new(
994            canonical_engine,
995        )?),
996        "javascript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new_js(
997            canonical_engine,
998        )?),
999        "typescript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new(canonical_engine)?),
1000        "javascript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new_js(canonical_engine)?),
1001        "typescript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new(
1002            canonical_engine,
1003        )?),
1004        "javascript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new_js(
1005            canonical_engine,
1006        )?),
1007        "typescript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new(
1008            canonical_engine,
1009        )?),
1010        "javascript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new_js(
1011            canonical_engine,
1012        )?),
1013        "typescript-kysely" | "kysely" => Box::new(typescript_kysely::TypescriptKyselyBackend::new(canonical_engine)?),
1014        "go-database-sql" => Box::new(go_database_sql::GoDatabaseSqlBackend::new(canonical_engine)?),
1015        "go-pgx" | "go" => Box::new(go_pgx::GoPgxBackend::new(canonical_engine)?),
1016        "java-jdbc" | "java" => Box::new(java_jdbc::JavaJdbcBackend::new(canonical_engine)?),
1017        "java-r2dbc" | "r2dbc-java" => Box::new(java_r2dbc::JavaR2dbcBackend::new(canonical_engine)?),
1018        "kotlin-exposed" | "exposed" => Box::new(kotlin_exposed::KotlinExposedBackend::new(canonical_engine)?),
1019        "kotlin-jdbc" | "kotlin" | "kt" => Box::new(kotlin_jdbc::KotlinJdbcBackend::new(canonical_engine)?),
1020        "kotlin-r2dbc" | "r2dbc-kotlin" => Box::new(kotlin_r2dbc::KotlinR2dbcBackend::new(canonical_engine)?),
1021        "csharp-npgsql" | "csharp" | "c#" | "dotnet" => {
1022            Box::new(csharp_npgsql::CsharpNpgsqlBackend::new(canonical_engine)?)
1023        }
1024        "csharp-mysqlconnector" => Box::new(csharp_mysqlconnector::CsharpMysqlConnectorBackend::new(
1025            canonical_engine,
1026        )?),
1027        "csharp-microsoft-sqlite" => Box::new(csharp_microsoft_sqlite::CsharpMicrosoftSqliteBackend::new(
1028            canonical_engine,
1029        )?),
1030        "elixir-postgrex" | "elixir" | "ex" => Box::new(elixir_postgrex::ElixirPostgrexBackend::new(canonical_engine)?),
1031        "elixir-ecto" | "ecto" => Box::new(elixir_ecto::ElixirEctoBackend::new(canonical_engine)?),
1032        "elixir-myxql" => Box::new(elixir_myxql::ElixirMyxqlBackend::new(canonical_engine)?),
1033        "elixir-exqlite" => Box::new(elixir_exqlite::ElixirExqliteBackend::new(canonical_engine)?),
1034        "ruby-pg" | "ruby" | "rb" => Box::new(ruby_pg::RubyPgBackend::new(canonical_engine)?),
1035        "ruby-mysql2" => Box::new(ruby_mysql2::RubyMysql2Backend::new(canonical_engine)?),
1036        "ruby-sqlite3" => Box::new(ruby_sqlite3::RubySqlite3Backend::new(canonical_engine)?),
1037        "ruby-trilogy" | "trilogy" => Box::new(ruby_trilogy::RubyTrilogyBackend::new(canonical_engine)?),
1038        "php-pdo" | "php" => Box::new(php_pdo::PhpPdoBackend::new(canonical_engine)?),
1039        "php-amphp" | "amphp" => Box::new(php_amphp::PhpAmphpBackend::new(canonical_engine)?),
1040        "rust-tiberius" | "tiberius" => Box::new(rust_tiberius::RustTiberiusBackend::new(canonical_engine)?),
1041        "python-pyodbc" | "pyodbc" => Box::new(python_pyodbc::PythonPyodbcBackend::new(canonical_engine)?),
1042        "typescript-mssql" | "tedious" => Box::new(typescript_mssql::TypescriptMssqlBackend::new(canonical_engine)?),
1043        "javascript-mssql" => Box::new(typescript_mssql::TypescriptMssqlBackend::new_js(canonical_engine)?),
1044        "csharp-sqlclient" => Box::new(csharp_sqlclient::CsharpSqlClientBackend::new(canonical_engine)?),
1045        "ruby-tiny-tds" | "tiny-tds" | "tiny_tds" => {
1046            Box::new(ruby_tiny_tds::RubyTinyTdsBackend::new(canonical_engine)?)
1047        }
1048        "elixir-tds" | "tds" => Box::new(elixir_tds::ElixirTdsBackend::new(canonical_engine)?),
1049        "rust-sibyl" | "sibyl" => Box::new(rust_sibyl::RustSibylBackend::new(canonical_engine)?),
1050        "python-oracledb" | "oracledb" => Box::new(python_oracledb::PythonOracledbBackend::new(canonical_engine)?),
1051        "typescript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new(canonical_engine)?),
1052        "javascript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new_js(
1053            canonical_engine,
1054        )?),
1055        "go-godror" | "godror" => Box::new(go_godror::GoGodrorBackend::new(canonical_engine)?),
1056        "csharp-oracle" => Box::new(csharp_oracle::CsharpOracleBackend::new(canonical_engine)?),
1057        "ruby-oci8" | "oci8" => Box::new(ruby_oci8::RubyOci8Backend::new(canonical_engine)?),
1058        "elixir-jamdb" | "jamdb" => Box::new(elixir_jamdb::ElixirJamdbBackend::new(canonical_engine)?),
1059        "python-snowflake" => Box::new(python_snowflake::PythonSnowflakeBackend::new(canonical_engine)?),
1060        "typescript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new(canonical_engine)?),
1061        "javascript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new_js(
1062            canonical_engine,
1063        )?),
1064        "go-gosnowflake" | "gosnowflake" => Box::new(go_gosnowflake::GoGosnowflakeBackend::new(canonical_engine)?),
1065        "csharp-snowflake" => Box::new(csharp_snowflake::CsharpSnowflakeBackend::new(canonical_engine)?),
1066        _ => {
1067            return Err(ScytheError::new(
1068                ErrorCode::InvalidConfig,
1069                format!("unknown backend: {}", name),
1070            ));
1071        }
1072    };
1073
1074    if !backend
1075        .supported_engines()
1076        .iter()
1077        .any(|e| normalize_engine(e) == canonical_engine)
1078    {
1079        return Err(ScytheError::new(
1080            ErrorCode::InvalidConfig,
1081            format!(
1082                "backend '{}' does not support engine '{}'. Supported: {:?}",
1083                name,
1084                engine,
1085                backend.supported_engines()
1086            ),
1087        ));
1088    }
1089
1090    Ok(backend)
1091}
1092
1093/// Normalize engine name to canonical form.
1094///
1095/// Public so callers outside this crate (e.g. `scythe-cli`) can classify a
1096/// `[[sql]]` block's configured engine without duplicating the alias table —
1097/// for example, to decide whether a block is PostgreSQL-wire-compatible
1098/// before attempting live-database verification.
1099pub fn normalize_engine(engine: &str) -> &str {
1100    match engine {
1101        "postgresql" | "postgres" | "pg" | "cockroachdb" | "crdb" => "postgresql",
1102        "mysql" => "mysql",
1103        "mariadb" => "mariadb",
1104        "sqlite" | "sqlite3" => "sqlite",
1105        "duckdb" => "duckdb",
1106        "mssql" | "sqlserver" | "tsql" => "mssql",
1107        "oracle" => "oracle",
1108        "snowflake" => "snowflake",
1109        "redshift" => "redshift",
1110        other => other,
1111    }
1112}
1113
1114/// Exact-value characterization tests for `clean_sql`, `clean_sql_oneline`,
1115/// and `rewrite_pg_placeholders`, kept in their own file so the pinned
1116/// behaviour is readable as a specification for the planned dialect-aware
1117/// tokenizer.
1118#[cfg(test)]
1119mod sql_text_characterization_tests;
1120
1121#[cfg(test)]
1122mod tests {
1123    use super::*;
1124
1125    fn param(name: &str, position: i64) -> AnalyzedParam {
1126        AnalyzedParam {
1127            name: name.to_string(),
1128            neutral_type: "string".to_string(),
1129            nullable: true,
1130            position,
1131            source_relation: None,
1132        }
1133    }
1134
1135    /// Structural guard for #82: no source file under `src/backends` may
1136    /// reference a manifest path relative to the process working directory,
1137    /// however that reference is spelled. This catches a reintroduction of
1138    /// the filesystem-lookup pattern written differently from the original
1139    /// (different variable names, a helper function, etc.) that a simple
1140    /// function-name grep for the old `load_or_default_manifest` helper would
1141    /// miss.
1142    ///
1143    /// The forbidden needle is built at runtime, not written as a literal
1144    /// here, so this check does not trip over its own source describing what
1145    /// it looks for.
1146    #[test]
1147    fn test_no_backends_relative_manifest_path_literals() {
1148        let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src/backends");
1149        let word: String = format!("backend{}", "s/");
1150        let needle: String = format!("{}{}", '"', word);
1151
1152        let mut offenders = Vec::new();
1153        collect_rs_files(std::path::Path::new(root), &mut offenders);
1154
1155        let offenders: Vec<_> = offenders
1156            .into_iter()
1157            .filter(|path| {
1158                let contents = std::fs::read_to_string(path).expect("failed to read source file");
1159                contents.contains(&needle)
1160            })
1161            .collect();
1162
1163        assert!(
1164            offenders.is_empty(),
1165            "found a working-directory-relative manifest path literal in: {offenders:?} (#82 regression \
1166             -- manifest selection must be a pure function of (backend, engine), no filesystem lookup)"
1167        );
1168    }
1169
1170    /// Recursively collect every `.rs` file path under `dir`.
1171    fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
1172        let entries = std::fs::read_dir(dir).expect("failed to read backends dir");
1173        for entry in entries {
1174            let entry = entry.expect("failed to read dir entry");
1175            let path = entry.path();
1176            if path.is_dir() {
1177                collect_rs_files(&path, out);
1178            } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
1179                out.push(path);
1180            }
1181        }
1182    }
1183
1184    /// Documents the engine-blind collision the deleted CWD-relative lookup
1185    /// would have caused: two backends sharing a backend-name-only manifest
1186    /// path (`backends/<name>/manifest.toml`) but supporting different
1187    /// engines would have silently shared one manifest file on disk. Pure
1188    /// `(backend, engine)` selection must not exhibit this collision.
1189    #[test]
1190    fn test_get_backend_rust_sqlx_mysql_has_no_postgresql_scalars() {
1191        let backend = get_backend("rust-sqlx", "mysql").expect("rust-sqlx should support mysql");
1192        let scalars = &backend.manifest().types.scalars;
1193        assert_ne!(
1194            scalars.get("uuid").map(String::as_str),
1195            Some("uuid::Uuid"),
1196            "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1197        );
1198        assert_ne!(
1199            scalars.get("inet").map(String::as_str),
1200            Some("ipnetwork::IpNetwork"),
1201            "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'inet' mapping"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_get_backend_java_jdbc_sqlite_has_no_postgresql_scalars() {
1207        let backend = get_backend("java-jdbc", "sqlite").expect("java-jdbc should support sqlite");
1208        let scalars = &backend.manifest().types.scalars;
1209        assert_ne!(
1210            scalars.get("uuid").map(String::as_str),
1211            Some("java.util.UUID"),
1212            "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1213        );
1214        assert_ne!(
1215            scalars.get("decimal").map(String::as_str),
1216            Some("java.math.BigDecimal"),
1217            "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'decimal' mapping"
1218        );
1219    }
1220
1221    #[test]
1222    fn test_normalize_engine_cockroachdb() {
1223        assert_eq!(normalize_engine("cockroachdb"), "postgresql");
1224        assert_eq!(normalize_engine("crdb"), "postgresql");
1225    }
1226
1227    #[test]
1228    fn test_get_backend_cockroachdb_with_pg_backends() {
1229        let pg_backends = [
1230            "rust-sqlx",
1231            "rust-tokio-postgres",
1232            "python-psycopg3",
1233            "python-asyncpg",
1234            "typescript-postgres",
1235            "typescript-pg",
1236            "typescript-kysely",
1237            "go-pgx",
1238            "ruby-pg",
1239            "elixir-postgrex",
1240            "csharp-npgsql",
1241            "php-pdo",
1242            "php-amphp",
1243        ];
1244        for backend_name in &pg_backends {
1245            let result = get_backend(backend_name, "cockroachdb");
1246            assert!(
1247                result.is_ok(),
1248                "backend '{}' should accept cockroachdb engine, got: {:?}",
1249                backend_name,
1250                result.err()
1251            );
1252        }
1253    }
1254
1255    #[test]
1256    fn test_get_backend_crdb_alias() {
1257        let result = get_backend("rust-sqlx", "crdb");
1258        assert!(result.is_ok(), "rust-sqlx should accept 'crdb' engine alias");
1259    }
1260
1261    #[test]
1262    fn test_normalize_engine_duckdb() {
1263        assert_eq!(normalize_engine("duckdb"), "duckdb");
1264    }
1265
1266    #[test]
1267    fn test_get_backend_duckdb_with_compatible_backends() {
1268        let duckdb_backends = [
1269            "python-duckdb",
1270            "typescript-duckdb",
1271            "go-database-sql",
1272            "java-jdbc",
1273            "kotlin-jdbc",
1274        ];
1275        for backend_name in &duckdb_backends {
1276            let result = get_backend(backend_name, "duckdb");
1277            assert!(
1278                result.is_ok(),
1279                "backend '{}' should accept duckdb engine, got: {:?}",
1280                backend_name,
1281                result.err()
1282            );
1283        }
1284    }
1285
1286    #[test]
1287    fn test_get_backend_duckdb_rejected_by_pg_only() {
1288        let result = get_backend("rust-sqlx", "duckdb");
1289        assert!(result.is_err(), "rust-sqlx should reject duckdb engine");
1290    }
1291
1292    #[test]
1293    fn test_rewrite_simple_equality() {
1294        let sql = "SELECT * FROM users WHERE status = $1";
1295        let params = vec![param("status", 1)];
1296        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1297        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status = $1)");
1298    }
1299
1300    #[test]
1301    fn test_rewrite_qualified_column() {
1302        let sql = "SELECT * FROM users u WHERE u.status = $1";
1303        let params = vec![param("status", 1)];
1304        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1305        assert_eq!(result, "SELECT * FROM users u WHERE ($1 IS NULL OR u.status = $1)");
1306    }
1307
1308    #[test]
1309    fn test_rewrite_multiple_optional() {
1310        let sql = "SELECT * FROM users WHERE status = $1 AND name = $2";
1311        let params = vec![param("status", 1), param("name", 2)];
1312        let result = rewrite_optional_params(sql, &["status".to_string(), "name".to_string()], &params);
1313        assert_eq!(
1314            result,
1315            "SELECT * FROM users WHERE ($1 IS NULL OR status = $1) AND ($2 IS NULL OR name = $2)"
1316        );
1317    }
1318
1319    #[test]
1320    fn test_rewrite_mixed_optional_required() {
1321        let sql = "SELECT * FROM users WHERE id = $1 AND status = $2";
1322        let params = vec![param("id", 1), param("status", 2)];
1323        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1324        assert_eq!(
1325            result,
1326            "SELECT * FROM users WHERE id = $1 AND ($2 IS NULL OR status = $2)"
1327        );
1328    }
1329
1330    #[test]
1331    fn test_rewrite_like_operator() {
1332        let sql = "SELECT * FROM users WHERE name LIKE $1";
1333        let params = vec![param("name", 1)];
1334        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
1335        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name LIKE $1)");
1336    }
1337
1338    #[test]
1339    fn test_rewrite_ilike_operator() {
1340        let sql = "SELECT * FROM users WHERE name ILIKE $1";
1341        let params = vec![param("name", 1)];
1342        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
1343        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name ILIKE $1)");
1344    }
1345
1346    #[test]
1347    fn test_rewrite_comparison_operators() {
1348        let sql = "SELECT * FROM users WHERE age >= $1";
1349        let params = vec![param("age", 1)];
1350        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
1351        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age >= $1)");
1352    }
1353
1354    #[test]
1355    fn test_rewrite_less_than() {
1356        let sql = "SELECT * FROM users WHERE age < $1";
1357        let params = vec![param("age", 1)];
1358        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
1359        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age < $1)");
1360    }
1361
1362    #[test]
1363    fn test_no_rewrite_without_optional() {
1364        let sql = "SELECT * FROM users WHERE status = $1";
1365        let params = vec![param("status", 1)];
1366        let result = rewrite_optional_params(sql, &[], &params);
1367        assert_eq!(result, sql);
1368    }
1369
1370    #[test]
1371    fn test_rewrite_not_equal() {
1372        let sql = "SELECT * FROM users WHERE status <> $1";
1373        let params = vec![param("status", 1)];
1374        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1375        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status <> $1)");
1376    }
1377
1378    #[test]
1379    fn test_rewrite_does_not_match_similar_placeholder() {
1380        let sql = "SELECT * FROM users WHERE status = $10";
1381        let params = vec![param("status", 1)];
1382        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1383        assert_eq!(result, sql);
1384    }
1385
1386    #[test]
1387    fn test_normalize_engine_mariadb() {
1388        assert_eq!(normalize_engine("mariadb"), "mariadb");
1389    }
1390
1391    #[test]
1392    fn test_get_backend_mariadb_with_mysql_backends() {
1393        let mariadb_backends = [
1394            "rust-sqlx",
1395            "python-aiomysql",
1396            "typescript-mysql2",
1397            "go-database-sql",
1398            "java-jdbc",
1399            "java-r2dbc",
1400            "kotlin-jdbc",
1401            "kotlin-r2dbc",
1402            "csharp-mysqlconnector",
1403            "elixir-myxql",
1404            "ruby-mysql2",
1405            "ruby-trilogy",
1406            "php-pdo",
1407            "php-amphp",
1408        ];
1409        for backend_name in &mariadb_backends {
1410            let result = get_backend(backend_name, "mariadb");
1411            assert!(
1412                result.is_ok(),
1413                "backend '{}' should accept mariadb engine, got: {:?}",
1414                backend_name,
1415                result.err()
1416            );
1417        }
1418    }
1419
1420    // -----------------------------------------------------------------
1421    // SQL lexer / rewriting tests (board #148, #186, #149-adjacent).
1422    //
1423    // These pin down the behaviour of `clean_sql`, `clean_sql_oneline`, and
1424    // `rewrite_pg_placeholders` against a real SQL lexer instead of the
1425    // character-scanning implementation these functions previously had.
1426    // Written before the lexer existed, to prove each defect is real.
1427    // -----------------------------------------------------------------
1428
1429    #[test]
1430    fn test_clean_sql_oneline_strips_mid_line_comment_but_keeps_rest_of_query() {
1431        let sql = "SELECT id, name\nFROM users\nWHERE id > $1 -- skip low ids\n  AND name IS NOT NULL;";
1432        let result = clean_sql_oneline(sql);
1433        assert!(
1434            result.contains("AND name IS NOT NULL"),
1435            "mid-line comment must not swallow the rest of the query, got: {result:?}"
1436        );
1437        assert!(
1438            !result.contains("--"),
1439            "comment marker must be stripped, got: {result:?}"
1440        );
1441    }
1442
1443    #[test]
1444    fn test_clean_sql_strips_mid_line_comment_but_keeps_rest_of_query() {
1445        let sql = "SELECT id\nFROM users\nWHERE id > $1 -- skip low ids\nAND active = true;";
1446        let result = clean_sql(sql);
1447        assert!(result.contains("AND active = true"), "got: {result:?}");
1448        assert!(!result.contains("--"), "got: {result:?}");
1449    }
1450
1451    #[test]
1452    fn test_clean_sql_preserves_double_dash_inside_single_quoted_string() {
1453        let sql = "SELECT * FROM t WHERE label = 'a -- not a comment' AND id = $1;";
1454        let result = clean_sql_oneline(sql);
1455        assert!(result.contains("'a -- not a comment'"), "got: {result:?}");
1456        assert!(result.contains("id = $1"), "got: {result:?}");
1457    }
1458
1459    #[test]
1460    fn test_clean_sql_preserves_double_dash_inside_double_quoted_identifier() {
1461        let sql = "SELECT \"weird--col\" FROM t WHERE id = $1;";
1462        let result = clean_sql_oneline(sql);
1463        assert!(result.contains("\"weird--col\""), "got: {result:?}");
1464    }
1465
1466    #[test]
1467    fn test_clean_sql_preserves_double_dash_inside_dollar_quoted_string() {
1468        let sql = "SELECT $$has -- inside$$ AS x FROM t WHERE id = $1;";
1469        let result = clean_sql_oneline(sql);
1470        assert!(result.contains("$$has -- inside$$"), "got: {result:?}");
1471    }
1472
1473    #[test]
1474    fn test_clean_sql_strips_nested_block_comments() {
1475        let sql = "SELECT id /* outer /* inner */ still outer */ FROM t WHERE id = $1;";
1476        let result = clean_sql_oneline(sql);
1477        assert!(!result.contains("outer"), "got: {result:?}");
1478        assert!(!result.contains("inner"), "got: {result:?}");
1479        assert!(result.contains("SELECT id"), "got: {result:?}");
1480        assert!(result.contains("FROM t WHERE id = $1"), "got: {result:?}");
1481    }
1482
1483    #[test]
1484    fn test_clean_sql_unterminated_block_comment_consumes_to_end_without_panicking() {
1485        let sql = "SELECT id FROM t /* oops forgot to close";
1486        let result = clean_sql_oneline(sql);
1487        assert_eq!(result, "SELECT id FROM t");
1488    }
1489
1490    #[test]
1491    fn test_clean_sql_drops_lines_that_are_only_a_header_comment() {
1492        // Regression guard: header comment lines (`-- @name ...`) must be
1493        // deleted entirely, not replaced by a blank line -- every generated
1494        // fixture depends on this.
1495        let sql = "-- @name Foo\n-- @returns :many\nSELECT id FROM t WHERE id = $1;";
1496        let result = clean_sql(sql);
1497        assert_eq!(result, "SELECT id FROM t WHERE id = $1");
1498    }
1499
1500    #[test]
1501    fn test_rewrite_pg_placeholders_preserves_dollar_quoted_body() {
1502        let sql =
1503            "SELECT id FROM t WHERE body = $$literal -- not comment, ' quote, $1 not a placeholder$$ AND id = $2;";
1504        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1505        assert!(
1506            result.contains("$$literal -- not comment, ' quote, $1 not a placeholder$$"),
1507            "dollar-quoted body must survive untouched, got: {result:?}"
1508        );
1509        assert!(result.contains("id = @p2"), "got: {result:?}");
1510    }
1511
1512    #[test]
1513    fn test_rewrite_pg_placeholders_handles_escaped_single_quotes() {
1514        let sql = "SELECT id FROM t WHERE label = 'it''s $1 not a placeholder' AND id = $2;";
1515        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1516        assert!(result.contains("'it''s $1 not a placeholder'"), "got: {result:?}");
1517        assert!(result.contains("id = @p2"), "got: {result:?}");
1518    }
1519
1520    #[test]
1521    fn test_rewrite_pg_placeholders_preserves_jsonb_operators() {
1522        let sql = "SELECT id FROM docs WHERE data ? 'mykey' AND tags ?| array['a','b'] AND tags ?& array['a','b'] AND id = $1;";
1523        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1524        assert!(result.contains("data ? 'mykey'"), "got: {result:?}");
1525        assert!(result.contains("tags ?| array"), "got: {result:?}");
1526        assert!(result.contains("tags ?& array"), "got: {result:?}");
1527        assert!(result.contains("id = @p1"), "got: {result:?}");
1528    }
1529
1530    /// Coordinator repro: a JSONB query with *zero* `$N` placeholders, so the
1531    /// `uses_dollar_placeholders` heuristic has nothing to anchor on. `?|`
1532    /// and `?&` are unambiguous -- never placeholder syntax in any dialect
1533    /// this crate targets -- so they must survive regardless of that
1534    /// heuristic. The bare `?` (`data ? 'active'`) is genuinely ambiguous
1535    /// without dialect information and is intentionally still rewritten
1536    /// here; see the residual-limitation comment on
1537    /// `rewrite_code_span_placeholders`.
1538    #[test]
1539    fn test_rewrite_pg_placeholders_preserves_jsonb_multi_char_operators_with_zero_dollar_placeholders() {
1540        let sql = "SELECT * FROM docs WHERE data ? 'active' AND tags ?| ARRAY['a'] AND meta ?& ARRAY['b']";
1541        let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1542        assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1543        assert!(result.contains("meta ?& ARRAY['b']"), "got: {result:?}");
1544    }
1545
1546    #[test]
1547    fn test_rewrite_pg_placeholders_preserves_geometry_operators() {
1548        let sql = "SELECT id FROM shapes WHERE line1 ?- line2 AND line1 ?-| line2 AND line1 ?|| line2 AND id = $1;";
1549        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1550        assert!(result.contains("line1 ?- line2"), "got: {result:?}");
1551        assert!(result.contains("line1 ?-| line2"), "got: {result:?}");
1552        assert!(result.contains("line1 ?|| line2"), "got: {result:?}");
1553        assert!(result.contains("id = @p1"), "got: {result:?}");
1554    }
1555
1556    #[test]
1557    fn test_rewrite_pg_placeholders_preserves_operators_mixed_with_dollar_placeholder() {
1558        let sql = "SELECT id FROM docs WHERE tags ?| ARRAY['a'] AND doc @? '$.a' AND id = $1;";
1559        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1560        assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1561        assert!(result.contains("doc @? '$.a'"), "got: {result:?}");
1562        assert!(result.contains("id = @p1"), "got: {result:?}");
1563    }
1564
1565    #[test]
1566    fn test_rewrite_pg_placeholders_number_boundary() {
1567        let sql = "SELECT $1, $10, $1x";
1568        let result = rewrite_pg_placeholders(sql, |n| format!("P{n}"));
1569        assert_eq!(result, "SELECT P1, P10, P1x");
1570    }
1571
1572    #[test]
1573    fn test_rewrite_pg_placeholders_ignores_question_mark_inside_string_literal() {
1574        let sql = "SELECT id FROM t WHERE note = 'is this a question?' AND age > ?;";
1575        let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1576        assert!(result.contains("'is this a question?'"), "got: {result:?}");
1577        assert!(result.contains("age > $1"), "got: {result:?}");
1578    }
1579
1580    #[test]
1581    fn test_clean_sql_handles_non_ascii_in_comment_and_string() {
1582        let sql = "SELECT name -- 名前を取得 comment\nFROM t WHERE label = 'héllo wörld' AND id = $1;";
1583        let result = clean_sql_oneline(sql);
1584        assert!(!result.contains('名'), "got: {result:?}");
1585        assert!(result.contains("'héllo wörld'"), "got: {result:?}");
1586        assert!(result.contains("id = $1"), "got: {result:?}");
1587    }
1588
1589    #[test]
1590    fn test_clean_sql_handles_crlf_line_endings() {
1591        let sql = "SELECT id\r\nFROM users\r\n-- a comment\r\nWHERE id = $1;";
1592        let result = clean_sql(sql);
1593        assert!(!result.contains('\r'), "got: {result:?}");
1594        assert!(!result.contains("-- a comment"), "got: {result:?}");
1595        assert_eq!(result, "SELECT id\nFROM users\nWHERE id = $1");
1596    }
1597
1598    #[test]
1599    fn test_rewrite_optional_params_handles_not_like() {
1600        let sql = "SELECT id FROM t WHERE name NOT LIKE $1";
1601        let params = vec![param("name", 1)];
1602        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
1603        assert_eq!(result, "SELECT id FROM t WHERE ($1 IS NULL OR name NOT LIKE $1)");
1604    }
1605
1606    // -----------------------------------------------------------------
1607    // Dialect-aware tokenizer / placeholder tests (board #148, GH #149).
1608    //
1609    // These exercise `rewrite_placeholders_indexed` and `clean_sql_dialect`/
1610    // `clean_sql_oneline_dialect` directly -- the dialect-driven replacements
1611    // every owned backend (java-jdbc, kotlin-jdbc, kotlin-exposed, php-amphp)
1612    // now calls instead of the heuristic-based `rewrite_pg_placeholders`/
1613    // `clean_sql`/`clean_sql_oneline`. Each test states, in its doc comment or
1614    // inline, what the OLD (heuristic-based, dialect-blind) code produced for
1615    // the same input.
1616    // -----------------------------------------------------------------
1617
1618    /// The headline board #148 defect, reproduced against both the OLD heuristic
1619    /// entry point and the NEW dialect-driven one on the exact same input.
1620    /// `rewrite_pg_placeholders` (unchanged, kept for callers this change did
1621    /// not reach) has no `$N` to anchor its heuristic on, so it guesses "no
1622    /// dollar placeholders exist -> bare `?` is positional" and corrupts the
1623    /// JSONB key-existence operator into a placeholder reference:
1624    /// `WHERE data $1 'active'`. `rewrite_placeholders_indexed` under
1625    /// `SqlDialect::PostgreSQL` is told the dialect directly and never
1626    /// rewrites it, with zero occurrences recorded.
1627    #[test]
1628    fn test_bare_question_mark_is_never_a_placeholder_under_postgresql_even_with_zero_dollar_placeholders() {
1629        let sql = "SELECT * FROM docs WHERE data ? 'active'";
1630
1631        let old_result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1632        assert_eq!(
1633            old_result, "SELECT * FROM docs WHERE data $1 'active'",
1634            "pins the OLD heuristic's corruption of the JSONB operator"
1635        );
1636
1637        let (new_result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::PostgreSQL, |n| format!("${n}"));
1638        assert_eq!(
1639            new_result, sql,
1640            "dialect-driven rewrite must leave the JSONB operator untouched"
1641        );
1642        assert!(
1643            occurrences.is_empty(),
1644            "no placeholder was ever present; got: {occurrences:?}"
1645        );
1646    }
1647
1648    /// Same bare `?`, but under a dialect that genuinely uses it as a
1649    /// positional placeholder -- proving the decision is dialect-driven, not
1650    /// "always leave `?` alone now".
1651    #[test]
1652    fn test_bare_question_mark_is_a_placeholder_under_mysql() {
1653        let sql = "SELECT * FROM docs WHERE data ? 'active'";
1654        let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("${n}"));
1655        assert_eq!(result, "SELECT * FROM docs WHERE data $1 'active'");
1656        assert_eq!(occurrences, vec![1]);
1657    }
1658
1659    /// GH #149: `$2` declared before `$1` in the SQL text must report its
1660    /// own position at each occurrence, in textual order -- this is what lets
1661    /// a backend bind per SQL-text occurrence instead of assuming declaration
1662    /// order.
1663    #[test]
1664    fn test_rewrite_placeholders_indexed_returns_positions_in_textual_order_out_of_order() {
1665        let (result, occurrences) =
1666            rewrite_placeholders_indexed("WHERE b = $2 AND a = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1667        assert_eq!(result, "WHERE b = ? AND a = ?");
1668        assert_eq!(occurrences, vec![2, 1]);
1669    }
1670
1671    /// GH #149: a repeated `$1` must report position `1` twice, once per
1672    /// occurrence -- not collapse to a single entry the way a per-declared-
1673    /// parameter bind loop would.
1674    #[test]
1675    fn test_rewrite_placeholders_indexed_repeated_placeholder_reports_one_occurrence_per_use() {
1676        let (result, occurrences) =
1677            rewrite_placeholders_indexed("WHERE a = $1 OR b = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1678        assert_eq!(result, "WHERE a = ? OR b = ?");
1679        assert_eq!(occurrences, vec![1, 1]);
1680    }
1681
1682    /// board #148 item 2/#186: a MySQL backtick-quoted identifier containing a bare
1683    /// `?` must not have the `?` inside it rewritten, and the identifier's own
1684    /// characters must not desynchronize the *later* placeholder's number.
1685    /// Matches the `WRONG:`-documented expectation in
1686    /// `sql_text_characterization_tests.rs`'s
1687    /// `question_mark_inside_a_mysql_backtick_identifier_is_rewritten_as_a_placeholder`.
1688    #[test]
1689    fn test_mysql_backtick_identifier_is_not_corrupted_by_bare_question_mark() {
1690        let sql = "SELECT `a?b` FROM t WHERE c = ?";
1691        let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("[P{n}]"));
1692        assert_eq!(result, "SELECT `a?b` FROM t WHERE c = [P1]");
1693        assert_eq!(occurrences, vec![1]);
1694    }
1695
1696    /// board #148 item 4: same as above for an MSSQL `[bracketed]` identifier.
1697    #[test]
1698    fn test_mssql_bracket_identifier_is_not_corrupted_by_bare_question_mark() {
1699        let sql = "SELECT [a?b] FROM t WHERE c = ?";
1700        let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MsSql, |n| format!("[P{n}]"));
1701        assert_eq!(result, "SELECT [a?b] FROM t WHERE c = [P1]");
1702        assert_eq!(occurrences, vec![1]);
1703    }
1704
1705    /// board #148 item 2: a MySQL backtick identifier containing `-- ` must not be
1706    /// read as opening a line comment. Matches the `clean_sql_dialect`
1707    /// counterpart of the `WRONG:`-documented
1708    /// `clean_sql_treats_a_double_dash_inside_a_mysql_backtick_identifier_as_a_comment`
1709    /// characterization test (which pins the OLD, dialect-blind `clean_sql`'s
1710    /// corruption of the same input to `"SELECT \`a"`).
1711    #[test]
1712    fn test_clean_sql_dialect_mysql_backtick_preserves_double_dash_inside() {
1713        let sql = "SELECT `a -- b` FROM t";
1714        assert_eq!(
1715            clean_sql(sql),
1716            "SELECT `a",
1717            "pins the OLD dialect-blind clean_sql's corruption"
1718        );
1719        assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), sql);
1720    }
1721
1722    /// board #148 item 4: same as above for an MSSQL `[bracketed]` identifier.
1723    #[test]
1724    fn test_clean_sql_dialect_mssql_bracket_preserves_double_dash_inside() {
1725        let sql = "SELECT [a -- b] FROM t";
1726        assert_eq!(
1727            clean_sql(sql),
1728            "SELECT [a",
1729            "pins the OLD dialect-blind clean_sql's corruption"
1730        );
1731        assert_eq!(clean_sql_dialect(sql, SqlDialect::MsSql), sql);
1732    }
1733
1734    /// board #148 item 3: MySQL `#` line comments were deliberately unrecognized
1735    /// dialect-blind (`#` collides with PostgreSQL's `#>`/`#>>` JSON
1736    /// operators), so the OLD `clean_sql` leaves the whole comment in the
1737    /// output. Under `SqlDialect::MySQL` it must be stripped like `--`.
1738    #[test]
1739    fn test_clean_sql_dialect_mysql_hash_comment_is_stripped() {
1740        let sql = "SELECT 1 # note\nFROM t";
1741        assert_eq!(
1742            clean_sql(sql),
1743            "SELECT 1 # note\nFROM t",
1744            "pins the OLD dialect-blind clean_sql leaving the # comment in place"
1745        );
1746        // WRONG-adjacent (matches the pre-existing `--` wart pinned by
1747        // `clean_sql_strips_a_trailing_line_comment_but_leaves_the_space_before_it`
1748        // in sql_text_characterization_tests.rs): stripping a trailing
1749        // comment does not also strip the space that preceded it.
1750        assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), "SELECT 1 \nFROM t");
1751    }
1752
1753    /// The oneline variant of the `#` test above, since every owned backend
1754    /// calls `clean_sql_oneline_dialect` (or its `_with_optional` wrapper),
1755    /// never `clean_sql_dialect`, to flatten SQL into one embedded literal.
1756    #[test]
1757    fn test_clean_sql_oneline_dialect_mysql_hash_comment_is_stripped() {
1758        let sql = "SELECT 1 # note\nFROM t";
1759        assert_eq!(clean_sql_oneline_dialect(sql, SqlDialect::MySQL), "SELECT 1  FROM t");
1760    }
1761
1762    /// PostgreSQL's `#>`/`#>>` JSON operators are exactly why `#` cannot be
1763    /// treated as a comment dialect-blind -- confirm they still survive
1764    /// untouched under `SqlDialect::PostgreSQL`.
1765    #[test]
1766    fn test_clean_sql_dialect_postgresql_hash_is_not_a_comment() {
1767        let sql = "SELECT data #> '{a,b}' AS x FROM t";
1768        assert_eq!(clean_sql_dialect(sql, SqlDialect::PostgreSQL), sql);
1769    }
1770}