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/// ~keep Rewrite live SQL placeholders and append PostgreSQL casts derived from their resolved types.
887pub(crate) fn rewrite_pg_typed_placeholders(
888    sql: &str,
889    analyzed_params: &[AnalyzedParam],
890    resolved_params: &[ResolvedParam],
891    cast_enums: bool,
892    formatter: impl Fn(u32) -> String,
893) -> (String, Vec<u32>) {
894    rewrite_placeholders_indexed(sql, SqlDialect::PostgreSQL, |position| {
895        let mut placeholder = formatter(position);
896        let param = resolved_param_for_position(analyzed_params, resolved_params, position);
897        if cast_enums && let Some(enum_type) = param.neutral_type.strip_prefix("enum::") {
898            placeholder.push_str("::");
899            placeholder.push_str(enum_type);
900        } else if let Some(composite_type) = param.neutral_type.strip_prefix("composite::") {
901            placeholder.push_str("::text::");
902            placeholder.push_str(composite_type);
903        }
904        placeholder
905    })
906}
907
908/// Whether `text` (a `Code`-span substring, guaranteed free of strings and
909/// comments) contains a `$` immediately followed by an ASCII digit.
910fn code_span_has_dollar_number(text: &str) -> bool {
911    let chars: Vec<char> = text.chars().collect();
912    (0..chars.len()).any(|i| chars[i] == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()))
913}
914
915/// Multi-character PostgreSQL operators built on `?` or `@?` that are never
916/// placeholder syntax in any dialect this crate targets -- JSONB key-array
917/// existence (`?|`, `?&`) and jsonpath existence (`@?`), plus the geometric
918/// comparison operators (`?-`, `?-|`, `?||`). Longer operators are listed
919/// before the shorter operators they prefix (`?-|` before `?-`, `?||` before
920/// `?|`) so the greedy match in [`match_unambiguous_operator`] picks the
921/// longest one, not "the first two characters".
922const UNAMBIGUOUS_OPERATORS: [&str; 6] = ["?-|", "?||", "?|", "?&", "?-", "@?"];
923
924/// If `chars[i..]` starts with one of [`UNAMBIGUOUS_OPERATORS`], return its
925/// length so the caller can copy it through untouched.
926fn match_unambiguous_operator(chars: &[char], i: usize) -> Option<usize> {
927    UNAMBIGUOUS_OPERATORS.iter().find_map(|op| {
928        let op_chars: Vec<char> = op.chars().collect();
929        let end = i + op_chars.len();
930        (end <= chars.len() && chars[i..end] == op_chars[..]).then_some(op_chars.len())
931    })
932}
933
934/// Rewrite `$N` and (when `bare_question_is_placeholder` is true) bare `?`
935/// placeholders within a single `Code` span, appending the result to `out`,
936/// advancing `counter` for each `?` consumed, and recording each rewritten
937/// occurrence's resolved position (in output order) into `occurrences`.
938///
939/// Shared by [`rewrite_pg_placeholders`] (which computes
940/// `bare_question_is_placeholder` from the `$N`-occurrence heuristic and
941/// discards `occurrences`) and [`rewrite_placeholders_indexed`] (which
942/// computes it from the dialect and returns `occurrences`) -- the actual
943/// rewriting is one implementation; only how the two callers decide whether a
944/// bare `?` counts differs. The multi-character operators in
945/// [`UNAMBIGUOUS_OPERATORS`] have no such ambiguity and are always passed
946/// through, regardless of `bare_question_is_placeholder`.
947fn rewrite_code_span_placeholders(
948    text: &str,
949    bare_question_is_placeholder: bool,
950    formatter: &impl Fn(u32) -> String,
951    counter: &mut u32,
952    out: &mut String,
953    occurrences: &mut Vec<u32>,
954) {
955    let chars: Vec<char> = text.chars().collect();
956    let len = chars.len();
957    let mut i = 0;
958    while i < len {
959        if let Some(op_len) = match_unambiguous_operator(&chars, i) {
960            out.extend(&chars[i..i + op_len]);
961            i += op_len;
962            continue;
963        }
964
965        let ch = chars[i];
966        if ch == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
967            let mut j = i + 1;
968            let mut num_str = String::new();
969            while j < len && chars[j].is_ascii_digit() {
970                num_str.push(chars[j]);
971                j += 1;
972            }
973            let num: u32 = num_str.parse().unwrap_or(0);
974            out.push_str(&formatter(num));
975            occurrences.push(num);
976            i = j;
977        } else if ch == '?' && bare_question_is_placeholder && !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
978            *counter += 1;
979            out.push_str(&formatter(*counter));
980            occurrences.push(*counter);
981            i += 1;
982        } else {
983            out.push(ch);
984            i += 1;
985        }
986    }
987}
988
989/// Get a backend by name and database engine.
990///
991/// The `engine` parameter (e.g., "postgresql", "mysql", "sqlite") determines
992/// which manifest is loaded for type mappings. PG-only backends reject non-PG engines.
993pub fn get_backend(name: &str, engine: &str) -> Result<Box<dyn CodegenBackend>, ScytheError> {
994    let canonical_engine = normalize_engine(engine);
995    let backend: Box<dyn CodegenBackend> = match name {
996        "rust-sqlx" | "sqlx" | "rust" => Box::new(sqlx::SqlxBackend::new(canonical_engine)?),
997        "rust-tokio-postgres" | "tokio-postgres" => {
998            Box::new(tokio_postgres::TokioPostgresBackend::new(canonical_engine)?)
999        }
1000        "python-psycopg3" | "python" => Box::new(python_psycopg3::PythonPsycopg3Backend::new(canonical_engine)?),
1001        "python-asyncpg" => Box::new(python_asyncpg::PythonAsyncpgBackend::new(canonical_engine)?),
1002        "python-aiomysql" => Box::new(python_aiomysql::PythonAiomysqlBackend::new(canonical_engine)?),
1003        "python-aiosqlite" => Box::new(python_aiosqlite::PythonAiosqliteBackend::new(canonical_engine)?),
1004        "python-duckdb" => Box::new(python_duckdb::PythonDuckdbBackend::new(canonical_engine)?),
1005        "typescript-postgres" | "ts" | "typescript" => {
1006            Box::new(typescript_postgres::TypescriptPostgresBackend::new(canonical_engine)?)
1007        }
1008        "javascript-postgres" => Box::new(typescript_postgres::TypescriptPostgresBackend::new_js(
1009            canonical_engine,
1010        )?),
1011        "typescript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new(canonical_engine)?),
1012        "javascript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new_js(canonical_engine)?),
1013        "typescript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new(canonical_engine)?),
1014        "javascript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new_js(canonical_engine)?),
1015        "typescript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new(
1016            canonical_engine,
1017        )?),
1018        "javascript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new_js(
1019            canonical_engine,
1020        )?),
1021        "typescript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new(canonical_engine)?),
1022        "javascript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new_js(canonical_engine)?),
1023        "typescript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new(
1024            canonical_engine,
1025        )?),
1026        "javascript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new_js(
1027            canonical_engine,
1028        )?),
1029        "typescript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new(
1030            canonical_engine,
1031        )?),
1032        "javascript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new_js(
1033            canonical_engine,
1034        )?),
1035        "typescript-kysely" | "kysely" => Box::new(typescript_kysely::TypescriptKyselyBackend::new(canonical_engine)?),
1036        "go-database-sql" => Box::new(go_database_sql::GoDatabaseSqlBackend::new(canonical_engine)?),
1037        "go-pgx" | "go" => Box::new(go_pgx::GoPgxBackend::new(canonical_engine)?),
1038        "java-jdbc" | "java" => Box::new(java_jdbc::JavaJdbcBackend::new(canonical_engine)?),
1039        "java-r2dbc" | "r2dbc-java" => Box::new(java_r2dbc::JavaR2dbcBackend::new(canonical_engine)?),
1040        "kotlin-exposed" | "exposed" => Box::new(kotlin_exposed::KotlinExposedBackend::new(canonical_engine)?),
1041        "kotlin-jdbc" | "kotlin" | "kt" => Box::new(kotlin_jdbc::KotlinJdbcBackend::new(canonical_engine)?),
1042        "kotlin-r2dbc" | "r2dbc-kotlin" => Box::new(kotlin_r2dbc::KotlinR2dbcBackend::new(canonical_engine)?),
1043        "csharp-npgsql" | "csharp" | "c#" | "dotnet" => {
1044            Box::new(csharp_npgsql::CsharpNpgsqlBackend::new(canonical_engine)?)
1045        }
1046        "csharp-mysqlconnector" => Box::new(csharp_mysqlconnector::CsharpMysqlConnectorBackend::new(
1047            canonical_engine,
1048        )?),
1049        "csharp-microsoft-sqlite" => Box::new(csharp_microsoft_sqlite::CsharpMicrosoftSqliteBackend::new(
1050            canonical_engine,
1051        )?),
1052        "elixir-postgrex" | "elixir" | "ex" => Box::new(elixir_postgrex::ElixirPostgrexBackend::new(canonical_engine)?),
1053        "elixir-ecto" | "ecto" => Box::new(elixir_ecto::ElixirEctoBackend::new(canonical_engine)?),
1054        "elixir-myxql" => Box::new(elixir_myxql::ElixirMyxqlBackend::new(canonical_engine)?),
1055        "elixir-exqlite" => Box::new(elixir_exqlite::ElixirExqliteBackend::new(canonical_engine)?),
1056        "ruby-pg" | "ruby" | "rb" => Box::new(ruby_pg::RubyPgBackend::new(canonical_engine)?),
1057        "ruby-mysql2" => Box::new(ruby_mysql2::RubyMysql2Backend::new(canonical_engine)?),
1058        "ruby-sqlite3" => Box::new(ruby_sqlite3::RubySqlite3Backend::new(canonical_engine)?),
1059        "ruby-trilogy" | "trilogy" => Box::new(ruby_trilogy::RubyTrilogyBackend::new(canonical_engine)?),
1060        "php-pdo" | "php" => Box::new(php_pdo::PhpPdoBackend::new(canonical_engine)?),
1061        "php-amphp" | "amphp" => Box::new(php_amphp::PhpAmphpBackend::new(canonical_engine)?),
1062        "rust-tiberius" | "tiberius" => Box::new(rust_tiberius::RustTiberiusBackend::new(canonical_engine)?),
1063        "python-pyodbc" | "pyodbc" => Box::new(python_pyodbc::PythonPyodbcBackend::new(canonical_engine)?),
1064        "typescript-mssql" | "tedious" => Box::new(typescript_mssql::TypescriptMssqlBackend::new(canonical_engine)?),
1065        "javascript-mssql" => Box::new(typescript_mssql::TypescriptMssqlBackend::new_js(canonical_engine)?),
1066        "csharp-sqlclient" => Box::new(csharp_sqlclient::CsharpSqlClientBackend::new(canonical_engine)?),
1067        "ruby-tiny-tds" | "tiny-tds" | "tiny_tds" => {
1068            Box::new(ruby_tiny_tds::RubyTinyTdsBackend::new(canonical_engine)?)
1069        }
1070        "elixir-tds" | "tds" => Box::new(elixir_tds::ElixirTdsBackend::new(canonical_engine)?),
1071        "rust-sibyl" | "sibyl" => Box::new(rust_sibyl::RustSibylBackend::new(canonical_engine)?),
1072        "python-oracledb" | "oracledb" => Box::new(python_oracledb::PythonOracledbBackend::new(canonical_engine)?),
1073        "typescript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new(canonical_engine)?),
1074        "javascript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new_js(
1075            canonical_engine,
1076        )?),
1077        "go-godror" | "godror" => Box::new(go_godror::GoGodrorBackend::new(canonical_engine)?),
1078        "csharp-oracle" => Box::new(csharp_oracle::CsharpOracleBackend::new(canonical_engine)?),
1079        "ruby-oci8" | "oci8" => Box::new(ruby_oci8::RubyOci8Backend::new(canonical_engine)?),
1080        "elixir-jamdb" | "jamdb" => Box::new(elixir_jamdb::ElixirJamdbBackend::new(canonical_engine)?),
1081        "python-snowflake" => Box::new(python_snowflake::PythonSnowflakeBackend::new(canonical_engine)?),
1082        "typescript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new(canonical_engine)?),
1083        "javascript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new_js(
1084            canonical_engine,
1085        )?),
1086        "go-gosnowflake" | "gosnowflake" => Box::new(go_gosnowflake::GoGosnowflakeBackend::new(canonical_engine)?),
1087        "csharp-snowflake" => Box::new(csharp_snowflake::CsharpSnowflakeBackend::new(canonical_engine)?),
1088        _ => {
1089            return Err(ScytheError::new(
1090                ErrorCode::InvalidConfig,
1091                format!("unknown backend: {}", name),
1092            ));
1093        }
1094    };
1095
1096    if !backend
1097        .supported_engines()
1098        .iter()
1099        .any(|e| normalize_engine(e) == canonical_engine)
1100    {
1101        return Err(ScytheError::new(
1102            ErrorCode::InvalidConfig,
1103            format!(
1104                "backend '{}' does not support engine '{}'. Supported: {:?}",
1105                name,
1106                engine,
1107                backend.supported_engines()
1108            ),
1109        ));
1110    }
1111
1112    Ok(backend)
1113}
1114
1115/// Normalize engine name to canonical form.
1116///
1117/// Public so callers outside this crate (e.g. `scythe-cli`) can classify a
1118/// `[[sql]]` block's configured engine without duplicating the alias table —
1119/// for example, to decide whether a block is PostgreSQL-wire-compatible
1120/// before attempting live-database verification.
1121pub fn normalize_engine(engine: &str) -> &str {
1122    match engine {
1123        "postgresql" | "postgres" | "pg" | "cockroachdb" | "crdb" => "postgresql",
1124        "mysql" => "mysql",
1125        "mariadb" => "mariadb",
1126        "sqlite" | "sqlite3" => "sqlite",
1127        "duckdb" => "duckdb",
1128        "mssql" | "sqlserver" | "tsql" => "mssql",
1129        "oracle" => "oracle",
1130        "snowflake" => "snowflake",
1131        "redshift" => "redshift",
1132        other => other,
1133    }
1134}
1135
1136/// Exact-value characterization tests for `clean_sql`, `clean_sql_oneline`,
1137/// and `rewrite_pg_placeholders`, kept in their own file so the pinned
1138/// behaviour is readable as a specification for the planned dialect-aware
1139/// tokenizer.
1140#[cfg(test)]
1141mod sql_text_characterization_tests;
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    fn param(name: &str, position: i64) -> AnalyzedParam {
1148        AnalyzedParam {
1149            name: name.to_string(),
1150            neutral_type: "string".to_string(),
1151            nullable: true,
1152            position,
1153            source_relation: None,
1154        }
1155    }
1156
1157    fn resolved_param(name: &str, neutral_type: &str) -> ResolvedParam {
1158        ResolvedParam {
1159            name: name.to_string(),
1160            field_name: name.to_string(),
1161            lang_type: "String".to_string(),
1162            full_type: "String".to_string(),
1163            borrowed_type: "&str".to_string(),
1164            neutral_type: neutral_type.to_string(),
1165            nullable: false,
1166        }
1167    }
1168
1169    #[test]
1170    fn typed_placeholder_casts_only_rewrite_live_tokens_and_keep_number_boundaries() {
1171        let analyzed: Vec<AnalyzedParam> = (1..=10)
1172            .map(|position| param(&format!("p{position}"), position))
1173            .collect();
1174        let resolved: Vec<ResolvedParam> = (1..=10)
1175            .map(|position| {
1176                let neutral_type = match position {
1177                    1 => "composite::address",
1178                    10 => "enum::status",
1179                    _ => "string",
1180                };
1181                resolved_param(&format!("p{position}"), neutral_type)
1182            })
1183            .collect();
1184        let sql = "SELECT $1, $10, '$1', \"$10\", $$ $1 $10 $$ -- $1\n/* $10 */ WHERE x = $2";
1185
1186        let (rewritten, occurrences) =
1187            rewrite_pg_typed_placeholders(sql, &analyzed, &resolved, true, |position| format!("@p{position}"));
1188
1189        assert_eq!(
1190            rewritten,
1191            "SELECT @p1::text::address, @p10::status, '$1', \"$10\", $$ $1 $10 $$ -- $1\n/* $10 */ WHERE x = @p2"
1192        );
1193        assert_eq!(occurrences, vec![1, 10, 2]);
1194    }
1195
1196    /// Structural guard for #82: no source file under `src/backends` may
1197    /// reference a manifest path relative to the process working directory,
1198    /// however that reference is spelled. This catches a reintroduction of
1199    /// the filesystem-lookup pattern written differently from the original
1200    /// (different variable names, a helper function, etc.) that a simple
1201    /// function-name grep for the old `load_or_default_manifest` helper would
1202    /// miss.
1203    ///
1204    /// The forbidden needle is built at runtime, not written as a literal
1205    /// here, so this check does not trip over its own source describing what
1206    /// it looks for.
1207    #[test]
1208    fn test_no_backends_relative_manifest_path_literals() {
1209        let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src/backends");
1210        let word: String = format!("backend{}", "s/");
1211        let needle: String = format!("{}{}", '"', word);
1212
1213        let mut offenders = Vec::new();
1214        collect_rs_files(std::path::Path::new(root), &mut offenders);
1215
1216        let offenders: Vec<_> = offenders
1217            .into_iter()
1218            .filter(|path| {
1219                let contents = std::fs::read_to_string(path).expect("failed to read source file");
1220                contents.contains(&needle)
1221            })
1222            .collect();
1223
1224        assert!(
1225            offenders.is_empty(),
1226            "found a working-directory-relative manifest path literal in: {offenders:?} (#82 regression \
1227             -- manifest selection must be a pure function of (backend, engine), no filesystem lookup)"
1228        );
1229    }
1230
1231    /// Recursively collect every `.rs` file path under `dir`.
1232    fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
1233        let entries = std::fs::read_dir(dir).expect("failed to read backends dir");
1234        for entry in entries {
1235            let entry = entry.expect("failed to read dir entry");
1236            let path = entry.path();
1237            if path.is_dir() {
1238                collect_rs_files(&path, out);
1239            } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
1240                out.push(path);
1241            }
1242        }
1243    }
1244
1245    /// Documents the engine-blind collision the deleted CWD-relative lookup
1246    /// would have caused: two backends sharing a backend-name-only manifest
1247    /// path (`backends/<name>/manifest.toml`) but supporting different
1248    /// engines would have silently shared one manifest file on disk. Pure
1249    /// `(backend, engine)` selection must not exhibit this collision.
1250    #[test]
1251    fn test_get_backend_rust_sqlx_mysql_has_no_postgresql_scalars() {
1252        let backend = get_backend("rust-sqlx", "mysql").expect("rust-sqlx should support mysql");
1253        let scalars = &backend.manifest().types.scalars;
1254        assert_ne!(
1255            scalars.get("uuid").map(String::as_str),
1256            Some("uuid::Uuid"),
1257            "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1258        );
1259        assert_ne!(
1260            scalars.get("inet").map(String::as_str),
1261            Some("ipnetwork::IpNetwork"),
1262            "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'inet' mapping"
1263        );
1264    }
1265
1266    #[test]
1267    fn test_get_backend_java_jdbc_sqlite_has_no_postgresql_scalars() {
1268        let backend = get_backend("java-jdbc", "sqlite").expect("java-jdbc should support sqlite");
1269        let scalars = &backend.manifest().types.scalars;
1270        assert_ne!(
1271            scalars.get("uuid").map(String::as_str),
1272            Some("java.util.UUID"),
1273            "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1274        );
1275        assert_ne!(
1276            scalars.get("decimal").map(String::as_str),
1277            Some("java.math.BigDecimal"),
1278            "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'decimal' mapping"
1279        );
1280    }
1281
1282    #[test]
1283    fn test_normalize_engine_cockroachdb() {
1284        assert_eq!(normalize_engine("cockroachdb"), "postgresql");
1285        assert_eq!(normalize_engine("crdb"), "postgresql");
1286    }
1287
1288    #[test]
1289    fn test_get_backend_cockroachdb_with_pg_backends() {
1290        let pg_backends = [
1291            "rust-sqlx",
1292            "rust-tokio-postgres",
1293            "python-psycopg3",
1294            "python-asyncpg",
1295            "typescript-postgres",
1296            "typescript-pg",
1297            "typescript-kysely",
1298            "go-pgx",
1299            "ruby-pg",
1300            "elixir-postgrex",
1301            "csharp-npgsql",
1302            "php-pdo",
1303            "php-amphp",
1304        ];
1305        for backend_name in &pg_backends {
1306            let result = get_backend(backend_name, "cockroachdb");
1307            assert!(
1308                result.is_ok(),
1309                "backend '{}' should accept cockroachdb engine, got: {:?}",
1310                backend_name,
1311                result.err()
1312            );
1313        }
1314    }
1315
1316    #[test]
1317    fn test_get_backend_crdb_alias() {
1318        let result = get_backend("rust-sqlx", "crdb");
1319        assert!(result.is_ok(), "rust-sqlx should accept 'crdb' engine alias");
1320    }
1321
1322    #[test]
1323    fn test_normalize_engine_duckdb() {
1324        assert_eq!(normalize_engine("duckdb"), "duckdb");
1325    }
1326
1327    #[test]
1328    fn test_get_backend_duckdb_with_compatible_backends() {
1329        let duckdb_backends = [
1330            "python-duckdb",
1331            "typescript-duckdb",
1332            "go-database-sql",
1333            "java-jdbc",
1334            "kotlin-jdbc",
1335        ];
1336        for backend_name in &duckdb_backends {
1337            let result = get_backend(backend_name, "duckdb");
1338            assert!(
1339                result.is_ok(),
1340                "backend '{}' should accept duckdb engine, got: {:?}",
1341                backend_name,
1342                result.err()
1343            );
1344        }
1345    }
1346
1347    #[test]
1348    fn test_get_backend_duckdb_rejected_by_pg_only() {
1349        let result = get_backend("rust-sqlx", "duckdb");
1350        assert!(result.is_err(), "rust-sqlx should reject duckdb engine");
1351    }
1352
1353    #[test]
1354    fn test_rewrite_simple_equality() {
1355        let sql = "SELECT * FROM users WHERE status = $1";
1356        let params = vec![param("status", 1)];
1357        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1358        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status = $1)");
1359    }
1360
1361    #[test]
1362    fn test_rewrite_qualified_column() {
1363        let sql = "SELECT * FROM users u WHERE u.status = $1";
1364        let params = vec![param("status", 1)];
1365        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1366        assert_eq!(result, "SELECT * FROM users u WHERE ($1 IS NULL OR u.status = $1)");
1367    }
1368
1369    #[test]
1370    fn test_rewrite_multiple_optional() {
1371        let sql = "SELECT * FROM users WHERE status = $1 AND name = $2";
1372        let params = vec![param("status", 1), param("name", 2)];
1373        let result = rewrite_optional_params(sql, &["status".to_string(), "name".to_string()], &params);
1374        assert_eq!(
1375            result,
1376            "SELECT * FROM users WHERE ($1 IS NULL OR status = $1) AND ($2 IS NULL OR name = $2)"
1377        );
1378    }
1379
1380    #[test]
1381    fn test_rewrite_mixed_optional_required() {
1382        let sql = "SELECT * FROM users WHERE id = $1 AND status = $2";
1383        let params = vec![param("id", 1), param("status", 2)];
1384        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1385        assert_eq!(
1386            result,
1387            "SELECT * FROM users WHERE id = $1 AND ($2 IS NULL OR status = $2)"
1388        );
1389    }
1390
1391    #[test]
1392    fn test_rewrite_like_operator() {
1393        let sql = "SELECT * FROM users WHERE name LIKE $1";
1394        let params = vec![param("name", 1)];
1395        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
1396        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name LIKE $1)");
1397    }
1398
1399    #[test]
1400    fn test_rewrite_ilike_operator() {
1401        let sql = "SELECT * FROM users WHERE name ILIKE $1";
1402        let params = vec![param("name", 1)];
1403        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
1404        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name ILIKE $1)");
1405    }
1406
1407    #[test]
1408    fn test_rewrite_comparison_operators() {
1409        let sql = "SELECT * FROM users WHERE age >= $1";
1410        let params = vec![param("age", 1)];
1411        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
1412        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age >= $1)");
1413    }
1414
1415    #[test]
1416    fn test_rewrite_less_than() {
1417        let sql = "SELECT * FROM users WHERE age < $1";
1418        let params = vec![param("age", 1)];
1419        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
1420        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age < $1)");
1421    }
1422
1423    #[test]
1424    fn test_no_rewrite_without_optional() {
1425        let sql = "SELECT * FROM users WHERE status = $1";
1426        let params = vec![param("status", 1)];
1427        let result = rewrite_optional_params(sql, &[], &params);
1428        assert_eq!(result, sql);
1429    }
1430
1431    #[test]
1432    fn test_rewrite_not_equal() {
1433        let sql = "SELECT * FROM users WHERE status <> $1";
1434        let params = vec![param("status", 1)];
1435        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1436        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status <> $1)");
1437    }
1438
1439    #[test]
1440    fn test_rewrite_does_not_match_similar_placeholder() {
1441        let sql = "SELECT * FROM users WHERE status = $10";
1442        let params = vec![param("status", 1)];
1443        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
1444        assert_eq!(result, sql);
1445    }
1446
1447    #[test]
1448    fn test_normalize_engine_mariadb() {
1449        assert_eq!(normalize_engine("mariadb"), "mariadb");
1450    }
1451
1452    #[test]
1453    fn test_get_backend_mariadb_with_mysql_backends() {
1454        let mariadb_backends = [
1455            "rust-sqlx",
1456            "python-aiomysql",
1457            "typescript-mysql2",
1458            "go-database-sql",
1459            "java-jdbc",
1460            "java-r2dbc",
1461            "kotlin-jdbc",
1462            "kotlin-r2dbc",
1463            "csharp-mysqlconnector",
1464            "elixir-myxql",
1465            "ruby-mysql2",
1466            "ruby-trilogy",
1467            "php-pdo",
1468            "php-amphp",
1469        ];
1470        for backend_name in &mariadb_backends {
1471            let result = get_backend(backend_name, "mariadb");
1472            assert!(
1473                result.is_ok(),
1474                "backend '{}' should accept mariadb engine, got: {:?}",
1475                backend_name,
1476                result.err()
1477            );
1478        }
1479    }
1480
1481    // -----------------------------------------------------------------
1482    // SQL lexer / rewriting tests (board #148, #186, #149-adjacent).
1483    //
1484    // These pin down the behaviour of `clean_sql`, `clean_sql_oneline`, and
1485    // `rewrite_pg_placeholders` against a real SQL lexer instead of the
1486    // character-scanning implementation these functions previously had.
1487    // Written before the lexer existed, to prove each defect is real.
1488    // -----------------------------------------------------------------
1489
1490    #[test]
1491    fn test_clean_sql_oneline_strips_mid_line_comment_but_keeps_rest_of_query() {
1492        let sql = "SELECT id, name\nFROM users\nWHERE id > $1 -- skip low ids\n  AND name IS NOT NULL;";
1493        let result = clean_sql_oneline(sql);
1494        assert!(
1495            result.contains("AND name IS NOT NULL"),
1496            "mid-line comment must not swallow the rest of the query, got: {result:?}"
1497        );
1498        assert!(
1499            !result.contains("--"),
1500            "comment marker must be stripped, got: {result:?}"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_clean_sql_strips_mid_line_comment_but_keeps_rest_of_query() {
1506        let sql = "SELECT id\nFROM users\nWHERE id > $1 -- skip low ids\nAND active = true;";
1507        let result = clean_sql(sql);
1508        assert!(result.contains("AND active = true"), "got: {result:?}");
1509        assert!(!result.contains("--"), "got: {result:?}");
1510    }
1511
1512    #[test]
1513    fn test_clean_sql_preserves_double_dash_inside_single_quoted_string() {
1514        let sql = "SELECT * FROM t WHERE label = 'a -- not a comment' AND id = $1;";
1515        let result = clean_sql_oneline(sql);
1516        assert!(result.contains("'a -- not a comment'"), "got: {result:?}");
1517        assert!(result.contains("id = $1"), "got: {result:?}");
1518    }
1519
1520    #[test]
1521    fn test_clean_sql_preserves_double_dash_inside_double_quoted_identifier() {
1522        let sql = "SELECT \"weird--col\" FROM t WHERE id = $1;";
1523        let result = clean_sql_oneline(sql);
1524        assert!(result.contains("\"weird--col\""), "got: {result:?}");
1525    }
1526
1527    #[test]
1528    fn test_clean_sql_preserves_double_dash_inside_dollar_quoted_string() {
1529        let sql = "SELECT $$has -- inside$$ AS x FROM t WHERE id = $1;";
1530        let result = clean_sql_oneline(sql);
1531        assert!(result.contains("$$has -- inside$$"), "got: {result:?}");
1532    }
1533
1534    #[test]
1535    fn test_clean_sql_strips_nested_block_comments() {
1536        let sql = "SELECT id /* outer /* inner */ still outer */ FROM t WHERE id = $1;";
1537        let result = clean_sql_oneline(sql);
1538        assert!(!result.contains("outer"), "got: {result:?}");
1539        assert!(!result.contains("inner"), "got: {result:?}");
1540        assert!(result.contains("SELECT id"), "got: {result:?}");
1541        assert!(result.contains("FROM t WHERE id = $1"), "got: {result:?}");
1542    }
1543
1544    #[test]
1545    fn test_clean_sql_unterminated_block_comment_consumes_to_end_without_panicking() {
1546        let sql = "SELECT id FROM t /* oops forgot to close";
1547        let result = clean_sql_oneline(sql);
1548        assert_eq!(result, "SELECT id FROM t");
1549    }
1550
1551    #[test]
1552    fn test_clean_sql_drops_lines_that_are_only_a_header_comment() {
1553        // Regression guard: header comment lines (`-- @name ...`) must be
1554        // deleted entirely, not replaced by a blank line -- every generated
1555        // fixture depends on this.
1556        let sql = "-- @name Foo\n-- @returns :many\nSELECT id FROM t WHERE id = $1;";
1557        let result = clean_sql(sql);
1558        assert_eq!(result, "SELECT id FROM t WHERE id = $1");
1559    }
1560
1561    #[test]
1562    fn test_rewrite_pg_placeholders_preserves_dollar_quoted_body() {
1563        let sql =
1564            "SELECT id FROM t WHERE body = $$literal -- not comment, ' quote, $1 not a placeholder$$ AND id = $2;";
1565        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1566        assert!(
1567            result.contains("$$literal -- not comment, ' quote, $1 not a placeholder$$"),
1568            "dollar-quoted body must survive untouched, got: {result:?}"
1569        );
1570        assert!(result.contains("id = @p2"), "got: {result:?}");
1571    }
1572
1573    #[test]
1574    fn test_rewrite_pg_placeholders_handles_escaped_single_quotes() {
1575        let sql = "SELECT id FROM t WHERE label = 'it''s $1 not a placeholder' AND id = $2;";
1576        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1577        assert!(result.contains("'it''s $1 not a placeholder'"), "got: {result:?}");
1578        assert!(result.contains("id = @p2"), "got: {result:?}");
1579    }
1580
1581    #[test]
1582    fn test_rewrite_pg_placeholders_preserves_jsonb_operators() {
1583        let sql = "SELECT id FROM docs WHERE data ? 'mykey' AND tags ?| array['a','b'] AND tags ?& array['a','b'] AND id = $1;";
1584        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1585        assert!(result.contains("data ? 'mykey'"), "got: {result:?}");
1586        assert!(result.contains("tags ?| array"), "got: {result:?}");
1587        assert!(result.contains("tags ?& array"), "got: {result:?}");
1588        assert!(result.contains("id = @p1"), "got: {result:?}");
1589    }
1590
1591    /// Coordinator repro: a JSONB query with *zero* `$N` placeholders, so the
1592    /// `uses_dollar_placeholders` heuristic has nothing to anchor on. `?|`
1593    /// and `?&` are unambiguous -- never placeholder syntax in any dialect
1594    /// this crate targets -- so they must survive regardless of that
1595    /// heuristic. The bare `?` (`data ? 'active'`) is genuinely ambiguous
1596    /// without dialect information and is intentionally still rewritten
1597    /// here; see the residual-limitation comment on
1598    /// `rewrite_code_span_placeholders`.
1599    #[test]
1600    fn test_rewrite_pg_placeholders_preserves_jsonb_multi_char_operators_with_zero_dollar_placeholders() {
1601        let sql = "SELECT * FROM docs WHERE data ? 'active' AND tags ?| ARRAY['a'] AND meta ?& ARRAY['b']";
1602        let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1603        assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1604        assert!(result.contains("meta ?& ARRAY['b']"), "got: {result:?}");
1605    }
1606
1607    #[test]
1608    fn test_rewrite_pg_placeholders_preserves_geometry_operators() {
1609        let sql = "SELECT id FROM shapes WHERE line1 ?- line2 AND line1 ?-| line2 AND line1 ?|| line2 AND id = $1;";
1610        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1611        assert!(result.contains("line1 ?- line2"), "got: {result:?}");
1612        assert!(result.contains("line1 ?-| line2"), "got: {result:?}");
1613        assert!(result.contains("line1 ?|| line2"), "got: {result:?}");
1614        assert!(result.contains("id = @p1"), "got: {result:?}");
1615    }
1616
1617    #[test]
1618    fn test_rewrite_pg_placeholders_preserves_operators_mixed_with_dollar_placeholder() {
1619        let sql = "SELECT id FROM docs WHERE tags ?| ARRAY['a'] AND doc @? '$.a' AND id = $1;";
1620        let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1621        assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1622        assert!(result.contains("doc @? '$.a'"), "got: {result:?}");
1623        assert!(result.contains("id = @p1"), "got: {result:?}");
1624    }
1625
1626    #[test]
1627    fn test_rewrite_pg_placeholders_number_boundary() {
1628        let sql = "SELECT $1, $10, $1x";
1629        let result = rewrite_pg_placeholders(sql, |n| format!("P{n}"));
1630        assert_eq!(result, "SELECT P1, P10, P1x");
1631    }
1632
1633    #[test]
1634    fn test_rewrite_pg_placeholders_ignores_question_mark_inside_string_literal() {
1635        let sql = "SELECT id FROM t WHERE note = 'is this a question?' AND age > ?;";
1636        let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1637        assert!(result.contains("'is this a question?'"), "got: {result:?}");
1638        assert!(result.contains("age > $1"), "got: {result:?}");
1639    }
1640
1641    #[test]
1642    fn test_clean_sql_handles_non_ascii_in_comment_and_string() {
1643        let sql = "SELECT name -- 名前を取得 comment\nFROM t WHERE label = 'héllo wörld' AND id = $1;";
1644        let result = clean_sql_oneline(sql);
1645        assert!(!result.contains('名'), "got: {result:?}");
1646        assert!(result.contains("'héllo wörld'"), "got: {result:?}");
1647        assert!(result.contains("id = $1"), "got: {result:?}");
1648    }
1649
1650    #[test]
1651    fn test_clean_sql_handles_crlf_line_endings() {
1652        let sql = "SELECT id\r\nFROM users\r\n-- a comment\r\nWHERE id = $1;";
1653        let result = clean_sql(sql);
1654        assert!(!result.contains('\r'), "got: {result:?}");
1655        assert!(!result.contains("-- a comment"), "got: {result:?}");
1656        assert_eq!(result, "SELECT id\nFROM users\nWHERE id = $1");
1657    }
1658
1659    #[test]
1660    fn test_rewrite_optional_params_handles_not_like() {
1661        let sql = "SELECT id FROM t WHERE name NOT LIKE $1";
1662        let params = vec![param("name", 1)];
1663        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
1664        assert_eq!(result, "SELECT id FROM t WHERE ($1 IS NULL OR name NOT LIKE $1)");
1665    }
1666
1667    // -----------------------------------------------------------------
1668    // Dialect-aware tokenizer / placeholder tests (board #148, GH #149).
1669    //
1670    // These exercise `rewrite_placeholders_indexed` and `clean_sql_dialect`/
1671    // `clean_sql_oneline_dialect` directly -- the dialect-driven replacements
1672    // every owned backend (java-jdbc, kotlin-jdbc, kotlin-exposed, php-amphp)
1673    // now calls instead of the heuristic-based `rewrite_pg_placeholders`/
1674    // `clean_sql`/`clean_sql_oneline`. Each test states, in its doc comment or
1675    // inline, what the OLD (heuristic-based, dialect-blind) code produced for
1676    // the same input.
1677    // -----------------------------------------------------------------
1678
1679    /// The headline board #148 defect, reproduced against both the OLD heuristic
1680    /// entry point and the NEW dialect-driven one on the exact same input.
1681    /// `rewrite_pg_placeholders` (unchanged, kept for callers this change did
1682    /// not reach) has no `$N` to anchor its heuristic on, so it guesses "no
1683    /// dollar placeholders exist -> bare `?` is positional" and corrupts the
1684    /// JSONB key-existence operator into a placeholder reference:
1685    /// `WHERE data $1 'active'`. `rewrite_placeholders_indexed` under
1686    /// `SqlDialect::PostgreSQL` is told the dialect directly and never
1687    /// rewrites it, with zero occurrences recorded.
1688    #[test]
1689    fn test_bare_question_mark_is_never_a_placeholder_under_postgresql_even_with_zero_dollar_placeholders() {
1690        let sql = "SELECT * FROM docs WHERE data ? 'active'";
1691
1692        let old_result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1693        assert_eq!(
1694            old_result, "SELECT * FROM docs WHERE data $1 'active'",
1695            "pins the OLD heuristic's corruption of the JSONB operator"
1696        );
1697
1698        let (new_result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::PostgreSQL, |n| format!("${n}"));
1699        assert_eq!(
1700            new_result, sql,
1701            "dialect-driven rewrite must leave the JSONB operator untouched"
1702        );
1703        assert!(
1704            occurrences.is_empty(),
1705            "no placeholder was ever present; got: {occurrences:?}"
1706        );
1707    }
1708
1709    /// Same bare `?`, but under a dialect that genuinely uses it as a
1710    /// positional placeholder -- proving the decision is dialect-driven, not
1711    /// "always leave `?` alone now".
1712    #[test]
1713    fn test_bare_question_mark_is_a_placeholder_under_mysql() {
1714        let sql = "SELECT * FROM docs WHERE data ? 'active'";
1715        let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("${n}"));
1716        assert_eq!(result, "SELECT * FROM docs WHERE data $1 'active'");
1717        assert_eq!(occurrences, vec![1]);
1718    }
1719
1720    /// GH #149: `$2` declared before `$1` in the SQL text must report its
1721    /// own position at each occurrence, in textual order -- this is what lets
1722    /// a backend bind per SQL-text occurrence instead of assuming declaration
1723    /// order.
1724    #[test]
1725    fn test_rewrite_placeholders_indexed_returns_positions_in_textual_order_out_of_order() {
1726        let (result, occurrences) =
1727            rewrite_placeholders_indexed("WHERE b = $2 AND a = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1728        assert_eq!(result, "WHERE b = ? AND a = ?");
1729        assert_eq!(occurrences, vec![2, 1]);
1730    }
1731
1732    /// GH #149: a repeated `$1` must report position `1` twice, once per
1733    /// occurrence -- not collapse to a single entry the way a per-declared-
1734    /// parameter bind loop would.
1735    #[test]
1736    fn test_rewrite_placeholders_indexed_repeated_placeholder_reports_one_occurrence_per_use() {
1737        let (result, occurrences) =
1738            rewrite_placeholders_indexed("WHERE a = $1 OR b = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1739        assert_eq!(result, "WHERE a = ? OR b = ?");
1740        assert_eq!(occurrences, vec![1, 1]);
1741    }
1742
1743    /// board #148 item 2/#186: a MySQL backtick-quoted identifier containing a bare
1744    /// `?` must not have the `?` inside it rewritten, and the identifier's own
1745    /// characters must not desynchronize the *later* placeholder's number.
1746    /// Matches the `WRONG:`-documented expectation in
1747    /// `sql_text_characterization_tests.rs`'s
1748    /// `question_mark_inside_a_mysql_backtick_identifier_is_rewritten_as_a_placeholder`.
1749    #[test]
1750    fn test_mysql_backtick_identifier_is_not_corrupted_by_bare_question_mark() {
1751        let sql = "SELECT `a?b` FROM t WHERE c = ?";
1752        let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("[P{n}]"));
1753        assert_eq!(result, "SELECT `a?b` FROM t WHERE c = [P1]");
1754        assert_eq!(occurrences, vec![1]);
1755    }
1756
1757    /// board #148 item 4: same as above for an MSSQL `[bracketed]` identifier.
1758    #[test]
1759    fn test_mssql_bracket_identifier_is_not_corrupted_by_bare_question_mark() {
1760        let sql = "SELECT [a?b] FROM t WHERE c = ?";
1761        let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MsSql, |n| format!("[P{n}]"));
1762        assert_eq!(result, "SELECT [a?b] FROM t WHERE c = [P1]");
1763        assert_eq!(occurrences, vec![1]);
1764    }
1765
1766    /// board #148 item 2: a MySQL backtick identifier containing `-- ` must not be
1767    /// read as opening a line comment. Matches the `clean_sql_dialect`
1768    /// counterpart of the `WRONG:`-documented
1769    /// `clean_sql_treats_a_double_dash_inside_a_mysql_backtick_identifier_as_a_comment`
1770    /// characterization test (which pins the OLD, dialect-blind `clean_sql`'s
1771    /// corruption of the same input to `"SELECT \`a"`).
1772    #[test]
1773    fn test_clean_sql_dialect_mysql_backtick_preserves_double_dash_inside() {
1774        let sql = "SELECT `a -- b` FROM t";
1775        assert_eq!(
1776            clean_sql(sql),
1777            "SELECT `a",
1778            "pins the OLD dialect-blind clean_sql's corruption"
1779        );
1780        assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), sql);
1781    }
1782
1783    /// board #148 item 4: same as above for an MSSQL `[bracketed]` identifier.
1784    #[test]
1785    fn test_clean_sql_dialect_mssql_bracket_preserves_double_dash_inside() {
1786        let sql = "SELECT [a -- b] FROM t";
1787        assert_eq!(
1788            clean_sql(sql),
1789            "SELECT [a",
1790            "pins the OLD dialect-blind clean_sql's corruption"
1791        );
1792        assert_eq!(clean_sql_dialect(sql, SqlDialect::MsSql), sql);
1793    }
1794
1795    /// board #148 item 3: MySQL `#` line comments were deliberately unrecognized
1796    /// dialect-blind (`#` collides with PostgreSQL's `#>`/`#>>` JSON
1797    /// operators), so the OLD `clean_sql` leaves the whole comment in the
1798    /// output. Under `SqlDialect::MySQL` it must be stripped like `--`.
1799    #[test]
1800    fn test_clean_sql_dialect_mysql_hash_comment_is_stripped() {
1801        let sql = "SELECT 1 # note\nFROM t";
1802        assert_eq!(
1803            clean_sql(sql),
1804            "SELECT 1 # note\nFROM t",
1805            "pins the OLD dialect-blind clean_sql leaving the # comment in place"
1806        );
1807        // WRONG-adjacent (matches the pre-existing `--` wart pinned by
1808        // `clean_sql_strips_a_trailing_line_comment_but_leaves_the_space_before_it`
1809        // in sql_text_characterization_tests.rs): stripping a trailing
1810        // comment does not also strip the space that preceded it.
1811        assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), "SELECT 1 \nFROM t");
1812    }
1813
1814    /// The oneline variant of the `#` test above, since every owned backend
1815    /// calls `clean_sql_oneline_dialect` (or its `_with_optional` wrapper),
1816    /// never `clean_sql_dialect`, to flatten SQL into one embedded literal.
1817    #[test]
1818    fn test_clean_sql_oneline_dialect_mysql_hash_comment_is_stripped() {
1819        let sql = "SELECT 1 # note\nFROM t";
1820        assert_eq!(clean_sql_oneline_dialect(sql, SqlDialect::MySQL), "SELECT 1  FROM t");
1821    }
1822
1823    /// PostgreSQL's `#>`/`#>>` JSON operators are exactly why `#` cannot be
1824    /// treated as a comment dialect-blind -- confirm they still survive
1825    /// untouched under `SqlDialect::PostgreSQL`.
1826    #[test]
1827    fn test_clean_sql_dialect_postgresql_hash_is_not_a_comment() {
1828        let sql = "SELECT data #> '{a,b}' AS x FROM t";
1829        assert_eq!(clean_sql_dialect(sql, SqlDialect::PostgreSQL), sql);
1830    }
1831}