1#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
2#[non_exhaustive]
3pub enum SqlError {
4 #[error("missing ending single quote")]
5 MissingEndingSingleQuote,
6 #[error("missing ending double quote")]
7 MissingEndingDoubleQuote,
8}
9
10pub type Result<T> = std::result::Result<T, SqlError>;
11
12pub fn plsql_function_return_bind_name(statement: &str) -> Option<String> {
13 let rest = statement.trim_start();
14 if !rest.get(.."begin".len())?.eq_ignore_ascii_case("begin") {
15 return None;
16 }
17 let rest = rest.get("begin".len()..)?.trim_start();
18 let rest = rest.strip_prefix(':')?;
19 let mut name_end = 0;
20 for (offset, ch) in rest.char_indices() {
21 if is_bind_name_char(ch) {
22 name_end = offset + ch.len_utf8();
23 } else {
24 break;
25 }
26 }
27 if name_end == 0 {
28 return None;
29 }
30 let (name, rest) = rest.split_at(name_end);
31 rest.trim_start()
32 .starts_with(":=")
33 .then(|| name.to_string())
34}
35
36pub fn unique_bind_names(statement: &str) -> Result<Vec<String>> {
37 let mut names: Vec<String> = Vec::new();
38 for name in scan_bind_names(statement)? {
39 if !names
40 .iter()
41 .any(|existing| bind_names_equal(existing, &name))
42 {
43 names.push(name);
44 }
45 }
46 Ok(names)
47}
48
49pub fn bind_names_per_occurrence(statement: &str) -> Result<Vec<String>> {
55 if statement_is_plsql(statement) {
56 return unique_bind_names(statement);
57 }
58 scan_bind_names(statement)
59}
60
61pub fn public_bind_name(name: &str) -> String {
62 if is_quoted_bind_name(name) {
63 name[1..name.len() - 1].to_string()
64 } else {
65 name.to_uppercase()
66 }
67}
68
69pub fn returning_bind_names(statement: &str) -> Result<Vec<String>> {
70 if statement_is_plsql(statement) {
71 return Ok(Vec::new());
72 }
73 let lower = statement.to_ascii_lowercase();
74 let Some(returning_pos) = lower.find("returning") else {
75 return Ok(Vec::new());
76 };
77 let Some(into_relative_pos) = lower[returning_pos..].find("into") else {
78 return Ok(Vec::new());
79 };
80 let into_pos = returning_pos + into_relative_pos + "into".len();
81 scan_bind_names(&statement[into_pos..])
82}
83
84pub fn dml_returning_single_bind_name(statement: &str) -> Result<Option<String>> {
85 let Some(parts) = dml_returning_projection_parts(statement)? else {
86 return Ok(None);
87 };
88 if parts.bind_names.len() == 1 {
89 Ok(parts.bind_names.into_iter().next())
90 } else {
91 Ok(None)
92 }
93}
94
95pub fn rewrite_dml_returning_projection(
96 statement: &str,
97 attr_name: &str,
98) -> Result<Option<String>> {
99 let Some(parts) = dml_returning_projection_parts(statement)? else {
100 return Ok(None);
101 };
102 if parts.bind_names.len() != 1 {
103 return Ok(None);
104 }
105 Ok(Some(format!(
106 "{}returning ({}).{} into{}",
107 &statement[..parts.returning_pos],
108 parts.return_expr,
109 attr_name,
110 &statement[parts.binds_start..]
111 )))
112}
113
114pub fn plsql_assignment_bind_names(statement: &str) -> Result<Vec<String>> {
115 if !statement_is_plsql(statement) {
116 return Ok(Vec::new());
117 }
118 let bytes = statement.as_bytes();
119 let mut names: Vec<String> = Vec::new();
120 let mut index = 0;
121 while index < bytes.len() {
122 match bytes[index] {
123 b'\'' => {
124 index += 1;
125 while index < bytes.len() {
126 if is_single_quote_byte(bytes.get(index)) {
127 if is_single_quote_byte(bytes.get(index + 1)) {
128 index += 2;
129 } else {
130 index += 1;
131 break;
132 }
133 } else {
134 index += 1;
135 }
136 }
137 if index >= bytes.len() && !is_single_quote_byte(bytes.last()) {
138 return Err(SqlError::MissingEndingSingleQuote);
139 }
140 }
141 b':' => {
142 let start = index + 1;
143 let Some(&next) = bytes.get(start) else {
144 index += 1;
145 continue;
146 };
147 let (name, end) = if is_double_quote_byte(Some(&next)) {
148 let mut end = start + 1;
149 while end < bytes.len() && !is_double_quote_byte(bytes.get(end)) {
150 end += 1;
151 }
152 if end >= bytes.len() {
153 index = start;
154 continue;
155 }
156 (statement[start..=end].to_string(), end + 1)
157 } else {
158 let mut end = start;
159 for (offset, ch) in statement[start..].char_indices() {
160 if is_bind_name_char(ch) {
161 end = start + offset + ch.len_utf8();
162 } else {
163 break;
164 }
165 }
166 if end <= start {
167 index += 1;
168 continue;
169 }
170 (statement[start..end].to_string(), end)
171 };
172 let mut after_name = end;
173 while bytes
174 .get(after_name)
175 .is_some_and(|byte| byte.is_ascii_whitespace())
176 {
177 after_name += 1;
178 }
179 if matches!(bytes.get(after_name), Some(b':'))
180 && matches!(bytes.get(after_name + 1), Some(b'='))
181 && !names
182 .iter()
183 .any(|existing| bind_names_equal(existing, &name))
184 {
185 names.push(name);
186 }
187 index = end;
188 }
189 _ => index += 1,
190 }
191 }
192 Ok(names)
193}
194
195fn keyword_token_positions(statement: &str, keyword: &str) -> Result<Vec<usize>> {
204 let bytes = statement.as_bytes();
205 let kw = keyword.as_bytes();
206 let klen = kw.len();
207 let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
208 let mut positions = Vec::new();
209 let mut index = 0;
210 let mut last_ch = '\0';
211 while index < statement.len() {
212 let Some((ch, ch_len)) = char_at(statement, index) else {
213 break;
214 };
215 if ch == '\'' {
216 index = if matches!(last_ch, 'q' | 'Q') {
217 qstring_end(statement, index)?
218 } else {
219 quoted_string_end(statement, index, '\'')?
220 };
221 } else if ch == '"' {
222 index = quoted_string_end(statement, index, '"')?;
223 } else if ch == '-' {
224 index = single_line_comment_end(statement, index).unwrap_or(index + ch_len);
225 } else if ch == '/' {
226 index = multiple_line_comment_end(statement, index).unwrap_or(index + ch_len);
227 } else {
228 if index + klen <= bytes.len() && bytes[index..index + klen].eq_ignore_ascii_case(kw) {
229 let before_ok = index == 0 || !is_ident(bytes[index - 1]);
230 let after_ok = bytes.get(index + klen).is_none_or(|&b| !is_ident(b));
231 if before_ok && after_ok {
232 positions.push(index);
233 }
234 }
235 index += ch_len;
236 }
237 last_ch = ch;
238 }
239 Ok(positions)
240}
241
242pub fn plsql_output_bind_names(statement: &str) -> Result<Vec<String>> {
250 let mut names = plsql_assignment_bind_names(statement)?;
251 if !statement_is_plsql(statement) {
252 return Ok(names);
253 }
254 let lower = statement.to_ascii_lowercase();
255 let bytes = statement.as_bytes();
256 let into_positions = keyword_token_positions(statement, "into")?;
257 for &into_pos in &into_positions {
258 let mut bind_start = into_pos + "into".len();
259 while bytes
260 .get(bind_start)
261 .is_some_and(|byte| byte.is_ascii_whitespace())
262 {
263 bind_start += 1;
264 }
265 if matches!(bytes.get(bind_start), Some(b':')) {
266 let tail = &lower[bind_start..];
267 let end = tail
268 .find(" from ")
269 .map(|relative| bind_start + relative)
270 .or_else(|| tail.find(';').map(|relative| bind_start + relative))
271 .unwrap_or(statement.len());
272 for name in scan_bind_names(&statement[bind_start..end])? {
273 if !names
274 .iter()
275 .any(|existing| bind_names_equal(existing, &name))
276 {
277 names.push(name);
278 }
279 }
280 }
281 }
282 for returning_pos in keyword_token_positions(statement, "returning")? {
283 let Some(&into_pos) = into_positions.iter().find(|&&p| p > returning_pos) else {
284 continue;
285 };
286 let after_into = into_pos + "into".len();
287 let end = statement[after_into..]
288 .find(';')
289 .map(|relative| after_into + relative)
290 .unwrap_or(statement.len());
291 for name in scan_bind_names(&statement[after_into..end])? {
292 if !names
293 .iter()
294 .any(|existing| bind_names_equal(existing, &name))
295 {
296 names.push(name);
297 }
298 }
299 }
300 Ok(names)
301}
302
303pub fn statement_is_plsql(statement: &str) -> bool {
304 statement
305 .trim_start()
306 .split(|ch: char| !ch.is_ascii_alphabetic())
307 .next()
308 .is_some_and(|keyword| {
309 keyword.eq_ignore_ascii_case("begin")
310 || keyword.eq_ignore_ascii_case("declare")
311 || keyword.eq_ignore_ascii_case("call")
312 })
313}
314
315pub fn statement_is_ddl(statement: &str) -> bool {
318 statement
319 .trim_start()
320 .split(|ch: char| !ch.is_ascii_alphabetic())
321 .next()
322 .is_some_and(|keyword| {
323 [
324 "create", "alter", "drop", "grant", "revoke", "analyze", "audit", "comment",
325 "truncate",
326 ]
327 .iter()
328 .any(|candidate| keyword.eq_ignore_ascii_case(candidate))
329 })
330}
331
332pub fn statement_is_dml(statement: &str) -> bool {
335 statement
336 .trim_start()
337 .split(|ch: char| !ch.is_ascii_alphabetic())
338 .next()
339 .is_some_and(|keyword| {
340 keyword.eq_ignore_ascii_case("insert")
341 || keyword.eq_ignore_ascii_case("update")
342 || keyword.eq_ignore_ascii_case("delete")
343 || keyword.eq_ignore_ascii_case("merge")
344 })
345}
346
347pub fn is_bind_name_char(ch: char) -> bool {
348 ch.is_alphanumeric() || matches!(ch, '_' | '$' | '#')
349}
350
351pub fn scan_bind_names(statement: &str) -> Result<Vec<String>> {
352 let mut names = Vec::new();
353 let mut index = 0;
354 let mut last_ch = '\0';
355 let mut last_was_string = false;
356 while index < statement.len() {
357 let Some((ch, ch_len)) = char_at(statement, index) else {
358 break;
359 };
360 if ch == '\'' {
361 index = if matches!(last_ch, 'q' | 'Q') {
362 qstring_end(statement, index)?
363 } else {
364 quoted_string_end(statement, index, '\'')?
365 };
366 last_was_string = true;
367 } else if ch.is_whitespace() {
368 index += ch_len;
369 } else if ch == '-' {
370 if let Some(end) = single_line_comment_end(statement, index) {
371 index = end;
372 } else {
373 index += ch_len;
374 }
375 last_was_string = false;
376 } else if ch == '/' {
377 if let Some(end) = multiple_line_comment_end(statement, index) {
378 index = end;
379 } else {
380 index += ch_len;
381 }
382 last_was_string = false;
383 } else if ch == '"' {
384 index = quoted_string_end(statement, index, '"')?;
385 last_was_string = false;
386 } else if ch == ':' && !last_was_string {
387 let (end, name) = parse_bind_name(statement, index);
388 if let Some(name) = name {
389 names.push(name);
390 }
391 index = end;
392 last_was_string = false;
393 } else {
394 index += ch_len;
395 last_was_string = false;
396 }
397 last_ch = ch;
398 }
399 Ok(names)
400}
401
402pub fn is_quoted_bind_name(name: &str) -> bool {
411 name.len() >= 2 && name.starts_with('"') && name.ends_with('"')
412}
413
414pub fn bind_names_equal(left: &str, right: &str) -> bool {
415 if is_quoted_bind_name(left) || is_quoted_bind_name(right) {
416 left == right
417 } else {
418 left.eq_ignore_ascii_case(right)
419 }
420}
421
422pub fn bind_name_matches_key(bind_name: &str, key: &str) -> bool {
423 let key = key.strip_prefix(':').unwrap_or(key);
426 if is_quoted_bind_name(bind_name) || is_quoted_bind_name(key) {
427 bind_name == key
428 } else {
429 bind_name.eq_ignore_ascii_case(key)
430 }
431}
432
433pub fn single_quote_end(statement: &str, start: usize) -> usize {
434 let bytes = statement.as_bytes();
435 let mut index = start + 1;
436 while index < bytes.len() {
437 if is_single_quote_byte(bytes.get(index)) {
438 if is_single_quote_byte(bytes.get(index + 1)) {
439 index += 2;
440 } else {
441 return index + 1;
442 }
443 } else {
444 index += 1;
445 }
446 }
447 statement.len()
448}
449
450pub fn generated_object_attr_bind_name(bind_name: &str, attr_name: &str) -> String {
451 let bind = bind_name
452 .chars()
453 .map(|ch| {
454 if ch.is_ascii_alphanumeric() {
455 ch.to_ascii_uppercase()
456 } else {
457 '_'
458 }
459 })
460 .collect::<String>();
461 format!("ORADB_OBJ_{bind}_{}", attr_name.to_ascii_uppercase())
462}
463
464pub fn replace_input_bind_placeholder(
465 statement: &str,
466 bind_name: &str,
467 replacement: &str,
468) -> String {
469 let lower = statement.to_ascii_lowercase();
470 let split = lower.find("returning").unwrap_or(statement.len());
471 let (prefix, suffix) = statement.split_at(split);
472 format!(
473 "{}{}",
474 replace_bind_placeholder(prefix, bind_name, replacement),
475 suffix
476 )
477}
478
479pub fn replace_bind_placeholder(statement: &str, bind_name: &str, replacement: &str) -> String {
480 let mut result = String::with_capacity(statement.len() + replacement.len());
481 let mut index = 0;
482 while index < statement.len() {
483 let rest = &statement[index..];
484 if rest.starts_with('\'') {
485 let end = single_quote_end(statement, index);
486 result.push_str(&statement[index..end]);
487 index = end;
488 continue;
489 }
490 if rest.starts_with(':') {
491 let name_start = index + 1;
492 let mut name_end = name_start;
493 for (offset, ch) in statement[name_start..].char_indices() {
494 if is_bind_name_char(ch) {
495 name_end = name_start + offset + ch.len_utf8();
496 } else {
497 break;
498 }
499 }
500 if name_end > name_start {
501 let found_name = &statement[name_start..name_end];
502 if bind_names_equal(found_name, bind_name) {
503 result.push_str(replacement);
504 } else {
505 result.push_str(&statement[index..name_end]);
506 }
507 index = name_end;
508 continue;
509 }
510 }
511 let Some(ch) = rest.chars().next() else {
512 break;
513 };
514 result.push(ch);
515 index += ch.len_utf8();
516 }
517 result
518}
519
520struct DmlReturningProjectionParts<'a> {
521 returning_pos: usize,
522 binds_start: usize,
523 return_expr: &'a str,
524 bind_names: Vec<String>,
525}
526
527fn dml_returning_projection_parts(
528 statement: &str,
529) -> Result<Option<DmlReturningProjectionParts<'_>>> {
530 if statement_is_plsql(statement) {
531 return Ok(None);
532 }
533 let lower = statement.to_ascii_lowercase();
534 let Some(returning_pos) = lower.find("returning") else {
535 return Ok(None);
536 };
537 let Some(into_relative_pos) = lower[returning_pos..].find("into") else {
538 return Ok(None);
539 };
540 let expr_start = returning_pos + "returning".len();
541 let into_start = returning_pos + into_relative_pos;
542 let binds_start = into_start + "into".len();
543 let return_expr = statement[expr_start..into_start].trim();
544 if return_expr.contains(',') || return_expr.is_empty() {
545 return Ok(None);
546 }
547 let bind_names = scan_bind_names(&statement[binds_start..])?;
548 Ok(Some(DmlReturningProjectionParts {
549 returning_pos,
550 binds_start,
551 return_expr,
552 bind_names,
553 }))
554}
555
556fn is_single_quote_byte(byte: Option<&u8>) -> bool {
557 matches!(byte, Some(b'\''))
558}
559
560fn is_double_quote_byte(byte: Option<&u8>) -> bool {
561 matches!(byte, Some(b'"'))
562}
563
564fn char_at(statement: &str, index: usize) -> Option<(char, usize)> {
565 statement[index..]
566 .chars()
567 .next()
568 .map(|ch| (ch, ch.len_utf8()))
569}
570
571fn single_line_comment_end(statement: &str, index: usize) -> Option<usize> {
572 statement[index..].starts_with("--").then(|| {
573 statement[index + 2..]
574 .find('\n')
575 .map_or(statement.len(), |offset| index + 2 + offset + 1)
576 })
577}
578
579fn multiple_line_comment_end(statement: &str, index: usize) -> Option<usize> {
580 statement[index..].starts_with("/*").then(|| {
581 statement[index + 2..]
582 .find("*/")
583 .map_or(statement.len(), |offset| index + 2 + offset + 2)
584 })
585}
586
587fn quoted_string_end(statement: &str, start: usize, quote: char) -> Result<usize> {
588 let mut index = start + quote.len_utf8();
589 while index < statement.len() {
590 let Some((ch, ch_len)) = char_at(statement, index) else {
591 break;
592 };
593 index += ch_len;
594 if ch == quote {
595 if quote == '\'' && matches!(char_at(statement, index), Some(('\'', _))) {
596 index += quote.len_utf8();
597 continue;
598 }
599 return Ok(index);
600 }
601 }
602 if quote == '\'' {
603 Err(SqlError::MissingEndingSingleQuote)
604 } else {
605 Err(SqlError::MissingEndingDoubleQuote)
606 }
607}
608
609fn qstring_end(statement: &str, quote_index: usize) -> Result<usize> {
610 let Some((open_sep, open_len)) = char_at(statement, quote_index + 1) else {
611 return Err(SqlError::MissingEndingSingleQuote);
612 };
613 let close_sep = match open_sep {
614 '[' => ']',
615 '{' => '}',
616 '<' => '>',
617 '(' => ')',
618 _ => open_sep,
619 };
620 let mut index = quote_index + 1 + open_len;
621 let mut exiting_qstring = false;
622 while index < statement.len() {
623 let Some((ch, ch_len)) = char_at(statement, index) else {
624 break;
625 };
626 if !exiting_qstring && ch == close_sep {
627 exiting_qstring = true;
628 } else if exiting_qstring {
629 if ch == '\'' {
630 return Ok(index + ch_len);
631 }
632 if ch != close_sep {
633 exiting_qstring = false;
634 }
635 }
636 index += ch_len;
637 }
638 Err(SqlError::MissingEndingSingleQuote)
639}
640
641fn parse_bind_name(statement: &str, colon_index: usize) -> (usize, Option<String>) {
642 let mut index = colon_index + 1;
643 while index < statement.len() {
644 let Some((ch, ch_len)) = char_at(statement, index) else {
645 return (index, None);
646 };
647 if !ch.is_whitespace() {
648 break;
649 }
650 index += ch_len;
651 }
652 let Some((first_ch, first_len)) = char_at(statement, index) else {
653 return (index, None);
654 };
655 if first_ch == '"' {
656 let mut end = index + first_len;
657 while end < statement.len() {
658 let Some((ch, ch_len)) = char_at(statement, end) else {
659 break;
660 };
661 end += ch_len;
662 if ch == '"' {
663 return (end, Some(statement[index..end].to_string()));
664 }
665 }
666 return (statement.len(), Some(statement[index..].to_string()));
667 }
668 if first_ch.is_numeric() {
669 let mut end = index + first_len;
670 while end < statement.len() {
671 let Some((ch, ch_len)) = char_at(statement, end) else {
672 break;
673 };
674 if !ch.is_numeric() {
675 break;
676 }
677 end += ch_len;
678 }
679 return (end, Some(statement[index..end].to_string()));
680 }
681 if !first_ch.is_alphabetic() {
682 return (colon_index + 1, None);
683 }
684 let mut end = index + first_len;
685 while end < statement.len() {
686 let Some((ch, ch_len)) = char_at(statement, end) else {
687 break;
688 };
689 if !(ch.is_alphanumeric() || matches!(ch, '_' | '$' | '#')) {
690 break;
691 }
692 end += ch_len;
693 }
694 (end, Some(statement[index..end].to_string()))
695}
696
697pub fn simple_sql_identifier(value: &str) -> Option<String> {
702 value
703 .chars()
704 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '#'))
705 .then(|| value.to_string())
706}
707
708pub fn parse_alter_session_value(statement: &str, key: &str) -> Option<String> {
715 let trimmed = statement.trim().trim_end_matches(';').trim();
716 let lower = trimmed.to_ascii_lowercase();
717 let prefix = format!("alter session set {key}");
718 if !lower.starts_with(&prefix) {
719 return None;
720 }
721 let mut value = trimmed.get(prefix.len()..)?.trim_start();
722 if let Some(stripped) = value.strip_prefix('=') {
723 value = stripped.trim_start();
724 }
725 value
726 .split_whitespace()
727 .next()
728 .map(|value| value.trim_matches('"').to_string())
729 .filter(|value| !value.is_empty())
730}
731
732#[cfg(test)]
733mod tests {
734 use super::*;
735
736 #[test]
737 fn classifies_plsql_statements_by_first_keyword() {
738 assert!(statement_is_plsql(" begin null; end;"));
739 assert!(statement_is_plsql("DECLARE v number; begin null; end;"));
740 assert!(statement_is_plsql("call pkg.proc(:x)"));
741 assert!(!statement_is_plsql("select :x from dual"));
742 assert!(!statement_is_plsql("update t set c = :x"));
743 }
744
745 #[test]
746 fn scans_bind_names_outside_single_quoted_strings() {
747 let names = scan_bind_names("select ':skip', 'it''s :skip2', :a, :\"MiX\" from dual")
748 .expect("bind scan should succeed");
749 assert_eq!(names, vec!["a".to_string(), "\"MiX\"".to_string()]);
750 }
751
752 #[test]
753 fn counts_bind_occurrences_for_plain_sql_but_coalesces_plsql() {
754 let sql = "insert into t (a, b) values (:1, udt_array(:1, :2, :3))";
757 assert_eq!(
758 bind_names_per_occurrence(sql).expect("scan"),
759 vec![
760 "1".to_string(),
761 "1".to_string(),
762 "2".to_string(),
763 "3".to_string()
764 ]
765 );
766 assert_eq!(
768 unique_bind_names(sql).expect("scan"),
769 vec!["1".to_string(), "2".to_string(), "3".to_string()]
770 );
771 let plsql = "begin proc(:x, :x, :y); end;";
773 assert_eq!(
774 bind_names_per_occurrence(plsql).expect("scan"),
775 vec!["x".to_string(), "y".to_string()]
776 );
777 }
778
779 #[test]
780 fn reports_unclosed_single_quote() {
781 let err = scan_bind_names("select ':not_closed from dual")
782 .expect_err("unclosed quote should be rejected");
783 assert_eq!(err, SqlError::MissingEndingSingleQuote);
784 }
785
786 #[test]
787 fn reports_unclosed_double_quote() {
788 let err = scan_bind_names("select \"col from dual")
793 .expect_err("unclosed quoted identifier should be rejected");
794 assert_eq!(err, SqlError::MissingEndingDoubleQuote);
795 }
796
797 #[test]
798 fn deduplicates_unquoted_names_case_insensitively() {
799 let names = unique_bind_names(":a, :A, :\"A\", :\"A\"").expect("unique names");
800 assert_eq!(names, vec!["a".to_string(), "\"A\"".to_string()]);
801 }
802
803 #[test]
804 fn extracts_dml_returning_bind_names() {
805 let names = returning_bind_names(
806 "insert into t (value) values (:value) returning id into :id, :row_id",
807 )
808 .expect("returning bind names");
809 assert_eq!(names, vec!["id".to_string(), "row_id".to_string()]);
810 }
811
812 #[test]
813 fn extracts_single_dml_returning_projection_bind_name() {
814 let name = dml_returning_single_bind_name(
815 "insert into t (value) values (:value) returning obj into :out",
816 )
817 .expect("returning statement should parse");
818 assert_eq!(name, Some("out".to_string()));
819
820 let name = dml_returning_single_bind_name(
821 "insert into t (value) values (:value) returning obj into :out, :extra",
822 )
823 .expect("returning statement should parse");
824 assert_eq!(name, None);
825 }
826
827 #[test]
828 fn rewrites_single_dml_returning_projection() {
829 let statement = "insert into t (value) values (:value) returning obj_col into :out";
830 let rewritten = rewrite_dml_returning_projection(statement, "STRINGVALUE")
831 .expect("returning statement should parse");
832 assert_eq!(
833 rewritten,
834 Some(
835 "insert into t (value) values (:value) returning (obj_col).STRINGVALUE into :out"
836 .to_string()
837 )
838 );
839 }
840
841 #[test]
842 fn extracts_unique_plsql_assignment_output_binds() {
843 let names = plsql_assignment_bind_names("begin :out := func(:in_value); :OUT := 1; end;")
844 .expect("assignment bind names");
845 assert_eq!(names, vec!["out".to_string()]);
846 }
847
848 #[test]
849 fn plsql_output_binds_combine_assignment_into_and_returning_into() {
850 assert!(plsql_output_bind_names("select :a from dual")
852 .expect("scan")
853 .is_empty());
854
855 assert_eq!(
857 plsql_output_bind_names("begin :out := func(:in_value); end;").expect("scan"),
858 vec!["out".to_string()]
859 );
860
861 assert_eq!(
863 plsql_output_bind_names("begin select c1, c2 into :a, :b from t; end;").expect("scan"),
864 vec!["a".to_string(), "b".to_string()]
865 );
866
867 assert_eq!(
869 plsql_output_bind_names("begin update t set c = 1 returning id into :rid; end;")
870 .expect("scan"),
871 vec!["rid".to_string()]
872 );
873
874 assert_eq!(
876 plsql_output_bind_names(
877 "begin :out := 1; select c into :a from t; \
878 update t set c = 2 returning id into :A; end;"
879 )
880 .expect("scan"),
881 vec!["out".to_string(), "a".to_string()]
882 );
883 }
884
885 #[test]
886 fn plsql_output_ignores_into_inside_string_literal() {
887 assert!(
892 plsql_output_bind_names("begin proc('into :x', :realbind); end;")
893 .expect("scan")
894 .is_empty(),
895 "an INTO inside a string literal must not produce an output bind"
896 );
897 assert_eq!(
900 plsql_output_bind_names("begin select 'into :x', c into :real from t; end;")
901 .expect("scan"),
902 vec!["real".to_string()]
903 );
904 assert!(
906 plsql_output_bind_names("begin proc('returning id into :x', :y); end;")
907 .expect("scan")
908 .is_empty(),
909 "a RETURNING inside a string literal must not produce an output bind"
910 );
911 }
912
913 #[test]
914 fn extracts_plsql_function_return_bind_name() {
915 assert_eq!(
916 plsql_function_return_bind_name("begin :ret := pkg.func(:arg); end;"),
917 Some("ret".to_string())
918 );
919 assert_eq!(
920 plsql_function_return_bind_name("begin pkg.proc(:arg); end;"),
921 None
922 );
923 }
924
925 #[test]
926 fn converts_public_bind_names_like_python_oracledb() {
927 assert_eq!(public_bind_name("abc"), "ABC");
928 assert_eq!(public_bind_name("\"MiX\""), "MiX");
929 }
930
931 #[test]
939 fn public_bind_name_never_panics_on_a_lone_quote_character() {
940 assert!(!is_quoted_bind_name("\""));
941 assert_eq!(public_bind_name("\""), "\"");
942 assert!(!is_quoted_bind_name(""));
943 assert_eq!(public_bind_name(""), "");
944 assert!(is_quoted_bind_name("\"\""));
946 assert_eq!(public_bind_name("\"\""), "");
947 }
948
949 #[test]
950 fn rewrites_bind_placeholders_before_returning_only() {
951 assert_eq!(
952 generated_object_attr_bind_name("value-1", "attr"),
953 "ORADB_OBJ_VALUE_1_ATTR"
954 );
955 assert_eq!(
956 replace_input_bind_placeholder(
957 "insert into t values (:value, ':value') returning obj into :value",
958 "value",
959 "OBJ(:ORADB_OBJ_VALUE_ATTR)"
960 ),
961 "insert into t values (OBJ(:ORADB_OBJ_VALUE_ATTR), ':value') returning obj into :value"
962 );
963 }
964
965 #[test]
966 fn skips_comments_and_quoted_identifiers_like_reference_parser() {
967 assert_eq!(
968 public_unique_names(
969 "--begin :value2 := :a + :b + :c +:a +3; end;\n\
970 begin :value2 := :a + :c +3; end; -- not a :bind_variable"
971 ),
972 vec!["VALUE2", "A", "C"]
973 );
974 assert_eq!(
975 public_unique_names(
976 "/*--select * from :a where :a = 1\n\
977 select * from table_names where :a = 1*/\n\
978 select :table_name, :value from dual"
979 ),
980 vec!["TABLE_NAME", "VALUE"]
981 );
982 assert_eq!(
983 public_unique_names(r#"select ":test", :a from dual"#),
984 vec!["A"]
985 );
986 assert_eq!(
987 public_unique_names(r#"select "/*_value1" + : "VaLue_2" + :"*/3VALUE" from dual"#),
988 vec!["VaLue_2", "*/3VALUE"]
989 );
990 }
991
992 #[test]
993 fn supports_reference_quoted_bind_names() {
994 assert_eq!(
995 public_unique_names(r#"select :"percent%" from dual"#),
996 vec!["percent%"]
997 );
998 assert_eq!(
999 public_unique_names(r#"select : "q?marks" from dual"#),
1000 vec!["q?marks"]
1001 );
1002 assert_eq!(
1003 public_unique_names(r#"select "col:nns", :"col:ons", :id from dual"#),
1004 vec!["col:ons", "ID"]
1005 );
1006 }
1007
1008 #[test]
1009 fn skips_qstrings_and_json_constant_colons() {
1010 assert_eq!(
1011 public_unique_names(
1012 "select :a, q'{This contains ' and \" and : just fine}', :b, \
1013 q'[This contains ' and \" and : just fine]', :c, \
1014 q'<This contains ' and \" and : just fine>', :d, \
1015 q'(This contains ' and \" and : just fine)', :e, \
1016 q'$This contains ' and \" and : just fine$', :f from dual"
1017 ),
1018 vec!["A", "B", "C", "D", "E", "F"]
1019 );
1020 assert_eq!(
1021 public_unique_names(
1022 "select json_object('foo':dummy), :bv1, json_object('foo'::bv2), \
1023 :bv3, json { 'key1': 57, 'key2' : 58 }, :bv4 from dual"
1024 ),
1025 vec!["BV1", "BV2", "BV3", "BV4"]
1026 );
1027 }
1028
1029 #[test]
1030 fn reports_reference_qstring_errors() {
1031 assert_eq!(
1032 scan_bind_names("select q'[something from dual")
1033 .expect_err("unclosed q-string should be rejected"),
1034 SqlError::MissingEndingSingleQuote
1035 );
1036 assert_eq!(
1037 scan_bind_names("select q'[abc'], 5 from dual")
1038 .expect_err("unclosed q-string should be rejected"),
1039 SqlError::MissingEndingSingleQuote
1040 );
1041 }
1042
1043 fn public_unique_names(statement: &str) -> Vec<String> {
1044 unique_bind_names(statement)
1045 .expect("statement should parse")
1046 .iter()
1047 .map(|name| public_bind_name(name))
1048 .collect()
1049 }
1050
1051 #[test]
1052 fn simple_identifier_accepts_bare_rejects_quoted() {
1053 assert_eq!(simple_sql_identifier("MY_SCHEMA"), Some("MY_SCHEMA".into()));
1054 assert_eq!(simple_sql_identifier("a$b#c1"), Some("a$b#c1".into()));
1055 assert_eq!(simple_sql_identifier("needs space"), None);
1056 assert_eq!(simple_sql_identifier("has\"quote"), None);
1057 }
1058
1059 #[test]
1060 fn parses_alter_session_value_case_insensitively() {
1061 assert_eq!(
1062 parse_alter_session_value("ALTER SESSION SET CURRENT_SCHEMA = HR", "current_schema"),
1063 Some("HR".into())
1064 );
1065 assert_eq!(
1066 parse_alter_session_value("alter session set edition=ed1;", "edition"),
1067 Some("ed1".into())
1068 );
1069 assert_eq!(
1071 parse_alter_session_value("alter session set current_schema = HR", "edition"),
1072 None
1073 );
1074 assert_eq!(
1075 parse_alter_session_value("select 1 from dual", "current_schema"),
1076 None
1077 );
1078 }
1079}