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 "javascript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new_js(canonical_engine)?),
1001 "typescript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new(
1002 canonical_engine,
1003 )?),
1004 "javascript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new_js(
1005 canonical_engine,
1006 )?),
1007 "typescript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new(
1008 canonical_engine,
1009 )?),
1010 "javascript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new_js(
1011 canonical_engine,
1012 )?),
1013 "typescript-kysely" | "kysely" => Box::new(typescript_kysely::TypescriptKyselyBackend::new(canonical_engine)?),
1014 "go-database-sql" => Box::new(go_database_sql::GoDatabaseSqlBackend::new(canonical_engine)?),
1015 "go-pgx" | "go" => Box::new(go_pgx::GoPgxBackend::new(canonical_engine)?),
1016 "java-jdbc" | "java" => Box::new(java_jdbc::JavaJdbcBackend::new(canonical_engine)?),
1017 "java-r2dbc" | "r2dbc-java" => Box::new(java_r2dbc::JavaR2dbcBackend::new(canonical_engine)?),
1018 "kotlin-exposed" | "exposed" => Box::new(kotlin_exposed::KotlinExposedBackend::new(canonical_engine)?),
1019 "kotlin-jdbc" | "kotlin" | "kt" => Box::new(kotlin_jdbc::KotlinJdbcBackend::new(canonical_engine)?),
1020 "kotlin-r2dbc" | "r2dbc-kotlin" => Box::new(kotlin_r2dbc::KotlinR2dbcBackend::new(canonical_engine)?),
1021 "csharp-npgsql" | "csharp" | "c#" | "dotnet" => {
1022 Box::new(csharp_npgsql::CsharpNpgsqlBackend::new(canonical_engine)?)
1023 }
1024 "csharp-mysqlconnector" => Box::new(csharp_mysqlconnector::CsharpMysqlConnectorBackend::new(
1025 canonical_engine,
1026 )?),
1027 "csharp-microsoft-sqlite" => Box::new(csharp_microsoft_sqlite::CsharpMicrosoftSqliteBackend::new(
1028 canonical_engine,
1029 )?),
1030 "elixir-postgrex" | "elixir" | "ex" => Box::new(elixir_postgrex::ElixirPostgrexBackend::new(canonical_engine)?),
1031 "elixir-ecto" | "ecto" => Box::new(elixir_ecto::ElixirEctoBackend::new(canonical_engine)?),
1032 "elixir-myxql" => Box::new(elixir_myxql::ElixirMyxqlBackend::new(canonical_engine)?),
1033 "elixir-exqlite" => Box::new(elixir_exqlite::ElixirExqliteBackend::new(canonical_engine)?),
1034 "ruby-pg" | "ruby" | "rb" => Box::new(ruby_pg::RubyPgBackend::new(canonical_engine)?),
1035 "ruby-mysql2" => Box::new(ruby_mysql2::RubyMysql2Backend::new(canonical_engine)?),
1036 "ruby-sqlite3" => Box::new(ruby_sqlite3::RubySqlite3Backend::new(canonical_engine)?),
1037 "ruby-trilogy" | "trilogy" => Box::new(ruby_trilogy::RubyTrilogyBackend::new(canonical_engine)?),
1038 "php-pdo" | "php" => Box::new(php_pdo::PhpPdoBackend::new(canonical_engine)?),
1039 "php-amphp" | "amphp" => Box::new(php_amphp::PhpAmphpBackend::new(canonical_engine)?),
1040 "rust-tiberius" | "tiberius" => Box::new(rust_tiberius::RustTiberiusBackend::new(canonical_engine)?),
1041 "python-pyodbc" | "pyodbc" => Box::new(python_pyodbc::PythonPyodbcBackend::new(canonical_engine)?),
1042 "typescript-mssql" | "tedious" => Box::new(typescript_mssql::TypescriptMssqlBackend::new(canonical_engine)?),
1043 "javascript-mssql" => Box::new(typescript_mssql::TypescriptMssqlBackend::new_js(canonical_engine)?),
1044 "csharp-sqlclient" => Box::new(csharp_sqlclient::CsharpSqlClientBackend::new(canonical_engine)?),
1045 "ruby-tiny-tds" | "tiny-tds" | "tiny_tds" => {
1046 Box::new(ruby_tiny_tds::RubyTinyTdsBackend::new(canonical_engine)?)
1047 }
1048 "elixir-tds" | "tds" => Box::new(elixir_tds::ElixirTdsBackend::new(canonical_engine)?),
1049 "rust-sibyl" | "sibyl" => Box::new(rust_sibyl::RustSibylBackend::new(canonical_engine)?),
1050 "python-oracledb" | "oracledb" => Box::new(python_oracledb::PythonOracledbBackend::new(canonical_engine)?),
1051 "typescript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new(canonical_engine)?),
1052 "javascript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new_js(
1053 canonical_engine,
1054 )?),
1055 "go-godror" | "godror" => Box::new(go_godror::GoGodrorBackend::new(canonical_engine)?),
1056 "csharp-oracle" => Box::new(csharp_oracle::CsharpOracleBackend::new(canonical_engine)?),
1057 "ruby-oci8" | "oci8" => Box::new(ruby_oci8::RubyOci8Backend::new(canonical_engine)?),
1058 "elixir-jamdb" | "jamdb" => Box::new(elixir_jamdb::ElixirJamdbBackend::new(canonical_engine)?),
1059 "python-snowflake" => Box::new(python_snowflake::PythonSnowflakeBackend::new(canonical_engine)?),
1060 "typescript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new(canonical_engine)?),
1061 "javascript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new_js(
1062 canonical_engine,
1063 )?),
1064 "go-gosnowflake" | "gosnowflake" => Box::new(go_gosnowflake::GoGosnowflakeBackend::new(canonical_engine)?),
1065 "csharp-snowflake" => Box::new(csharp_snowflake::CsharpSnowflakeBackend::new(canonical_engine)?),
1066 _ => {
1067 return Err(ScytheError::new(
1068 ErrorCode::InvalidConfig,
1069 format!("unknown backend: {}", name),
1070 ));
1071 }
1072 };
1073
1074 if !backend
1075 .supported_engines()
1076 .iter()
1077 .any(|e| normalize_engine(e) == canonical_engine)
1078 {
1079 return Err(ScytheError::new(
1080 ErrorCode::InvalidConfig,
1081 format!(
1082 "backend '{}' does not support engine '{}'. Supported: {:?}",
1083 name,
1084 engine,
1085 backend.supported_engines()
1086 ),
1087 ));
1088 }
1089
1090 Ok(backend)
1091}
1092
1093pub fn normalize_engine(engine: &str) -> &str {
1100 match engine {
1101 "postgresql" | "postgres" | "pg" | "cockroachdb" | "crdb" => "postgresql",
1102 "mysql" => "mysql",
1103 "mariadb" => "mariadb",
1104 "sqlite" | "sqlite3" => "sqlite",
1105 "duckdb" => "duckdb",
1106 "mssql" | "sqlserver" | "tsql" => "mssql",
1107 "oracle" => "oracle",
1108 "snowflake" => "snowflake",
1109 "redshift" => "redshift",
1110 other => other,
1111 }
1112}
1113
1114#[cfg(test)]
1119mod sql_text_characterization_tests;
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124
1125 fn param(name: &str, position: i64) -> AnalyzedParam {
1126 AnalyzedParam {
1127 name: name.to_string(),
1128 neutral_type: "string".to_string(),
1129 nullable: true,
1130 position,
1131 source_relation: None,
1132 }
1133 }
1134
1135 #[test]
1147 fn test_no_backends_relative_manifest_path_literals() {
1148 let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src/backends");
1149 let word: String = format!("backend{}", "s/");
1150 let needle: String = format!("{}{}", '"', word);
1151
1152 let mut offenders = Vec::new();
1153 collect_rs_files(std::path::Path::new(root), &mut offenders);
1154
1155 let offenders: Vec<_> = offenders
1156 .into_iter()
1157 .filter(|path| {
1158 let contents = std::fs::read_to_string(path).expect("failed to read source file");
1159 contents.contains(&needle)
1160 })
1161 .collect();
1162
1163 assert!(
1164 offenders.is_empty(),
1165 "found a working-directory-relative manifest path literal in: {offenders:?} (#82 regression \
1166 -- manifest selection must be a pure function of (backend, engine), no filesystem lookup)"
1167 );
1168 }
1169
1170 fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
1172 let entries = std::fs::read_dir(dir).expect("failed to read backends dir");
1173 for entry in entries {
1174 let entry = entry.expect("failed to read dir entry");
1175 let path = entry.path();
1176 if path.is_dir() {
1177 collect_rs_files(&path, out);
1178 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
1179 out.push(path);
1180 }
1181 }
1182 }
1183
1184 #[test]
1190 fn test_get_backend_rust_sqlx_mysql_has_no_postgresql_scalars() {
1191 let backend = get_backend("rust-sqlx", "mysql").expect("rust-sqlx should support mysql");
1192 let scalars = &backend.manifest().types.scalars;
1193 assert_ne!(
1194 scalars.get("uuid").map(String::as_str),
1195 Some("uuid::Uuid"),
1196 "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1197 );
1198 assert_ne!(
1199 scalars.get("inet").map(String::as_str),
1200 Some("ipnetwork::IpNetwork"),
1201 "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'inet' mapping"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_get_backend_java_jdbc_sqlite_has_no_postgresql_scalars() {
1207 let backend = get_backend("java-jdbc", "sqlite").expect("java-jdbc should support sqlite");
1208 let scalars = &backend.manifest().types.scalars;
1209 assert_ne!(
1210 scalars.get("uuid").map(String::as_str),
1211 Some("java.util.UUID"),
1212 "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1213 );
1214 assert_ne!(
1215 scalars.get("decimal").map(String::as_str),
1216 Some("java.math.BigDecimal"),
1217 "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'decimal' mapping"
1218 );
1219 }
1220
1221 #[test]
1222 fn test_normalize_engine_cockroachdb() {
1223 assert_eq!(normalize_engine("cockroachdb"), "postgresql");
1224 assert_eq!(normalize_engine("crdb"), "postgresql");
1225 }
1226
1227 #[test]
1228 fn test_get_backend_cockroachdb_with_pg_backends() {
1229 let pg_backends = [
1230 "rust-sqlx",
1231 "rust-tokio-postgres",
1232 "python-psycopg3",
1233 "python-asyncpg",
1234 "typescript-postgres",
1235 "typescript-pg",
1236 "typescript-kysely",
1237 "go-pgx",
1238 "ruby-pg",
1239 "elixir-postgrex",
1240 "csharp-npgsql",
1241 "php-pdo",
1242 "php-amphp",
1243 ];
1244 for backend_name in &pg_backends {
1245 let result = get_backend(backend_name, "cockroachdb");
1246 assert!(
1247 result.is_ok(),
1248 "backend '{}' should accept cockroachdb engine, got: {:?}",
1249 backend_name,
1250 result.err()
1251 );
1252 }
1253 }
1254
1255 #[test]
1256 fn test_get_backend_crdb_alias() {
1257 let result = get_backend("rust-sqlx", "crdb");
1258 assert!(result.is_ok(), "rust-sqlx should accept 'crdb' engine alias");
1259 }
1260
1261 #[test]
1262 fn test_normalize_engine_duckdb() {
1263 assert_eq!(normalize_engine("duckdb"), "duckdb");
1264 }
1265
1266 #[test]
1267 fn test_get_backend_duckdb_with_compatible_backends() {
1268 let duckdb_backends = [
1269 "python-duckdb",
1270 "typescript-duckdb",
1271 "go-database-sql",
1272 "java-jdbc",
1273 "kotlin-jdbc",
1274 ];
1275 for backend_name in &duckdb_backends {
1276 let result = get_backend(backend_name, "duckdb");
1277 assert!(
1278 result.is_ok(),
1279 "backend '{}' should accept duckdb engine, got: {:?}",
1280 backend_name,
1281 result.err()
1282 );
1283 }
1284 }
1285
1286 #[test]
1287 fn test_get_backend_duckdb_rejected_by_pg_only() {
1288 let result = get_backend("rust-sqlx", "duckdb");
1289 assert!(result.is_err(), "rust-sqlx should reject duckdb engine");
1290 }
1291
1292 #[test]
1293 fn test_rewrite_simple_equality() {
1294 let sql = "SELECT * FROM users WHERE status = $1";
1295 let params = vec![param("status", 1)];
1296 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1297 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status = $1)");
1298 }
1299
1300 #[test]
1301 fn test_rewrite_qualified_column() {
1302 let sql = "SELECT * FROM users u WHERE u.status = $1";
1303 let params = vec![param("status", 1)];
1304 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1305 assert_eq!(result, "SELECT * FROM users u WHERE ($1 IS NULL OR u.status = $1)");
1306 }
1307
1308 #[test]
1309 fn test_rewrite_multiple_optional() {
1310 let sql = "SELECT * FROM users WHERE status = $1 AND name = $2";
1311 let params = vec![param("status", 1), param("name", 2)];
1312 let result = rewrite_optional_params(sql, &["status".to_string(), "name".to_string()], ¶ms);
1313 assert_eq!(
1314 result,
1315 "SELECT * FROM users WHERE ($1 IS NULL OR status = $1) AND ($2 IS NULL OR name = $2)"
1316 );
1317 }
1318
1319 #[test]
1320 fn test_rewrite_mixed_optional_required() {
1321 let sql = "SELECT * FROM users WHERE id = $1 AND status = $2";
1322 let params = vec![param("id", 1), param("status", 2)];
1323 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1324 assert_eq!(
1325 result,
1326 "SELECT * FROM users WHERE id = $1 AND ($2 IS NULL OR status = $2)"
1327 );
1328 }
1329
1330 #[test]
1331 fn test_rewrite_like_operator() {
1332 let sql = "SELECT * FROM users WHERE name LIKE $1";
1333 let params = vec![param("name", 1)];
1334 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1335 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name LIKE $1)");
1336 }
1337
1338 #[test]
1339 fn test_rewrite_ilike_operator() {
1340 let sql = "SELECT * FROM users WHERE name ILIKE $1";
1341 let params = vec![param("name", 1)];
1342 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1343 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name ILIKE $1)");
1344 }
1345
1346 #[test]
1347 fn test_rewrite_comparison_operators() {
1348 let sql = "SELECT * FROM users WHERE age >= $1";
1349 let params = vec![param("age", 1)];
1350 let result = rewrite_optional_params(sql, &["age".to_string()], ¶ms);
1351 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age >= $1)");
1352 }
1353
1354 #[test]
1355 fn test_rewrite_less_than() {
1356 let sql = "SELECT * FROM users WHERE age < $1";
1357 let params = vec![param("age", 1)];
1358 let result = rewrite_optional_params(sql, &["age".to_string()], ¶ms);
1359 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age < $1)");
1360 }
1361
1362 #[test]
1363 fn test_no_rewrite_without_optional() {
1364 let sql = "SELECT * FROM users WHERE status = $1";
1365 let params = vec![param("status", 1)];
1366 let result = rewrite_optional_params(sql, &[], ¶ms);
1367 assert_eq!(result, sql);
1368 }
1369
1370 #[test]
1371 fn test_rewrite_not_equal() {
1372 let sql = "SELECT * FROM users WHERE status <> $1";
1373 let params = vec![param("status", 1)];
1374 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1375 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status <> $1)");
1376 }
1377
1378 #[test]
1379 fn test_rewrite_does_not_match_similar_placeholder() {
1380 let sql = "SELECT * FROM users WHERE status = $10";
1381 let params = vec![param("status", 1)];
1382 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1383 assert_eq!(result, sql);
1384 }
1385
1386 #[test]
1387 fn test_normalize_engine_mariadb() {
1388 assert_eq!(normalize_engine("mariadb"), "mariadb");
1389 }
1390
1391 #[test]
1392 fn test_get_backend_mariadb_with_mysql_backends() {
1393 let mariadb_backends = [
1394 "rust-sqlx",
1395 "python-aiomysql",
1396 "typescript-mysql2",
1397 "go-database-sql",
1398 "java-jdbc",
1399 "java-r2dbc",
1400 "kotlin-jdbc",
1401 "kotlin-r2dbc",
1402 "csharp-mysqlconnector",
1403 "elixir-myxql",
1404 "ruby-mysql2",
1405 "ruby-trilogy",
1406 "php-pdo",
1407 "php-amphp",
1408 ];
1409 for backend_name in &mariadb_backends {
1410 let result = get_backend(backend_name, "mariadb");
1411 assert!(
1412 result.is_ok(),
1413 "backend '{}' should accept mariadb engine, got: {:?}",
1414 backend_name,
1415 result.err()
1416 );
1417 }
1418 }
1419
1420 #[test]
1430 fn test_clean_sql_oneline_strips_mid_line_comment_but_keeps_rest_of_query() {
1431 let sql = "SELECT id, name\nFROM users\nWHERE id > $1 -- skip low ids\n AND name IS NOT NULL;";
1432 let result = clean_sql_oneline(sql);
1433 assert!(
1434 result.contains("AND name IS NOT NULL"),
1435 "mid-line comment must not swallow the rest of the query, got: {result:?}"
1436 );
1437 assert!(
1438 !result.contains("--"),
1439 "comment marker must be stripped, got: {result:?}"
1440 );
1441 }
1442
1443 #[test]
1444 fn test_clean_sql_strips_mid_line_comment_but_keeps_rest_of_query() {
1445 let sql = "SELECT id\nFROM users\nWHERE id > $1 -- skip low ids\nAND active = true;";
1446 let result = clean_sql(sql);
1447 assert!(result.contains("AND active = true"), "got: {result:?}");
1448 assert!(!result.contains("--"), "got: {result:?}");
1449 }
1450
1451 #[test]
1452 fn test_clean_sql_preserves_double_dash_inside_single_quoted_string() {
1453 let sql = "SELECT * FROM t WHERE label = 'a -- not a comment' AND id = $1;";
1454 let result = clean_sql_oneline(sql);
1455 assert!(result.contains("'a -- not a comment'"), "got: {result:?}");
1456 assert!(result.contains("id = $1"), "got: {result:?}");
1457 }
1458
1459 #[test]
1460 fn test_clean_sql_preserves_double_dash_inside_double_quoted_identifier() {
1461 let sql = "SELECT \"weird--col\" FROM t WHERE id = $1;";
1462 let result = clean_sql_oneline(sql);
1463 assert!(result.contains("\"weird--col\""), "got: {result:?}");
1464 }
1465
1466 #[test]
1467 fn test_clean_sql_preserves_double_dash_inside_dollar_quoted_string() {
1468 let sql = "SELECT $$has -- inside$$ AS x FROM t WHERE id = $1;";
1469 let result = clean_sql_oneline(sql);
1470 assert!(result.contains("$$has -- inside$$"), "got: {result:?}");
1471 }
1472
1473 #[test]
1474 fn test_clean_sql_strips_nested_block_comments() {
1475 let sql = "SELECT id /* outer /* inner */ still outer */ FROM t WHERE id = $1;";
1476 let result = clean_sql_oneline(sql);
1477 assert!(!result.contains("outer"), "got: {result:?}");
1478 assert!(!result.contains("inner"), "got: {result:?}");
1479 assert!(result.contains("SELECT id"), "got: {result:?}");
1480 assert!(result.contains("FROM t WHERE id = $1"), "got: {result:?}");
1481 }
1482
1483 #[test]
1484 fn test_clean_sql_unterminated_block_comment_consumes_to_end_without_panicking() {
1485 let sql = "SELECT id FROM t /* oops forgot to close";
1486 let result = clean_sql_oneline(sql);
1487 assert_eq!(result, "SELECT id FROM t");
1488 }
1489
1490 #[test]
1491 fn test_clean_sql_drops_lines_that_are_only_a_header_comment() {
1492 let sql = "-- @name Foo\n-- @returns :many\nSELECT id FROM t WHERE id = $1;";
1496 let result = clean_sql(sql);
1497 assert_eq!(result, "SELECT id FROM t WHERE id = $1");
1498 }
1499
1500 #[test]
1501 fn test_rewrite_pg_placeholders_preserves_dollar_quoted_body() {
1502 let sql =
1503 "SELECT id FROM t WHERE body = $$literal -- not comment, ' quote, $1 not a placeholder$$ AND id = $2;";
1504 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1505 assert!(
1506 result.contains("$$literal -- not comment, ' quote, $1 not a placeholder$$"),
1507 "dollar-quoted body must survive untouched, got: {result:?}"
1508 );
1509 assert!(result.contains("id = @p2"), "got: {result:?}");
1510 }
1511
1512 #[test]
1513 fn test_rewrite_pg_placeholders_handles_escaped_single_quotes() {
1514 let sql = "SELECT id FROM t WHERE label = 'it''s $1 not a placeholder' AND id = $2;";
1515 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1516 assert!(result.contains("'it''s $1 not a placeholder'"), "got: {result:?}");
1517 assert!(result.contains("id = @p2"), "got: {result:?}");
1518 }
1519
1520 #[test]
1521 fn test_rewrite_pg_placeholders_preserves_jsonb_operators() {
1522 let sql = "SELECT id FROM docs WHERE data ? 'mykey' AND tags ?| array['a','b'] AND tags ?& array['a','b'] AND id = $1;";
1523 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1524 assert!(result.contains("data ? 'mykey'"), "got: {result:?}");
1525 assert!(result.contains("tags ?| array"), "got: {result:?}");
1526 assert!(result.contains("tags ?& array"), "got: {result:?}");
1527 assert!(result.contains("id = @p1"), "got: {result:?}");
1528 }
1529
1530 #[test]
1539 fn test_rewrite_pg_placeholders_preserves_jsonb_multi_char_operators_with_zero_dollar_placeholders() {
1540 let sql = "SELECT * FROM docs WHERE data ? 'active' AND tags ?| ARRAY['a'] AND meta ?& ARRAY['b']";
1541 let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1542 assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1543 assert!(result.contains("meta ?& ARRAY['b']"), "got: {result:?}");
1544 }
1545
1546 #[test]
1547 fn test_rewrite_pg_placeholders_preserves_geometry_operators() {
1548 let sql = "SELECT id FROM shapes WHERE line1 ?- line2 AND line1 ?-| line2 AND line1 ?|| line2 AND id = $1;";
1549 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1550 assert!(result.contains("line1 ?- line2"), "got: {result:?}");
1551 assert!(result.contains("line1 ?-| line2"), "got: {result:?}");
1552 assert!(result.contains("line1 ?|| line2"), "got: {result:?}");
1553 assert!(result.contains("id = @p1"), "got: {result:?}");
1554 }
1555
1556 #[test]
1557 fn test_rewrite_pg_placeholders_preserves_operators_mixed_with_dollar_placeholder() {
1558 let sql = "SELECT id FROM docs WHERE tags ?| ARRAY['a'] AND doc @? '$.a' AND id = $1;";
1559 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1560 assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1561 assert!(result.contains("doc @? '$.a'"), "got: {result:?}");
1562 assert!(result.contains("id = @p1"), "got: {result:?}");
1563 }
1564
1565 #[test]
1566 fn test_rewrite_pg_placeholders_number_boundary() {
1567 let sql = "SELECT $1, $10, $1x";
1568 let result = rewrite_pg_placeholders(sql, |n| format!("P{n}"));
1569 assert_eq!(result, "SELECT P1, P10, P1x");
1570 }
1571
1572 #[test]
1573 fn test_rewrite_pg_placeholders_ignores_question_mark_inside_string_literal() {
1574 let sql = "SELECT id FROM t WHERE note = 'is this a question?' AND age > ?;";
1575 let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1576 assert!(result.contains("'is this a question?'"), "got: {result:?}");
1577 assert!(result.contains("age > $1"), "got: {result:?}");
1578 }
1579
1580 #[test]
1581 fn test_clean_sql_handles_non_ascii_in_comment_and_string() {
1582 let sql = "SELECT name -- 名前を取得 comment\nFROM t WHERE label = 'héllo wörld' AND id = $1;";
1583 let result = clean_sql_oneline(sql);
1584 assert!(!result.contains('名'), "got: {result:?}");
1585 assert!(result.contains("'héllo wörld'"), "got: {result:?}");
1586 assert!(result.contains("id = $1"), "got: {result:?}");
1587 }
1588
1589 #[test]
1590 fn test_clean_sql_handles_crlf_line_endings() {
1591 let sql = "SELECT id\r\nFROM users\r\n-- a comment\r\nWHERE id = $1;";
1592 let result = clean_sql(sql);
1593 assert!(!result.contains('\r'), "got: {result:?}");
1594 assert!(!result.contains("-- a comment"), "got: {result:?}");
1595 assert_eq!(result, "SELECT id\nFROM users\nWHERE id = $1");
1596 }
1597
1598 #[test]
1599 fn test_rewrite_optional_params_handles_not_like() {
1600 let sql = "SELECT id FROM t WHERE name NOT LIKE $1";
1601 let params = vec![param("name", 1)];
1602 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1603 assert_eq!(result, "SELECT id FROM t WHERE ($1 IS NULL OR name NOT LIKE $1)");
1604 }
1605
1606 #[test]
1628 fn test_bare_question_mark_is_never_a_placeholder_under_postgresql_even_with_zero_dollar_placeholders() {
1629 let sql = "SELECT * FROM docs WHERE data ? 'active'";
1630
1631 let old_result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1632 assert_eq!(
1633 old_result, "SELECT * FROM docs WHERE data $1 'active'",
1634 "pins the OLD heuristic's corruption of the JSONB operator"
1635 );
1636
1637 let (new_result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::PostgreSQL, |n| format!("${n}"));
1638 assert_eq!(
1639 new_result, sql,
1640 "dialect-driven rewrite must leave the JSONB operator untouched"
1641 );
1642 assert!(
1643 occurrences.is_empty(),
1644 "no placeholder was ever present; got: {occurrences:?}"
1645 );
1646 }
1647
1648 #[test]
1652 fn test_bare_question_mark_is_a_placeholder_under_mysql() {
1653 let sql = "SELECT * FROM docs WHERE data ? 'active'";
1654 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("${n}"));
1655 assert_eq!(result, "SELECT * FROM docs WHERE data $1 'active'");
1656 assert_eq!(occurrences, vec![1]);
1657 }
1658
1659 #[test]
1664 fn test_rewrite_placeholders_indexed_returns_positions_in_textual_order_out_of_order() {
1665 let (result, occurrences) =
1666 rewrite_placeholders_indexed("WHERE b = $2 AND a = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1667 assert_eq!(result, "WHERE b = ? AND a = ?");
1668 assert_eq!(occurrences, vec![2, 1]);
1669 }
1670
1671 #[test]
1675 fn test_rewrite_placeholders_indexed_repeated_placeholder_reports_one_occurrence_per_use() {
1676 let (result, occurrences) =
1677 rewrite_placeholders_indexed("WHERE a = $1 OR b = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1678 assert_eq!(result, "WHERE a = ? OR b = ?");
1679 assert_eq!(occurrences, vec![1, 1]);
1680 }
1681
1682 #[test]
1689 fn test_mysql_backtick_identifier_is_not_corrupted_by_bare_question_mark() {
1690 let sql = "SELECT `a?b` FROM t WHERE c = ?";
1691 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("[P{n}]"));
1692 assert_eq!(result, "SELECT `a?b` FROM t WHERE c = [P1]");
1693 assert_eq!(occurrences, vec![1]);
1694 }
1695
1696 #[test]
1698 fn test_mssql_bracket_identifier_is_not_corrupted_by_bare_question_mark() {
1699 let sql = "SELECT [a?b] FROM t WHERE c = ?";
1700 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MsSql, |n| format!("[P{n}]"));
1701 assert_eq!(result, "SELECT [a?b] FROM t WHERE c = [P1]");
1702 assert_eq!(occurrences, vec![1]);
1703 }
1704
1705 #[test]
1712 fn test_clean_sql_dialect_mysql_backtick_preserves_double_dash_inside() {
1713 let sql = "SELECT `a -- b` FROM t";
1714 assert_eq!(
1715 clean_sql(sql),
1716 "SELECT `a",
1717 "pins the OLD dialect-blind clean_sql's corruption"
1718 );
1719 assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), sql);
1720 }
1721
1722 #[test]
1724 fn test_clean_sql_dialect_mssql_bracket_preserves_double_dash_inside() {
1725 let sql = "SELECT [a -- b] FROM t";
1726 assert_eq!(
1727 clean_sql(sql),
1728 "SELECT [a",
1729 "pins the OLD dialect-blind clean_sql's corruption"
1730 );
1731 assert_eq!(clean_sql_dialect(sql, SqlDialect::MsSql), sql);
1732 }
1733
1734 #[test]
1739 fn test_clean_sql_dialect_mysql_hash_comment_is_stripped() {
1740 let sql = "SELECT 1 # note\nFROM t";
1741 assert_eq!(
1742 clean_sql(sql),
1743 "SELECT 1 # note\nFROM t",
1744 "pins the OLD dialect-blind clean_sql leaving the # comment in place"
1745 );
1746 assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), "SELECT 1 \nFROM t");
1751 }
1752
1753 #[test]
1757 fn test_clean_sql_oneline_dialect_mysql_hash_comment_is_stripped() {
1758 let sql = "SELECT 1 # note\nFROM t";
1759 assert_eq!(clean_sql_oneline_dialect(sql, SqlDialect::MySQL), "SELECT 1 FROM t");
1760 }
1761
1762 #[test]
1766 fn test_clean_sql_dialect_postgresql_hash_is_not_a_comment() {
1767 let sql = "SELECT data #> '{a,b}' AS x FROM t";
1768 assert_eq!(clean_sql_dialect(sql, SqlDialect::PostgreSQL), sql);
1769 }
1770}