1pub(crate) mod csharp_microsoft_sqlite;
2pub(crate) mod csharp_mysqlconnector;
3pub(crate) mod csharp_npgsql;
4pub(crate) mod csharp_oracle;
5pub(crate) mod csharp_snowflake;
6pub(crate) mod csharp_sqlclient;
7pub(crate) mod elixir_common;
8pub(crate) mod elixir_ecto;
9pub(crate) mod elixir_exqlite;
10pub(crate) mod elixir_jamdb;
11pub(crate) mod elixir_myxql;
12pub(crate) mod elixir_postgrex;
13pub(crate) mod elixir_tds;
14pub(crate) mod go_common;
15pub(crate) mod go_database_sql;
16pub(crate) mod go_godror;
17pub(crate) mod go_gosnowflake;
18pub(crate) mod go_pgx;
19pub(crate) mod java_jdbc;
20pub(crate) mod java_r2dbc;
21pub(crate) mod jvm_common;
22pub(crate) mod kotlin_exposed;
23pub(crate) mod kotlin_jdbc;
24pub(crate) mod kotlin_r2dbc;
25pub(crate) mod php_amphp;
26pub(crate) mod php_common;
27pub(crate) mod php_pdo;
28pub(crate) mod python_aiomysql;
29pub(crate) mod python_aiosqlite;
30pub(crate) mod python_asyncpg;
31pub(crate) mod python_common;
32pub(crate) mod python_duckdb;
33pub(crate) mod python_oracledb;
34pub(crate) mod python_psycopg3;
35pub(crate) mod python_pyodbc;
36pub(crate) mod python_snowflake;
37pub(crate) mod ruby_mysql2;
38pub(crate) mod ruby_oci8;
39pub(crate) mod ruby_pg;
40pub(crate) mod ruby_rbs;
41pub(crate) mod ruby_sqlite3;
42pub(crate) mod ruby_tiny_tds;
43pub(crate) mod ruby_trilogy;
44pub(crate) mod rust_sibyl;
45pub(crate) mod rust_tiberius;
46pub(crate) mod sqlx;
47pub(crate) mod tokio_postgres;
48pub(crate) mod typescript_better_sqlite3;
49pub(crate) mod typescript_common;
50pub(crate) mod typescript_duckdb;
51pub(crate) mod typescript_kysely;
52pub(crate) mod typescript_mssql;
53pub(crate) mod typescript_mysql2;
54pub(crate) mod typescript_node_sqlite;
55pub(crate) mod typescript_oracledb;
56pub(crate) mod typescript_pg;
57pub(crate) mod typescript_postgres;
58pub(crate) mod typescript_snowflake;
59pub(crate) mod typescript_wasm_sqlite;
60
61use scythe_backend::manifest::BackendManifest;
62use scythe_backend::naming::NamingConfig;
63use scythe_core::SqlDialect;
64use scythe_core::analyzer::AnalyzedParam;
65use scythe_core::errors::{ErrorCode, ScytheError};
66
67use crate::backend_trait::{CodegenBackend, ResolvedParam};
68
69pub(crate) fn parse_manifest(manifest_toml: &str) -> Result<BackendManifest, ScytheError> {
75 toml::from_str(manifest_toml).map_err(|e| ScytheError::new(ErrorCode::InternalError, format!("manifest: {e}")))
76}
77
78pub(crate) fn apply_field_case_option(
95 naming: &mut NamingConfig,
96 backend_name: &str,
97 value: &str,
98) -> Result<(), ScytheError> {
99 match value {
100 "snake_case" | "camelCase" => {
101 naming.field_case = value.to_string();
102 Ok(())
103 }
104 other => Err(ScytheError::new(
105 ErrorCode::InvalidConfig,
106 format!("{backend_name}: invalid field_case '{other}' (expected 'snake_case' or 'camelCase')"),
107 )),
108 }
109}
110
111pub(crate) fn engine_supports_nested_aggregates(engine: &str) -> bool {
126 matches!(engine, "postgresql" | "postgres" | "pg")
127}
128
129pub(crate) fn csharp_typed_reader_method(lang_type: &str) -> String {
157 format!("GetFieldValue<{lang_type}>")
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170enum SqlSpanKind {
171 Code,
173 SingleQuoted,
175 DoubleQuoted,
177 DollarQuoted,
179 Backtick,
183 Bracketed,
188 LineComment,
191 BlockComment,
194}
195
196fn tokenize_sql(sql: &str, dialect: SqlDialect) -> Vec<(SqlSpanKind, String)> {
209 let chars: Vec<char> = sql.chars().collect();
210 let len = chars.len();
211 let mut spans: Vec<(SqlSpanKind, String)> = Vec::new();
212 let mut code_buf = String::new();
213 let mut i = 0;
214
215 while i < len {
216 let c = chars[i];
217
218 if c == '-' && i + 1 < len && chars[i + 1] == '-' {
219 flush_code_buf(&mut code_buf, &mut spans);
220 let start = i;
221 i += 2;
222 while i < len && chars[i] != '\n' {
223 i += 1;
224 }
225 spans.push((SqlSpanKind::LineComment, chars[start..i].iter().collect()));
226 continue;
227 }
228
229 if c == '/' && i + 1 < len && chars[i + 1] == '*' {
230 flush_code_buf(&mut code_buf, &mut spans);
231 let start = i;
232 i += 2;
233 let mut depth = 1u32;
234 while i < len && depth > 0 {
235 if chars[i] == '/' && i + 1 < len && chars[i + 1] == '*' {
236 depth += 1;
237 i += 2;
238 } else if chars[i] == '*' && i + 1 < len && chars[i + 1] == '/' {
239 depth -= 1;
240 i += 2;
241 } else {
242 i += 1;
243 }
244 }
245 spans.push((SqlSpanKind::BlockComment, chars[start..i].iter().collect()));
246 continue;
247 }
248
249 if c == '\'' {
250 flush_code_buf(&mut code_buf, &mut spans);
251 let start = i;
252 i += 1;
253 while i < len {
254 if chars[i] == '\'' {
255 if i + 1 < len && chars[i + 1] == '\'' {
256 i += 2;
257 continue;
258 }
259 i += 1;
260 break;
261 }
262 i += 1;
263 }
264 spans.push((SqlSpanKind::SingleQuoted, chars[start..i].iter().collect()));
265 continue;
266 }
267
268 if c == '"' {
269 flush_code_buf(&mut code_buf, &mut spans);
270 let start = i;
271 i += 1;
272 while i < len {
273 if chars[i] == '"' {
274 if i + 1 < len && chars[i + 1] == '"' {
275 i += 2;
276 continue;
277 }
278 i += 1;
279 break;
280 }
281 i += 1;
282 }
283 spans.push((SqlSpanKind::DoubleQuoted, chars[start..i].iter().collect()));
284 continue;
285 }
286
287 if c == '`' && dialect == SqlDialect::MySQL {
288 flush_code_buf(&mut code_buf, &mut spans);
289 let start = i;
290 i += 1;
291 while i < len {
292 if chars[i] == '`' {
293 if i + 1 < len && chars[i + 1] == '`' {
294 i += 2;
295 continue;
296 }
297 i += 1;
298 break;
299 }
300 i += 1;
301 }
302 spans.push((SqlSpanKind::Backtick, chars[start..i].iter().collect()));
303 continue;
304 }
305
306 if c == '#' && dialect == SqlDialect::MySQL {
307 flush_code_buf(&mut code_buf, &mut spans);
308 let start = i;
309 while i < len && chars[i] != '\n' {
310 i += 1;
311 }
312 spans.push((SqlSpanKind::LineComment, chars[start..i].iter().collect()));
313 continue;
314 }
315
316 if c == '[' && dialect == SqlDialect::MsSql {
317 flush_code_buf(&mut code_buf, &mut spans);
318 let start = i;
319 i += 1;
320 while i < len {
321 if chars[i] == ']' {
322 if i + 1 < len && chars[i + 1] == ']' {
323 i += 2;
324 continue;
325 }
326 i += 1;
327 break;
328 }
329 i += 1;
330 }
331 spans.push((SqlSpanKind::Bracketed, chars[start..i].iter().collect()));
332 continue;
333 }
334
335 if c == '$'
336 && let Some(open_end) = match_dollar_quote_open(&chars, i)
337 {
338 flush_code_buf(&mut code_buf, &mut spans);
339 let start = i;
340 let delim: Vec<char> = chars[i..open_end].to_vec();
341 let mut j = open_end;
342 let mut found = false;
343 while j < len {
344 if chars[j] == '$' && j + delim.len() <= len && chars[j..j + delim.len()] == delim[..] {
345 j += delim.len();
346 found = true;
347 break;
348 }
349 j += 1;
350 }
351 if !found {
352 j = len;
353 }
354 spans.push((SqlSpanKind::DollarQuoted, chars[start..j].iter().collect()));
355 i = j;
356 continue;
357 }
358
359 code_buf.push(c);
360 i += 1;
361 }
362
363 flush_code_buf(&mut code_buf, &mut spans);
364 spans
365}
366
367fn flush_code_buf(code_buf: &mut String, spans: &mut Vec<(SqlSpanKind, String)>) {
368 if !code_buf.is_empty() {
369 spans.push((SqlSpanKind::Code, std::mem::take(code_buf)));
370 }
371}
372
373fn match_dollar_quote_open(chars: &[char], i: usize) -> Option<usize> {
378 let mut j = i + 1;
379 if j < chars.len() && chars[j] == '$' {
380 return Some(j + 1);
381 }
382 let tag_start = j;
383 while j < chars.len() && (chars[j].is_alphanumeric() || chars[j] == '_') {
384 j += 1;
385 }
386 if j > tag_start && j < chars.len() && chars[j] == '$' && !chars[tag_start].is_ascii_digit() {
387 return Some(j + 1);
388 }
389 None
390}
391
392pub(crate) fn clean_sql(sql: &str) -> String {
401 clean_sql_lines(sql, SqlDialect::PostgreSQL)
402 .join("\n")
403 .trim()
404 .trim_end_matches(';')
405 .trim()
406 .to_string()
407}
408
409pub(crate) fn clean_sql_oneline(sql: &str) -> String {
412 clean_sql_lines(sql, SqlDialect::PostgreSQL)
413 .join(" ")
414 .trim()
415 .trim_end_matches(';')
416 .trim()
417 .to_string()
418}
419
420#[cfg_attr(
432 not(test),
433 expect(dead_code, reason = "staged board #148 migration; see doc comment")
434)]
435pub(crate) fn clean_sql_dialect(sql: &str, dialect: SqlDialect) -> String {
436 clean_sql_lines(sql, dialect)
437 .join("\n")
438 .trim()
439 .trim_end_matches(';')
440 .trim()
441 .to_string()
442}
443
444pub(crate) fn clean_sql_oneline_dialect(sql: &str, dialect: SqlDialect) -> String {
446 clean_sql_lines(sql, dialect)
447 .join(" ")
448 .trim()
449 .trim_end_matches(';')
450 .trim()
451 .to_string()
452}
453
454fn clean_sql_lines(sql: &str, dialect: SqlDialect) -> Vec<String> {
466 let normalized = sql.replace("\r\n", "\n");
467 let spans = tokenize_sql(&normalized, dialect);
468
469 let mut lines: Vec<String> = vec![String::new()];
470 let mut touched_by_comment: Vec<bool> = vec![false];
471
472 for (kind, text) in &spans {
473 let is_comment = matches!(kind, SqlSpanKind::LineComment | SqlSpanKind::BlockComment);
474 for ch in text.chars() {
475 if ch == '\n' {
476 if is_comment {
477 *touched_by_comment.last_mut().expect("at least one line") = true;
478 }
479 lines.push(String::new());
480 touched_by_comment.push(false);
481 } else if is_comment {
482 *touched_by_comment.last_mut().expect("at least one line") = true;
483 } else {
484 lines.last_mut().expect("at least one line").push(ch);
485 }
486 }
487 }
488
489 lines
490 .into_iter()
491 .zip(touched_by_comment)
492 .filter(|(kept, touched)| !(*touched && kept.trim().is_empty()))
493 .map(|(kept, _)| kept)
494 .collect()
495}
496
497pub(crate) fn rewrite_optional_params(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
505 if optional_params.is_empty() {
506 return sql.to_string();
507 }
508
509 let mut result = sql.to_string();
510
511 for opt_name in optional_params {
512 let Some(param) = params.iter().find(|p| p.name == *opt_name) else {
513 continue;
514 };
515 let placeholder = format!("${}", param.position);
516
517 for op in &[
518 ">=",
519 "<=",
520 "<>",
521 "!=",
522 ">",
523 "<",
524 "=",
525 "NOT ILIKE",
526 "not ilike",
527 "NOT LIKE",
528 "not like",
529 "ILIKE",
530 "ilike",
531 "LIKE",
532 "like",
533 ] {
534 result = rewrite_comparison(&result, &placeholder, op);
535 }
536 }
537
538 result
539}
540
541fn rewrite_comparison(sql: &str, placeholder: &str, op: &str) -> String {
544 let mut result = String::with_capacity(sql.len() + 32);
545 let chars: Vec<char> = sql.chars().collect();
546 let len = chars.len();
547 let mut i = 0;
548
549 while i < len {
550 if let Some((_start, col, end)) = try_match_col_op_ph(&chars, i, op, placeholder) {
551 result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
552 i = end;
553 continue;
554 }
555
556 if let Some((end, col)) = try_match_ph_op_col(&chars, i, op, placeholder) {
557 result.push_str(&format!("({placeholder} IS NULL OR {col} {op} {placeholder})"));
558 i = end;
559 continue;
560 }
561
562 result.push(chars[i]);
563 i += 1;
564 }
565
566 result
567}
568
569fn is_reserved_non_column_keyword(ident: &str) -> bool {
578 matches!(
579 ident.to_ascii_uppercase().as_str(),
580 "NOT" | "AND" | "OR" | "IS" | "NULL"
581 )
582}
583
584fn match_op_tokens(chars: &[char], mut j: usize, op: &str) -> Option<usize> {
589 for (idx, word) in op.split_whitespace().enumerate() {
590 if idx > 0 {
591 let ws_start = j;
592 while j < chars.len() && chars[j].is_whitespace() {
593 j += 1;
594 }
595 if j == ws_start {
596 return None;
597 }
598 }
599 let word_chars: Vec<char> = word.chars().collect();
600 if j + word_chars.len() > chars.len() {
601 return None;
602 }
603 for (k, wc) in word_chars.iter().enumerate() {
604 if chars[j + k] != *wc {
605 return None;
606 }
607 }
608 j += word_chars.len();
609 }
610 Some(j)
611}
612
613fn try_match_col_op_ph(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String, usize)> {
616 if !is_ident_char(chars[i]) {
617 return None;
618 }
619 if i > 0 && is_ident_char(chars[i - 1]) {
620 return None;
621 }
622
623 let ident_start = i;
624 let mut j = i;
625 while j < chars.len() && is_ident_char(chars[j]) {
626 j += 1;
627 }
628 let ident: String = chars[ident_start..j].iter().collect();
629 if is_reserved_non_column_keyword(&ident) {
630 return None;
631 }
632
633 while j < chars.len() && chars[j].is_whitespace() {
634 j += 1;
635 }
636
637 j = match_op_tokens(chars, j, op)?;
638
639 while j < chars.len() && chars[j].is_whitespace() {
640 j += 1;
641 }
642
643 let ph_chars: Vec<char> = placeholder.chars().collect();
644 if j + ph_chars.len() > chars.len() {
645 return None;
646 }
647 for (k, pc) in ph_chars.iter().enumerate() {
648 if chars[j + k] != *pc {
649 return None;
650 }
651 }
652 j += ph_chars.len();
653
654 if j < chars.len() && chars[j].is_ascii_digit() {
655 return None;
656 }
657
658 Some((i, ident, j))
659}
660
661fn try_match_ph_op_col(chars: &[char], i: usize, op: &str, placeholder: &str) -> Option<(usize, String)> {
664 let ph_chars: Vec<char> = placeholder.chars().collect();
665 if i + ph_chars.len() > chars.len() {
666 return None;
667 }
668
669 if i > 0 && (chars[i - 1] == '$' || chars[i - 1].is_ascii_digit()) {
670 return None;
671 }
672
673 for (k, pc) in ph_chars.iter().enumerate() {
674 if chars[i + k] != *pc {
675 return None;
676 }
677 }
678 let mut j = i + ph_chars.len();
679
680 if j < chars.len() && chars[j].is_ascii_digit() {
681 return None;
682 }
683
684 while j < chars.len() && chars[j].is_whitespace() {
685 j += 1;
686 }
687
688 j = match_op_tokens(chars, j, op)?;
689
690 while j < chars.len() && chars[j].is_whitespace() {
691 j += 1;
692 }
693
694 if j >= chars.len() || !is_ident_char(chars[j]) {
695 return None;
696 }
697 let ident_start = j;
698 while j < chars.len() && is_ident_char(chars[j]) {
699 j += 1;
700 }
701 let ident: String = chars[ident_start..j].iter().collect();
702
703 if is_reserved_non_column_keyword(&ident) {
704 return None;
705 }
706
707 Some((j, ident))
708}
709
710pub(crate) fn clean_sql_with_optional(sql: &str, optional_params: &[String], params: &[AnalyzedParam]) -> String {
713 let cleaned = clean_sql(sql);
714 rewrite_optional_params(&cleaned, optional_params, params)
715}
716
717pub(crate) fn clean_sql_oneline_with_optional(
720 sql: &str,
721 optional_params: &[String],
722 params: &[AnalyzedParam],
723) -> String {
724 let cleaned = clean_sql_oneline(sql);
725 rewrite_optional_params(&cleaned, optional_params, params)
726}
727
728pub(crate) fn clean_sql_oneline_with_optional_dialect(
730 sql: &str,
731 dialect: SqlDialect,
732 optional_params: &[String],
733 params: &[AnalyzedParam],
734) -> String {
735 let cleaned = clean_sql_oneline_dialect(sql, dialect);
736 rewrite_optional_params(&cleaned, optional_params, params)
737}
738
739fn is_ident_char(c: char) -> bool {
740 c.is_alphanumeric() || c == '_' || c == '.'
741}
742
743pub(crate) fn rewrite_pg_placeholders(sql: &str, formatter: impl Fn(u32) -> String) -> String {
763 let spans = tokenize_sql(sql, SqlDialect::PostgreSQL);
764 let uses_dollar_placeholders = spans
765 .iter()
766 .any(|(kind, text)| *kind == SqlSpanKind::Code && code_span_has_dollar_number(text));
767
768 let mut result = String::with_capacity(sql.len());
769 let mut positional_counter: u32 = 0;
770 let mut occurrences: Vec<u32> = Vec::new();
771 for (kind, text) in &spans {
772 if *kind == SqlSpanKind::Code {
773 rewrite_code_span_placeholders(
774 text,
775 !uses_dollar_placeholders,
776 &formatter,
777 &mut positional_counter,
778 &mut result,
779 &mut occurrences,
780 );
781 } else {
782 result.push_str(text);
783 }
784 }
785 result
786}
787
788fn dialect_allows_bare_question_mark_placeholder(dialect: SqlDialect) -> bool {
807 !matches!(dialect, SqlDialect::PostgreSQL)
808}
809
810pub(crate) fn rewrite_placeholders_indexed(
829 sql: &str,
830 dialect: SqlDialect,
831 formatter: impl Fn(u32) -> String,
832) -> (String, Vec<u32>) {
833 let spans = tokenize_sql(sql, dialect);
834 let bare_question_is_placeholder = dialect_allows_bare_question_mark_placeholder(dialect);
835
836 let mut result = String::with_capacity(sql.len());
837 let mut positional_counter: u32 = 0;
838 let mut occurrences: Vec<u32> = Vec::new();
839 for (kind, text) in &spans {
840 if *kind == SqlSpanKind::Code {
841 rewrite_code_span_placeholders(
842 text,
843 bare_question_is_placeholder,
844 &formatter,
845 &mut positional_counter,
846 &mut result,
847 &mut occurrences,
848 );
849 } else {
850 result.push_str(text);
851 }
852 }
853 (result, occurrences)
854}
855
856pub(crate) fn resolved_param_for_position<'a>(
875 analyzed_params: &[AnalyzedParam],
876 resolved: &'a [ResolvedParam],
877 position: u32,
878) -> &'a ResolvedParam {
879 let idx = analyzed_params
880 .iter()
881 .position(|p| p.position == i64::from(position))
882 .unwrap_or_else(|| panic!("no analyzed parameter at position {position}; analyzed_params={analyzed_params:?}"));
883 &resolved[idx]
884}
885
886fn code_span_has_dollar_number(text: &str) -> bool {
889 let chars: Vec<char> = text.chars().collect();
890 (0..chars.len()).any(|i| chars[i] == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()))
891}
892
893const UNAMBIGUOUS_OPERATORS: [&str; 6] = ["?-|", "?||", "?|", "?&", "?-", "@?"];
901
902fn match_unambiguous_operator(chars: &[char], i: usize) -> Option<usize> {
905 UNAMBIGUOUS_OPERATORS.iter().find_map(|op| {
906 let op_chars: Vec<char> = op.chars().collect();
907 let end = i + op_chars.len();
908 (end <= chars.len() && chars[i..end] == op_chars[..]).then_some(op_chars.len())
909 })
910}
911
912fn rewrite_code_span_placeholders(
926 text: &str,
927 bare_question_is_placeholder: bool,
928 formatter: &impl Fn(u32) -> String,
929 counter: &mut u32,
930 out: &mut String,
931 occurrences: &mut Vec<u32>,
932) {
933 let chars: Vec<char> = text.chars().collect();
934 let len = chars.len();
935 let mut i = 0;
936 while i < len {
937 if let Some(op_len) = match_unambiguous_operator(&chars, i) {
938 out.extend(&chars[i..i + op_len]);
939 i += op_len;
940 continue;
941 }
942
943 let ch = chars[i];
944 if ch == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
945 let mut j = i + 1;
946 let mut num_str = String::new();
947 while j < len && chars[j].is_ascii_digit() {
948 num_str.push(chars[j]);
949 j += 1;
950 }
951 let num: u32 = num_str.parse().unwrap_or(0);
952 out.push_str(&formatter(num));
953 occurrences.push(num);
954 i = j;
955 } else if ch == '?' && bare_question_is_placeholder && !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
956 *counter += 1;
957 out.push_str(&formatter(*counter));
958 occurrences.push(*counter);
959 i += 1;
960 } else {
961 out.push(ch);
962 i += 1;
963 }
964 }
965}
966
967pub fn get_backend(name: &str, engine: &str) -> Result<Box<dyn CodegenBackend>, ScytheError> {
972 let canonical_engine = normalize_engine(engine);
973 let backend: Box<dyn CodegenBackend> = match name {
974 "rust-sqlx" | "sqlx" | "rust" => Box::new(sqlx::SqlxBackend::new(canonical_engine)?),
975 "rust-tokio-postgres" | "tokio-postgres" => {
976 Box::new(tokio_postgres::TokioPostgresBackend::new(canonical_engine)?)
977 }
978 "python-psycopg3" | "python" => Box::new(python_psycopg3::PythonPsycopg3Backend::new(canonical_engine)?),
979 "python-asyncpg" => Box::new(python_asyncpg::PythonAsyncpgBackend::new(canonical_engine)?),
980 "python-aiomysql" => Box::new(python_aiomysql::PythonAiomysqlBackend::new(canonical_engine)?),
981 "python-aiosqlite" => Box::new(python_aiosqlite::PythonAiosqliteBackend::new(canonical_engine)?),
982 "python-duckdb" => Box::new(python_duckdb::PythonDuckdbBackend::new(canonical_engine)?),
983 "typescript-postgres" | "ts" | "typescript" => {
984 Box::new(typescript_postgres::TypescriptPostgresBackend::new(canonical_engine)?)
985 }
986 "javascript-postgres" => Box::new(typescript_postgres::TypescriptPostgresBackend::new_js(
987 canonical_engine,
988 )?),
989 "typescript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new(canonical_engine)?),
990 "javascript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new_js(canonical_engine)?),
991 "typescript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new(canonical_engine)?),
992 "javascript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new_js(canonical_engine)?),
993 "typescript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new(
994 canonical_engine,
995 )?),
996 "javascript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new_js(
997 canonical_engine,
998 )?),
999 "typescript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new(canonical_engine)?),
1000 "typescript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new(
1001 canonical_engine,
1002 )?),
1003 "typescript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new(
1004 canonical_engine,
1005 )?),
1006 "typescript-kysely" | "kysely" => Box::new(typescript_kysely::TypescriptKyselyBackend::new(canonical_engine)?),
1007 "go-database-sql" => Box::new(go_database_sql::GoDatabaseSqlBackend::new(canonical_engine)?),
1008 "go-pgx" | "go" => Box::new(go_pgx::GoPgxBackend::new(canonical_engine)?),
1009 "java-jdbc" | "java" => Box::new(java_jdbc::JavaJdbcBackend::new(canonical_engine)?),
1010 "java-r2dbc" | "r2dbc-java" => Box::new(java_r2dbc::JavaR2dbcBackend::new(canonical_engine)?),
1011 "kotlin-exposed" | "exposed" => Box::new(kotlin_exposed::KotlinExposedBackend::new(canonical_engine)?),
1012 "kotlin-jdbc" | "kotlin" | "kt" => Box::new(kotlin_jdbc::KotlinJdbcBackend::new(canonical_engine)?),
1013 "kotlin-r2dbc" | "r2dbc-kotlin" => Box::new(kotlin_r2dbc::KotlinR2dbcBackend::new(canonical_engine)?),
1014 "csharp-npgsql" | "csharp" | "c#" | "dotnet" => {
1015 Box::new(csharp_npgsql::CsharpNpgsqlBackend::new(canonical_engine)?)
1016 }
1017 "csharp-mysqlconnector" => Box::new(csharp_mysqlconnector::CsharpMysqlConnectorBackend::new(
1018 canonical_engine,
1019 )?),
1020 "csharp-microsoft-sqlite" => Box::new(csharp_microsoft_sqlite::CsharpMicrosoftSqliteBackend::new(
1021 canonical_engine,
1022 )?),
1023 "elixir-postgrex" | "elixir" | "ex" => Box::new(elixir_postgrex::ElixirPostgrexBackend::new(canonical_engine)?),
1024 "elixir-ecto" | "ecto" => Box::new(elixir_ecto::ElixirEctoBackend::new(canonical_engine)?),
1025 "elixir-myxql" => Box::new(elixir_myxql::ElixirMyxqlBackend::new(canonical_engine)?),
1026 "elixir-exqlite" => Box::new(elixir_exqlite::ElixirExqliteBackend::new(canonical_engine)?),
1027 "ruby-pg" | "ruby" | "rb" => Box::new(ruby_pg::RubyPgBackend::new(canonical_engine)?),
1028 "ruby-mysql2" => Box::new(ruby_mysql2::RubyMysql2Backend::new(canonical_engine)?),
1029 "ruby-sqlite3" => Box::new(ruby_sqlite3::RubySqlite3Backend::new(canonical_engine)?),
1030 "ruby-trilogy" | "trilogy" => Box::new(ruby_trilogy::RubyTrilogyBackend::new(canonical_engine)?),
1031 "php-pdo" | "php" => Box::new(php_pdo::PhpPdoBackend::new(canonical_engine)?),
1032 "php-amphp" | "amphp" => Box::new(php_amphp::PhpAmphpBackend::new(canonical_engine)?),
1033 "rust-tiberius" | "tiberius" => Box::new(rust_tiberius::RustTiberiusBackend::new(canonical_engine)?),
1034 "python-pyodbc" | "pyodbc" => Box::new(python_pyodbc::PythonPyodbcBackend::new(canonical_engine)?),
1035 "typescript-mssql" | "tedious" => Box::new(typescript_mssql::TypescriptMssqlBackend::new(canonical_engine)?),
1036 "csharp-sqlclient" => Box::new(csharp_sqlclient::CsharpSqlClientBackend::new(canonical_engine)?),
1037 "ruby-tiny-tds" | "tiny-tds" | "tiny_tds" => {
1038 Box::new(ruby_tiny_tds::RubyTinyTdsBackend::new(canonical_engine)?)
1039 }
1040 "elixir-tds" | "tds" => Box::new(elixir_tds::ElixirTdsBackend::new(canonical_engine)?),
1041 "rust-sibyl" | "sibyl" => Box::new(rust_sibyl::RustSibylBackend::new(canonical_engine)?),
1042 "python-oracledb" | "oracledb" => Box::new(python_oracledb::PythonOracledbBackend::new(canonical_engine)?),
1043 "typescript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new(canonical_engine)?),
1044 "go-godror" | "godror" => Box::new(go_godror::GoGodrorBackend::new(canonical_engine)?),
1045 "csharp-oracle" => Box::new(csharp_oracle::CsharpOracleBackend::new(canonical_engine)?),
1046 "ruby-oci8" | "oci8" => Box::new(ruby_oci8::RubyOci8Backend::new(canonical_engine)?),
1047 "elixir-jamdb" | "jamdb" => Box::new(elixir_jamdb::ElixirJamdbBackend::new(canonical_engine)?),
1048 "python-snowflake" => Box::new(python_snowflake::PythonSnowflakeBackend::new(canonical_engine)?),
1049 "typescript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new(canonical_engine)?),
1050 "go-gosnowflake" | "gosnowflake" => Box::new(go_gosnowflake::GoGosnowflakeBackend::new(canonical_engine)?),
1051 "csharp-snowflake" => Box::new(csharp_snowflake::CsharpSnowflakeBackend::new(canonical_engine)?),
1052 _ => {
1053 return Err(ScytheError::new(
1054 ErrorCode::InvalidConfig,
1055 format!("unknown backend: {}", name),
1056 ));
1057 }
1058 };
1059
1060 if !backend
1061 .supported_engines()
1062 .iter()
1063 .any(|e| normalize_engine(e) == canonical_engine)
1064 {
1065 return Err(ScytheError::new(
1066 ErrorCode::InvalidConfig,
1067 format!(
1068 "backend '{}' does not support engine '{}'. Supported: {:?}",
1069 name,
1070 engine,
1071 backend.supported_engines()
1072 ),
1073 ));
1074 }
1075
1076 Ok(backend)
1077}
1078
1079pub fn normalize_engine(engine: &str) -> &str {
1086 match engine {
1087 "postgresql" | "postgres" | "pg" | "cockroachdb" | "crdb" => "postgresql",
1088 "mysql" => "mysql",
1089 "mariadb" => "mariadb",
1090 "sqlite" | "sqlite3" => "sqlite",
1091 "duckdb" => "duckdb",
1092 "mssql" | "sqlserver" | "tsql" => "mssql",
1093 "oracle" => "oracle",
1094 "snowflake" => "snowflake",
1095 "redshift" => "redshift",
1096 other => other,
1097 }
1098}
1099
1100#[cfg(test)]
1105mod sql_text_characterization_tests;
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110
1111 fn param(name: &str, position: i64) -> AnalyzedParam {
1112 AnalyzedParam {
1113 name: name.to_string(),
1114 neutral_type: "string".to_string(),
1115 nullable: true,
1116 position,
1117 source_relation: None,
1118 }
1119 }
1120
1121 #[test]
1133 fn test_no_backends_relative_manifest_path_literals() {
1134 let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src/backends");
1135 let word: String = format!("backend{}", "s/");
1136 let needle: String = format!("{}{}", '"', word);
1137
1138 let mut offenders = Vec::new();
1139 collect_rs_files(std::path::Path::new(root), &mut offenders);
1140
1141 let offenders: Vec<_> = offenders
1142 .into_iter()
1143 .filter(|path| {
1144 let contents = std::fs::read_to_string(path).expect("failed to read source file");
1145 contents.contains(&needle)
1146 })
1147 .collect();
1148
1149 assert!(
1150 offenders.is_empty(),
1151 "found a working-directory-relative manifest path literal in: {offenders:?} (#82 regression \
1152 -- manifest selection must be a pure function of (backend, engine), no filesystem lookup)"
1153 );
1154 }
1155
1156 fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
1158 let entries = std::fs::read_dir(dir).expect("failed to read backends dir");
1159 for entry in entries {
1160 let entry = entry.expect("failed to read dir entry");
1161 let path = entry.path();
1162 if path.is_dir() {
1163 collect_rs_files(&path, out);
1164 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
1165 out.push(path);
1166 }
1167 }
1168 }
1169
1170 #[test]
1176 fn test_get_backend_rust_sqlx_mysql_has_no_postgresql_scalars() {
1177 let backend = get_backend("rust-sqlx", "mysql").expect("rust-sqlx should support mysql");
1178 let scalars = &backend.manifest().types.scalars;
1179 assert_ne!(
1180 scalars.get("uuid").map(String::as_str),
1181 Some("uuid::Uuid"),
1182 "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1183 );
1184 assert_ne!(
1185 scalars.get("inet").map(String::as_str),
1186 Some("ipnetwork::IpNetwork"),
1187 "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'inet' mapping"
1188 );
1189 }
1190
1191 #[test]
1192 fn test_get_backend_java_jdbc_sqlite_has_no_postgresql_scalars() {
1193 let backend = get_backend("java-jdbc", "sqlite").expect("java-jdbc should support sqlite");
1194 let scalars = &backend.manifest().types.scalars;
1195 assert_ne!(
1196 scalars.get("uuid").map(String::as_str),
1197 Some("java.util.UUID"),
1198 "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1199 );
1200 assert_ne!(
1201 scalars.get("decimal").map(String::as_str),
1202 Some("java.math.BigDecimal"),
1203 "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'decimal' mapping"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_normalize_engine_cockroachdb() {
1209 assert_eq!(normalize_engine("cockroachdb"), "postgresql");
1210 assert_eq!(normalize_engine("crdb"), "postgresql");
1211 }
1212
1213 #[test]
1214 fn test_get_backend_cockroachdb_with_pg_backends() {
1215 let pg_backends = [
1216 "rust-sqlx",
1217 "rust-tokio-postgres",
1218 "python-psycopg3",
1219 "python-asyncpg",
1220 "typescript-postgres",
1221 "typescript-pg",
1222 "typescript-kysely",
1223 "go-pgx",
1224 "ruby-pg",
1225 "elixir-postgrex",
1226 "csharp-npgsql",
1227 "php-pdo",
1228 "php-amphp",
1229 ];
1230 for backend_name in &pg_backends {
1231 let result = get_backend(backend_name, "cockroachdb");
1232 assert!(
1233 result.is_ok(),
1234 "backend '{}' should accept cockroachdb engine, got: {:?}",
1235 backend_name,
1236 result.err()
1237 );
1238 }
1239 }
1240
1241 #[test]
1242 fn test_get_backend_crdb_alias() {
1243 let result = get_backend("rust-sqlx", "crdb");
1244 assert!(result.is_ok(), "rust-sqlx should accept 'crdb' engine alias");
1245 }
1246
1247 #[test]
1248 fn test_normalize_engine_duckdb() {
1249 assert_eq!(normalize_engine("duckdb"), "duckdb");
1250 }
1251
1252 #[test]
1253 fn test_get_backend_duckdb_with_compatible_backends() {
1254 let duckdb_backends = [
1255 "python-duckdb",
1256 "typescript-duckdb",
1257 "go-database-sql",
1258 "java-jdbc",
1259 "kotlin-jdbc",
1260 ];
1261 for backend_name in &duckdb_backends {
1262 let result = get_backend(backend_name, "duckdb");
1263 assert!(
1264 result.is_ok(),
1265 "backend '{}' should accept duckdb engine, got: {:?}",
1266 backend_name,
1267 result.err()
1268 );
1269 }
1270 }
1271
1272 #[test]
1273 fn test_get_backend_duckdb_rejected_by_pg_only() {
1274 let result = get_backend("rust-sqlx", "duckdb");
1275 assert!(result.is_err(), "rust-sqlx should reject duckdb engine");
1276 }
1277
1278 #[test]
1279 fn test_rewrite_simple_equality() {
1280 let sql = "SELECT * FROM users WHERE status = $1";
1281 let params = vec![param("status", 1)];
1282 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1283 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status = $1)");
1284 }
1285
1286 #[test]
1287 fn test_rewrite_qualified_column() {
1288 let sql = "SELECT * FROM users u WHERE u.status = $1";
1289 let params = vec![param("status", 1)];
1290 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1291 assert_eq!(result, "SELECT * FROM users u WHERE ($1 IS NULL OR u.status = $1)");
1292 }
1293
1294 #[test]
1295 fn test_rewrite_multiple_optional() {
1296 let sql = "SELECT * FROM users WHERE status = $1 AND name = $2";
1297 let params = vec![param("status", 1), param("name", 2)];
1298 let result = rewrite_optional_params(sql, &["status".to_string(), "name".to_string()], ¶ms);
1299 assert_eq!(
1300 result,
1301 "SELECT * FROM users WHERE ($1 IS NULL OR status = $1) AND ($2 IS NULL OR name = $2)"
1302 );
1303 }
1304
1305 #[test]
1306 fn test_rewrite_mixed_optional_required() {
1307 let sql = "SELECT * FROM users WHERE id = $1 AND status = $2";
1308 let params = vec![param("id", 1), param("status", 2)];
1309 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1310 assert_eq!(
1311 result,
1312 "SELECT * FROM users WHERE id = $1 AND ($2 IS NULL OR status = $2)"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_rewrite_like_operator() {
1318 let sql = "SELECT * FROM users WHERE name LIKE $1";
1319 let params = vec![param("name", 1)];
1320 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1321 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name LIKE $1)");
1322 }
1323
1324 #[test]
1325 fn test_rewrite_ilike_operator() {
1326 let sql = "SELECT * FROM users WHERE name ILIKE $1";
1327 let params = vec![param("name", 1)];
1328 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1329 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name ILIKE $1)");
1330 }
1331
1332 #[test]
1333 fn test_rewrite_comparison_operators() {
1334 let sql = "SELECT * FROM users WHERE age >= $1";
1335 let params = vec![param("age", 1)];
1336 let result = rewrite_optional_params(sql, &["age".to_string()], ¶ms);
1337 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age >= $1)");
1338 }
1339
1340 #[test]
1341 fn test_rewrite_less_than() {
1342 let sql = "SELECT * FROM users WHERE age < $1";
1343 let params = vec![param("age", 1)];
1344 let result = rewrite_optional_params(sql, &["age".to_string()], ¶ms);
1345 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age < $1)");
1346 }
1347
1348 #[test]
1349 fn test_no_rewrite_without_optional() {
1350 let sql = "SELECT * FROM users WHERE status = $1";
1351 let params = vec![param("status", 1)];
1352 let result = rewrite_optional_params(sql, &[], ¶ms);
1353 assert_eq!(result, sql);
1354 }
1355
1356 #[test]
1357 fn test_rewrite_not_equal() {
1358 let sql = "SELECT * FROM users WHERE status <> $1";
1359 let params = vec![param("status", 1)];
1360 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1361 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status <> $1)");
1362 }
1363
1364 #[test]
1365 fn test_rewrite_does_not_match_similar_placeholder() {
1366 let sql = "SELECT * FROM users WHERE status = $10";
1367 let params = vec![param("status", 1)];
1368 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1369 assert_eq!(result, sql);
1370 }
1371
1372 #[test]
1373 fn test_normalize_engine_mariadb() {
1374 assert_eq!(normalize_engine("mariadb"), "mariadb");
1375 }
1376
1377 #[test]
1378 fn test_get_backend_mariadb_with_mysql_backends() {
1379 let mariadb_backends = [
1380 "rust-sqlx",
1381 "python-aiomysql",
1382 "typescript-mysql2",
1383 "go-database-sql",
1384 "java-jdbc",
1385 "java-r2dbc",
1386 "kotlin-jdbc",
1387 "kotlin-r2dbc",
1388 "csharp-mysqlconnector",
1389 "elixir-myxql",
1390 "ruby-mysql2",
1391 "ruby-trilogy",
1392 "php-pdo",
1393 "php-amphp",
1394 ];
1395 for backend_name in &mariadb_backends {
1396 let result = get_backend(backend_name, "mariadb");
1397 assert!(
1398 result.is_ok(),
1399 "backend '{}' should accept mariadb engine, got: {:?}",
1400 backend_name,
1401 result.err()
1402 );
1403 }
1404 }
1405
1406 #[test]
1416 fn test_clean_sql_oneline_strips_mid_line_comment_but_keeps_rest_of_query() {
1417 let sql = "SELECT id, name\nFROM users\nWHERE id > $1 -- skip low ids\n AND name IS NOT NULL;";
1418 let result = clean_sql_oneline(sql);
1419 assert!(
1420 result.contains("AND name IS NOT NULL"),
1421 "mid-line comment must not swallow the rest of the query, got: {result:?}"
1422 );
1423 assert!(
1424 !result.contains("--"),
1425 "comment marker must be stripped, got: {result:?}"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_clean_sql_strips_mid_line_comment_but_keeps_rest_of_query() {
1431 let sql = "SELECT id\nFROM users\nWHERE id > $1 -- skip low ids\nAND active = true;";
1432 let result = clean_sql(sql);
1433 assert!(result.contains("AND active = true"), "got: {result:?}");
1434 assert!(!result.contains("--"), "got: {result:?}");
1435 }
1436
1437 #[test]
1438 fn test_clean_sql_preserves_double_dash_inside_single_quoted_string() {
1439 let sql = "SELECT * FROM t WHERE label = 'a -- not a comment' AND id = $1;";
1440 let result = clean_sql_oneline(sql);
1441 assert!(result.contains("'a -- not a comment'"), "got: {result:?}");
1442 assert!(result.contains("id = $1"), "got: {result:?}");
1443 }
1444
1445 #[test]
1446 fn test_clean_sql_preserves_double_dash_inside_double_quoted_identifier() {
1447 let sql = "SELECT \"weird--col\" FROM t WHERE id = $1;";
1448 let result = clean_sql_oneline(sql);
1449 assert!(result.contains("\"weird--col\""), "got: {result:?}");
1450 }
1451
1452 #[test]
1453 fn test_clean_sql_preserves_double_dash_inside_dollar_quoted_string() {
1454 let sql = "SELECT $$has -- inside$$ AS x FROM t WHERE id = $1;";
1455 let result = clean_sql_oneline(sql);
1456 assert!(result.contains("$$has -- inside$$"), "got: {result:?}");
1457 }
1458
1459 #[test]
1460 fn test_clean_sql_strips_nested_block_comments() {
1461 let sql = "SELECT id /* outer /* inner */ still outer */ FROM t WHERE id = $1;";
1462 let result = clean_sql_oneline(sql);
1463 assert!(!result.contains("outer"), "got: {result:?}");
1464 assert!(!result.contains("inner"), "got: {result:?}");
1465 assert!(result.contains("SELECT id"), "got: {result:?}");
1466 assert!(result.contains("FROM t WHERE id = $1"), "got: {result:?}");
1467 }
1468
1469 #[test]
1470 fn test_clean_sql_unterminated_block_comment_consumes_to_end_without_panicking() {
1471 let sql = "SELECT id FROM t /* oops forgot to close";
1472 let result = clean_sql_oneline(sql);
1473 assert_eq!(result, "SELECT id FROM t");
1474 }
1475
1476 #[test]
1477 fn test_clean_sql_drops_lines_that_are_only_a_header_comment() {
1478 let sql = "-- @name Foo\n-- @returns :many\nSELECT id FROM t WHERE id = $1;";
1482 let result = clean_sql(sql);
1483 assert_eq!(result, "SELECT id FROM t WHERE id = $1");
1484 }
1485
1486 #[test]
1487 fn test_rewrite_pg_placeholders_preserves_dollar_quoted_body() {
1488 let sql =
1489 "SELECT id FROM t WHERE body = $$literal -- not comment, ' quote, $1 not a placeholder$$ AND id = $2;";
1490 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1491 assert!(
1492 result.contains("$$literal -- not comment, ' quote, $1 not a placeholder$$"),
1493 "dollar-quoted body must survive untouched, got: {result:?}"
1494 );
1495 assert!(result.contains("id = @p2"), "got: {result:?}");
1496 }
1497
1498 #[test]
1499 fn test_rewrite_pg_placeholders_handles_escaped_single_quotes() {
1500 let sql = "SELECT id FROM t WHERE label = 'it''s $1 not a placeholder' AND id = $2;";
1501 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1502 assert!(result.contains("'it''s $1 not a placeholder'"), "got: {result:?}");
1503 assert!(result.contains("id = @p2"), "got: {result:?}");
1504 }
1505
1506 #[test]
1507 fn test_rewrite_pg_placeholders_preserves_jsonb_operators() {
1508 let sql = "SELECT id FROM docs WHERE data ? 'mykey' AND tags ?| array['a','b'] AND tags ?& array['a','b'] AND id = $1;";
1509 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1510 assert!(result.contains("data ? 'mykey'"), "got: {result:?}");
1511 assert!(result.contains("tags ?| array"), "got: {result:?}");
1512 assert!(result.contains("tags ?& array"), "got: {result:?}");
1513 assert!(result.contains("id = @p1"), "got: {result:?}");
1514 }
1515
1516 #[test]
1525 fn test_rewrite_pg_placeholders_preserves_jsonb_multi_char_operators_with_zero_dollar_placeholders() {
1526 let sql = "SELECT * FROM docs WHERE data ? 'active' AND tags ?| ARRAY['a'] AND meta ?& ARRAY['b']";
1527 let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1528 assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1529 assert!(result.contains("meta ?& ARRAY['b']"), "got: {result:?}");
1530 }
1531
1532 #[test]
1533 fn test_rewrite_pg_placeholders_preserves_geometry_operators() {
1534 let sql = "SELECT id FROM shapes WHERE line1 ?- line2 AND line1 ?-| line2 AND line1 ?|| line2 AND id = $1;";
1535 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1536 assert!(result.contains("line1 ?- line2"), "got: {result:?}");
1537 assert!(result.contains("line1 ?-| line2"), "got: {result:?}");
1538 assert!(result.contains("line1 ?|| line2"), "got: {result:?}");
1539 assert!(result.contains("id = @p1"), "got: {result:?}");
1540 }
1541
1542 #[test]
1543 fn test_rewrite_pg_placeholders_preserves_operators_mixed_with_dollar_placeholder() {
1544 let sql = "SELECT id FROM docs WHERE tags ?| ARRAY['a'] AND doc @? '$.a' AND id = $1;";
1545 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1546 assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1547 assert!(result.contains("doc @? '$.a'"), "got: {result:?}");
1548 assert!(result.contains("id = @p1"), "got: {result:?}");
1549 }
1550
1551 #[test]
1552 fn test_rewrite_pg_placeholders_number_boundary() {
1553 let sql = "SELECT $1, $10, $1x";
1554 let result = rewrite_pg_placeholders(sql, |n| format!("P{n}"));
1555 assert_eq!(result, "SELECT P1, P10, P1x");
1556 }
1557
1558 #[test]
1559 fn test_rewrite_pg_placeholders_ignores_question_mark_inside_string_literal() {
1560 let sql = "SELECT id FROM t WHERE note = 'is this a question?' AND age > ?;";
1561 let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1562 assert!(result.contains("'is this a question?'"), "got: {result:?}");
1563 assert!(result.contains("age > $1"), "got: {result:?}");
1564 }
1565
1566 #[test]
1567 fn test_clean_sql_handles_non_ascii_in_comment_and_string() {
1568 let sql = "SELECT name -- 名前を取得 comment\nFROM t WHERE label = 'héllo wörld' AND id = $1;";
1569 let result = clean_sql_oneline(sql);
1570 assert!(!result.contains('名'), "got: {result:?}");
1571 assert!(result.contains("'héllo wörld'"), "got: {result:?}");
1572 assert!(result.contains("id = $1"), "got: {result:?}");
1573 }
1574
1575 #[test]
1576 fn test_clean_sql_handles_crlf_line_endings() {
1577 let sql = "SELECT id\r\nFROM users\r\n-- a comment\r\nWHERE id = $1;";
1578 let result = clean_sql(sql);
1579 assert!(!result.contains('\r'), "got: {result:?}");
1580 assert!(!result.contains("-- a comment"), "got: {result:?}");
1581 assert_eq!(result, "SELECT id\nFROM users\nWHERE id = $1");
1582 }
1583
1584 #[test]
1585 fn test_rewrite_optional_params_handles_not_like() {
1586 let sql = "SELECT id FROM t WHERE name NOT LIKE $1";
1587 let params = vec![param("name", 1)];
1588 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1589 assert_eq!(result, "SELECT id FROM t WHERE ($1 IS NULL OR name NOT LIKE $1)");
1590 }
1591
1592 #[test]
1614 fn test_bare_question_mark_is_never_a_placeholder_under_postgresql_even_with_zero_dollar_placeholders() {
1615 let sql = "SELECT * FROM docs WHERE data ? 'active'";
1616
1617 let old_result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1618 assert_eq!(
1619 old_result, "SELECT * FROM docs WHERE data $1 'active'",
1620 "pins the OLD heuristic's corruption of the JSONB operator"
1621 );
1622
1623 let (new_result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::PostgreSQL, |n| format!("${n}"));
1624 assert_eq!(
1625 new_result, sql,
1626 "dialect-driven rewrite must leave the JSONB operator untouched"
1627 );
1628 assert!(
1629 occurrences.is_empty(),
1630 "no placeholder was ever present; got: {occurrences:?}"
1631 );
1632 }
1633
1634 #[test]
1638 fn test_bare_question_mark_is_a_placeholder_under_mysql() {
1639 let sql = "SELECT * FROM docs WHERE data ? 'active'";
1640 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("${n}"));
1641 assert_eq!(result, "SELECT * FROM docs WHERE data $1 'active'");
1642 assert_eq!(occurrences, vec![1]);
1643 }
1644
1645 #[test]
1650 fn test_rewrite_placeholders_indexed_returns_positions_in_textual_order_out_of_order() {
1651 let (result, occurrences) =
1652 rewrite_placeholders_indexed("WHERE b = $2 AND a = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1653 assert_eq!(result, "WHERE b = ? AND a = ?");
1654 assert_eq!(occurrences, vec![2, 1]);
1655 }
1656
1657 #[test]
1661 fn test_rewrite_placeholders_indexed_repeated_placeholder_reports_one_occurrence_per_use() {
1662 let (result, occurrences) =
1663 rewrite_placeholders_indexed("WHERE a = $1 OR b = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1664 assert_eq!(result, "WHERE a = ? OR b = ?");
1665 assert_eq!(occurrences, vec![1, 1]);
1666 }
1667
1668 #[test]
1675 fn test_mysql_backtick_identifier_is_not_corrupted_by_bare_question_mark() {
1676 let sql = "SELECT `a?b` FROM t WHERE c = ?";
1677 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("[P{n}]"));
1678 assert_eq!(result, "SELECT `a?b` FROM t WHERE c = [P1]");
1679 assert_eq!(occurrences, vec![1]);
1680 }
1681
1682 #[test]
1684 fn test_mssql_bracket_identifier_is_not_corrupted_by_bare_question_mark() {
1685 let sql = "SELECT [a?b] FROM t WHERE c = ?";
1686 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MsSql, |n| format!("[P{n}]"));
1687 assert_eq!(result, "SELECT [a?b] FROM t WHERE c = [P1]");
1688 assert_eq!(occurrences, vec![1]);
1689 }
1690
1691 #[test]
1698 fn test_clean_sql_dialect_mysql_backtick_preserves_double_dash_inside() {
1699 let sql = "SELECT `a -- b` FROM t";
1700 assert_eq!(
1701 clean_sql(sql),
1702 "SELECT `a",
1703 "pins the OLD dialect-blind clean_sql's corruption"
1704 );
1705 assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), sql);
1706 }
1707
1708 #[test]
1710 fn test_clean_sql_dialect_mssql_bracket_preserves_double_dash_inside() {
1711 let sql = "SELECT [a -- b] FROM t";
1712 assert_eq!(
1713 clean_sql(sql),
1714 "SELECT [a",
1715 "pins the OLD dialect-blind clean_sql's corruption"
1716 );
1717 assert_eq!(clean_sql_dialect(sql, SqlDialect::MsSql), sql);
1718 }
1719
1720 #[test]
1725 fn test_clean_sql_dialect_mysql_hash_comment_is_stripped() {
1726 let sql = "SELECT 1 # note\nFROM t";
1727 assert_eq!(
1728 clean_sql(sql),
1729 "SELECT 1 # note\nFROM t",
1730 "pins the OLD dialect-blind clean_sql leaving the # comment in place"
1731 );
1732 assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), "SELECT 1 \nFROM t");
1737 }
1738
1739 #[test]
1743 fn test_clean_sql_oneline_dialect_mysql_hash_comment_is_stripped() {
1744 let sql = "SELECT 1 # note\nFROM t";
1745 assert_eq!(clean_sql_oneline_dialect(sql, SqlDialect::MySQL), "SELECT 1 FROM t");
1746 }
1747
1748 #[test]
1752 fn test_clean_sql_dialect_postgresql_hash_is_not_a_comment() {
1753 let sql = "SELECT data #> '{a,b}' AS x FROM t";
1754 assert_eq!(clean_sql_dialect(sql, SqlDialect::PostgreSQL), sql);
1755 }
1756}