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 {
403 name.starts_with('"') && name.ends_with('"')
404}
405
406pub fn bind_names_equal(left: &str, right: &str) -> bool {
407 if is_quoted_bind_name(left) || is_quoted_bind_name(right) {
408 left == right
409 } else {
410 left.eq_ignore_ascii_case(right)
411 }
412}
413
414pub fn bind_name_matches_key(bind_name: &str, key: &str) -> bool {
415 let key = key.strip_prefix(':').unwrap_or(key);
418 if is_quoted_bind_name(bind_name) || is_quoted_bind_name(key) {
419 bind_name == key
420 } else {
421 bind_name.eq_ignore_ascii_case(key)
422 }
423}
424
425pub fn single_quote_end(statement: &str, start: usize) -> usize {
426 let bytes = statement.as_bytes();
427 let mut index = start + 1;
428 while index < bytes.len() {
429 if is_single_quote_byte(bytes.get(index)) {
430 if is_single_quote_byte(bytes.get(index + 1)) {
431 index += 2;
432 } else {
433 return index + 1;
434 }
435 } else {
436 index += 1;
437 }
438 }
439 statement.len()
440}
441
442pub fn generated_object_attr_bind_name(bind_name: &str, attr_name: &str) -> String {
443 let bind = bind_name
444 .chars()
445 .map(|ch| {
446 if ch.is_ascii_alphanumeric() {
447 ch.to_ascii_uppercase()
448 } else {
449 '_'
450 }
451 })
452 .collect::<String>();
453 format!("ORADB_OBJ_{bind}_{}", attr_name.to_ascii_uppercase())
454}
455
456pub fn replace_input_bind_placeholder(
457 statement: &str,
458 bind_name: &str,
459 replacement: &str,
460) -> String {
461 let lower = statement.to_ascii_lowercase();
462 let split = lower.find("returning").unwrap_or(statement.len());
463 let (prefix, suffix) = statement.split_at(split);
464 format!(
465 "{}{}",
466 replace_bind_placeholder(prefix, bind_name, replacement),
467 suffix
468 )
469}
470
471pub fn replace_bind_placeholder(statement: &str, bind_name: &str, replacement: &str) -> String {
472 let mut result = String::with_capacity(statement.len() + replacement.len());
473 let mut index = 0;
474 while index < statement.len() {
475 let rest = &statement[index..];
476 if rest.starts_with('\'') {
477 let end = single_quote_end(statement, index);
478 result.push_str(&statement[index..end]);
479 index = end;
480 continue;
481 }
482 if rest.starts_with(':') {
483 let name_start = index + 1;
484 let mut name_end = name_start;
485 for (offset, ch) in statement[name_start..].char_indices() {
486 if is_bind_name_char(ch) {
487 name_end = name_start + offset + ch.len_utf8();
488 } else {
489 break;
490 }
491 }
492 if name_end > name_start {
493 let found_name = &statement[name_start..name_end];
494 if bind_names_equal(found_name, bind_name) {
495 result.push_str(replacement);
496 } else {
497 result.push_str(&statement[index..name_end]);
498 }
499 index = name_end;
500 continue;
501 }
502 }
503 let Some(ch) = rest.chars().next() else {
504 break;
505 };
506 result.push(ch);
507 index += ch.len_utf8();
508 }
509 result
510}
511
512struct DmlReturningProjectionParts<'a> {
513 returning_pos: usize,
514 binds_start: usize,
515 return_expr: &'a str,
516 bind_names: Vec<String>,
517}
518
519fn dml_returning_projection_parts(
520 statement: &str,
521) -> Result<Option<DmlReturningProjectionParts<'_>>> {
522 if statement_is_plsql(statement) {
523 return Ok(None);
524 }
525 let lower = statement.to_ascii_lowercase();
526 let Some(returning_pos) = lower.find("returning") else {
527 return Ok(None);
528 };
529 let Some(into_relative_pos) = lower[returning_pos..].find("into") else {
530 return Ok(None);
531 };
532 let expr_start = returning_pos + "returning".len();
533 let into_start = returning_pos + into_relative_pos;
534 let binds_start = into_start + "into".len();
535 let return_expr = statement[expr_start..into_start].trim();
536 if return_expr.contains(',') || return_expr.is_empty() {
537 return Ok(None);
538 }
539 let bind_names = scan_bind_names(&statement[binds_start..])?;
540 Ok(Some(DmlReturningProjectionParts {
541 returning_pos,
542 binds_start,
543 return_expr,
544 bind_names,
545 }))
546}
547
548fn is_single_quote_byte(byte: Option<&u8>) -> bool {
549 matches!(byte, Some(b'\''))
550}
551
552fn is_double_quote_byte(byte: Option<&u8>) -> bool {
553 matches!(byte, Some(b'"'))
554}
555
556fn char_at(statement: &str, index: usize) -> Option<(char, usize)> {
557 statement[index..]
558 .chars()
559 .next()
560 .map(|ch| (ch, ch.len_utf8()))
561}
562
563fn single_line_comment_end(statement: &str, index: usize) -> Option<usize> {
564 statement[index..].starts_with("--").then(|| {
565 statement[index + 2..]
566 .find('\n')
567 .map_or(statement.len(), |offset| index + 2 + offset + 1)
568 })
569}
570
571fn multiple_line_comment_end(statement: &str, index: usize) -> Option<usize> {
572 statement[index..].starts_with("/*").then(|| {
573 statement[index + 2..]
574 .find("*/")
575 .map_or(statement.len(), |offset| index + 2 + offset + 2)
576 })
577}
578
579fn quoted_string_end(statement: &str, start: usize, quote: char) -> Result<usize> {
580 let mut index = start + quote.len_utf8();
581 while index < statement.len() {
582 let Some((ch, ch_len)) = char_at(statement, index) else {
583 break;
584 };
585 index += ch_len;
586 if ch == quote {
587 if quote == '\'' && matches!(char_at(statement, index), Some(('\'', _))) {
588 index += quote.len_utf8();
589 continue;
590 }
591 return Ok(index);
592 }
593 }
594 if quote == '\'' {
595 Err(SqlError::MissingEndingSingleQuote)
596 } else {
597 Err(SqlError::MissingEndingDoubleQuote)
598 }
599}
600
601fn qstring_end(statement: &str, quote_index: usize) -> Result<usize> {
602 let Some((open_sep, open_len)) = char_at(statement, quote_index + 1) else {
603 return Err(SqlError::MissingEndingSingleQuote);
604 };
605 let close_sep = match open_sep {
606 '[' => ']',
607 '{' => '}',
608 '<' => '>',
609 '(' => ')',
610 _ => open_sep,
611 };
612 let mut index = quote_index + 1 + open_len;
613 let mut exiting_qstring = false;
614 while index < statement.len() {
615 let Some((ch, ch_len)) = char_at(statement, index) else {
616 break;
617 };
618 if !exiting_qstring && ch == close_sep {
619 exiting_qstring = true;
620 } else if exiting_qstring {
621 if ch == '\'' {
622 return Ok(index + ch_len);
623 }
624 if ch != close_sep {
625 exiting_qstring = false;
626 }
627 }
628 index += ch_len;
629 }
630 Err(SqlError::MissingEndingSingleQuote)
631}
632
633fn parse_bind_name(statement: &str, colon_index: usize) -> (usize, Option<String>) {
634 let mut index = colon_index + 1;
635 while index < statement.len() {
636 let Some((ch, ch_len)) = char_at(statement, index) else {
637 return (index, None);
638 };
639 if !ch.is_whitespace() {
640 break;
641 }
642 index += ch_len;
643 }
644 let Some((first_ch, first_len)) = char_at(statement, index) else {
645 return (index, None);
646 };
647 if first_ch == '"' {
648 let mut end = index + first_len;
649 while end < statement.len() {
650 let Some((ch, ch_len)) = char_at(statement, end) else {
651 break;
652 };
653 end += ch_len;
654 if ch == '"' {
655 return (end, Some(statement[index..end].to_string()));
656 }
657 }
658 return (statement.len(), Some(statement[index..].to_string()));
659 }
660 if first_ch.is_numeric() {
661 let mut end = index + first_len;
662 while end < statement.len() {
663 let Some((ch, ch_len)) = char_at(statement, end) else {
664 break;
665 };
666 if !ch.is_numeric() {
667 break;
668 }
669 end += ch_len;
670 }
671 return (end, Some(statement[index..end].to_string()));
672 }
673 if !first_ch.is_alphabetic() {
674 return (colon_index + 1, None);
675 }
676 let mut end = index + first_len;
677 while end < statement.len() {
678 let Some((ch, ch_len)) = char_at(statement, end) else {
679 break;
680 };
681 if !(ch.is_alphanumeric() || matches!(ch, '_' | '$' | '#')) {
682 break;
683 }
684 end += ch_len;
685 }
686 (end, Some(statement[index..end].to_string()))
687}
688
689pub fn simple_sql_identifier(value: &str) -> Option<String> {
694 value
695 .chars()
696 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '#'))
697 .then(|| value.to_string())
698}
699
700pub fn parse_alter_session_value(statement: &str, key: &str) -> Option<String> {
707 let trimmed = statement.trim().trim_end_matches(';').trim();
708 let lower = trimmed.to_ascii_lowercase();
709 let prefix = format!("alter session set {key}");
710 if !lower.starts_with(&prefix) {
711 return None;
712 }
713 let mut value = trimmed.get(prefix.len()..)?.trim_start();
714 if let Some(stripped) = value.strip_prefix('=') {
715 value = stripped.trim_start();
716 }
717 value
718 .split_whitespace()
719 .next()
720 .map(|value| value.trim_matches('"').to_string())
721 .filter(|value| !value.is_empty())
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn classifies_plsql_statements_by_first_keyword() {
730 assert!(statement_is_plsql(" begin null; end;"));
731 assert!(statement_is_plsql("DECLARE v number; begin null; end;"));
732 assert!(statement_is_plsql("call pkg.proc(:x)"));
733 assert!(!statement_is_plsql("select :x from dual"));
734 assert!(!statement_is_plsql("update t set c = :x"));
735 }
736
737 #[test]
738 fn scans_bind_names_outside_single_quoted_strings() {
739 let names = scan_bind_names("select ':skip', 'it''s :skip2', :a, :\"MiX\" from dual")
740 .expect("bind scan should succeed");
741 assert_eq!(names, vec!["a".to_string(), "\"MiX\"".to_string()]);
742 }
743
744 #[test]
745 fn counts_bind_occurrences_for_plain_sql_but_coalesces_plsql() {
746 let sql = "insert into t (a, b) values (:1, udt_array(:1, :2, :3))";
749 assert_eq!(
750 bind_names_per_occurrence(sql).expect("scan"),
751 vec![
752 "1".to_string(),
753 "1".to_string(),
754 "2".to_string(),
755 "3".to_string()
756 ]
757 );
758 assert_eq!(
760 unique_bind_names(sql).expect("scan"),
761 vec!["1".to_string(), "2".to_string(), "3".to_string()]
762 );
763 let plsql = "begin proc(:x, :x, :y); end;";
765 assert_eq!(
766 bind_names_per_occurrence(plsql).expect("scan"),
767 vec!["x".to_string(), "y".to_string()]
768 );
769 }
770
771 #[test]
772 fn reports_unclosed_single_quote() {
773 let err = scan_bind_names("select ':not_closed from dual")
774 .expect_err("unclosed quote should be rejected");
775 assert_eq!(err, SqlError::MissingEndingSingleQuote);
776 }
777
778 #[test]
779 fn deduplicates_unquoted_names_case_insensitively() {
780 let names = unique_bind_names(":a, :A, :\"A\", :\"A\"").expect("unique names");
781 assert_eq!(names, vec!["a".to_string(), "\"A\"".to_string()]);
782 }
783
784 #[test]
785 fn extracts_dml_returning_bind_names() {
786 let names = returning_bind_names(
787 "insert into t (value) values (:value) returning id into :id, :row_id",
788 )
789 .expect("returning bind names");
790 assert_eq!(names, vec!["id".to_string(), "row_id".to_string()]);
791 }
792
793 #[test]
794 fn extracts_single_dml_returning_projection_bind_name() {
795 let name = dml_returning_single_bind_name(
796 "insert into t (value) values (:value) returning obj into :out",
797 )
798 .expect("returning statement should parse");
799 assert_eq!(name, Some("out".to_string()));
800
801 let name = dml_returning_single_bind_name(
802 "insert into t (value) values (:value) returning obj into :out, :extra",
803 )
804 .expect("returning statement should parse");
805 assert_eq!(name, None);
806 }
807
808 #[test]
809 fn rewrites_single_dml_returning_projection() {
810 let statement = "insert into t (value) values (:value) returning obj_col into :out";
811 let rewritten = rewrite_dml_returning_projection(statement, "STRINGVALUE")
812 .expect("returning statement should parse");
813 assert_eq!(
814 rewritten,
815 Some(
816 "insert into t (value) values (:value) returning (obj_col).STRINGVALUE into :out"
817 .to_string()
818 )
819 );
820 }
821
822 #[test]
823 fn extracts_unique_plsql_assignment_output_binds() {
824 let names = plsql_assignment_bind_names("begin :out := func(:in_value); :OUT := 1; end;")
825 .expect("assignment bind names");
826 assert_eq!(names, vec!["out".to_string()]);
827 }
828
829 #[test]
830 fn plsql_output_binds_combine_assignment_into_and_returning_into() {
831 assert!(plsql_output_bind_names("select :a from dual")
833 .expect("scan")
834 .is_empty());
835
836 assert_eq!(
838 plsql_output_bind_names("begin :out := func(:in_value); end;").expect("scan"),
839 vec!["out".to_string()]
840 );
841
842 assert_eq!(
844 plsql_output_bind_names("begin select c1, c2 into :a, :b from t; end;").expect("scan"),
845 vec!["a".to_string(), "b".to_string()]
846 );
847
848 assert_eq!(
850 plsql_output_bind_names("begin update t set c = 1 returning id into :rid; end;")
851 .expect("scan"),
852 vec!["rid".to_string()]
853 );
854
855 assert_eq!(
857 plsql_output_bind_names(
858 "begin :out := 1; select c into :a from t; \
859 update t set c = 2 returning id into :A; end;"
860 )
861 .expect("scan"),
862 vec!["out".to_string(), "a".to_string()]
863 );
864 }
865
866 #[test]
867 fn plsql_output_ignores_into_inside_string_literal() {
868 assert!(
873 plsql_output_bind_names("begin proc('into :x', :realbind); end;")
874 .expect("scan")
875 .is_empty(),
876 "an INTO inside a string literal must not produce an output bind"
877 );
878 assert_eq!(
881 plsql_output_bind_names("begin select 'into :x', c into :real from t; end;")
882 .expect("scan"),
883 vec!["real".to_string()]
884 );
885 assert!(
887 plsql_output_bind_names("begin proc('returning id into :x', :y); end;")
888 .expect("scan")
889 .is_empty(),
890 "a RETURNING inside a string literal must not produce an output bind"
891 );
892 }
893
894 #[test]
895 fn extracts_plsql_function_return_bind_name() {
896 assert_eq!(
897 plsql_function_return_bind_name("begin :ret := pkg.func(:arg); end;"),
898 Some("ret".to_string())
899 );
900 assert_eq!(
901 plsql_function_return_bind_name("begin pkg.proc(:arg); end;"),
902 None
903 );
904 }
905
906 #[test]
907 fn converts_public_bind_names_like_python_oracledb() {
908 assert_eq!(public_bind_name("abc"), "ABC");
909 assert_eq!(public_bind_name("\"MiX\""), "MiX");
910 }
911
912 #[test]
913 fn rewrites_bind_placeholders_before_returning_only() {
914 assert_eq!(
915 generated_object_attr_bind_name("value-1", "attr"),
916 "ORADB_OBJ_VALUE_1_ATTR"
917 );
918 assert_eq!(
919 replace_input_bind_placeholder(
920 "insert into t values (:value, ':value') returning obj into :value",
921 "value",
922 "OBJ(:ORADB_OBJ_VALUE_ATTR)"
923 ),
924 "insert into t values (OBJ(:ORADB_OBJ_VALUE_ATTR), ':value') returning obj into :value"
925 );
926 }
927
928 #[test]
929 fn skips_comments_and_quoted_identifiers_like_reference_parser() {
930 assert_eq!(
931 public_unique_names(
932 "--begin :value2 := :a + :b + :c +:a +3; end;\n\
933 begin :value2 := :a + :c +3; end; -- not a :bind_variable"
934 ),
935 vec!["VALUE2", "A", "C"]
936 );
937 assert_eq!(
938 public_unique_names(
939 "/*--select * from :a where :a = 1\n\
940 select * from table_names where :a = 1*/\n\
941 select :table_name, :value from dual"
942 ),
943 vec!["TABLE_NAME", "VALUE"]
944 );
945 assert_eq!(
946 public_unique_names(r#"select ":test", :a from dual"#),
947 vec!["A"]
948 );
949 assert_eq!(
950 public_unique_names(r#"select "/*_value1" + : "VaLue_2" + :"*/3VALUE" from dual"#),
951 vec!["VaLue_2", "*/3VALUE"]
952 );
953 }
954
955 #[test]
956 fn supports_reference_quoted_bind_names() {
957 assert_eq!(
958 public_unique_names(r#"select :"percent%" from dual"#),
959 vec!["percent%"]
960 );
961 assert_eq!(
962 public_unique_names(r#"select : "q?marks" from dual"#),
963 vec!["q?marks"]
964 );
965 assert_eq!(
966 public_unique_names(r#"select "col:nns", :"col:ons", :id from dual"#),
967 vec!["col:ons", "ID"]
968 );
969 }
970
971 #[test]
972 fn skips_qstrings_and_json_constant_colons() {
973 assert_eq!(
974 public_unique_names(
975 "select :a, q'{This contains ' and \" and : just fine}', :b, \
976 q'[This contains ' and \" and : just fine]', :c, \
977 q'<This contains ' and \" and : just fine>', :d, \
978 q'(This contains ' and \" and : just fine)', :e, \
979 q'$This contains ' and \" and : just fine$', :f from dual"
980 ),
981 vec!["A", "B", "C", "D", "E", "F"]
982 );
983 assert_eq!(
984 public_unique_names(
985 "select json_object('foo':dummy), :bv1, json_object('foo'::bv2), \
986 :bv3, json { 'key1': 57, 'key2' : 58 }, :bv4 from dual"
987 ),
988 vec!["BV1", "BV2", "BV3", "BV4"]
989 );
990 }
991
992 #[test]
993 fn reports_reference_qstring_errors() {
994 assert_eq!(
995 scan_bind_names("select q'[something from dual")
996 .expect_err("unclosed q-string should be rejected"),
997 SqlError::MissingEndingSingleQuote
998 );
999 assert_eq!(
1000 scan_bind_names("select q'[abc'], 5 from dual")
1001 .expect_err("unclosed q-string should be rejected"),
1002 SqlError::MissingEndingSingleQuote
1003 );
1004 }
1005
1006 fn public_unique_names(statement: &str) -> Vec<String> {
1007 unique_bind_names(statement)
1008 .expect("statement should parse")
1009 .iter()
1010 .map(|name| public_bind_name(name))
1011 .collect()
1012 }
1013
1014 #[test]
1015 fn simple_identifier_accepts_bare_rejects_quoted() {
1016 assert_eq!(simple_sql_identifier("MY_SCHEMA"), Some("MY_SCHEMA".into()));
1017 assert_eq!(simple_sql_identifier("a$b#c1"), Some("a$b#c1".into()));
1018 assert_eq!(simple_sql_identifier("needs space"), None);
1019 assert_eq!(simple_sql_identifier("has\"quote"), None);
1020 }
1021
1022 #[test]
1023 fn parses_alter_session_value_case_insensitively() {
1024 assert_eq!(
1025 parse_alter_session_value("ALTER SESSION SET CURRENT_SCHEMA = HR", "current_schema"),
1026 Some("HR".into())
1027 );
1028 assert_eq!(
1029 parse_alter_session_value("alter session set edition=ed1;", "edition"),
1030 Some("ed1".into())
1031 );
1032 assert_eq!(
1034 parse_alter_session_value("alter session set current_schema = HR", "edition"),
1035 None
1036 );
1037 assert_eq!(
1038 parse_alter_session_value("select 1 from dual", "current_schema"),
1039 None
1040 );
1041 }
1042}