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
688pub fn simple_sql_identifier(value: &str) -> Option<String> {
693 value
694 .chars()
695 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '#'))
696 .then(|| value.to_string())
697}
698
699pub fn parse_alter_session_value(statement: &str, key: &str) -> Option<String> {
706 let trimmed = statement.trim().trim_end_matches(';').trim();
707 let lower = trimmed.to_ascii_lowercase();
708 let prefix = format!("alter session set {key}");
709 if !lower.starts_with(&prefix) {
710 return None;
711 }
712 let mut value = trimmed.get(prefix.len()..)?.trim_start();
713 if let Some(stripped) = value.strip_prefix('=') {
714 value = stripped.trim_start();
715 }
716 value
717 .split_whitespace()
718 .next()
719 .map(|value| value.trim_matches('"').to_string())
720 .filter(|value| !value.is_empty())
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn classifies_plsql_statements_by_first_keyword() {
729 assert!(statement_is_plsql(" begin null; end;"));
730 assert!(statement_is_plsql("DECLARE v number; begin null; end;"));
731 assert!(statement_is_plsql("call pkg.proc(:x)"));
732 assert!(!statement_is_plsql("select :x from dual"));
733 assert!(!statement_is_plsql("update t set c = :x"));
734 }
735
736 #[test]
737 fn scans_bind_names_outside_single_quoted_strings() {
738 let names = scan_bind_names("select ':skip', 'it''s :skip2', :a, :\"MiX\" from dual")
739 .expect("bind scan should succeed");
740 assert_eq!(names, vec!["a".to_string(), "\"MiX\"".to_string()]);
741 }
742
743 #[test]
744 fn counts_bind_occurrences_for_plain_sql_but_coalesces_plsql() {
745 let sql = "insert into t (a, b) values (:1, udt_array(:1, :2, :3))";
748 assert_eq!(
749 bind_names_per_occurrence(sql).expect("scan"),
750 vec![
751 "1".to_string(),
752 "1".to_string(),
753 "2".to_string(),
754 "3".to_string()
755 ]
756 );
757 assert_eq!(
759 unique_bind_names(sql).expect("scan"),
760 vec!["1".to_string(), "2".to_string(), "3".to_string()]
761 );
762 let plsql = "begin proc(:x, :x, :y); end;";
764 assert_eq!(
765 bind_names_per_occurrence(plsql).expect("scan"),
766 vec!["x".to_string(), "y".to_string()]
767 );
768 }
769
770 #[test]
771 fn reports_unclosed_single_quote() {
772 let err = scan_bind_names("select ':not_closed from dual")
773 .expect_err("unclosed quote should be rejected");
774 assert_eq!(err, SqlError::MissingEndingSingleQuote);
775 }
776
777 #[test]
778 fn deduplicates_unquoted_names_case_insensitively() {
779 let names = unique_bind_names(":a, :A, :\"A\", :\"A\"").expect("unique names");
780 assert_eq!(names, vec!["a".to_string(), "\"A\"".to_string()]);
781 }
782
783 #[test]
784 fn extracts_dml_returning_bind_names() {
785 let names = returning_bind_names(
786 "insert into t (value) values (:value) returning id into :id, :row_id",
787 )
788 .expect("returning bind names");
789 assert_eq!(names, vec!["id".to_string(), "row_id".to_string()]);
790 }
791
792 #[test]
793 fn extracts_single_dml_returning_projection_bind_name() {
794 let name = dml_returning_single_bind_name(
795 "insert into t (value) values (:value) returning obj into :out",
796 )
797 .expect("returning statement should parse");
798 assert_eq!(name, Some("out".to_string()));
799
800 let name = dml_returning_single_bind_name(
801 "insert into t (value) values (:value) returning obj into :out, :extra",
802 )
803 .expect("returning statement should parse");
804 assert_eq!(name, None);
805 }
806
807 #[test]
808 fn rewrites_single_dml_returning_projection() {
809 let statement = "insert into t (value) values (:value) returning obj_col into :out";
810 let rewritten = rewrite_dml_returning_projection(statement, "STRINGVALUE")
811 .expect("returning statement should parse");
812 assert_eq!(
813 rewritten,
814 Some(
815 "insert into t (value) values (:value) returning (obj_col).STRINGVALUE into :out"
816 .to_string()
817 )
818 );
819 }
820
821 #[test]
822 fn extracts_unique_plsql_assignment_output_binds() {
823 let names = plsql_assignment_bind_names("begin :out := func(:in_value); :OUT := 1; end;")
824 .expect("assignment bind names");
825 assert_eq!(names, vec!["out".to_string()]);
826 }
827
828 #[test]
829 fn plsql_output_binds_combine_assignment_into_and_returning_into() {
830 assert!(plsql_output_bind_names("select :a from dual")
832 .expect("scan")
833 .is_empty());
834
835 assert_eq!(
837 plsql_output_bind_names("begin :out := func(:in_value); end;").expect("scan"),
838 vec!["out".to_string()]
839 );
840
841 assert_eq!(
843 plsql_output_bind_names("begin select c1, c2 into :a, :b from t; end;").expect("scan"),
844 vec!["a".to_string(), "b".to_string()]
845 );
846
847 assert_eq!(
849 plsql_output_bind_names("begin update t set c = 1 returning id into :rid; end;")
850 .expect("scan"),
851 vec!["rid".to_string()]
852 );
853
854 assert_eq!(
856 plsql_output_bind_names(
857 "begin :out := 1; select c into :a from t; \
858 update t set c = 2 returning id into :A; end;"
859 )
860 .expect("scan"),
861 vec!["out".to_string(), "a".to_string()]
862 );
863 }
864
865 #[test]
866 fn plsql_output_ignores_into_inside_string_literal() {
867 assert!(
872 plsql_output_bind_names("begin proc('into :x', :realbind); end;")
873 .expect("scan")
874 .is_empty(),
875 "an INTO inside a string literal must not produce an output bind"
876 );
877 assert_eq!(
880 plsql_output_bind_names("begin select 'into :x', c into :real from t; end;")
881 .expect("scan"),
882 vec!["real".to_string()]
883 );
884 assert!(
886 plsql_output_bind_names("begin proc('returning id into :x', :y); end;")
887 .expect("scan")
888 .is_empty(),
889 "a RETURNING inside a string literal must not produce an output bind"
890 );
891 }
892
893 #[test]
894 fn extracts_plsql_function_return_bind_name() {
895 assert_eq!(
896 plsql_function_return_bind_name("begin :ret := pkg.func(:arg); end;"),
897 Some("ret".to_string())
898 );
899 assert_eq!(
900 plsql_function_return_bind_name("begin pkg.proc(:arg); end;"),
901 None
902 );
903 }
904
905 #[test]
906 fn converts_public_bind_names_like_python_oracledb() {
907 assert_eq!(public_bind_name("abc"), "ABC");
908 assert_eq!(public_bind_name("\"MiX\""), "MiX");
909 }
910
911 #[test]
912 fn rewrites_bind_placeholders_before_returning_only() {
913 assert_eq!(
914 generated_object_attr_bind_name("value-1", "attr"),
915 "ORADB_OBJ_VALUE_1_ATTR"
916 );
917 assert_eq!(
918 replace_input_bind_placeholder(
919 "insert into t values (:value, ':value') returning obj into :value",
920 "value",
921 "OBJ(:ORADB_OBJ_VALUE_ATTR)"
922 ),
923 "insert into t values (OBJ(:ORADB_OBJ_VALUE_ATTR), ':value') returning obj into :value"
924 );
925 }
926
927 #[test]
928 fn skips_comments_and_quoted_identifiers_like_reference_parser() {
929 assert_eq!(
930 public_unique_names(
931 "--begin :value2 := :a + :b + :c +:a +3; end;\n\
932 begin :value2 := :a + :c +3; end; -- not a :bind_variable"
933 ),
934 vec!["VALUE2", "A", "C"]
935 );
936 assert_eq!(
937 public_unique_names(
938 "/*--select * from :a where :a = 1\n\
939 select * from table_names where :a = 1*/\n\
940 select :table_name, :value from dual"
941 ),
942 vec!["TABLE_NAME", "VALUE"]
943 );
944 assert_eq!(
945 public_unique_names(r#"select ":test", :a from dual"#),
946 vec!["A"]
947 );
948 assert_eq!(
949 public_unique_names(r#"select "/*_value1" + : "VaLue_2" + :"*/3VALUE" from dual"#),
950 vec!["VaLue_2", "*/3VALUE"]
951 );
952 }
953
954 #[test]
955 fn supports_reference_quoted_bind_names() {
956 assert_eq!(
957 public_unique_names(r#"select :"percent%" from dual"#),
958 vec!["percent%"]
959 );
960 assert_eq!(
961 public_unique_names(r#"select : "q?marks" from dual"#),
962 vec!["q?marks"]
963 );
964 assert_eq!(
965 public_unique_names(r#"select "col:nns", :"col:ons", :id from dual"#),
966 vec!["col:ons", "ID"]
967 );
968 }
969
970 #[test]
971 fn skips_qstrings_and_json_constant_colons() {
972 assert_eq!(
973 public_unique_names(
974 "select :a, q'{This contains ' and \" and : just fine}', :b, \
975 q'[This contains ' and \" and : just fine]', :c, \
976 q'<This contains ' and \" and : just fine>', :d, \
977 q'(This contains ' and \" and : just fine)', :e, \
978 q'$This contains ' and \" and : just fine$', :f from dual"
979 ),
980 vec!["A", "B", "C", "D", "E", "F"]
981 );
982 assert_eq!(
983 public_unique_names(
984 "select json_object('foo':dummy), :bv1, json_object('foo'::bv2), \
985 :bv3, json { 'key1': 57, 'key2' : 58 }, :bv4 from dual"
986 ),
987 vec!["BV1", "BV2", "BV3", "BV4"]
988 );
989 }
990
991 #[test]
992 fn reports_reference_qstring_errors() {
993 assert_eq!(
994 scan_bind_names("select q'[something from dual")
995 .expect_err("unclosed q-string should be rejected"),
996 SqlError::MissingEndingSingleQuote
997 );
998 assert_eq!(
999 scan_bind_names("select q'[abc'], 5 from dual")
1000 .expect_err("unclosed q-string should be rejected"),
1001 SqlError::MissingEndingSingleQuote
1002 );
1003 }
1004
1005 fn public_unique_names(statement: &str) -> Vec<String> {
1006 unique_bind_names(statement)
1007 .expect("statement should parse")
1008 .iter()
1009 .map(|name| public_bind_name(name))
1010 .collect()
1011 }
1012
1013 #[test]
1014 fn simple_identifier_accepts_bare_rejects_quoted() {
1015 assert_eq!(simple_sql_identifier("MY_SCHEMA"), Some("MY_SCHEMA".into()));
1016 assert_eq!(simple_sql_identifier("a$b#c1"), Some("a$b#c1".into()));
1017 assert_eq!(simple_sql_identifier("needs space"), None);
1018 assert_eq!(simple_sql_identifier("has\"quote"), None);
1019 }
1020
1021 #[test]
1022 fn parses_alter_session_value_case_insensitively() {
1023 assert_eq!(
1024 parse_alter_session_value("ALTER SESSION SET CURRENT_SCHEMA = HR", "current_schema"),
1025 Some("HR".into())
1026 );
1027 assert_eq!(
1028 parse_alter_session_value("alter session set edition=ed1;", "edition"),
1029 Some("ed1".into())
1030 );
1031 assert_eq!(
1033 parse_alter_session_value("alter session set current_schema = HR", "edition"),
1034 None
1035 );
1036 assert_eq!(
1037 parse_alter_session_value("select 1 from dual", "current_schema"),
1038 None
1039 );
1040 }
1041}