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_ecto;
8pub(crate) mod elixir_exqlite;
9pub(crate) mod elixir_jamdb;
10pub(crate) mod elixir_myxql;
11pub(crate) mod elixir_postgrex;
12pub(crate) mod elixir_tds;
13pub(crate) mod go_database_sql;
14pub(crate) mod go_godror;
15pub(crate) mod go_gosnowflake;
16pub(crate) mod go_pgx;
17pub(crate) mod java_jdbc;
18pub(crate) mod java_r2dbc;
19pub(crate) mod kotlin_exposed;
20pub(crate) mod kotlin_jdbc;
21pub(crate) mod kotlin_r2dbc;
22pub(crate) mod php_amphp;
23pub(crate) mod php_pdo;
24pub(crate) mod python_aiomysql;
25pub(crate) mod python_aiosqlite;
26pub(crate) mod python_asyncpg;
27pub(crate) mod python_common;
28pub(crate) mod python_duckdb;
29pub(crate) mod python_oracledb;
30pub(crate) mod python_psycopg3;
31pub(crate) mod python_pyodbc;
32pub(crate) mod python_snowflake;
33pub(crate) mod ruby_mysql2;
34pub(crate) mod ruby_oci8;
35pub(crate) mod ruby_pg;
36pub(crate) mod ruby_rbs;
37pub(crate) mod ruby_sqlite3;
38pub(crate) mod ruby_tiny_tds;
39pub(crate) mod ruby_trilogy;
40pub(crate) mod rust_sibyl;
41pub(crate) mod rust_tiberius;
42pub(crate) mod sqlx;
43pub(crate) mod tokio_postgres;
44pub(crate) mod typescript_better_sqlite3;
45pub(crate) mod typescript_common;
46pub(crate) mod typescript_duckdb;
47pub(crate) mod typescript_kysely;
48pub(crate) mod typescript_mssql;
49pub(crate) mod typescript_mysql2;
50pub(crate) mod typescript_node_sqlite;
51pub(crate) mod typescript_oracledb;
52pub(crate) mod typescript_pg;
53pub(crate) mod typescript_postgres;
54pub(crate) mod typescript_snowflake;
55pub(crate) mod typescript_wasm_sqlite;
56
57use scythe_backend::manifest::BackendManifest;
58use scythe_core::analyzer::AnalyzedParam;
59use scythe_core::errors::{ErrorCode, ScytheError};
60
61use crate::backend_trait::CodegenBackend;
62
63/// Parse a backend's compiled-in manifest.
64///
65/// Manifest selection is a pure function of (backend, engine). There is
66/// deliberately no filesystem lookup here: generated output must not depend
67/// on the process working directory (#82).
68pub(crate) fn parse_manifest(manifest_toml: &str) -> Result<BackendManifest, ScytheError> {
69    toml::from_str(manifest_toml).map_err(|e| ScytheError::new(ErrorCode::InternalError, format!("manifest: {e}")))
70}
71
72/// Strip SQL comments, trailing semicolons, and excess whitespace.
73/// Preserves newlines between lines.
74pub(crate) fn clean_sql(sql: &str) -> String {
75    sql.lines()
76        .filter(|line| !line.trim_start().starts_with("--"))
77        .collect::<Vec<_>>()
78        .join("\n")
79        .trim()
80        .trim_end_matches(';')
81        .trim()
82        .to_string()
83}
84
85/// Like clean_sql but joins lines with spaces (for languages that embed SQL inline).
86pub(crate) fn clean_sql_oneline(sql: &str) -> String {
87    sql.lines()
88        .filter(|line| !line.trim_start().starts_with("--"))
89        .collect::<Vec<_>>()
90        .join(" ")
91        .trim()
92        .trim_end_matches(';')
93        .trim()
94        .to_string()
95}
96
97/// Rewrite SQL for optional parameters.
98///
99/// For each optional param, finds `column = $N` (or `column <> $N`, `column != $N`)
100/// and rewrites to `($N IS NULL OR column = $N)`. This allows callers to pass NULL
101/// to skip a filter condition at runtime.
102///
103/// This operates on the raw SQL before any backend-specific placeholder rewriting.
104pub(crate) fn rewrite_optional_params(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
105    if optional_params.is_empty() {
106        return sql.to_string();
107    }
108
109    let mut result = sql.to_string();
110
111    for opt_name in optional_params {
112        let Some(param) = params.iter().find(|p| p.name == *opt_name) else {
113            continue;
114        };
115        let placeholder = format!("${}", param.position);
116
117        for op in &[">=", "<=", "<>", "!=", ">", "<", "=", "ILIKE", "ilike", "LIKE", "like"] {
118            result = rewrite_comparison(&result, &placeholder, op);
119        }
120    }
121
122    result
123}
124
125/// Rewrite a single `column <op> $N` pattern to `($N IS NULL OR column <op> $N)`.
126/// Handles both `column <op> $N` and `$N <op> column` orderings.
127fn rewrite_comparison(sql: &str, placeholder: &str, op: &str) -> String {
128    let mut result = String::with_capacity(sql.len() + 32);
129    let chars: Vec<char> = sql.chars().collect();
130    let len = chars.len();
131    let mut i = 0;
132
133    while i < len {
134        if let Some((_start, col, end)) = try_match_col_op_ph(&chars, i, op, placeholder) {
135            result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
136            i = end;
137            continue;
138        }
139
140        if let Some((end, col)) = try_match_ph_op_col(&chars, i, op, placeholder) {
141            result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
142            i = end;
143            continue;
144        }
145
146        result.push(chars[i]);
147        i += 1;
148    }
149
150    result
151}
152
153/// Try to match `identifier <ws>* <op> <ws>* placeholder` starting at position `i`.
154/// Returns `(match_start, column_name, match_end)` if found.
155fn try_match_col_op_ph(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String, usize)> {
156    if !is_ident_char(chars[i]) {
157        return None;
158    }
159    if i > 0 && is_ident_char(chars[i - 1]) {
160        return None;
161    }
162
163    let ident_start = i;
164    let mut j = i;
165    while j < chars.len() && is_ident_char(chars[j]) {
166        j += 1;
167    }
168    let ident: String = chars[ident_start..j].iter().collect();
169
170    while j < chars.len() && chars[j].is_whitespace() {
171        j += 1;
172    }
173
174    let op_chars: Vec<char> = op.chars().collect();
175    if j + op_chars.len() > chars.len() {
176        return None;
177    }
178    for (k, oc) in op_chars.iter().enumerate() {
179        if chars[j + k] != *oc {
180            return None;
181        }
182    }
183    j += op_chars.len();
184
185    while j < chars.len() && chars[j].is_whitespace() {
186        j += 1;
187    }
188
189    let ph_chars: Vec<char> = placeholder.chars().collect();
190    if j + ph_chars.len() > chars.len() {
191        return None;
192    }
193    for (k, pc) in ph_chars.iter().enumerate() {
194        if chars[j + k] != *pc {
195            return None;
196        }
197    }
198    j += ph_chars.len();
199
200    if j < chars.len() && chars[j].is_ascii_digit() {
201        return None;
202    }
203
204    Some((i, ident, j))
205}
206
207/// Try to match `placeholder <ws>* <op> <ws>* identifier` starting at position `i`.
208/// Returns `(match_end, column_name)` if found.
209fn try_match_ph_op_col(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String)> {
210    let ph_chars: Vec<char> = placeholder.chars().collect();
211    if i + ph_chars.len() > chars.len() {
212        return None;
213    }
214
215    if i > 0 && (chars[i - 1] == '$' || chars[i - 1].is_ascii_digit()) {
216        return None;
217    }
218
219    for (k, pc) in ph_chars.iter().enumerate() {
220        if chars[i + k] != *pc {
221            return None;
222        }
223    }
224    let mut j = i + ph_chars.len();
225
226    if j < chars.len() && chars[j].is_ascii_digit() {
227        return None;
228    }
229
230    while j < chars.len() && chars[j].is_whitespace() {
231        j += 1;
232    }
233
234    let op_chars: Vec<char> = op.chars().collect();
235    if j + op_chars.len() > chars.len() {
236        return None;
237    }
238    for (k, oc) in op_chars.iter().enumerate() {
239        if chars[j + k] != *oc {
240            return None;
241        }
242    }
243    j += op_chars.len();
244
245    while j < chars.len() && chars[j].is_whitespace() {
246        j += 1;
247    }
248
249    if j >= chars.len() || !is_ident_char(chars[j]) {
250        return None;
251    }
252    let ident_start = j;
253    while j < chars.len() && is_ident_char(chars[j]) {
254        j += 1;
255    }
256    let ident: String = chars[ident_start..j].iter().collect();
257
258    if ident == "NULL" {
259        return None;
260    }
261
262    Some((j, ident))
263}
264
265/// Clean SQL and apply optional parameter rewriting.
266pub(crate) fn clean_sql_with_optional(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
267    let cleaned = clean_sql(sql);
268    rewrite_optional_params(&cleaned, optional_params, params)
269}
270
271/// Clean SQL (oneline) and apply optional parameter rewriting.
272pub(crate) fn clean_sql_oneline_with_optional(
273    sql: &str,
274    optional_params: &[String],
275    params: &[AnalyzedParam],
276) -> String {
277    let cleaned = clean_sql_oneline(sql);
278    rewrite_optional_params(&cleaned, optional_params, params)
279}
280
281fn is_ident_char(c: char) -> bool {
282    c.is_alphanumeric() || c == '_' || c == '.'
283}
284
285/// Rewrite PostgreSQL `$1, $2, ...` positional placeholders to a target format.
286/// Rewrite SQL placeholders (`$N` or `?`) to a target format.
287/// Skips placeholders inside single-quoted SQL string literals.
288/// The `formatter` closure receives the parameter number (1-based) and returns the replacement.
289/// Handles both PostgreSQL `$N` and positional `?` placeholders.
290pub(crate) fn rewrite_pg_placeholders(sql: &str, formatter: impl Fn(u32) -> String) -> String {
291    let mut result = String::with_capacity(sql.len());
292    let mut chars = sql.chars().peekable();
293    let mut positional_counter: u32 = 0;
294    while let Some(ch) = chars.next() {
295        if ch == '\'' {
296            result.push(ch);
297            while let Some(inner) = chars.next() {
298                result.push(inner);
299                if inner == '\'' {
300                    if chars.peek() == Some(&'\'') {
301                        result.push(chars.next().unwrap());
302                    } else {
303                        break;
304                    }
305                }
306            }
307        } else if ch == '$' {
308            if chars.peek().is_some_and(|c| c.is_ascii_digit()) {
309                let mut num_str = String::new();
310                while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
311                    num_str.push(chars.next().unwrap());
312                }
313                let num: u32 = num_str.parse().unwrap_or(0);
314                result.push_str(&formatter(num));
315            } else {
316                result.push(ch);
317            }
318        } else if ch == '?' && !chars.peek().is_some_and(|c| c.is_ascii_digit()) {
319            positional_counter += 1;
320            result.push_str(&formatter(positional_counter));
321        } else {
322            result.push(ch);
323        }
324    }
325    result
326}
327
328/// Get a backend by name and database engine.
329///
330/// The `engine` parameter (e.g., "postgresql", "mysql", "sqlite") determines
331/// which manifest is loaded for type mappings. PG-only backends reject non-PG engines.
332pub fn get_backend(name: &str, engine: &str) -> Result<Box<dyn CodegenBackend>, ScytheError> {
333    let canonical_engine = normalize_engine(engine);
334    let backend: Box<dyn CodegenBackend> = match name {
335        "rust-sqlx" | "sqlx" | "rust" => Box::new(sqlx::SqlxBackend::new(canonical_engine)?),
336        "rust-tokio-postgres" | "tokio-postgres" => {
337            Box::new(tokio_postgres::TokioPostgresBackend::new(canonical_engine)?)
338        }
339        "python-psycopg3" | "python" => Box::new(python_psycopg3::PythonPsycopg3Backend::new(canonical_engine)?),
340        "python-asyncpg" => Box::new(python_asyncpg::PythonAsyncpgBackend::new(canonical_engine)?),
341        "python-aiomysql" => Box::new(python_aiomysql::PythonAiomysqlBackend::new(canonical_engine)?),
342        "python-aiosqlite" => Box::new(python_aiosqlite::PythonAiosqliteBackend::new(canonical_engine)?),
343        "python-duckdb" => Box::new(python_duckdb::PythonDuckdbBackend::new(canonical_engine)?),
344        "typescript-postgres" | "ts" | "typescript" => {
345            Box::new(typescript_postgres::TypescriptPostgresBackend::new(canonical_engine)?)
346        }
347        "typescript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new(canonical_engine)?),
348        "typescript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new(canonical_engine)?),
349        "typescript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new(
350            canonical_engine,
351        )?),
352        "typescript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new(canonical_engine)?),
353        "typescript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new(
354            canonical_engine,
355        )?),
356        "typescript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new(
357            canonical_engine,
358        )?),
359        "typescript-kysely" | "kysely" => Box::new(typescript_kysely::TypescriptKyselyBackend::new(canonical_engine)?),
360        "go-database-sql" => Box::new(go_database_sql::GoDatabaseSqlBackend::new(canonical_engine)?),
361        "go-pgx" | "go" => Box::new(go_pgx::GoPgxBackend::new(canonical_engine)?),
362        "java-jdbc" | "java" => Box::new(java_jdbc::JavaJdbcBackend::new(canonical_engine)?),
363        "java-r2dbc" | "r2dbc-java" => Box::new(java_r2dbc::JavaR2dbcBackend::new(canonical_engine)?),
364        "kotlin-exposed" | "exposed" => Box::new(kotlin_exposed::KotlinExposedBackend::new(canonical_engine)?),
365        "kotlin-jdbc" | "kotlin" | "kt" => Box::new(kotlin_jdbc::KotlinJdbcBackend::new(canonical_engine)?),
366        "kotlin-r2dbc" | "r2dbc-kotlin" => Box::new(kotlin_r2dbc::KotlinR2dbcBackend::new(canonical_engine)?),
367        "csharp-npgsql" | "csharp" | "c#" | "dotnet" => {
368            Box::new(csharp_npgsql::CsharpNpgsqlBackend::new(canonical_engine)?)
369        }
370        "csharp-mysqlconnector" => Box::new(csharp_mysqlconnector::CsharpMysqlConnectorBackend::new(
371            canonical_engine,
372        )?),
373        "csharp-microsoft-sqlite" => Box::new(csharp_microsoft_sqlite::CsharpMicrosoftSqliteBackend::new(
374            canonical_engine,
375        )?),
376        "elixir-postgrex" | "elixir" | "ex" => Box::new(elixir_postgrex::ElixirPostgrexBackend::new(canonical_engine)?),
377        "elixir-ecto" | "ecto" => Box::new(elixir_ecto::ElixirEctoBackend::new(canonical_engine)?),
378        "elixir-myxql" => Box::new(elixir_myxql::ElixirMyxqlBackend::new(canonical_engine)?),
379        "elixir-exqlite" => Box::new(elixir_exqlite::ElixirExqliteBackend::new(canonical_engine)?),
380        "ruby-pg" | "ruby" | "rb" => Box::new(ruby_pg::RubyPgBackend::new(canonical_engine)?),
381        "ruby-mysql2" => Box::new(ruby_mysql2::RubyMysql2Backend::new(canonical_engine)?),
382        "ruby-sqlite3" => Box::new(ruby_sqlite3::RubySqlite3Backend::new(canonical_engine)?),
383        "ruby-trilogy" | "trilogy" => Box::new(ruby_trilogy::RubyTrilogyBackend::new(canonical_engine)?),
384        "php-pdo" | "php" => Box::new(php_pdo::PhpPdoBackend::new(canonical_engine)?),
385        "php-amphp" | "amphp" => Box::new(php_amphp::PhpAmphpBackend::new(canonical_engine)?),
386        "rust-tiberius" | "tiberius" => Box::new(rust_tiberius::RustTiberiusBackend::new(canonical_engine)?),
387        "python-pyodbc" | "pyodbc" => Box::new(python_pyodbc::PythonPyodbcBackend::new(canonical_engine)?),
388        "typescript-mssql" | "tedious" => Box::new(typescript_mssql::TypescriptMssqlBackend::new(canonical_engine)?),
389        "csharp-sqlclient" => Box::new(csharp_sqlclient::CsharpSqlClientBackend::new(canonical_engine)?),
390        "ruby-tiny-tds" | "tiny-tds" | "tiny_tds" => {
391            Box::new(ruby_tiny_tds::RubyTinyTdsBackend::new(canonical_engine)?)
392        }
393        "elixir-tds" | "tds" => Box::new(elixir_tds::ElixirTdsBackend::new(canonical_engine)?),
394        "rust-sibyl" | "sibyl" => Box::new(rust_sibyl::RustSibylBackend::new(canonical_engine)?),
395        "python-oracledb" | "oracledb" => Box::new(python_oracledb::PythonOracledbBackend::new(canonical_engine)?),
396        "typescript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new(canonical_engine)?),
397        "go-godror" | "godror" => Box::new(go_godror::GoGodrorBackend::new(canonical_engine)?),
398        "csharp-oracle" => Box::new(csharp_oracle::CsharpOracleBackend::new(canonical_engine)?),
399        "ruby-oci8" | "oci8" => Box::new(ruby_oci8::RubyOci8Backend::new(canonical_engine)?),
400        "elixir-jamdb" | "jamdb" => Box::new(elixir_jamdb::ElixirJamdbBackend::new(canonical_engine)?),
401        "python-snowflake" => Box::new(python_snowflake::PythonSnowflakeBackend::new(canonical_engine)?),
402        "typescript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new(canonical_engine)?),
403        "go-gosnowflake" | "gosnowflake" => Box::new(go_gosnowflake::GoGosnowflakeBackend::new(canonical_engine)?),
404        "csharp-snowflake" => Box::new(csharp_snowflake::CsharpSnowflakeBackend::new(canonical_engine)?),
405        _ => {
406            return Err(ScytheError::new(
407                ErrorCode::InternalError,
408                format!("unknown backend: {}", name),
409            ));
410        }
411    };
412
413    if !backend
414        .supported_engines()
415        .iter()
416        .any(|e| normalize_engine(e) == canonical_engine)
417    {
418        return Err(ScytheError::new(
419            ErrorCode::InternalError,
420            format!(
421                "backend '{}' does not support engine '{}'. Supported: {:?}",
422                name,
423                engine,
424                backend.supported_engines()
425            ),
426        ));
427    }
428
429    Ok(backend)
430}
431
432/// Normalize engine name to canonical form.
433///
434/// Public so callers outside this crate (e.g. `scythe-cli`) can classify a
435/// `[[sql]]` block's configured engine without duplicating the alias table —
436/// for example, to decide whether a block is PostgreSQL-wire-compatible
437/// before attempting live-database verification.
438pub fn normalize_engine(engine: &str) -> &str {
439    match engine {
440        "postgresql" | "postgres" | "pg" | "cockroachdb" | "crdb" => "postgresql",
441        "mysql" => "mysql",
442        "mariadb" => "mariadb",
443        "sqlite" | "sqlite3" => "sqlite",
444        "duckdb" => "duckdb",
445        "mssql" | "sqlserver" | "tsql" => "mssql",
446        "oracle" => "oracle",
447        "snowflake" => "snowflake",
448        "redshift" => "redshift",
449        other => other,
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    fn param(name: &str, position: i64) -> AnalyzedParam {
458        AnalyzedParam {
459            name: name.to_string(),
460            neutral_type: "string".to_string(),
461            nullable: true,
462            position,
463        }
464    }
465
466    /// Structural guard for #82: no source file under `src/backends` may
467    /// reference a manifest path relative to the process working directory,
468    /// however that reference is spelled. This catches a reintroduction of
469    /// the filesystem-lookup pattern written differently from the original
470    /// (different variable names, a helper function, etc.) that a simple
471    /// function-name grep for the old `load_or_default_manifest` helper would
472    /// miss.
473    ///
474    /// The forbidden needle is built at runtime, not written as a literal
475    /// here, so this check does not trip over its own source describing what
476    /// it looks for.
477    #[test]
478    fn test_no_backends_relative_manifest_path_literals() {
479        let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src/backends");
480        let word: String = format!("backend{}", "s/");
481        let needle: String = format!("{}{}", '"', word);
482
483        let mut offenders = Vec::new();
484        collect_rs_files(std::path::Path::new(root), &mut offenders);
485
486        let offenders: Vec<_> = offenders
487            .into_iter()
488            .filter(|path| {
489                let contents = std::fs::read_to_string(path).expect("failed to read source file");
490                contents.contains(&needle)
491            })
492            .collect();
493
494        assert!(
495            offenders.is_empty(),
496            "found a working-directory-relative manifest path literal in: {offenders:?} (#82 regression \
497             -- manifest selection must be a pure function of (backend, engine), no filesystem lookup)"
498        );
499    }
500
501    /// Recursively collect every `.rs` file path under `dir`.
502    fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
503        let entries = std::fs::read_dir(dir).expect("failed to read backends dir");
504        for entry in entries {
505            let entry = entry.expect("failed to read dir entry");
506            let path = entry.path();
507            if path.is_dir() {
508                collect_rs_files(&path, out);
509            } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
510                out.push(path);
511            }
512        }
513    }
514
515    /// Documents the engine-blind collision the deleted CWD-relative lookup
516    /// would have caused: two backends sharing a backend-name-only manifest
517    /// path (`backends/<name>/manifest.toml`) but supporting different
518    /// engines would have silently shared one manifest file on disk. Pure
519    /// `(backend, engine)` selection must not exhibit this collision.
520    #[test]
521    fn test_get_backend_rust_sqlx_mysql_has_no_postgresql_scalars() {
522        let backend = get_backend("rust-sqlx", "mysql").expect("rust-sqlx should support mysql");
523        let scalars = &backend.manifest().types.scalars;
524        assert_ne!(
525            scalars.get("uuid").map(String::as_str),
526            Some("uuid::Uuid"),
527            "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'uuid' mapping"
528        );
529        assert_ne!(
530            scalars.get("inet").map(String::as_str),
531            Some("ipnetwork::IpNetwork"),
532            "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'inet' mapping"
533        );
534    }
535
536    #[test]
537    fn test_get_backend_java_jdbc_sqlite_has_no_postgresql_scalars() {
538        let backend = get_backend("java-jdbc", "sqlite").expect("java-jdbc should support sqlite");
539        let scalars = &backend.manifest().types.scalars;
540        assert_ne!(
541            scalars.get("uuid").map(String::as_str),
542            Some("java.util.UUID"),
543            "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'uuid' mapping"
544        );
545        assert_ne!(
546            scalars.get("decimal").map(String::as_str),
547            Some("java.math.BigDecimal"),
548            "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'decimal' mapping"
549        );
550    }
551
552    #[test]
553    fn test_normalize_engine_cockroachdb() {
554        assert_eq!(normalize_engine("cockroachdb"), "postgresql");
555        assert_eq!(normalize_engine("crdb"), "postgresql");
556    }
557
558    #[test]
559    fn test_get_backend_cockroachdb_with_pg_backends() {
560        let pg_backends = [
561            "rust-sqlx",
562            "rust-tokio-postgres",
563            "python-psycopg3",
564            "python-asyncpg",
565            "typescript-postgres",
566            "typescript-pg",
567            "typescript-kysely",
568            "go-pgx",
569            "ruby-pg",
570            "elixir-postgrex",
571            "csharp-npgsql",
572            "php-pdo",
573            "php-amphp",
574        ];
575        for backend_name in &pg_backends {
576            let result = get_backend(backend_name, "cockroachdb");
577            assert!(
578                result.is_ok(),
579                "backend '{}' should accept cockroachdb engine, got: {:?}",
580                backend_name,
581                result.err()
582            );
583        }
584    }
585
586    #[test]
587    fn test_get_backend_crdb_alias() {
588        let result = get_backend("rust-sqlx", "crdb");
589        assert!(result.is_ok(), "rust-sqlx should accept 'crdb' engine alias");
590    }
591
592    #[test]
593    fn test_normalize_engine_duckdb() {
594        assert_eq!(normalize_engine("duckdb"), "duckdb");
595    }
596
597    #[test]
598    fn test_get_backend_duckdb_with_compatible_backends() {
599        let duckdb_backends = [
600            "python-duckdb",
601            "typescript-duckdb",
602            "go-database-sql",
603            "java-jdbc",
604            "kotlin-jdbc",
605        ];
606        for backend_name in &duckdb_backends {
607            let result = get_backend(backend_name, "duckdb");
608            assert!(
609                result.is_ok(),
610                "backend '{}' should accept duckdb engine, got: {:?}",
611                backend_name,
612                result.err()
613            );
614        }
615    }
616
617    #[test]
618    fn test_get_backend_duckdb_rejected_by_pg_only() {
619        let result = get_backend("rust-sqlx", "duckdb");
620        assert!(result.is_err(), "rust-sqlx should reject duckdb engine");
621    }
622
623    #[test]
624    fn test_rewrite_simple_equality() {
625        let sql = "SELECT * FROM users WHERE status = $1";
626        let params = vec![param("status", 1)];
627        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
628        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status = $1)");
629    }
630
631    #[test]
632    fn test_rewrite_qualified_column() {
633        let sql = "SELECT * FROM users u WHERE u.status = $1";
634        let params = vec![param("status", 1)];
635        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
636        assert_eq!(result, "SELECT * FROM users u WHERE ($1 IS NULL OR u.status = $1)");
637    }
638
639    #[test]
640    fn test_rewrite_multiple_optional() {
641        let sql = "SELECT * FROM users WHERE status = $1 AND name = $2";
642        let params = vec![param("status", 1), param("name", 2)];
643        let result = rewrite_optional_params(sql, &["status".to_string(), "name".to_string()], &params);
644        assert_eq!(
645            result,
646            "SELECT * FROM users WHERE ($1 IS NULL OR status = $1) AND ($2 IS NULL OR name = $2)"
647        );
648    }
649
650    #[test]
651    fn test_rewrite_mixed_optional_required() {
652        let sql = "SELECT * FROM users WHERE id = $1 AND status = $2";
653        let params = vec![param("id", 1), param("status", 2)];
654        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
655        assert_eq!(
656            result,
657            "SELECT * FROM users WHERE id = $1 AND ($2 IS NULL OR status = $2)"
658        );
659    }
660
661    #[test]
662    fn test_rewrite_like_operator() {
663        let sql = "SELECT * FROM users WHERE name LIKE $1";
664        let params = vec![param("name", 1)];
665        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
666        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name LIKE $1)");
667    }
668
669    #[test]
670    fn test_rewrite_ilike_operator() {
671        let sql = "SELECT * FROM users WHERE name ILIKE $1";
672        let params = vec![param("name", 1)];
673        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
674        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name ILIKE $1)");
675    }
676
677    #[test]
678    fn test_rewrite_comparison_operators() {
679        let sql = "SELECT * FROM users WHERE age >= $1";
680        let params = vec![param("age", 1)];
681        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
682        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age >= $1)");
683    }
684
685    #[test]
686    fn test_rewrite_less_than() {
687        let sql = "SELECT * FROM users WHERE age < $1";
688        let params = vec![param("age", 1)];
689        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
690        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age < $1)");
691    }
692
693    #[test]
694    fn test_no_rewrite_without_optional() {
695        let sql = "SELECT * FROM users WHERE status = $1";
696        let params = vec![param("status", 1)];
697        let result = rewrite_optional_params(sql, &[], &params);
698        assert_eq!(result, sql);
699    }
700
701    #[test]
702    fn test_rewrite_not_equal() {
703        let sql = "SELECT * FROM users WHERE status <> $1";
704        let params = vec![param("status", 1)];
705        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
706        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status <> $1)");
707    }
708
709    #[test]
710    fn test_rewrite_does_not_match_similar_placeholder() {
711        let sql = "SELECT * FROM users WHERE status = $10";
712        let params = vec![param("status", 1)];
713        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
714        assert_eq!(result, sql);
715    }
716
717    #[test]
718    fn test_normalize_engine_mariadb() {
719        assert_eq!(normalize_engine("mariadb"), "mariadb");
720    }
721
722    #[test]
723    fn test_get_backend_mariadb_with_mysql_backends() {
724        let mariadb_backends = [
725            "rust-sqlx",
726            "python-aiomysql",
727            "typescript-mysql2",
728            "go-database-sql",
729            "java-jdbc",
730            "java-r2dbc",
731            "kotlin-jdbc",
732            "kotlin-r2dbc",
733            "csharp-mysqlconnector",
734            "elixir-myxql",
735            "ruby-mysql2",
736            "ruby-trilogy",
737            "php-pdo",
738            "php-amphp",
739        ];
740        for backend_name in &mariadb_backends {
741            let result = get_backend(backend_name, "mariadb");
742            assert!(
743                result.is_ok(),
744                "backend '{}' should accept mariadb engine, got: {:?}",
745                backend_name,
746                result.err()
747            );
748        }
749    }
750}