Skip to main content

oracledb_protocol/
sql.rs

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
48/// Returns one bind-name entry per placeholder occurrence for non-PL/SQL SQL,
49/// and the unique names for PL/SQL, mirroring the reference `_add_bind`
50/// (impl/thin/statement.pyx:337-354): PL/SQL coalesces duplicate placeholders
51/// into a single bind, whereas plain SQL binds each occurrence separately so a
52/// repeated placeholder consumes one positional value per occurrence.
53pub 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
194/// Byte positions where `keyword` (ASCII lowercase) occurs as a standalone
195/// token OUTSIDE single/double-quoted strings, q-strings, and `--` / `/* */`
196/// comments — mirroring `scan_bind_names`' tokenizer so keyword detection is
197/// consistent with bind discovery and the reference (statement.pyx). Word-
198/// bounded: a leading/trailing ASCII alphanumeric or `_` disqualifies a match,
199/// so `pinto` / `into_x` never match `into`. A naive substring search would
200/// otherwise match `into` inside a literal like `'into :x'` and misclassify an
201/// ordinary bind as a PL/SQL output bind (bead rust-oracledb-l3z).
202fn 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
241/// The complete set of PL/SQL output bind names: the assignment targets plus,
242/// for PL/SQL statements, the binds in `SELECT ... INTO` and `RETURNING ... INTO`
243/// clauses. For non-PL/SQL statements this is just `plsql_assignment_bind_names`
244/// (which is empty). Names are deduplicated case-insensitively in occurrence
245/// order, mirroring the reference's PL/SQL output-bind detection. The INTO /
246/// RETURNING keywords are located with `keyword_token_positions` so a keyword
247/// appearing inside a string literal or comment is never mistaken for a clause.
248pub 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
314/// Mirrors the reference statement-type classification for DDL
315/// (impl/thin/statement.pyx `_determine_statement_type`).
316pub 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
331/// Mirrors the reference statement-type classification for DML
332/// (impl/thin/statement.pyx `_determine_statement_type`).
333pub 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    // python-oracledb strips a leading ':' from bind keys before lookup
415    // (impl/thin/var.pyx:88-94).
416    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        // plain SQL: a repeated positional placeholder is one bind per
711        // occurrence (reference `_add_bind`)
712        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        // unique view still collapses duplicates
723        assert_eq!(
724            unique_bind_names(sql).expect("scan"),
725            vec!["1".to_string(), "2".to_string(), "3".to_string()]
726        );
727        // PL/SQL coalesces duplicate placeholders into a single bind
728        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        // Non-PL/SQL: identical to plsql_assignment_bind_names (empty here).
796        assert!(plsql_output_bind_names("select :a from dual")
797            .expect("scan")
798            .is_empty());
799
800        // PL/SQL assignment binds are included.
801        assert_eq!(
802            plsql_output_bind_names("begin :out := func(:in_value); end;").expect("scan"),
803            vec!["out".to_string()]
804        );
805
806        // PL/SQL SELECT ... INTO binds are appended (and deduplicated).
807        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        // RETURNING ... INTO inside PL/SQL contributes its INTO binds.
813        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        // Assignment + INTO + RETURNING-INTO together, deduplicated case-insensitively.
820        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        // bead rust-oracledb-l3z: an INTO/RETURNING keyword appearing inside a
833        // string literal must NOT be mistaken for a real clause. Before the
834        // tokenizer-aware fix, the substring search matched "into" inside the
835        // literal and misclassified an ordinary bind as a PL/SQL output bind.
836        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        // A genuine INTO alongside a literal containing 'into' yields only the
843        // real bind.
844        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        // 'returning' inside a literal must not start a RETURNING-INTO scan.
850        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}