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_mssql;
48pub(crate) mod typescript_mysql2;
49pub(crate) mod typescript_oracledb;
50pub(crate) mod typescript_pg;
51pub(crate) mod typescript_postgres;
52pub(crate) mod typescript_snowflake;
53
54use scythe_backend::manifest::BackendManifest;
55use scythe_core::analyzer::AnalyzedParam;
56use scythe_core::errors::{ErrorCode, ScytheError};
57
58use crate::backend_trait::CodegenBackend;
59
60/// Load a backend manifest, preferring a user-provided file at `override_path`
61/// and falling back to the embedded `default_toml` string.
62pub(crate) fn load_or_default_manifest(
63    override_path: &str,
64    default_toml: &str,
65) -> Result<BackendManifest, ScytheError> {
66    let path = std::path::Path::new(override_path);
67    if path.exists() {
68        scythe_backend::manifest::load_manifest(path)
69            .map_err(|e| ScytheError::new(ErrorCode::InternalError, format!("manifest: {e}")))
70    } else {
71        toml::from_str(default_toml).map_err(|e| ScytheError::new(ErrorCode::InternalError, format!("manifest: {e}")))
72    }
73}
74
75/// Strip SQL comments, trailing semicolons, and excess whitespace.
76/// Preserves newlines between lines.
77pub(crate) fn clean_sql(sql: &str) -> String {
78    sql.lines()
79        .filter(|line| !line.trim_start().starts_with("--"))
80        .collect::<Vec<_>>()
81        .join("\n")
82        .trim()
83        .trim_end_matches(';')
84        .trim()
85        .to_string()
86}
87
88/// Like clean_sql but joins lines with spaces (for languages that embed SQL inline).
89pub(crate) fn clean_sql_oneline(sql: &str) -> String {
90    sql.lines()
91        .filter(|line| !line.trim_start().starts_with("--"))
92        .collect::<Vec<_>>()
93        .join(" ")
94        .trim()
95        .trim_end_matches(';')
96        .trim()
97        .to_string()
98}
99
100/// Rewrite SQL for optional parameters.
101///
102/// For each optional param, finds `column = $N` (or `column <> $N`, `column != $N`)
103/// and rewrites to `($N IS NULL OR column = $N)`. This allows callers to pass NULL
104/// to skip a filter condition at runtime.
105///
106/// This operates on the raw SQL before any backend-specific placeholder rewriting.
107pub(crate) fn rewrite_optional_params(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
108    if optional_params.is_empty() {
109        return sql.to_string();
110    }
111
112    let mut result = sql.to_string();
113
114    for opt_name in optional_params {
115        let Some(param) = params.iter().find(|p| p.name == *opt_name) else {
116            continue;
117        };
118        let placeholder = format!("${}", param.position);
119
120        // Try each comparison operator
121        for op in &[">=", "<=", "<>", "!=", ">", "<", "=", "ILIKE", "ilike", "LIKE", "like"] {
122            result = rewrite_comparison(&result, &placeholder, op);
123        }
124    }
125
126    result
127}
128
129/// Rewrite a single `column <op> $N` pattern to `($N IS NULL OR column <op> $N)`.
130/// Handles both `column <op> $N` and `$N <op> column` orderings.
131fn rewrite_comparison(sql: &str, placeholder: &str, op: &str) -> String {
132    let mut result = String::with_capacity(sql.len() + 32);
133    let chars: Vec<char> = sql.chars().collect();
134    let len = chars.len();
135    let mut i = 0;
136
137    while i < len {
138        // Try to match `identifier <op> $N` at this position
139        if let Some((_start, col, end)) = try_match_col_op_ph(&chars, i, op, placeholder) {
140            result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
141            i = end;
142            continue;
143        }
144
145        // Try to match `$N <op> identifier` at this position
146        if let Some((end, col)) = try_match_ph_op_col(&chars, i, op, placeholder) {
147            result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
148            i = end;
149            continue;
150        }
151
152        result.push(chars[i]);
153        i += 1;
154    }
155
156    result
157}
158
159/// Try to match `identifier <ws>* <op> <ws>* placeholder` starting at position `i`.
160/// Returns `(match_start, column_name, match_end)` if found.
161fn try_match_col_op_ph(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String, usize)> {
162    // Must start with an identifier character (word char)
163    if !is_ident_char(chars[i]) {
164        return None;
165    }
166    // Must not be preceded by another ident char (whole-word boundary)
167    if i > 0 && is_ident_char(chars[i - 1]) {
168        return None;
169    }
170
171    // Read the identifier
172    let ident_start = i;
173    let mut j = i;
174    while j < chars.len() && is_ident_char(chars[j]) {
175        j += 1;
176    }
177    let ident: String = chars[ident_start..j].iter().collect();
178
179    // Skip whitespace
180    while j < chars.len() && chars[j].is_whitespace() {
181        j += 1;
182    }
183
184    // Match operator
185    let op_chars: Vec<char> = op.chars().collect();
186    if j + op_chars.len() > chars.len() {
187        return None;
188    }
189    for (k, oc) in op_chars.iter().enumerate() {
190        if chars[j + k] != *oc {
191            return None;
192        }
193    }
194    j += op_chars.len();
195
196    // Skip whitespace
197    while j < chars.len() && chars[j].is_whitespace() {
198        j += 1;
199    }
200
201    // Match placeholder
202    let ph_chars: Vec<char> = placeholder.chars().collect();
203    if j + ph_chars.len() > chars.len() {
204        return None;
205    }
206    for (k, pc) in ph_chars.iter().enumerate() {
207        if chars[j + k] != *pc {
208            return None;
209        }
210    }
211    j += ph_chars.len();
212
213    // Ensure placeholder is not followed by a digit (e.g., $1 vs $10)
214    if j < chars.len() && chars[j].is_ascii_digit() {
215        return None;
216    }
217
218    Some((i, ident, j))
219}
220
221/// Try to match `placeholder <ws>* <op> <ws>* identifier` starting at position `i`.
222/// Returns `(match_end, column_name)` if found.
223fn try_match_ph_op_col(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String)> {
224    let ph_chars: Vec<char> = placeholder.chars().collect();
225    if i + ph_chars.len() > chars.len() {
226        return None;
227    }
228
229    // Must not be preceded by $ or digit (boundary check)
230    if i > 0 && (chars[i - 1] == '$' || chars[i - 1].is_ascii_digit()) {
231        return None;
232    }
233
234    // Match placeholder
235    for (k, pc) in ph_chars.iter().enumerate() {
236        if chars[i + k] != *pc {
237            return None;
238        }
239    }
240    let mut j = i + ph_chars.len();
241
242    // Ensure placeholder is not followed by a digit
243    if j < chars.len() && chars[j].is_ascii_digit() {
244        return None;
245    }
246
247    // Skip whitespace
248    while j < chars.len() && chars[j].is_whitespace() {
249        j += 1;
250    }
251
252    // Match operator
253    let op_chars: Vec<char> = op.chars().collect();
254    if j + op_chars.len() > chars.len() {
255        return None;
256    }
257    for (k, oc) in op_chars.iter().enumerate() {
258        if chars[j + k] != *oc {
259            return None;
260        }
261    }
262    j += op_chars.len();
263
264    // Skip whitespace
265    while j < chars.len() && chars[j].is_whitespace() {
266        j += 1;
267    }
268
269    // Read the identifier
270    if j >= chars.len() || !is_ident_char(chars[j]) {
271        return None;
272    }
273    let ident_start = j;
274    while j < chars.len() && is_ident_char(chars[j]) {
275        j += 1;
276    }
277    let ident: String = chars[ident_start..j].iter().collect();
278
279    // Avoid matching "NULL" (from already-rewritten text)
280    if ident == "NULL" {
281        return None;
282    }
283
284    Some((j, ident))
285}
286
287/// Clean SQL and apply optional parameter rewriting.
288pub(crate) fn clean_sql_with_optional(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
289    let cleaned = clean_sql(sql);
290    rewrite_optional_params(&cleaned, optional_params, params)
291}
292
293/// Clean SQL (oneline) and apply optional parameter rewriting.
294pub(crate) fn clean_sql_oneline_with_optional(
295    sql: &str,
296    optional_params: &[String],
297    params: &[AnalyzedParam],
298) -> String {
299    let cleaned = clean_sql_oneline(sql);
300    rewrite_optional_params(&cleaned, optional_params, params)
301}
302
303fn is_ident_char(c: char) -> bool {
304    c.is_alphanumeric() || c == '_' || c == '.'
305}
306
307/// Rewrite PostgreSQL `$1, $2, ...` positional placeholders to a target format.
308/// Rewrite SQL placeholders (`$N` or `?`) to a target format.
309/// Skips placeholders inside single-quoted SQL string literals.
310/// The `formatter` closure receives the parameter number (1-based) and returns the replacement.
311/// Handles both PostgreSQL `$N` and positional `?` placeholders.
312pub(crate) fn rewrite_pg_placeholders(sql: &str, formatter: impl Fn(u32) -> String) -> String {
313    let mut result = String::with_capacity(sql.len());
314    let mut chars = sql.chars().peekable();
315    let mut positional_counter: u32 = 0;
316    while let Some(ch) = chars.next() {
317        if ch == '\'' {
318            result.push(ch);
319            while let Some(inner) = chars.next() {
320                result.push(inner);
321                if inner == '\'' {
322                    if chars.peek() == Some(&'\'') {
323                        result.push(chars.next().unwrap());
324                    } else {
325                        break;
326                    }
327                }
328            }
329        } else if ch == '$' {
330            if chars.peek().is_some_and(|c| c.is_ascii_digit()) {
331                let mut num_str = String::new();
332                while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
333                    num_str.push(chars.next().unwrap());
334                }
335                let num: u32 = num_str.parse().unwrap_or(0);
336                result.push_str(&formatter(num));
337            } else {
338                result.push(ch);
339            }
340        } else if ch == '?' && !chars.peek().is_some_and(|c| c.is_ascii_digit()) {
341            positional_counter += 1;
342            result.push_str(&formatter(positional_counter));
343        } else {
344            result.push(ch);
345        }
346    }
347    result
348}
349
350/// Get a backend by name and database engine.
351///
352/// The `engine` parameter (e.g., "postgresql", "mysql", "sqlite") determines
353/// which manifest is loaded for type mappings. PG-only backends reject non-PG engines.
354pub fn get_backend(name: &str, engine: &str) -> Result<Box<dyn CodegenBackend>, ScytheError> {
355    // Normalize engine aliases (e.g., "cockroachdb" -> "postgresql") before
356    // passing to backend constructors so each backend only needs to match
357    // canonical engine names.
358    let canonical_engine = normalize_engine(engine);
359    let backend: Box<dyn CodegenBackend> = match name {
360        "rust-sqlx" | "sqlx" | "rust" => Box::new(sqlx::SqlxBackend::new(canonical_engine)?),
361        "rust-tokio-postgres" | "tokio-postgres" => {
362            Box::new(tokio_postgres::TokioPostgresBackend::new(canonical_engine)?)
363        }
364        "python-psycopg3" | "python" => Box::new(python_psycopg3::PythonPsycopg3Backend::new(canonical_engine)?),
365        "python-asyncpg" => Box::new(python_asyncpg::PythonAsyncpgBackend::new(canonical_engine)?),
366        "python-aiomysql" => Box::new(python_aiomysql::PythonAiomysqlBackend::new(canonical_engine)?),
367        "python-aiosqlite" => Box::new(python_aiosqlite::PythonAiosqliteBackend::new(canonical_engine)?),
368        "python-duckdb" => Box::new(python_duckdb::PythonDuckdbBackend::new(canonical_engine)?),
369        "typescript-postgres" | "ts" | "typescript" => {
370            Box::new(typescript_postgres::TypescriptPostgresBackend::new(canonical_engine)?)
371        }
372        "typescript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new(canonical_engine)?),
373        "typescript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new(canonical_engine)?),
374        "typescript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new(
375            canonical_engine,
376        )?),
377        "typescript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new(canonical_engine)?),
378        "go-database-sql" => Box::new(go_database_sql::GoDatabaseSqlBackend::new(canonical_engine)?),
379        "go-pgx" | "go" => Box::new(go_pgx::GoPgxBackend::new(canonical_engine)?),
380        "java-jdbc" | "java" => Box::new(java_jdbc::JavaJdbcBackend::new(canonical_engine)?),
381        "java-r2dbc" | "r2dbc-java" => Box::new(java_r2dbc::JavaR2dbcBackend::new(canonical_engine)?),
382        "kotlin-exposed" | "exposed" => Box::new(kotlin_exposed::KotlinExposedBackend::new(canonical_engine)?),
383        "kotlin-jdbc" | "kotlin" | "kt" => Box::new(kotlin_jdbc::KotlinJdbcBackend::new(canonical_engine)?),
384        "kotlin-r2dbc" | "r2dbc-kotlin" => Box::new(kotlin_r2dbc::KotlinR2dbcBackend::new(canonical_engine)?),
385        "csharp-npgsql" | "csharp" | "c#" | "dotnet" => {
386            Box::new(csharp_npgsql::CsharpNpgsqlBackend::new(canonical_engine)?)
387        }
388        "csharp-mysqlconnector" => Box::new(csharp_mysqlconnector::CsharpMysqlConnectorBackend::new(
389            canonical_engine,
390        )?),
391        "csharp-microsoft-sqlite" => Box::new(csharp_microsoft_sqlite::CsharpMicrosoftSqliteBackend::new(
392            canonical_engine,
393        )?),
394        "elixir-postgrex" | "elixir" | "ex" => Box::new(elixir_postgrex::ElixirPostgrexBackend::new(canonical_engine)?),
395        "elixir-ecto" | "ecto" => Box::new(elixir_ecto::ElixirEctoBackend::new(canonical_engine)?),
396        "elixir-myxql" => Box::new(elixir_myxql::ElixirMyxqlBackend::new(canonical_engine)?),
397        "elixir-exqlite" => Box::new(elixir_exqlite::ElixirExqliteBackend::new(canonical_engine)?),
398        "ruby-pg" | "ruby" | "rb" => Box::new(ruby_pg::RubyPgBackend::new(canonical_engine)?),
399        "ruby-mysql2" => Box::new(ruby_mysql2::RubyMysql2Backend::new(canonical_engine)?),
400        "ruby-sqlite3" => Box::new(ruby_sqlite3::RubySqlite3Backend::new(canonical_engine)?),
401        "ruby-trilogy" | "trilogy" => Box::new(ruby_trilogy::RubyTrilogyBackend::new(canonical_engine)?),
402        "php-pdo" | "php" => Box::new(php_pdo::PhpPdoBackend::new(canonical_engine)?),
403        "php-amphp" | "amphp" => Box::new(php_amphp::PhpAmphpBackend::new(canonical_engine)?),
404        // MSSQL backends
405        "rust-tiberius" | "tiberius" => Box::new(rust_tiberius::RustTiberiusBackend::new(canonical_engine)?),
406        "python-pyodbc" | "pyodbc" => Box::new(python_pyodbc::PythonPyodbcBackend::new(canonical_engine)?),
407        "typescript-mssql" | "tedious" => Box::new(typescript_mssql::TypescriptMssqlBackend::new(canonical_engine)?),
408        "csharp-sqlclient" => Box::new(csharp_sqlclient::CsharpSqlClientBackend::new(canonical_engine)?),
409        "ruby-tiny-tds" | "tiny-tds" | "tiny_tds" => {
410            Box::new(ruby_tiny_tds::RubyTinyTdsBackend::new(canonical_engine)?)
411        }
412        "elixir-tds" | "tds" => Box::new(elixir_tds::ElixirTdsBackend::new(canonical_engine)?),
413        // Oracle backends
414        "rust-sibyl" | "sibyl" => Box::new(rust_sibyl::RustSibylBackend::new(canonical_engine)?),
415        "python-oracledb" | "oracledb" => Box::new(python_oracledb::PythonOracledbBackend::new(canonical_engine)?),
416        "typescript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new(canonical_engine)?),
417        "go-godror" | "godror" => Box::new(go_godror::GoGodrorBackend::new(canonical_engine)?),
418        "csharp-oracle" => Box::new(csharp_oracle::CsharpOracleBackend::new(canonical_engine)?),
419        "ruby-oci8" | "oci8" => Box::new(ruby_oci8::RubyOci8Backend::new(canonical_engine)?),
420        "elixir-jamdb" | "jamdb" => Box::new(elixir_jamdb::ElixirJamdbBackend::new(canonical_engine)?),
421        // Snowflake backends
422        "python-snowflake" => Box::new(python_snowflake::PythonSnowflakeBackend::new(canonical_engine)?),
423        "typescript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new(canonical_engine)?),
424        "go-gosnowflake" | "gosnowflake" => Box::new(go_gosnowflake::GoGosnowflakeBackend::new(canonical_engine)?),
425        "csharp-snowflake" => Box::new(csharp_snowflake::CsharpSnowflakeBackend::new(canonical_engine)?),
426        _ => {
427            return Err(ScytheError::new(
428                ErrorCode::InternalError,
429                format!("unknown backend: {}", name),
430            ));
431        }
432    };
433
434    // Validate engine is supported by this backend
435    if !backend
436        .supported_engines()
437        .iter()
438        .any(|e| normalize_engine(e) == canonical_engine)
439    {
440        return Err(ScytheError::new(
441            ErrorCode::InternalError,
442            format!(
443                "backend '{}' does not support engine '{}'. Supported: {:?}",
444                name,
445                engine,
446                backend.supported_engines()
447            ),
448        ));
449    }
450
451    Ok(backend)
452}
453
454/// Normalize engine name to canonical form.
455fn normalize_engine(engine: &str) -> &str {
456    match engine {
457        "postgresql" | "postgres" | "pg" | "cockroachdb" | "crdb" => "postgresql",
458        "mysql" => "mysql",
459        "mariadb" => "mariadb",
460        "sqlite" | "sqlite3" => "sqlite",
461        "duckdb" => "duckdb",
462        "mssql" | "sqlserver" | "tsql" => "mssql",
463        "oracle" => "oracle",
464        "snowflake" => "snowflake",
465        "redshift" => "redshift",
466        other => other,
467    }
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    fn param(name: &str, position: i64) -> AnalyzedParam {
475        AnalyzedParam {
476            name: name.to_string(),
477            neutral_type: "string".to_string(),
478            nullable: true,
479            position,
480        }
481    }
482
483    #[test]
484    fn test_normalize_engine_cockroachdb() {
485        assert_eq!(normalize_engine("cockroachdb"), "postgresql");
486        assert_eq!(normalize_engine("crdb"), "postgresql");
487    }
488
489    #[test]
490    fn test_get_backend_cockroachdb_with_pg_backends() {
491        // CockroachDB should work with all PostgreSQL-compatible backends
492        let pg_backends = [
493            "rust-sqlx",
494            "rust-tokio-postgres",
495            "python-psycopg3",
496            "python-asyncpg",
497            "typescript-postgres",
498            "typescript-pg",
499            "go-pgx",
500            "ruby-pg",
501            "elixir-postgrex",
502            "csharp-npgsql",
503            "php-pdo",
504            "php-amphp",
505        ];
506        for backend_name in &pg_backends {
507            let result = get_backend(backend_name, "cockroachdb");
508            assert!(
509                result.is_ok(),
510                "backend '{}' should accept cockroachdb engine, got: {:?}",
511                backend_name,
512                result.err()
513            );
514        }
515    }
516
517    #[test]
518    fn test_get_backend_crdb_alias() {
519        let result = get_backend("rust-sqlx", "crdb");
520        assert!(result.is_ok(), "rust-sqlx should accept 'crdb' engine alias");
521    }
522
523    #[test]
524    fn test_normalize_engine_duckdb() {
525        assert_eq!(normalize_engine("duckdb"), "duckdb");
526    }
527
528    #[test]
529    fn test_get_backend_duckdb_with_compatible_backends() {
530        let duckdb_backends = [
531            "python-duckdb",
532            "typescript-duckdb",
533            "go-database-sql",
534            "java-jdbc",
535            "kotlin-jdbc",
536        ];
537        for backend_name in &duckdb_backends {
538            let result = get_backend(backend_name, "duckdb");
539            assert!(
540                result.is_ok(),
541                "backend '{}' should accept duckdb engine, got: {:?}",
542                backend_name,
543                result.err()
544            );
545        }
546    }
547
548    #[test]
549    fn test_get_backend_duckdb_rejected_by_pg_only() {
550        let result = get_backend("rust-sqlx", "duckdb");
551        assert!(result.is_err(), "rust-sqlx should reject duckdb engine");
552    }
553
554    #[test]
555    fn test_rewrite_simple_equality() {
556        let sql = "SELECT * FROM users WHERE status = $1";
557        let params = vec![param("status", 1)];
558        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
559        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status = $1)");
560    }
561
562    #[test]
563    fn test_rewrite_qualified_column() {
564        let sql = "SELECT * FROM users u WHERE u.status = $1";
565        let params = vec![param("status", 1)];
566        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
567        assert_eq!(result, "SELECT * FROM users u WHERE ($1 IS NULL OR u.status = $1)");
568    }
569
570    #[test]
571    fn test_rewrite_multiple_optional() {
572        let sql = "SELECT * FROM users WHERE status = $1 AND name = $2";
573        let params = vec![param("status", 1), param("name", 2)];
574        let result = rewrite_optional_params(sql, &["status".to_string(), "name".to_string()], &params);
575        assert_eq!(
576            result,
577            "SELECT * FROM users WHERE ($1 IS NULL OR status = $1) AND ($2 IS NULL OR name = $2)"
578        );
579    }
580
581    #[test]
582    fn test_rewrite_mixed_optional_required() {
583        let sql = "SELECT * FROM users WHERE id = $1 AND status = $2";
584        let params = vec![param("id", 1), param("status", 2)];
585        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
586        assert_eq!(
587            result,
588            "SELECT * FROM users WHERE id = $1 AND ($2 IS NULL OR status = $2)"
589        );
590    }
591
592    #[test]
593    fn test_rewrite_like_operator() {
594        let sql = "SELECT * FROM users WHERE name LIKE $1";
595        let params = vec![param("name", 1)];
596        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
597        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name LIKE $1)");
598    }
599
600    #[test]
601    fn test_rewrite_ilike_operator() {
602        let sql = "SELECT * FROM users WHERE name ILIKE $1";
603        let params = vec![param("name", 1)];
604        let result = rewrite_optional_params(sql, &["name".to_string()], &params);
605        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name ILIKE $1)");
606    }
607
608    #[test]
609    fn test_rewrite_comparison_operators() {
610        let sql = "SELECT * FROM users WHERE age >= $1";
611        let params = vec![param("age", 1)];
612        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
613        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age >= $1)");
614    }
615
616    #[test]
617    fn test_rewrite_less_than() {
618        let sql = "SELECT * FROM users WHERE age < $1";
619        let params = vec![param("age", 1)];
620        let result = rewrite_optional_params(sql, &["age".to_string()], &params);
621        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age < $1)");
622    }
623
624    #[test]
625    fn test_no_rewrite_without_optional() {
626        let sql = "SELECT * FROM users WHERE status = $1";
627        let params = vec![param("status", 1)];
628        let result = rewrite_optional_params(sql, &[], &params);
629        assert_eq!(result, sql);
630    }
631
632    #[test]
633    fn test_rewrite_not_equal() {
634        let sql = "SELECT * FROM users WHERE status <> $1";
635        let params = vec![param("status", 1)];
636        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
637        assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status <> $1)");
638    }
639
640    #[test]
641    fn test_rewrite_does_not_match_similar_placeholder() {
642        // $1 should not match $10
643        let sql = "SELECT * FROM users WHERE status = $10";
644        let params = vec![param("status", 1)];
645        let result = rewrite_optional_params(sql, &["status".to_string()], &params);
646        // $1 placeholder doesn't appear, so no rewrite
647        assert_eq!(result, sql);
648    }
649
650    #[test]
651    fn test_normalize_engine_mariadb() {
652        assert_eq!(normalize_engine("mariadb"), "mariadb");
653    }
654
655    #[test]
656    fn test_get_backend_mariadb_with_mysql_backends() {
657        let mariadb_backends = [
658            "rust-sqlx",
659            "python-aiomysql",
660            "typescript-mysql2",
661            "go-database-sql",
662            "java-jdbc",
663            "java-r2dbc",
664            "kotlin-jdbc",
665            "kotlin-r2dbc",
666            "csharp-mysqlconnector",
667            "elixir-myxql",
668            "ruby-mysql2",
669            "ruby-trilogy",
670            "php-pdo",
671            "php-amphp",
672        ];
673        for backend_name in &mariadb_backends {
674            let result = get_backend(backend_name, "mariadb");
675            assert!(
676                result.is_ok(),
677                "backend '{}' should accept mariadb engine, got: {:?}",
678                backend_name,
679                result.err()
680            );
681        }
682    }
683}