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