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
886pub(crate) fn rewrite_pg_typed_placeholders(
888 sql: &str,
889 analyzed_params: &[AnalyzedParam],
890 resolved_params: &[ResolvedParam],
891 cast_enums: bool,
892 formatter: impl Fn(u32) -> String,
893) -> (String, Vec<u32>) {
894 rewrite_placeholders_indexed(sql, SqlDialect::PostgreSQL, |position| {
895 let mut placeholder = formatter(position);
896 let param = resolved_param_for_position(analyzed_params, resolved_params, position);
897 if cast_enums && let Some(enum_type) = param.neutral_type.strip_prefix("enum::") {
898 placeholder.push_str("::");
899 placeholder.push_str(enum_type);
900 } else if let Some(composite_type) = param.neutral_type.strip_prefix("composite::") {
901 placeholder.push_str("::text::");
902 placeholder.push_str(composite_type);
903 }
904 placeholder
905 })
906}
907
908fn code_span_has_dollar_number(text: &str) -> bool {
911 let chars: Vec<char> = text.chars().collect();
912 (0..chars.len()).any(|i| chars[i] == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()))
913}
914
915const UNAMBIGUOUS_OPERATORS: [&str; 6] = ["?-|", "?||", "?|", "?&", "?-", "@?"];
923
924fn match_unambiguous_operator(chars: &[char], i: usize) -> Option<usize> {
927 UNAMBIGUOUS_OPERATORS.iter().find_map(|op| {
928 let op_chars: Vec<char> = op.chars().collect();
929 let end = i + op_chars.len();
930 (end <= chars.len() && chars[i..end] == op_chars[..]).then_some(op_chars.len())
931 })
932}
933
934fn rewrite_code_span_placeholders(
948 text: &str,
949 bare_question_is_placeholder: bool,
950 formatter: &impl Fn(u32) -> String,
951 counter: &mut u32,
952 out: &mut String,
953 occurrences: &mut Vec<u32>,
954) {
955 let chars: Vec<char> = text.chars().collect();
956 let len = chars.len();
957 let mut i = 0;
958 while i < len {
959 if let Some(op_len) = match_unambiguous_operator(&chars, i) {
960 out.extend(&chars[i..i + op_len]);
961 i += op_len;
962 continue;
963 }
964
965 let ch = chars[i];
966 if ch == '$' && chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
967 let mut j = i + 1;
968 let mut num_str = String::new();
969 while j < len && chars[j].is_ascii_digit() {
970 num_str.push(chars[j]);
971 j += 1;
972 }
973 let num: u32 = num_str.parse().unwrap_or(0);
974 out.push_str(&formatter(num));
975 occurrences.push(num);
976 i = j;
977 } else if ch == '?' && bare_question_is_placeholder && !chars.get(i + 1).is_some_and(|c| c.is_ascii_digit()) {
978 *counter += 1;
979 out.push_str(&formatter(*counter));
980 occurrences.push(*counter);
981 i += 1;
982 } else {
983 out.push(ch);
984 i += 1;
985 }
986 }
987}
988
989pub fn get_backend(name: &str, engine: &str) -> Result<Box<dyn CodegenBackend>, ScytheError> {
994 let canonical_engine = normalize_engine(engine);
995 let backend: Box<dyn CodegenBackend> = match name {
996 "rust-sqlx" | "sqlx" | "rust" => Box::new(sqlx::SqlxBackend::new(canonical_engine)?),
997 "rust-tokio-postgres" | "tokio-postgres" => {
998 Box::new(tokio_postgres::TokioPostgresBackend::new(canonical_engine)?)
999 }
1000 "python-psycopg3" | "python" => Box::new(python_psycopg3::PythonPsycopg3Backend::new(canonical_engine)?),
1001 "python-asyncpg" => Box::new(python_asyncpg::PythonAsyncpgBackend::new(canonical_engine)?),
1002 "python-aiomysql" => Box::new(python_aiomysql::PythonAiomysqlBackend::new(canonical_engine)?),
1003 "python-aiosqlite" => Box::new(python_aiosqlite::PythonAiosqliteBackend::new(canonical_engine)?),
1004 "python-duckdb" => Box::new(python_duckdb::PythonDuckdbBackend::new(canonical_engine)?),
1005 "typescript-postgres" | "ts" | "typescript" => {
1006 Box::new(typescript_postgres::TypescriptPostgresBackend::new(canonical_engine)?)
1007 }
1008 "javascript-postgres" => Box::new(typescript_postgres::TypescriptPostgresBackend::new_js(
1009 canonical_engine,
1010 )?),
1011 "typescript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new(canonical_engine)?),
1012 "javascript-pg" => Box::new(typescript_pg::TypescriptPgBackend::new_js(canonical_engine)?),
1013 "typescript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new(canonical_engine)?),
1014 "javascript-mysql2" => Box::new(typescript_mysql2::TypescriptMysql2Backend::new_js(canonical_engine)?),
1015 "typescript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new(
1016 canonical_engine,
1017 )?),
1018 "javascript-better-sqlite3" => Box::new(typescript_better_sqlite3::TypescriptBetterSqlite3Backend::new_js(
1019 canonical_engine,
1020 )?),
1021 "typescript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new(canonical_engine)?),
1022 "javascript-duckdb" => Box::new(typescript_duckdb::TypescriptDuckdbBackend::new_js(canonical_engine)?),
1023 "typescript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new(
1024 canonical_engine,
1025 )?),
1026 "javascript-node-sqlite" => Box::new(typescript_node_sqlite::TypescriptNodeSqliteBackend::new_js(
1027 canonical_engine,
1028 )?),
1029 "typescript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new(
1030 canonical_engine,
1031 )?),
1032 "javascript-wasm-sqlite" => Box::new(typescript_wasm_sqlite::TypescriptWasmSqliteBackend::new_js(
1033 canonical_engine,
1034 )?),
1035 "typescript-kysely" | "kysely" => Box::new(typescript_kysely::TypescriptKyselyBackend::new(canonical_engine)?),
1036 "go-database-sql" => Box::new(go_database_sql::GoDatabaseSqlBackend::new(canonical_engine)?),
1037 "go-pgx" | "go" => Box::new(go_pgx::GoPgxBackend::new(canonical_engine)?),
1038 "java-jdbc" | "java" => Box::new(java_jdbc::JavaJdbcBackend::new(canonical_engine)?),
1039 "java-r2dbc" | "r2dbc-java" => Box::new(java_r2dbc::JavaR2dbcBackend::new(canonical_engine)?),
1040 "kotlin-exposed" | "exposed" => Box::new(kotlin_exposed::KotlinExposedBackend::new(canonical_engine)?),
1041 "kotlin-jdbc" | "kotlin" | "kt" => Box::new(kotlin_jdbc::KotlinJdbcBackend::new(canonical_engine)?),
1042 "kotlin-r2dbc" | "r2dbc-kotlin" => Box::new(kotlin_r2dbc::KotlinR2dbcBackend::new(canonical_engine)?),
1043 "csharp-npgsql" | "csharp" | "c#" | "dotnet" => {
1044 Box::new(csharp_npgsql::CsharpNpgsqlBackend::new(canonical_engine)?)
1045 }
1046 "csharp-mysqlconnector" => Box::new(csharp_mysqlconnector::CsharpMysqlConnectorBackend::new(
1047 canonical_engine,
1048 )?),
1049 "csharp-microsoft-sqlite" => Box::new(csharp_microsoft_sqlite::CsharpMicrosoftSqliteBackend::new(
1050 canonical_engine,
1051 )?),
1052 "elixir-postgrex" | "elixir" | "ex" => Box::new(elixir_postgrex::ElixirPostgrexBackend::new(canonical_engine)?),
1053 "elixir-ecto" | "ecto" => Box::new(elixir_ecto::ElixirEctoBackend::new(canonical_engine)?),
1054 "elixir-myxql" => Box::new(elixir_myxql::ElixirMyxqlBackend::new(canonical_engine)?),
1055 "elixir-exqlite" => Box::new(elixir_exqlite::ElixirExqliteBackend::new(canonical_engine)?),
1056 "ruby-pg" | "ruby" | "rb" => Box::new(ruby_pg::RubyPgBackend::new(canonical_engine)?),
1057 "ruby-mysql2" => Box::new(ruby_mysql2::RubyMysql2Backend::new(canonical_engine)?),
1058 "ruby-sqlite3" => Box::new(ruby_sqlite3::RubySqlite3Backend::new(canonical_engine)?),
1059 "ruby-trilogy" | "trilogy" => Box::new(ruby_trilogy::RubyTrilogyBackend::new(canonical_engine)?),
1060 "php-pdo" | "php" => Box::new(php_pdo::PhpPdoBackend::new(canonical_engine)?),
1061 "php-amphp" | "amphp" => Box::new(php_amphp::PhpAmphpBackend::new(canonical_engine)?),
1062 "rust-tiberius" | "tiberius" => Box::new(rust_tiberius::RustTiberiusBackend::new(canonical_engine)?),
1063 "python-pyodbc" | "pyodbc" => Box::new(python_pyodbc::PythonPyodbcBackend::new(canonical_engine)?),
1064 "typescript-mssql" | "tedious" => Box::new(typescript_mssql::TypescriptMssqlBackend::new(canonical_engine)?),
1065 "javascript-mssql" => Box::new(typescript_mssql::TypescriptMssqlBackend::new_js(canonical_engine)?),
1066 "csharp-sqlclient" => Box::new(csharp_sqlclient::CsharpSqlClientBackend::new(canonical_engine)?),
1067 "ruby-tiny-tds" | "tiny-tds" | "tiny_tds" => {
1068 Box::new(ruby_tiny_tds::RubyTinyTdsBackend::new(canonical_engine)?)
1069 }
1070 "elixir-tds" | "tds" => Box::new(elixir_tds::ElixirTdsBackend::new(canonical_engine)?),
1071 "rust-sibyl" | "sibyl" => Box::new(rust_sibyl::RustSibylBackend::new(canonical_engine)?),
1072 "python-oracledb" | "oracledb" => Box::new(python_oracledb::PythonOracledbBackend::new(canonical_engine)?),
1073 "typescript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new(canonical_engine)?),
1074 "javascript-oracledb" => Box::new(typescript_oracledb::TypescriptOracledbBackend::new_js(
1075 canonical_engine,
1076 )?),
1077 "go-godror" | "godror" => Box::new(go_godror::GoGodrorBackend::new(canonical_engine)?),
1078 "csharp-oracle" => Box::new(csharp_oracle::CsharpOracleBackend::new(canonical_engine)?),
1079 "ruby-oci8" | "oci8" => Box::new(ruby_oci8::RubyOci8Backend::new(canonical_engine)?),
1080 "elixir-jamdb" | "jamdb" => Box::new(elixir_jamdb::ElixirJamdbBackend::new(canonical_engine)?),
1081 "python-snowflake" => Box::new(python_snowflake::PythonSnowflakeBackend::new(canonical_engine)?),
1082 "typescript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new(canonical_engine)?),
1083 "javascript-snowflake" => Box::new(typescript_snowflake::TypescriptSnowflakeBackend::new_js(
1084 canonical_engine,
1085 )?),
1086 "go-gosnowflake" | "gosnowflake" => Box::new(go_gosnowflake::GoGosnowflakeBackend::new(canonical_engine)?),
1087 "csharp-snowflake" => Box::new(csharp_snowflake::CsharpSnowflakeBackend::new(canonical_engine)?),
1088 _ => {
1089 return Err(ScytheError::new(
1090 ErrorCode::InvalidConfig,
1091 format!("unknown backend: {}", name),
1092 ));
1093 }
1094 };
1095
1096 if !backend
1097 .supported_engines()
1098 .iter()
1099 .any(|e| normalize_engine(e) == canonical_engine)
1100 {
1101 return Err(ScytheError::new(
1102 ErrorCode::InvalidConfig,
1103 format!(
1104 "backend '{}' does not support engine '{}'. Supported: {:?}",
1105 name,
1106 engine,
1107 backend.supported_engines()
1108 ),
1109 ));
1110 }
1111
1112 Ok(backend)
1113}
1114
1115pub fn normalize_engine(engine: &str) -> &str {
1122 match engine {
1123 "postgresql" | "postgres" | "pg" | "cockroachdb" | "crdb" => "postgresql",
1124 "mysql" => "mysql",
1125 "mariadb" => "mariadb",
1126 "sqlite" | "sqlite3" => "sqlite",
1127 "duckdb" => "duckdb",
1128 "mssql" | "sqlserver" | "tsql" => "mssql",
1129 "oracle" => "oracle",
1130 "snowflake" => "snowflake",
1131 "redshift" => "redshift",
1132 other => other,
1133 }
1134}
1135
1136#[cfg(test)]
1141mod sql_text_characterization_tests;
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::*;
1146
1147 fn param(name: &str, position: i64) -> AnalyzedParam {
1148 AnalyzedParam {
1149 name: name.to_string(),
1150 neutral_type: "string".to_string(),
1151 nullable: true,
1152 position,
1153 source_relation: None,
1154 }
1155 }
1156
1157 fn resolved_param(name: &str, neutral_type: &str) -> ResolvedParam {
1158 ResolvedParam {
1159 name: name.to_string(),
1160 field_name: name.to_string(),
1161 lang_type: "String".to_string(),
1162 full_type: "String".to_string(),
1163 borrowed_type: "&str".to_string(),
1164 neutral_type: neutral_type.to_string(),
1165 nullable: false,
1166 }
1167 }
1168
1169 #[test]
1170 fn typed_placeholder_casts_only_rewrite_live_tokens_and_keep_number_boundaries() {
1171 let analyzed: Vec<AnalyzedParam> = (1..=10)
1172 .map(|position| param(&format!("p{position}"), position))
1173 .collect();
1174 let resolved: Vec<ResolvedParam> = (1..=10)
1175 .map(|position| {
1176 let neutral_type = match position {
1177 1 => "composite::address",
1178 10 => "enum::status",
1179 _ => "string",
1180 };
1181 resolved_param(&format!("p{position}"), neutral_type)
1182 })
1183 .collect();
1184 let sql = "SELECT $1, $10, '$1', \"$10\", $$ $1 $10 $$ -- $1\n/* $10 */ WHERE x = $2";
1185
1186 let (rewritten, occurrences) =
1187 rewrite_pg_typed_placeholders(sql, &analyzed, &resolved, true, |position| format!("@p{position}"));
1188
1189 assert_eq!(
1190 rewritten,
1191 "SELECT @p1::text::address, @p10::status, '$1', \"$10\", $$ $1 $10 $$ -- $1\n/* $10 */ WHERE x = @p2"
1192 );
1193 assert_eq!(occurrences, vec![1, 10, 2]);
1194 }
1195
1196 #[test]
1208 fn test_no_backends_relative_manifest_path_literals() {
1209 let root = concat!(env!("CARGO_MANIFEST_DIR"), "/src/backends");
1210 let word: String = format!("backend{}", "s/");
1211 let needle: String = format!("{}{}", '"', word);
1212
1213 let mut offenders = Vec::new();
1214 collect_rs_files(std::path::Path::new(root), &mut offenders);
1215
1216 let offenders: Vec<_> = offenders
1217 .into_iter()
1218 .filter(|path| {
1219 let contents = std::fs::read_to_string(path).expect("failed to read source file");
1220 contents.contains(&needle)
1221 })
1222 .collect();
1223
1224 assert!(
1225 offenders.is_empty(),
1226 "found a working-directory-relative manifest path literal in: {offenders:?} (#82 regression \
1227 -- manifest selection must be a pure function of (backend, engine), no filesystem lookup)"
1228 );
1229 }
1230
1231 fn collect_rs_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
1233 let entries = std::fs::read_dir(dir).expect("failed to read backends dir");
1234 for entry in entries {
1235 let entry = entry.expect("failed to read dir entry");
1236 let path = entry.path();
1237 if path.is_dir() {
1238 collect_rs_files(&path, out);
1239 } else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
1240 out.push(path);
1241 }
1242 }
1243 }
1244
1245 #[test]
1251 fn test_get_backend_rust_sqlx_mysql_has_no_postgresql_scalars() {
1252 let backend = get_backend("rust-sqlx", "mysql").expect("rust-sqlx should support mysql");
1253 let scalars = &backend.manifest().types.scalars;
1254 assert_ne!(
1255 scalars.get("uuid").map(String::as_str),
1256 Some("uuid::Uuid"),
1257 "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1258 );
1259 assert_ne!(
1260 scalars.get("inet").map(String::as_str),
1261 Some("ipnetwork::IpNetwork"),
1262 "rust-sqlx/mysql manifest should not carry the PostgreSQL-specific 'inet' mapping"
1263 );
1264 }
1265
1266 #[test]
1267 fn test_get_backend_java_jdbc_sqlite_has_no_postgresql_scalars() {
1268 let backend = get_backend("java-jdbc", "sqlite").expect("java-jdbc should support sqlite");
1269 let scalars = &backend.manifest().types.scalars;
1270 assert_ne!(
1271 scalars.get("uuid").map(String::as_str),
1272 Some("java.util.UUID"),
1273 "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'uuid' mapping"
1274 );
1275 assert_ne!(
1276 scalars.get("decimal").map(String::as_str),
1277 Some("java.math.BigDecimal"),
1278 "java-jdbc/sqlite manifest should not carry the PostgreSQL-specific 'decimal' mapping"
1279 );
1280 }
1281
1282 #[test]
1283 fn test_normalize_engine_cockroachdb() {
1284 assert_eq!(normalize_engine("cockroachdb"), "postgresql");
1285 assert_eq!(normalize_engine("crdb"), "postgresql");
1286 }
1287
1288 #[test]
1289 fn test_get_backend_cockroachdb_with_pg_backends() {
1290 let pg_backends = [
1291 "rust-sqlx",
1292 "rust-tokio-postgres",
1293 "python-psycopg3",
1294 "python-asyncpg",
1295 "typescript-postgres",
1296 "typescript-pg",
1297 "typescript-kysely",
1298 "go-pgx",
1299 "ruby-pg",
1300 "elixir-postgrex",
1301 "csharp-npgsql",
1302 "php-pdo",
1303 "php-amphp",
1304 ];
1305 for backend_name in &pg_backends {
1306 let result = get_backend(backend_name, "cockroachdb");
1307 assert!(
1308 result.is_ok(),
1309 "backend '{}' should accept cockroachdb engine, got: {:?}",
1310 backend_name,
1311 result.err()
1312 );
1313 }
1314 }
1315
1316 #[test]
1317 fn test_get_backend_crdb_alias() {
1318 let result = get_backend("rust-sqlx", "crdb");
1319 assert!(result.is_ok(), "rust-sqlx should accept 'crdb' engine alias");
1320 }
1321
1322 #[test]
1323 fn test_normalize_engine_duckdb() {
1324 assert_eq!(normalize_engine("duckdb"), "duckdb");
1325 }
1326
1327 #[test]
1328 fn test_get_backend_duckdb_with_compatible_backends() {
1329 let duckdb_backends = [
1330 "python-duckdb",
1331 "typescript-duckdb",
1332 "go-database-sql",
1333 "java-jdbc",
1334 "kotlin-jdbc",
1335 ];
1336 for backend_name in &duckdb_backends {
1337 let result = get_backend(backend_name, "duckdb");
1338 assert!(
1339 result.is_ok(),
1340 "backend '{}' should accept duckdb engine, got: {:?}",
1341 backend_name,
1342 result.err()
1343 );
1344 }
1345 }
1346
1347 #[test]
1348 fn test_get_backend_duckdb_rejected_by_pg_only() {
1349 let result = get_backend("rust-sqlx", "duckdb");
1350 assert!(result.is_err(), "rust-sqlx should reject duckdb engine");
1351 }
1352
1353 #[test]
1354 fn test_rewrite_simple_equality() {
1355 let sql = "SELECT * FROM users WHERE status = $1";
1356 let params = vec![param("status", 1)];
1357 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1358 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status = $1)");
1359 }
1360
1361 #[test]
1362 fn test_rewrite_qualified_column() {
1363 let sql = "SELECT * FROM users u WHERE u.status = $1";
1364 let params = vec![param("status", 1)];
1365 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1366 assert_eq!(result, "SELECT * FROM users u WHERE ($1 IS NULL OR u.status = $1)");
1367 }
1368
1369 #[test]
1370 fn test_rewrite_multiple_optional() {
1371 let sql = "SELECT * FROM users WHERE status = $1 AND name = $2";
1372 let params = vec![param("status", 1), param("name", 2)];
1373 let result = rewrite_optional_params(sql, &["status".to_string(), "name".to_string()], ¶ms);
1374 assert_eq!(
1375 result,
1376 "SELECT * FROM users WHERE ($1 IS NULL OR status = $1) AND ($2 IS NULL OR name = $2)"
1377 );
1378 }
1379
1380 #[test]
1381 fn test_rewrite_mixed_optional_required() {
1382 let sql = "SELECT * FROM users WHERE id = $1 AND status = $2";
1383 let params = vec![param("id", 1), param("status", 2)];
1384 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1385 assert_eq!(
1386 result,
1387 "SELECT * FROM users WHERE id = $1 AND ($2 IS NULL OR status = $2)"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_rewrite_like_operator() {
1393 let sql = "SELECT * FROM users WHERE name LIKE $1";
1394 let params = vec![param("name", 1)];
1395 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1396 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name LIKE $1)");
1397 }
1398
1399 #[test]
1400 fn test_rewrite_ilike_operator() {
1401 let sql = "SELECT * FROM users WHERE name ILIKE $1";
1402 let params = vec![param("name", 1)];
1403 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1404 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR name ILIKE $1)");
1405 }
1406
1407 #[test]
1408 fn test_rewrite_comparison_operators() {
1409 let sql = "SELECT * FROM users WHERE age >= $1";
1410 let params = vec![param("age", 1)];
1411 let result = rewrite_optional_params(sql, &["age".to_string()], ¶ms);
1412 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age >= $1)");
1413 }
1414
1415 #[test]
1416 fn test_rewrite_less_than() {
1417 let sql = "SELECT * FROM users WHERE age < $1";
1418 let params = vec![param("age", 1)];
1419 let result = rewrite_optional_params(sql, &["age".to_string()], ¶ms);
1420 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR age < $1)");
1421 }
1422
1423 #[test]
1424 fn test_no_rewrite_without_optional() {
1425 let sql = "SELECT * FROM users WHERE status = $1";
1426 let params = vec![param("status", 1)];
1427 let result = rewrite_optional_params(sql, &[], ¶ms);
1428 assert_eq!(result, sql);
1429 }
1430
1431 #[test]
1432 fn test_rewrite_not_equal() {
1433 let sql = "SELECT * FROM users WHERE status <> $1";
1434 let params = vec![param("status", 1)];
1435 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1436 assert_eq!(result, "SELECT * FROM users WHERE ($1 IS NULL OR status <> $1)");
1437 }
1438
1439 #[test]
1440 fn test_rewrite_does_not_match_similar_placeholder() {
1441 let sql = "SELECT * FROM users WHERE status = $10";
1442 let params = vec![param("status", 1)];
1443 let result = rewrite_optional_params(sql, &["status".to_string()], ¶ms);
1444 assert_eq!(result, sql);
1445 }
1446
1447 #[test]
1448 fn test_normalize_engine_mariadb() {
1449 assert_eq!(normalize_engine("mariadb"), "mariadb");
1450 }
1451
1452 #[test]
1453 fn test_get_backend_mariadb_with_mysql_backends() {
1454 let mariadb_backends = [
1455 "rust-sqlx",
1456 "python-aiomysql",
1457 "typescript-mysql2",
1458 "go-database-sql",
1459 "java-jdbc",
1460 "java-r2dbc",
1461 "kotlin-jdbc",
1462 "kotlin-r2dbc",
1463 "csharp-mysqlconnector",
1464 "elixir-myxql",
1465 "ruby-mysql2",
1466 "ruby-trilogy",
1467 "php-pdo",
1468 "php-amphp",
1469 ];
1470 for backend_name in &mariadb_backends {
1471 let result = get_backend(backend_name, "mariadb");
1472 assert!(
1473 result.is_ok(),
1474 "backend '{}' should accept mariadb engine, got: {:?}",
1475 backend_name,
1476 result.err()
1477 );
1478 }
1479 }
1480
1481 #[test]
1491 fn test_clean_sql_oneline_strips_mid_line_comment_but_keeps_rest_of_query() {
1492 let sql = "SELECT id, name\nFROM users\nWHERE id > $1 -- skip low ids\n AND name IS NOT NULL;";
1493 let result = clean_sql_oneline(sql);
1494 assert!(
1495 result.contains("AND name IS NOT NULL"),
1496 "mid-line comment must not swallow the rest of the query, got: {result:?}"
1497 );
1498 assert!(
1499 !result.contains("--"),
1500 "comment marker must be stripped, got: {result:?}"
1501 );
1502 }
1503
1504 #[test]
1505 fn test_clean_sql_strips_mid_line_comment_but_keeps_rest_of_query() {
1506 let sql = "SELECT id\nFROM users\nWHERE id > $1 -- skip low ids\nAND active = true;";
1507 let result = clean_sql(sql);
1508 assert!(result.contains("AND active = true"), "got: {result:?}");
1509 assert!(!result.contains("--"), "got: {result:?}");
1510 }
1511
1512 #[test]
1513 fn test_clean_sql_preserves_double_dash_inside_single_quoted_string() {
1514 let sql = "SELECT * FROM t WHERE label = 'a -- not a comment' AND id = $1;";
1515 let result = clean_sql_oneline(sql);
1516 assert!(result.contains("'a -- not a comment'"), "got: {result:?}");
1517 assert!(result.contains("id = $1"), "got: {result:?}");
1518 }
1519
1520 #[test]
1521 fn test_clean_sql_preserves_double_dash_inside_double_quoted_identifier() {
1522 let sql = "SELECT \"weird--col\" FROM t WHERE id = $1;";
1523 let result = clean_sql_oneline(sql);
1524 assert!(result.contains("\"weird--col\""), "got: {result:?}");
1525 }
1526
1527 #[test]
1528 fn test_clean_sql_preserves_double_dash_inside_dollar_quoted_string() {
1529 let sql = "SELECT $$has -- inside$$ AS x FROM t WHERE id = $1;";
1530 let result = clean_sql_oneline(sql);
1531 assert!(result.contains("$$has -- inside$$"), "got: {result:?}");
1532 }
1533
1534 #[test]
1535 fn test_clean_sql_strips_nested_block_comments() {
1536 let sql = "SELECT id /* outer /* inner */ still outer */ FROM t WHERE id = $1;";
1537 let result = clean_sql_oneline(sql);
1538 assert!(!result.contains("outer"), "got: {result:?}");
1539 assert!(!result.contains("inner"), "got: {result:?}");
1540 assert!(result.contains("SELECT id"), "got: {result:?}");
1541 assert!(result.contains("FROM t WHERE id = $1"), "got: {result:?}");
1542 }
1543
1544 #[test]
1545 fn test_clean_sql_unterminated_block_comment_consumes_to_end_without_panicking() {
1546 let sql = "SELECT id FROM t /* oops forgot to close";
1547 let result = clean_sql_oneline(sql);
1548 assert_eq!(result, "SELECT id FROM t");
1549 }
1550
1551 #[test]
1552 fn test_clean_sql_drops_lines_that_are_only_a_header_comment() {
1553 let sql = "-- @name Foo\n-- @returns :many\nSELECT id FROM t WHERE id = $1;";
1557 let result = clean_sql(sql);
1558 assert_eq!(result, "SELECT id FROM t WHERE id = $1");
1559 }
1560
1561 #[test]
1562 fn test_rewrite_pg_placeholders_preserves_dollar_quoted_body() {
1563 let sql =
1564 "SELECT id FROM t WHERE body = $$literal -- not comment, ' quote, $1 not a placeholder$$ AND id = $2;";
1565 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1566 assert!(
1567 result.contains("$$literal -- not comment, ' quote, $1 not a placeholder$$"),
1568 "dollar-quoted body must survive untouched, got: {result:?}"
1569 );
1570 assert!(result.contains("id = @p2"), "got: {result:?}");
1571 }
1572
1573 #[test]
1574 fn test_rewrite_pg_placeholders_handles_escaped_single_quotes() {
1575 let sql = "SELECT id FROM t WHERE label = 'it''s $1 not a placeholder' AND id = $2;";
1576 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1577 assert!(result.contains("'it''s $1 not a placeholder'"), "got: {result:?}");
1578 assert!(result.contains("id = @p2"), "got: {result:?}");
1579 }
1580
1581 #[test]
1582 fn test_rewrite_pg_placeholders_preserves_jsonb_operators() {
1583 let sql = "SELECT id FROM docs WHERE data ? 'mykey' AND tags ?| array['a','b'] AND tags ?& array['a','b'] AND id = $1;";
1584 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1585 assert!(result.contains("data ? 'mykey'"), "got: {result:?}");
1586 assert!(result.contains("tags ?| array"), "got: {result:?}");
1587 assert!(result.contains("tags ?& array"), "got: {result:?}");
1588 assert!(result.contains("id = @p1"), "got: {result:?}");
1589 }
1590
1591 #[test]
1600 fn test_rewrite_pg_placeholders_preserves_jsonb_multi_char_operators_with_zero_dollar_placeholders() {
1601 let sql = "SELECT * FROM docs WHERE data ? 'active' AND tags ?| ARRAY['a'] AND meta ?& ARRAY['b']";
1602 let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1603 assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1604 assert!(result.contains("meta ?& ARRAY['b']"), "got: {result:?}");
1605 }
1606
1607 #[test]
1608 fn test_rewrite_pg_placeholders_preserves_geometry_operators() {
1609 let sql = "SELECT id FROM shapes WHERE line1 ?- line2 AND line1 ?-| line2 AND line1 ?|| line2 AND id = $1;";
1610 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1611 assert!(result.contains("line1 ?- line2"), "got: {result:?}");
1612 assert!(result.contains("line1 ?-| line2"), "got: {result:?}");
1613 assert!(result.contains("line1 ?|| line2"), "got: {result:?}");
1614 assert!(result.contains("id = @p1"), "got: {result:?}");
1615 }
1616
1617 #[test]
1618 fn test_rewrite_pg_placeholders_preserves_operators_mixed_with_dollar_placeholder() {
1619 let sql = "SELECT id FROM docs WHERE tags ?| ARRAY['a'] AND doc @? '$.a' AND id = $1;";
1620 let result = rewrite_pg_placeholders(sql, |n| format!("@p{n}"));
1621 assert!(result.contains("tags ?| ARRAY['a']"), "got: {result:?}");
1622 assert!(result.contains("doc @? '$.a'"), "got: {result:?}");
1623 assert!(result.contains("id = @p1"), "got: {result:?}");
1624 }
1625
1626 #[test]
1627 fn test_rewrite_pg_placeholders_number_boundary() {
1628 let sql = "SELECT $1, $10, $1x";
1629 let result = rewrite_pg_placeholders(sql, |n| format!("P{n}"));
1630 assert_eq!(result, "SELECT P1, P10, P1x");
1631 }
1632
1633 #[test]
1634 fn test_rewrite_pg_placeholders_ignores_question_mark_inside_string_literal() {
1635 let sql = "SELECT id FROM t WHERE note = 'is this a question?' AND age > ?;";
1636 let result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1637 assert!(result.contains("'is this a question?'"), "got: {result:?}");
1638 assert!(result.contains("age > $1"), "got: {result:?}");
1639 }
1640
1641 #[test]
1642 fn test_clean_sql_handles_non_ascii_in_comment_and_string() {
1643 let sql = "SELECT name -- 名前を取得 comment\nFROM t WHERE label = 'héllo wörld' AND id = $1;";
1644 let result = clean_sql_oneline(sql);
1645 assert!(!result.contains('名'), "got: {result:?}");
1646 assert!(result.contains("'héllo wörld'"), "got: {result:?}");
1647 assert!(result.contains("id = $1"), "got: {result:?}");
1648 }
1649
1650 #[test]
1651 fn test_clean_sql_handles_crlf_line_endings() {
1652 let sql = "SELECT id\r\nFROM users\r\n-- a comment\r\nWHERE id = $1;";
1653 let result = clean_sql(sql);
1654 assert!(!result.contains('\r'), "got: {result:?}");
1655 assert!(!result.contains("-- a comment"), "got: {result:?}");
1656 assert_eq!(result, "SELECT id\nFROM users\nWHERE id = $1");
1657 }
1658
1659 #[test]
1660 fn test_rewrite_optional_params_handles_not_like() {
1661 let sql = "SELECT id FROM t WHERE name NOT LIKE $1";
1662 let params = vec![param("name", 1)];
1663 let result = rewrite_optional_params(sql, &["name".to_string()], ¶ms);
1664 assert_eq!(result, "SELECT id FROM t WHERE ($1 IS NULL OR name NOT LIKE $1)");
1665 }
1666
1667 #[test]
1689 fn test_bare_question_mark_is_never_a_placeholder_under_postgresql_even_with_zero_dollar_placeholders() {
1690 let sql = "SELECT * FROM docs WHERE data ? 'active'";
1691
1692 let old_result = rewrite_pg_placeholders(sql, |n| format!("${n}"));
1693 assert_eq!(
1694 old_result, "SELECT * FROM docs WHERE data $1 'active'",
1695 "pins the OLD heuristic's corruption of the JSONB operator"
1696 );
1697
1698 let (new_result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::PostgreSQL, |n| format!("${n}"));
1699 assert_eq!(
1700 new_result, sql,
1701 "dialect-driven rewrite must leave the JSONB operator untouched"
1702 );
1703 assert!(
1704 occurrences.is_empty(),
1705 "no placeholder was ever present; got: {occurrences:?}"
1706 );
1707 }
1708
1709 #[test]
1713 fn test_bare_question_mark_is_a_placeholder_under_mysql() {
1714 let sql = "SELECT * FROM docs WHERE data ? 'active'";
1715 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("${n}"));
1716 assert_eq!(result, "SELECT * FROM docs WHERE data $1 'active'");
1717 assert_eq!(occurrences, vec![1]);
1718 }
1719
1720 #[test]
1725 fn test_rewrite_placeholders_indexed_returns_positions_in_textual_order_out_of_order() {
1726 let (result, occurrences) =
1727 rewrite_placeholders_indexed("WHERE b = $2 AND a = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1728 assert_eq!(result, "WHERE b = ? AND a = ?");
1729 assert_eq!(occurrences, vec![2, 1]);
1730 }
1731
1732 #[test]
1736 fn test_rewrite_placeholders_indexed_repeated_placeholder_reports_one_occurrence_per_use() {
1737 let (result, occurrences) =
1738 rewrite_placeholders_indexed("WHERE a = $1 OR b = $1", SqlDialect::PostgreSQL, |_| "?".to_string());
1739 assert_eq!(result, "WHERE a = ? OR b = ?");
1740 assert_eq!(occurrences, vec![1, 1]);
1741 }
1742
1743 #[test]
1750 fn test_mysql_backtick_identifier_is_not_corrupted_by_bare_question_mark() {
1751 let sql = "SELECT `a?b` FROM t WHERE c = ?";
1752 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MySQL, |n| format!("[P{n}]"));
1753 assert_eq!(result, "SELECT `a?b` FROM t WHERE c = [P1]");
1754 assert_eq!(occurrences, vec![1]);
1755 }
1756
1757 #[test]
1759 fn test_mssql_bracket_identifier_is_not_corrupted_by_bare_question_mark() {
1760 let sql = "SELECT [a?b] FROM t WHERE c = ?";
1761 let (result, occurrences) = rewrite_placeholders_indexed(sql, SqlDialect::MsSql, |n| format!("[P{n}]"));
1762 assert_eq!(result, "SELECT [a?b] FROM t WHERE c = [P1]");
1763 assert_eq!(occurrences, vec![1]);
1764 }
1765
1766 #[test]
1773 fn test_clean_sql_dialect_mysql_backtick_preserves_double_dash_inside() {
1774 let sql = "SELECT `a -- b` FROM t";
1775 assert_eq!(
1776 clean_sql(sql),
1777 "SELECT `a",
1778 "pins the OLD dialect-blind clean_sql's corruption"
1779 );
1780 assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), sql);
1781 }
1782
1783 #[test]
1785 fn test_clean_sql_dialect_mssql_bracket_preserves_double_dash_inside() {
1786 let sql = "SELECT [a -- b] FROM t";
1787 assert_eq!(
1788 clean_sql(sql),
1789 "SELECT [a",
1790 "pins the OLD dialect-blind clean_sql's corruption"
1791 );
1792 assert_eq!(clean_sql_dialect(sql, SqlDialect::MsSql), sql);
1793 }
1794
1795 #[test]
1800 fn test_clean_sql_dialect_mysql_hash_comment_is_stripped() {
1801 let sql = "SELECT 1 # note\nFROM t";
1802 assert_eq!(
1803 clean_sql(sql),
1804 "SELECT 1 # note\nFROM t",
1805 "pins the OLD dialect-blind clean_sql leaving the # comment in place"
1806 );
1807 assert_eq!(clean_sql_dialect(sql, SqlDialect::MySQL), "SELECT 1 \nFROM t");
1812 }
1813
1814 #[test]
1818 fn test_clean_sql_oneline_dialect_mysql_hash_comment_is_stripped() {
1819 let sql = "SELECT 1 # note\nFROM t";
1820 assert_eq!(clean_sql_oneline_dialect(sql, SqlDialect::MySQL), "SELECT 1 FROM t");
1821 }
1822
1823 #[test]
1827 fn test_clean_sql_dialect_postgresql_hash_is_not_a_comment() {
1828 let sql = "SELECT data #> '{a,b}' AS x FROM t";
1829 assert_eq!(clean_sql_dialect(sql, SqlDialect::PostgreSQL), sql);
1830 }
1831}