1use sqlparser::parser::Parser;
2
3use crate::dialect::SqlDialect;
4use crate::errors::ScytheError;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum QueryCommand {
8 One,
9 Opt,
10 Many,
11 Exec,
12 ExecResult,
13 ExecRows,
14 Batch,
15 Grouped,
16}
17
18impl std::fmt::Display for QueryCommand {
19 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20 match self {
21 QueryCommand::One => write!(f, "one"),
22 QueryCommand::Opt => write!(f, "opt"),
23 QueryCommand::Many => write!(f, "many"),
24 QueryCommand::Exec => write!(f, "exec"),
25 QueryCommand::ExecResult => write!(f, "exec_result"),
26 QueryCommand::ExecRows => write!(f, "exec_rows"),
27 QueryCommand::Batch => write!(f, "batch"),
28 QueryCommand::Grouped => write!(f, "grouped"),
29 }
30 }
31}
32
33impl QueryCommand {
34 fn from_str(s: &str) -> Result<Self, ScytheError> {
35 match s {
36 "one" => Ok(QueryCommand::One),
37 "opt" => Ok(QueryCommand::Opt),
38 "many" => Ok(QueryCommand::Many),
39 "exec" => Ok(QueryCommand::Exec),
40 "exec_result" => Ok(QueryCommand::ExecResult),
41 "exec_rows" => Ok(QueryCommand::ExecRows),
42 "batch" => Ok(QueryCommand::Batch),
43 "grouped" => Ok(QueryCommand::Grouped),
44 other => Err(ScytheError::invalid_annotation(format!(
45 "invalid @returns value: {other}"
46 ))),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ParamDoc {
53 pub name: String,
54 pub description: String,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct JsonMapping {
59 pub column: String,
60 pub rust_type: String,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Annotations {
65 pub name: String,
66 pub command: QueryCommand,
67 pub param_docs: Vec<ParamDoc>,
68 pub nullable_overrides: Vec<String>,
69 pub nonnull_overrides: Vec<String>,
70 pub json_mappings: Vec<JsonMapping>,
71 pub deprecated: Option<String>,
72 pub optional_params: Vec<String>,
73 pub group_by: Option<String>,
74}
75
76#[derive(Debug)]
77pub struct Query {
78 pub name: String,
79 pub command: QueryCommand,
80 pub sql: String,
81 pub stmt: sqlparser::ast::Statement,
82 pub annotations: Annotations,
83}
84
85pub fn parse_query(query_sql: &str) -> Result<Query, ScytheError> {
87 parse_query_with_dialect(query_sql, &SqlDialect::PostgreSQL)
88}
89
90pub fn parse_query_with_dialect(
92 query_sql: &str,
93 dialect: &SqlDialect,
94) -> Result<Query, ScytheError> {
95 let mut name: Option<String> = None;
96 let mut command: Option<QueryCommand> = None;
97 let mut param_docs = Vec::new();
98 let mut nullable_overrides = Vec::new();
99 let mut nonnull_overrides = Vec::new();
100 let mut json_mappings = Vec::new();
101 let mut deprecated: Option<String> = None;
102 let mut optional_params = Vec::new();
103 let mut group_by: Option<String> = None;
104
105 let mut sql_lines = Vec::new();
106
107 for line in query_sql.lines() {
108 let trimmed = line.trim();
109
110 let annotation_body = if let Some(rest) = trimmed.strip_prefix("--") {
112 let rest = rest.trim_start();
113 rest.strip_prefix('@')
114 } else {
115 None
116 };
117
118 if let Some(body) = annotation_body {
119 let (keyword, value) = match body.find(|c: char| c.is_whitespace()) {
121 Some(pos) => (&body[..pos], body[pos..].trim()),
122 None => (body, ""),
123 };
124
125 match keyword.to_ascii_lowercase().as_str() {
126 "name" => {
127 name = Some(value.to_string());
128 }
129 "returns" => {
130 let cmd_str = value.strip_prefix(':').unwrap_or(value);
131 command = Some(QueryCommand::from_str(cmd_str)?);
132 }
133 "param" => {
134 if let Some(colon_pos) = value.find(':') {
136 let param_name = value[..colon_pos].trim().to_string();
137 let description = value[colon_pos + 1..].trim().to_string();
138 param_docs.push(ParamDoc {
139 name: param_name,
140 description,
141 });
142 } else {
143 param_docs.push(ParamDoc {
144 name: value.to_string(),
145 description: String::new(),
146 });
147 }
148 }
149 "nullable" => {
150 for col in value.split(',') {
151 let col = col.trim();
152 if !col.is_empty() {
153 nullable_overrides.push(col.to_string());
154 }
155 }
156 }
157 "nonnull" => {
158 for col in value.split(',') {
159 let col = col.trim();
160 if !col.is_empty() {
161 nonnull_overrides.push(col.to_string());
162 }
163 }
164 }
165 "json" => {
166 if let Some(eq_pos) = value.find('=') {
168 let column = value[..eq_pos].trim().to_string();
169 let rust_type = value[eq_pos + 1..].trim().to_string();
170 json_mappings.push(JsonMapping { column, rust_type });
171 }
172 }
173 "deprecated" => {
174 deprecated = Some(value.to_string());
175 }
176 "group_by" => {
177 group_by = Some(value.to_string());
178 }
179 "optional" => {
180 for param in value.split(',') {
181 let param = param.trim();
182 if !param.is_empty() {
183 optional_params.push(param.to_string());
184 }
185 }
186 }
187 _ => {
188 }
190 }
191 } else {
192 sql_lines.push(line);
193 }
194 }
195
196 let name = name.ok_or_else(|| ScytheError::missing_annotation("name"))?;
197 let command = command.ok_or_else(|| ScytheError::missing_annotation("returns"))?;
198
199 if command == QueryCommand::Grouped && group_by.is_none() {
200 return Err(ScytheError::invalid_annotation(
201 "@returns :grouped requires a @group_by annotation (e.g. @group_by users.id)",
202 ));
203 }
204
205 let sql = sql_lines.join("\n").trim().to_string();
206
207 if sql.is_empty() {
208 return Err(ScytheError::syntax("empty SQL body"));
209 }
210
211 let (sql, parse_sql) = if *dialect == SqlDialect::Oracle {
219 let processed = preprocess_oracle_sql(&sql);
220 (processed.clone(), processed)
221 } else if *dialect == SqlDialect::MsSql {
222 let codegen_sql = convert_mssql_placeholders(&sql);
224 let parse_sql = preprocess_mssql_sql(&sql);
226 (codegen_sql, parse_sql)
227 } else if *dialect == SqlDialect::PostgreSQL {
228 let parse_sql = preprocess_postgres_sql(&sql);
229 (sql.clone(), parse_sql)
230 } else {
231 (sql.clone(), sql)
232 };
233
234 let parser_dialect = dialect.to_sqlparser_dialect();
235 let statements = Parser::parse_sql(parser_dialect.as_ref(), &parse_sql)
236 .map_err(|e| ScytheError::syntax(format!("syntax error: {}", e)))?;
237
238 if statements.len() != 1 {
239 let non_empty: Vec<_> = statements
242 .into_iter()
243 .filter(|s| {
244 !matches!(s, sqlparser::ast::Statement::Flush { .. }) && format!("{s}") != ""
245 })
246 .collect();
247 if non_empty.len() != 1 {
248 return Err(ScytheError::syntax("expected exactly one SQL statement"));
249 }
250 let stmt = non_empty
251 .into_iter()
252 .next()
253 .expect("filtered to exactly one statement");
254 let annotations = Annotations {
255 name: name.clone(),
256 command: command.clone(),
257 param_docs,
258 nullable_overrides,
259 nonnull_overrides,
260 json_mappings,
261 deprecated,
262 optional_params,
263 group_by: group_by.clone(),
264 };
265 return Ok(Query {
266 name,
267 command,
268 sql,
269 stmt,
270 annotations,
271 });
272 }
273
274 let stmt = statements
275 .into_iter()
276 .next()
277 .expect("filtered to exactly one statement");
278
279 let annotations = Annotations {
280 name: name.clone(),
281 command: command.clone(),
282 param_docs,
283 nullable_overrides,
284 nonnull_overrides,
285 json_mappings,
286 deprecated,
287 optional_params,
288 group_by,
289 };
290
291 Ok(Query {
292 name,
293 command,
294 sql,
295 stmt,
296 annotations,
297 })
298}
299
300fn preprocess_postgres_sql(sql: &str) -> String {
306 let mask = mask_postgres_for_scan(sql);
310 let mask_bytes = mask.as_bytes();
311 let bytes = sql.as_bytes();
312 let mut search_from = 0;
313 let mut result = String::with_capacity(sql.len());
314 let mut last = 0;
315 while let Some(rel) = find_keyword(&mask[search_from..], "ON CONFLICT") {
316 let on_conflict_pos = search_from + rel;
317 let after_on_conflict = on_conflict_pos + "ON CONFLICT".len();
318 let mut idx = after_on_conflict;
319 while idx < mask_bytes.len() && mask_bytes[idx].is_ascii_whitespace() {
320 idx += 1;
321 }
322 if idx >= mask_bytes.len() || mask_bytes[idx] != b'(' {
323 search_from = after_on_conflict;
324 continue;
325 }
326 let mut depth = 0i32;
327 let mut close = idx;
328 while close < mask_bytes.len() {
329 match mask_bytes[close] {
330 b'(' => depth += 1,
331 b')' => {
332 depth -= 1;
333 if depth == 0 {
334 break;
335 }
336 }
337 _ => {}
338 }
339 close += 1;
340 }
341 if depth != 0 {
342 return sql.to_string();
343 }
344 let mut after_cols = close + 1;
345 while after_cols < mask_bytes.len() && mask_bytes[after_cols].is_ascii_whitespace() {
346 after_cols += 1;
347 }
348 if mask[after_cols..].starts_with("WHERE")
349 && let Some(do_rel) = find_keyword(&mask[after_cols + "WHERE".len()..], "DO")
350 {
351 let do_abs = after_cols + "WHERE".len() + do_rel;
352 result.push_str(std::str::from_utf8(&bytes[last..after_cols]).unwrap_or(""));
355 last = do_abs;
356 search_from = do_abs;
357 continue;
358 }
359 search_from = close + 1;
360 }
361 result.push_str(std::str::from_utf8(&bytes[last..]).unwrap_or(""));
362 result
363}
364
365fn mask_postgres_for_scan(sql: &str) -> String {
370 let bytes = sql.as_bytes();
371 let mut out = vec![b' '; bytes.len()];
372 let mut i = 0;
373 while i < bytes.len() {
374 let b = bytes[i];
375 if b == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-' {
376 while i < bytes.len() && bytes[i] != b'\n' {
378 out[i] = b' ';
379 i += 1;
380 }
381 continue;
382 }
383 if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
384 out[i] = b' ';
386 out[i + 1] = b' ';
387 i += 2;
388 while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
389 out[i] = b' ';
390 i += 1;
391 }
392 if i + 1 < bytes.len() {
393 out[i] = b' ';
394 out[i + 1] = b' ';
395 i += 2;
396 }
397 continue;
398 }
399 if b == b'\'' {
400 out[i] = b' ';
401 i += 1;
402 while i < bytes.len() {
403 if bytes[i] == b'\'' {
404 if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
405 out[i] = b' ';
406 out[i + 1] = b' ';
407 i += 2;
408 continue;
409 }
410 out[i] = b' ';
411 i += 1;
412 break;
413 }
414 out[i] = b' ';
415 i += 1;
416 }
417 continue;
418 }
419 if b.is_ascii() {
422 out[i] = b.to_ascii_uppercase();
423 } else {
424 out[i] = b' ';
425 }
426 i += 1;
427 }
428 String::from_utf8(out).expect("mask is ASCII by construction")
429}
430
431fn find_keyword(haystack: &str, keyword: &str) -> Option<usize> {
434 let bytes = haystack.as_bytes();
435 let key = keyword.as_bytes();
436 let mut i = 0;
437 while i + key.len() <= bytes.len() {
438 if &bytes[i..i + key.len()] == key {
439 let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
440 let next = i + key.len();
441 let next_ok = next >= bytes.len() || !bytes[next].is_ascii_alphanumeric();
442 if prev_ok && next_ok {
443 return Some(i);
444 }
445 }
446 i += 1;
447 }
448 None
449}
450
451fn preprocess_oracle_sql(sql: &str) -> String {
455 let sql = strip_returning_into(sql);
458
459 let mut result = String::with_capacity(sql.len());
461 let mut chars = sql.chars().peekable();
462 while let Some(ch) = chars.next() {
463 if ch == '\'' {
464 result.push(ch);
466 while let Some(inner) = chars.next() {
467 result.push(inner);
468 if inner == '\'' {
469 if chars.peek() == Some(&'\'') {
470 result.push(chars.next().unwrap());
471 } else {
472 break;
473 }
474 }
475 }
476 } else if ch == ':' && chars.peek().is_some_and(|c| c.is_ascii_digit()) {
477 result.push('?');
479 while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
480 chars.next();
481 }
482 } else {
483 result.push(ch);
484 }
485 }
486 result
487}
488
489fn convert_mssql_placeholders(sql: &str) -> String {
493 let mut result = String::with_capacity(sql.len());
494 let mut chars = sql.chars().peekable();
495 while let Some(ch) = chars.next() {
496 if ch == '\'' {
497 result.push(ch);
499 while let Some(inner) = chars.next() {
500 result.push(inner);
501 if inner == '\'' {
502 if chars.peek() == Some(&'\'') {
503 result.push(chars.next().unwrap());
505 } else {
506 break;
507 }
508 }
509 }
510 } else if ch == '@' && chars.peek().is_some_and(|c| *c == 'p' || *c == 'P') {
511 let mut lookahead = chars.clone();
513 lookahead.next(); if lookahead.peek().is_some_and(|c| c.is_ascii_digit()) {
515 chars.next(); while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
518 chars.next();
519 }
520 result.push('?');
521 } else {
522 result.push(ch);
523 }
524 } else {
525 result.push(ch);
526 }
527 }
528 result
529}
530
531fn preprocess_mssql_sql(sql: &str) -> String {
535 let sql = strip_and_convert_mssql_output(sql);
537 convert_mssql_placeholders(&sql)
539}
540
541fn strip_and_convert_mssql_output(sql: &str) -> String {
548 let upper = sql.to_uppercase();
550
551 if !upper.contains("INSERT") || !upper.contains("OUTPUT") {
553 return sql.to_string();
554 }
555
556 if let Some(output_pos) = find_word_position(&upper, "OUTPUT") {
558 let before_output = &upper[..output_pos];
560 if !before_output.contains("INSERT") {
561 return sql.to_string();
562 }
563
564 let after_output = &upper[output_pos + "OUTPUT".len()..];
566 if let Some(values_offset) = find_word_position(after_output, "VALUES") {
567 let values_pos = output_pos + "OUTPUT".len() + values_offset;
568
569 let output_cols_str = &sql[output_pos + "OUTPUT".len()..values_pos];
571
572 let cols = parse_inserted_columns(output_cols_str);
574
575 if !cols.is_empty() {
576 let before_output_sql = sql[..output_pos].trim_end();
579 let after_values = sql[values_pos..].trim_end();
580 let (values_body, trailing) = if let Some(stripped) = after_values.strip_suffix(';')
581 {
582 (stripped, ";")
583 } else {
584 (after_values, "")
585 };
586
587 return format!(
588 "{}\n{} RETURNING {}{}",
589 before_output_sql, values_body, cols, trailing
590 );
591 }
592 }
593 }
594
595 sql.to_string()
596}
597
598fn find_word_position(text: &str, word: &str) -> Option<usize> {
601 let mut pos = 0;
602 let word_len = word.len();
603 while let Some(idx) = text[pos..].find(word) {
604 let abs_idx = pos + idx;
605
606 let before_ok = abs_idx == 0
608 || !text
609 .as_bytes()
610 .get(abs_idx - 1)
611 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_');
612
613 let after_idx = abs_idx + word_len;
615 let after_ok = after_idx >= text.len()
616 || !text
617 .as_bytes()
618 .get(after_idx)
619 .is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_');
620
621 if before_ok && after_ok {
622 return Some(abs_idx);
623 }
624 pos = abs_idx + 1;
625 }
626 None
627}
628
629fn parse_inserted_columns(output_str: &str) -> String {
631 let mut cols = Vec::new();
632
633 for part in output_str.split(',') {
634 let trimmed = part.trim();
635
636 if let Some(after_inserted) = trimmed
638 .strip_prefix("INSERTED.")
639 .or_else(|| trimmed.strip_prefix("inserted."))
640 .or_else(|| trimmed.strip_prefix("INSERTED"))
641 .or_else(|| trimmed.strip_prefix("inserted"))
642 {
643 let col_name = after_inserted.trim().to_string();
644 if !col_name.is_empty() {
645 cols.push(col_name);
646 }
647 }
648 }
649
650 cols.join(", ")
651}
652
653fn strip_returning_into(sql: &str) -> String {
655 let upper = sql.to_uppercase();
657 if let Some(ret_pos) = upper.rfind("RETURNING") {
658 let after_returning = &upper[ret_pos + "RETURNING".len()..];
659 if let Some(into_offset) = after_returning.find("INTO") {
660 let into_pos = ret_pos + "RETURNING".len() + into_offset;
661 let trimmed = sql[..into_pos].trim_end();
663 return trimmed.to_string();
664 }
665 }
666 sql.to_string()
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::errors::ErrorCode;
673
674 fn parse(sql: &str) -> Result<Query, ScytheError> {
675 parse_query(sql)
676 }
677
678 #[test]
679 fn test_basic_parse() {
680 let input = "-- @name GetUsers\n-- @returns :many\nSELECT * FROM users;";
681 let q = parse(input).unwrap();
682 assert_eq!(q.name, "GetUsers");
683 assert_eq!(q.command, QueryCommand::Many);
684 assert!(q.sql.contains("SELECT"));
685 }
686
687 #[test]
688 fn test_all_command_types() {
689 let cases = vec![
690 (":one", QueryCommand::One),
691 (":many", QueryCommand::Many),
692 (":exec", QueryCommand::Exec),
693 (":exec_result", QueryCommand::ExecResult),
694 (":exec_rows", QueryCommand::ExecRows),
695 ];
696 for (tag, expected) in cases {
697 let input = format!("-- @name Q\n-- @returns {}\nSELECT 1", tag);
698 let q = parse(&input).unwrap();
699 assert_eq!(q.command, expected, "failed for {}", tag);
700 }
701 }
702
703 #[test]
704 fn test_case_insensitive_keywords() {
705 let input = "-- @Name GetUsers\n-- @RETURNS :many\nSELECT 1";
706 let q = parse(input).unwrap();
707 assert_eq!(q.name, "GetUsers");
708 assert_eq!(q.command, QueryCommand::Many);
709 }
710
711 #[test]
712 fn test_missing_name_errors() {
713 let input = "-- @returns :many\nSELECT 1";
714 let err = parse(input).unwrap_err();
715 assert_eq!(err.code, ErrorCode::MissingAnnotation);
716 assert!(err.message.contains("name"));
717 }
718
719 #[test]
720 fn test_missing_returns_errors() {
721 let input = "-- @name Foo\nSELECT 1";
722 let err = parse(input).unwrap_err();
723 assert_eq!(err.code, ErrorCode::MissingAnnotation);
724 assert!(err.message.contains("returns"));
725 }
726
727 #[test]
728 fn test_invalid_returns_value() {
729 let input = "-- @name Foo\n-- @returns :invalid\nSELECT 1";
730 let err = parse(input).unwrap_err();
731 assert_eq!(err.code, ErrorCode::InvalidAnnotation);
732 }
733
734 #[test]
735 fn test_empty_name_value() {
736 let input = "-- @name\n-- @returns :one\nSELECT 1";
738 let q = parse(input).unwrap();
739 assert_eq!(q.name, "");
740 }
741
742 #[test]
743 fn test_param_annotation() {
744 let input = "-- @name Foo\n-- @returns :one\n-- @param id: the user ID\nSELECT 1";
745 let q = parse(input).unwrap();
746 assert_eq!(q.annotations.param_docs.len(), 1);
747 assert_eq!(q.annotations.param_docs[0].name, "id");
748 assert_eq!(q.annotations.param_docs[0].description, "the user ID");
749 }
750
751 #[test]
752 fn test_param_no_description() {
753 let input = "-- @name Foo\n-- @returns :one\n-- @param id\nSELECT 1";
754 let q = parse(input).unwrap();
755 assert_eq!(q.annotations.param_docs.len(), 1);
756 assert_eq!(q.annotations.param_docs[0].name, "id");
757 assert_eq!(q.annotations.param_docs[0].description, "");
758 }
759
760 #[test]
761 fn test_nullable_annotation() {
762 let input = "-- @name Foo\n-- @returns :one\n-- @nullable col1, col2\nSELECT 1";
763 let q = parse(input).unwrap();
764 assert_eq!(q.annotations.nullable_overrides, vec!["col1", "col2"]);
765 }
766
767 #[test]
768 fn test_nonnull_annotation() {
769 let input = "-- @name Foo\n-- @returns :one\n-- @nonnull col1\nSELECT 1";
770 let q = parse(input).unwrap();
771 assert_eq!(q.annotations.nonnull_overrides, vec!["col1"]);
772 }
773
774 #[test]
775 fn test_json_annotation() {
776 let input = "-- @name Foo\n-- @returns :one\n-- @json data = EventData\nSELECT 1";
777 let q = parse(input).unwrap();
778 assert_eq!(q.annotations.json_mappings.len(), 1);
779 assert_eq!(q.annotations.json_mappings[0].column, "data");
780 assert_eq!(q.annotations.json_mappings[0].rust_type, "EventData");
781 }
782
783 #[test]
784 fn test_deprecated_annotation() {
785 let input = "-- @name Foo\n-- @returns :one\n-- @deprecated Use V2\nSELECT 1";
786 let q = parse(input).unwrap();
787 assert_eq!(q.annotations.deprecated, Some("Use V2".to_string()));
788 }
789
790 #[test]
791 fn test_sql_syntax_error() {
792 let input = "-- @name Foo\n-- @returns :one\nSELCT * FROM users";
793 let err = parse(input).unwrap_err();
794 assert_eq!(err.code, ErrorCode::SyntaxError);
795 }
796
797 #[test]
798 fn test_trailing_semicolon() {
799 let input = "-- @name Foo\n-- @returns :one\nSELECT 1;";
800 let q = parse(input).unwrap();
801 assert_eq!(q.name, "Foo");
802 }
803
804 #[test]
805 fn test_multiple_statements_error() {
806 let input = "-- @name Foo\n-- @returns :one\nSELECT 1; SELECT 2;";
807 let err = parse(input).unwrap_err();
808 assert_eq!(err.code, ErrorCode::SyntaxError);
809 }
810
811 #[test]
812 fn test_sql_preserved_without_annotations() {
813 let input = "-- @name Foo\n-- @returns :one\nSELECT id, name FROM users WHERE id = $1";
814 let q = parse(input).unwrap();
815 assert_eq!(q.sql, "SELECT id, name FROM users WHERE id = $1");
816 }
817
818 #[test]
819 fn test_returns_without_colon_prefix() {
820 let input = "-- @name Foo\n-- @returns many\nSELECT 1";
821 let q = parse(input).unwrap();
822 assert_eq!(q.command, QueryCommand::Many);
823 }
824
825 #[test]
826 fn test_batch_command() {
827 let input = "-- @name Foo\n-- @returns :batch\nSELECT 1";
828 let q = parse(input).unwrap();
829 assert_eq!(q.command, QueryCommand::Batch);
830 }
831
832 #[test]
833 fn test_grouped_command_with_group_by() {
834 let input = "-- @name GetUsersWithOrders\n-- @returns :grouped\n-- @group_by users.id\nSELECT u.id, u.name FROM users u JOIN orders o ON o.user_id = u.id";
835 let q = parse(input).unwrap();
836 assert_eq!(q.command, QueryCommand::Grouped);
837 assert_eq!(q.annotations.group_by, Some("users.id".to_string()));
838 }
839
840 #[test]
841 fn test_grouped_command_without_group_by_errors() {
842 let input = "-- @name Foo\n-- @returns :grouped\nSELECT 1";
843 let err = parse(input).unwrap_err();
844 assert_eq!(err.code, ErrorCode::InvalidAnnotation);
845 assert!(err.message.contains("@group_by"));
846 }
847
848 #[test]
849 fn test_group_by_without_grouped_is_ignored() {
850 let input = "-- @name Foo\n-- @returns :many\n-- @group_by users.id\nSELECT 1";
851 let q = parse(input).unwrap();
852 assert_eq!(q.command, QueryCommand::Many);
853 assert_eq!(q.annotations.group_by, Some("users.id".to_string()));
854 }
855
856 #[test]
857 fn test_preprocess_postgres_strips_partial_index_where() {
858 let sql = "INSERT INTO billing_events (project_id, stripe_event_id) \
859 VALUES ($1, $2) \
860 ON CONFLICT (stripe_event_id) WHERE stripe_event_id IS NOT NULL DO NOTHING";
861 let cleaned = preprocess_postgres_sql(sql);
862 assert!(
863 !cleaned
864 .to_uppercase()
865 .contains("WHERE STRIPE_EVENT_ID IS NOT NULL"),
866 "WHERE clause must be stripped between ON CONFLICT cols and DO; got: {cleaned}"
867 );
868 assert!(
869 cleaned
870 .to_uppercase()
871 .contains("ON CONFLICT (STRIPE_EVENT_ID) DO NOTHING")
872 );
873 sqlparser::parser::Parser::parse_sql(&sqlparser::dialect::PostgreSqlDialect {}, &cleaned)
875 .expect("cleaned SQL should parse");
876 }
877
878 #[test]
879 fn test_preprocess_postgres_no_op_when_no_partial_clause() {
880 let sql = "INSERT INTO t (a) VALUES ($1) ON CONFLICT (a) DO UPDATE SET a = EXCLUDED.a";
881 assert_eq!(preprocess_postgres_sql(sql), sql);
882 }
883
884 #[test]
885 fn test_preprocess_postgres_leaves_on_conflict_on_constraint_alone() {
886 let sql = "INSERT INTO t (a) VALUES ($1) ON CONFLICT ON CONSTRAINT t_a_uidx DO NOTHING";
887 assert_eq!(preprocess_postgres_sql(sql), sql);
888 }
889
890 #[test]
891 fn test_preprocess_postgres_handles_compound_index_cols() {
892 let sql = "INSERT INTO t (a, b) VALUES ($1, $2) \
893 ON CONFLICT (a, b) WHERE a IS NOT NULL AND b > 0 DO UPDATE SET b = EXCLUDED.b";
894 let cleaned = preprocess_postgres_sql(sql);
895 assert!(
896 cleaned
897 .to_uppercase()
898 .contains("ON CONFLICT (A, B) DO UPDATE")
899 );
900 assert!(!cleaned.to_uppercase().contains("WHERE A IS NOT NULL"));
901 }
902
903 #[test]
904 fn test_preprocess_postgres_preserves_unrelated_where() {
905 let sql = "DELETE FROM t WHERE id = $1";
908 assert_eq!(preprocess_postgres_sql(sql), sql);
909 }
910
911 #[test]
912 fn test_preprocess_postgres_ignores_text_inside_line_comments() {
913 let sql = "-- inline doc: `ON CONFLICT (col) WHERE …` is the partial form\n\
917 INSERT INTO t (a) VALUES ($1) \
918 ON CONFLICT (a) WHERE a IS NOT NULL DO NOTHING";
919 let cleaned = preprocess_postgres_sql(sql);
920 assert!(
921 cleaned.contains("-- inline doc"),
922 "comment must survive the pass; got: {cleaned}"
923 );
924 assert!(cleaned.contains("ON CONFLICT (a) DO NOTHING"));
925 }
926
927 #[test]
928 fn test_preprocess_postgres_ignores_text_inside_string_literals() {
929 let sql = "SELECT 'ON CONFLICT (a) WHERE a IS NOT NULL DO NOTHING' AS s";
930 assert_eq!(preprocess_postgres_sql(sql), sql);
931 }
932
933 #[test]
934 fn test_preprocess_oracle_colon_placeholders() {
935 assert_eq!(
936 preprocess_oracle_sql("SELECT * FROM users WHERE id = :1"),
937 "SELECT * FROM users WHERE id = ?"
938 );
939 assert_eq!(
940 preprocess_oracle_sql("INSERT INTO users (name, email) VALUES (:1, :2)"),
941 "INSERT INTO users (name, email) VALUES (?, ?)"
942 );
943 }
944
945 #[test]
946 fn test_preprocess_oracle_preserves_string_literals() {
947 assert_eq!(
948 preprocess_oracle_sql("SELECT * FROM users WHERE name = ':1' AND id = :1"),
949 "SELECT * FROM users WHERE name = ':1' AND id = ?"
950 );
951 }
952
953 #[test]
954 fn test_preprocess_oracle_strips_returning_into() {
955 assert_eq!(
956 preprocess_oracle_sql(
957 "INSERT INTO users (name) VALUES (:1) RETURNING id, name INTO :2, :3"
958 ),
959 "INSERT INTO users (name) VALUES (?) RETURNING id, name"
960 );
961 }
962
963 #[test]
964 fn test_preprocess_oracle_full_insert_returning_into() {
965 let sql = "INSERT INTO users (name, email, active) VALUES (:1, :2, :3) RETURNING id, name, email, active, created_at INTO :4, :5, :6, :7, :8";
966 let result = preprocess_oracle_sql(sql);
967 assert_eq!(
968 result,
969 "INSERT INTO users (name, email, active) VALUES (?, ?, ?) RETURNING id, name, email, active, created_at"
970 );
971 }
972
973 #[test]
974 fn test_preprocess_oracle_no_returning_into_unchanged() {
975 assert_eq!(
976 preprocess_oracle_sql("DELETE FROM users WHERE id = :1"),
977 "DELETE FROM users WHERE id = ?"
978 );
979 }
980
981 #[test]
982 fn test_preprocess_mssql_single_placeholder() {
983 assert_eq!(
984 preprocess_mssql_sql("SELECT * FROM users WHERE id = @p1"),
985 "SELECT * FROM users WHERE id = ?"
986 );
987 }
988
989 #[test]
990 fn test_preprocess_mssql_multiple_placeholders() {
991 assert_eq!(
992 preprocess_mssql_sql("INSERT INTO users (name, email) VALUES (@p1, @p2)"),
993 "INSERT INTO users (name, email) VALUES (?, ?)"
994 );
995 }
996
997 #[test]
998 fn test_preprocess_mssql_preserves_string_literals() {
999 assert_eq!(
1000 preprocess_mssql_sql("SELECT * FROM users WHERE name = '@p1' AND id = @p1"),
1001 "SELECT * FROM users WHERE name = '@p1' AND id = ?"
1002 );
1003 }
1004
1005 #[test]
1006 fn test_preprocess_mssql_case_insensitive_p() {
1007 assert_eq!(
1008 preprocess_mssql_sql("SELECT * FROM users WHERE id = @P1"),
1009 "SELECT * FROM users WHERE id = ?"
1010 );
1011 }
1012
1013 #[test]
1014 fn test_preprocess_mssql_non_placeholder_at_variable_unchanged() {
1015 assert_eq!(preprocess_mssql_sql("SELECT @myvar"), "SELECT @myvar");
1017 }
1018
1019 #[test]
1020 fn test_preprocess_mssql_multi_digit_placeholder() {
1021 assert_eq!(preprocess_mssql_sql("SELECT @p10, @p2"), "SELECT ?, ?");
1022 }
1023
1024 #[test]
1025 fn test_preprocess_mssql_output_inserted_simple() {
1026 let sql =
1027 "INSERT INTO users (id, name) OUTPUT INSERTED.id, INSERTED.name VALUES (@p1, @p2)";
1028 let result = preprocess_mssql_sql(sql);
1029 assert!(result.contains("RETURNING id, name"), "got: {}", result);
1031 assert!(result.contains("VALUES (?, ?)"), "got: {}", result);
1032 assert!(!result.contains("OUTPUT"), "got: {}", result);
1033 }
1034
1035 #[test]
1036 fn test_preprocess_mssql_output_inserted_full_example() {
1037 let sql = "INSERT INTO users (id, name, email, active) OUTPUT INSERTED.id, INSERTED.name, INSERTED.email, INSERTED.active, INSERTED.created_at VALUES (@p1, @p2, @p3, @p4)";
1038 let result = preprocess_mssql_sql(sql);
1039 assert!(
1040 result.contains("RETURNING id, name, email, active, created_at"),
1041 "got: {}",
1042 result
1043 );
1044 assert!(result.contains("VALUES (?, ?, ?, ?)"), "got: {}", result);
1045 }
1046
1047 #[test]
1048 fn test_preprocess_mssql_output_case_insensitive() {
1049 let sql = "INSERT INTO users (id) output inserted.id values (@p1)";
1050 let result = preprocess_mssql_sql(sql);
1051 assert!(result.contains("RETURNING id"), "got: {}", result);
1052 assert!(
1054 result.contains("values (?)") || result.contains("VALUES (?)"),
1055 "got: {}",
1056 result
1057 );
1058 }
1059
1060 #[test]
1061 fn test_preprocess_mssql_no_output_unchanged() {
1062 let sql = "INSERT INTO users (id, name) VALUES (@p1, @p2)";
1063 let result = preprocess_mssql_sql(sql);
1064 assert_eq!(result, "INSERT INTO users (id, name) VALUES (?, ?)");
1065 }
1066
1067 #[test]
1068 fn test_preprocess_mssql_output_with_string_literal() {
1069 let sql =
1071 "INSERT INTO users (id, name) OUTPUT INSERTED.id, INSERTED.name VALUES (@p1, '@p2')";
1072 let result = preprocess_mssql_sql(sql);
1073 assert!(result.contains("RETURNING id, name"), "got: {}", result);
1074 assert!(result.contains("(?, '@p2')"), "got: {}", result);
1075 }
1076
1077 #[test]
1078 fn test_preprocess_mssql_output_with_whitespace() {
1079 let sql =
1080 "INSERT INTO users (id, name)\nOUTPUT INSERTED.id,\n INSERTED.name\nVALUES (@p1, @p2)";
1081 let result = preprocess_mssql_sql(sql);
1082 assert!(result.contains("RETURNING id, name"), "got: {}", result);
1083 assert!(result.contains("VALUES (?, ?)"), "got: {}", result);
1084 }
1085}