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