Skip to main content

oracledb_protocol/
sql.rs

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
49/// Returns one bind-name entry per placeholder occurrence for non-PL/SQL SQL,
50/// and the unique names for PL/SQL, mirroring the reference `_add_bind`
51/// (impl/thin/statement.pyx:337-354): PL/SQL coalesces duplicate placeholders
52/// into a single bind, whereas plain SQL binds each occurrence separately so a
53/// repeated placeholder consumes one positional value per occurrence.
54pub 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
195/// Byte positions where `keyword` (ASCII lowercase) occurs as a standalone
196/// token OUTSIDE single/double-quoted strings, q-strings, and `--` / `/* */`
197/// comments — mirroring `scan_bind_names`' tokenizer so keyword detection is
198/// consistent with bind discovery and the reference (statement.pyx). Word-
199/// bounded: a leading/trailing ASCII alphanumeric or `_` disqualifies a match,
200/// so `pinto` / `into_x` never match `into`. A naive substring search would
201/// otherwise match `into` inside a literal like `'into :x'` and misclassify an
202/// ordinary bind as a PL/SQL output bind (bead rust-oracledb-l3z).
203fn 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
242/// The complete set of PL/SQL output bind names: the assignment targets plus,
243/// for PL/SQL statements, the binds in `SELECT ... INTO` and `RETURNING ... INTO`
244/// clauses. For non-PL/SQL statements this is just `plsql_assignment_bind_names`
245/// (which is empty). Names are deduplicated case-insensitively in occurrence
246/// order, mirroring the reference's PL/SQL output-bind detection. The INTO /
247/// RETURNING keywords are located with `keyword_token_positions` so a keyword
248/// appearing inside a string literal or comment is never mistaken for a clause.
249pub 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
315/// Mirrors the reference statement-type classification for DDL
316/// (impl/thin/statement.pyx `_determine_statement_type`).
317pub 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
332/// Mirrors the reference statement-type classification for DML
333/// (impl/thin/statement.pyx `_determine_statement_type`).
334pub 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    // python-oracledb strips a leading ':' from bind keys before lookup
416    // (impl/thin/var.pyx:88-94).
417    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
689/// Returns the identifier unchanged when it is a bare Oracle identifier (only
690/// `[A-Za-z0-9_$#]`), which needs no quoting. Returns `None` when it would
691/// require a quoted identifier (the caller decides how to surface that). Pure
692/// driver logic lifted out of the PyO3 shim (bead p5o).
693pub 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
700/// Parses the value assigned by an `ALTER SESSION SET <key> = <value>` statement,
701/// case-insensitively, returning the (unquoted) value when `statement` is exactly
702/// that alter for `key`. Used to track session state (e.g. `current_schema`,
703/// `edition`) that the server reflects back without a round trip (reference
704/// connection.pyx reads these from the executed `alter session`). Pure driver
705/// logic lifted out of the PyO3 shim (bead p5o).
706pub 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        // plain SQL: a repeated positional placeholder is one bind per
747        // occurrence (reference `_add_bind`)
748        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        // unique view still collapses duplicates
759        assert_eq!(
760            unique_bind_names(sql).expect("scan"),
761            vec!["1".to_string(), "2".to_string(), "3".to_string()]
762        );
763        // PL/SQL coalesces duplicate placeholders into a single bind
764        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        // Non-PL/SQL: identical to plsql_assignment_bind_names (empty here).
832        assert!(plsql_output_bind_names("select :a from dual")
833            .expect("scan")
834            .is_empty());
835
836        // PL/SQL assignment binds are included.
837        assert_eq!(
838            plsql_output_bind_names("begin :out := func(:in_value); end;").expect("scan"),
839            vec!["out".to_string()]
840        );
841
842        // PL/SQL SELECT ... INTO binds are appended (and deduplicated).
843        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        // RETURNING ... INTO inside PL/SQL contributes its INTO binds.
849        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        // Assignment + INTO + RETURNING-INTO together, deduplicated case-insensitively.
856        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        // bead rust-oracledb-l3z: an INTO/RETURNING keyword appearing inside a
869        // string literal must NOT be mistaken for a real clause. Before the
870        // tokenizer-aware fix, the substring search matched "into" inside the
871        // literal and misclassified an ordinary bind as a PL/SQL output bind.
872        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        // A genuine INTO alongside a literal containing 'into' yields only the
879        // real bind.
880        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        // 'returning' inside a literal must not start a RETURNING-INTO scan.
886        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        // Wrong key, or not an alter-session-set, yields nothing.
1033        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}