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
402/// `len() >= 2` matters: a lone `"` trivially satisfies both `starts_with`
403/// and `ends_with` on a one-character string, which is not a validly quoted
404/// name (there is no room for a distinct open quote, content, and close
405/// quote — even the empty quoted name `""` needs both delimiter bytes). Without
406/// this guard, [`public_bind_name`]'s `name[1..name.len() - 1]` slice
407/// underflows (`1..0`) and panics on that single-quote-character input —
408/// found by the `sql_statement_surface` fuzz target within seconds of
409/// mutating the seed corpus.
410pub fn is_quoted_bind_name(name: &str) -> bool {
411    name.len() >= 2 && name.starts_with('"') && name.ends_with('"')
412}
413
414pub fn bind_names_equal(left: &str, right: &str) -> bool {
415    if is_quoted_bind_name(left) || is_quoted_bind_name(right) {
416        left == right
417    } else {
418        left.eq_ignore_ascii_case(right)
419    }
420}
421
422pub fn bind_name_matches_key(bind_name: &str, key: &str) -> bool {
423    // python-oracledb strips a leading ':' from bind keys before lookup
424    // (impl/thin/var.pyx:88-94).
425    let key = key.strip_prefix(':').unwrap_or(key);
426    if is_quoted_bind_name(bind_name) || is_quoted_bind_name(key) {
427        bind_name == key
428    } else {
429        bind_name.eq_ignore_ascii_case(key)
430    }
431}
432
433pub fn single_quote_end(statement: &str, start: usize) -> usize {
434    let bytes = statement.as_bytes();
435    let mut index = start + 1;
436    while index < bytes.len() {
437        if is_single_quote_byte(bytes.get(index)) {
438            if is_single_quote_byte(bytes.get(index + 1)) {
439                index += 2;
440            } else {
441                return index + 1;
442            }
443        } else {
444            index += 1;
445        }
446    }
447    statement.len()
448}
449
450pub fn generated_object_attr_bind_name(bind_name: &str, attr_name: &str) -> String {
451    let bind = bind_name
452        .chars()
453        .map(|ch| {
454            if ch.is_ascii_alphanumeric() {
455                ch.to_ascii_uppercase()
456            } else {
457                '_'
458            }
459        })
460        .collect::<String>();
461    format!("ORADB_OBJ_{bind}_{}", attr_name.to_ascii_uppercase())
462}
463
464pub fn replace_input_bind_placeholder(
465    statement: &str,
466    bind_name: &str,
467    replacement: &str,
468) -> String {
469    let lower = statement.to_ascii_lowercase();
470    let split = lower.find("returning").unwrap_or(statement.len());
471    let (prefix, suffix) = statement.split_at(split);
472    format!(
473        "{}{}",
474        replace_bind_placeholder(prefix, bind_name, replacement),
475        suffix
476    )
477}
478
479pub fn replace_bind_placeholder(statement: &str, bind_name: &str, replacement: &str) -> String {
480    let mut result = String::with_capacity(statement.len() + replacement.len());
481    let mut index = 0;
482    while index < statement.len() {
483        let rest = &statement[index..];
484        if rest.starts_with('\'') {
485            let end = single_quote_end(statement, index);
486            result.push_str(&statement[index..end]);
487            index = end;
488            continue;
489        }
490        if rest.starts_with(':') {
491            let name_start = index + 1;
492            let mut name_end = name_start;
493            for (offset, ch) in statement[name_start..].char_indices() {
494                if is_bind_name_char(ch) {
495                    name_end = name_start + offset + ch.len_utf8();
496                } else {
497                    break;
498                }
499            }
500            if name_end > name_start {
501                let found_name = &statement[name_start..name_end];
502                if bind_names_equal(found_name, bind_name) {
503                    result.push_str(replacement);
504                } else {
505                    result.push_str(&statement[index..name_end]);
506                }
507                index = name_end;
508                continue;
509            }
510        }
511        let Some(ch) = rest.chars().next() else {
512            break;
513        };
514        result.push(ch);
515        index += ch.len_utf8();
516    }
517    result
518}
519
520struct DmlReturningProjectionParts<'a> {
521    returning_pos: usize,
522    binds_start: usize,
523    return_expr: &'a str,
524    bind_names: Vec<String>,
525}
526
527fn dml_returning_projection_parts(
528    statement: &str,
529) -> Result<Option<DmlReturningProjectionParts<'_>>> {
530    if statement_is_plsql(statement) {
531        return Ok(None);
532    }
533    let lower = statement.to_ascii_lowercase();
534    let Some(returning_pos) = lower.find("returning") else {
535        return Ok(None);
536    };
537    let Some(into_relative_pos) = lower[returning_pos..].find("into") else {
538        return Ok(None);
539    };
540    let expr_start = returning_pos + "returning".len();
541    let into_start = returning_pos + into_relative_pos;
542    let binds_start = into_start + "into".len();
543    let return_expr = statement[expr_start..into_start].trim();
544    if return_expr.contains(',') || return_expr.is_empty() {
545        return Ok(None);
546    }
547    let bind_names = scan_bind_names(&statement[binds_start..])?;
548    Ok(Some(DmlReturningProjectionParts {
549        returning_pos,
550        binds_start,
551        return_expr,
552        bind_names,
553    }))
554}
555
556fn is_single_quote_byte(byte: Option<&u8>) -> bool {
557    matches!(byte, Some(b'\''))
558}
559
560fn is_double_quote_byte(byte: Option<&u8>) -> bool {
561    matches!(byte, Some(b'"'))
562}
563
564fn char_at(statement: &str, index: usize) -> Option<(char, usize)> {
565    statement[index..]
566        .chars()
567        .next()
568        .map(|ch| (ch, ch.len_utf8()))
569}
570
571fn single_line_comment_end(statement: &str, index: usize) -> Option<usize> {
572    statement[index..].starts_with("--").then(|| {
573        statement[index + 2..]
574            .find('\n')
575            .map_or(statement.len(), |offset| index + 2 + offset + 1)
576    })
577}
578
579fn multiple_line_comment_end(statement: &str, index: usize) -> Option<usize> {
580    statement[index..].starts_with("/*").then(|| {
581        statement[index + 2..]
582            .find("*/")
583            .map_or(statement.len(), |offset| index + 2 + offset + 2)
584    })
585}
586
587fn quoted_string_end(statement: &str, start: usize, quote: char) -> Result<usize> {
588    let mut index = start + quote.len_utf8();
589    while index < statement.len() {
590        let Some((ch, ch_len)) = char_at(statement, index) else {
591            break;
592        };
593        index += ch_len;
594        if ch == quote {
595            if quote == '\'' && matches!(char_at(statement, index), Some(('\'', _))) {
596                index += quote.len_utf8();
597                continue;
598            }
599            return Ok(index);
600        }
601    }
602    if quote == '\'' {
603        Err(SqlError::MissingEndingSingleQuote)
604    } else {
605        Err(SqlError::MissingEndingDoubleQuote)
606    }
607}
608
609fn qstring_end(statement: &str, quote_index: usize) -> Result<usize> {
610    let Some((open_sep, open_len)) = char_at(statement, quote_index + 1) else {
611        return Err(SqlError::MissingEndingSingleQuote);
612    };
613    let close_sep = match open_sep {
614        '[' => ']',
615        '{' => '}',
616        '<' => '>',
617        '(' => ')',
618        _ => open_sep,
619    };
620    let mut index = quote_index + 1 + open_len;
621    let mut exiting_qstring = false;
622    while index < statement.len() {
623        let Some((ch, ch_len)) = char_at(statement, index) else {
624            break;
625        };
626        if !exiting_qstring && ch == close_sep {
627            exiting_qstring = true;
628        } else if exiting_qstring {
629            if ch == '\'' {
630                return Ok(index + ch_len);
631            }
632            if ch != close_sep {
633                exiting_qstring = false;
634            }
635        }
636        index += ch_len;
637    }
638    Err(SqlError::MissingEndingSingleQuote)
639}
640
641fn parse_bind_name(statement: &str, colon_index: usize) -> (usize, Option<String>) {
642    let mut index = colon_index + 1;
643    while index < statement.len() {
644        let Some((ch, ch_len)) = char_at(statement, index) else {
645            return (index, None);
646        };
647        if !ch.is_whitespace() {
648            break;
649        }
650        index += ch_len;
651    }
652    let Some((first_ch, first_len)) = char_at(statement, index) else {
653        return (index, None);
654    };
655    if first_ch == '"' {
656        let mut end = index + first_len;
657        while end < statement.len() {
658            let Some((ch, ch_len)) = char_at(statement, end) else {
659                break;
660            };
661            end += ch_len;
662            if ch == '"' {
663                return (end, Some(statement[index..end].to_string()));
664            }
665        }
666        return (statement.len(), Some(statement[index..].to_string()));
667    }
668    if first_ch.is_numeric() {
669        let mut end = index + first_len;
670        while end < statement.len() {
671            let Some((ch, ch_len)) = char_at(statement, end) else {
672                break;
673            };
674            if !ch.is_numeric() {
675                break;
676            }
677            end += ch_len;
678        }
679        return (end, Some(statement[index..end].to_string()));
680    }
681    if !first_ch.is_alphabetic() {
682        return (colon_index + 1, None);
683    }
684    let mut end = index + first_len;
685    while end < statement.len() {
686        let Some((ch, ch_len)) = char_at(statement, end) else {
687            break;
688        };
689        if !(ch.is_alphanumeric() || matches!(ch, '_' | '$' | '#')) {
690            break;
691        }
692        end += ch_len;
693    }
694    (end, Some(statement[index..end].to_string()))
695}
696
697/// Returns the identifier unchanged when it is a bare Oracle identifier (only
698/// `[A-Za-z0-9_$#]`), which needs no quoting. Returns `None` when it would
699/// require a quoted identifier (the caller decides how to surface that). Pure
700/// driver logic lifted out of the PyO3 shim (bead p5o).
701pub fn simple_sql_identifier(value: &str) -> Option<String> {
702    value
703        .chars()
704        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '#'))
705        .then(|| value.to_string())
706}
707
708/// Parses the value assigned by an `ALTER SESSION SET <key> = <value>` statement,
709/// case-insensitively, returning the (unquoted) value when `statement` is exactly
710/// that alter for `key`. Used to track session state (e.g. `current_schema`,
711/// `edition`) that the server reflects back without a round trip (reference
712/// connection.pyx reads these from the executed `alter session`). Pure driver
713/// logic lifted out of the PyO3 shim (bead p5o).
714pub fn parse_alter_session_value(statement: &str, key: &str) -> Option<String> {
715    let trimmed = statement.trim().trim_end_matches(';').trim();
716    let lower = trimmed.to_ascii_lowercase();
717    let prefix = format!("alter session set {key}");
718    if !lower.starts_with(&prefix) {
719        return None;
720    }
721    let mut value = trimmed.get(prefix.len()..)?.trim_start();
722    if let Some(stripped) = value.strip_prefix('=') {
723        value = stripped.trim_start();
724    }
725    value
726        .split_whitespace()
727        .next()
728        .map(|value| value.trim_matches('"').to_string())
729        .filter(|value| !value.is_empty())
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn classifies_plsql_statements_by_first_keyword() {
738        assert!(statement_is_plsql(" begin null; end;"));
739        assert!(statement_is_plsql("DECLARE v number; begin null; end;"));
740        assert!(statement_is_plsql("call pkg.proc(:x)"));
741        assert!(!statement_is_plsql("select :x from dual"));
742        assert!(!statement_is_plsql("update t set c = :x"));
743    }
744
745    #[test]
746    fn scans_bind_names_outside_single_quoted_strings() {
747        let names = scan_bind_names("select ':skip', 'it''s :skip2', :a, :\"MiX\" from dual")
748            .expect("bind scan should succeed");
749        assert_eq!(names, vec!["a".to_string(), "\"MiX\"".to_string()]);
750    }
751
752    #[test]
753    fn counts_bind_occurrences_for_plain_sql_but_coalesces_plsql() {
754        // plain SQL: a repeated positional placeholder is one bind per
755        // occurrence (reference `_add_bind`)
756        let sql = "insert into t (a, b) values (:1, udt_array(:1, :2, :3))";
757        assert_eq!(
758            bind_names_per_occurrence(sql).expect("scan"),
759            vec![
760                "1".to_string(),
761                "1".to_string(),
762                "2".to_string(),
763                "3".to_string()
764            ]
765        );
766        // unique view still collapses duplicates
767        assert_eq!(
768            unique_bind_names(sql).expect("scan"),
769            vec!["1".to_string(), "2".to_string(), "3".to_string()]
770        );
771        // PL/SQL coalesces duplicate placeholders into a single bind
772        let plsql = "begin proc(:x, :x, :y); end;";
773        assert_eq!(
774            bind_names_per_occurrence(plsql).expect("scan"),
775            vec!["x".to_string(), "y".to_string()]
776        );
777    }
778
779    #[test]
780    fn reports_unclosed_single_quote() {
781        let err = scan_bind_names("select ':not_closed from dual")
782            .expect_err("unclosed quote should be rejected");
783        assert_eq!(err, SqlError::MissingEndingSingleQuote);
784    }
785
786    #[test]
787    fn reports_unclosed_double_quote() {
788        // An unterminated quoted identifier must be distinguished from an
789        // unterminated string literal (SqlError::MissingEndingDoubleQuote vs
790        // MissingEndingSingleQuote) so callers can tell "bad ':bind'" apart
791        // from "bad \"IDENT\"" — this variant had zero test coverage before.
792        let err = scan_bind_names("select \"col from dual")
793            .expect_err("unclosed quoted identifier should be rejected");
794        assert_eq!(err, SqlError::MissingEndingDoubleQuote);
795    }
796
797    #[test]
798    fn deduplicates_unquoted_names_case_insensitively() {
799        let names = unique_bind_names(":a, :A, :\"A\", :\"A\"").expect("unique names");
800        assert_eq!(names, vec!["a".to_string(), "\"A\"".to_string()]);
801    }
802
803    #[test]
804    fn extracts_dml_returning_bind_names() {
805        let names = returning_bind_names(
806            "insert into t (value) values (:value) returning id into :id, :row_id",
807        )
808        .expect("returning bind names");
809        assert_eq!(names, vec!["id".to_string(), "row_id".to_string()]);
810    }
811
812    #[test]
813    fn extracts_single_dml_returning_projection_bind_name() {
814        let name = dml_returning_single_bind_name(
815            "insert into t (value) values (:value) returning obj into :out",
816        )
817        .expect("returning statement should parse");
818        assert_eq!(name, Some("out".to_string()));
819
820        let name = dml_returning_single_bind_name(
821            "insert into t (value) values (:value) returning obj into :out, :extra",
822        )
823        .expect("returning statement should parse");
824        assert_eq!(name, None);
825    }
826
827    #[test]
828    fn rewrites_single_dml_returning_projection() {
829        let statement = "insert into t (value) values (:value) returning obj_col into :out";
830        let rewritten = rewrite_dml_returning_projection(statement, "STRINGVALUE")
831            .expect("returning statement should parse");
832        assert_eq!(
833            rewritten,
834            Some(
835                "insert into t (value) values (:value) returning (obj_col).STRINGVALUE into :out"
836                    .to_string()
837            )
838        );
839    }
840
841    #[test]
842    fn extracts_unique_plsql_assignment_output_binds() {
843        let names = plsql_assignment_bind_names("begin :out := func(:in_value); :OUT := 1; end;")
844            .expect("assignment bind names");
845        assert_eq!(names, vec!["out".to_string()]);
846    }
847
848    #[test]
849    fn plsql_output_binds_combine_assignment_into_and_returning_into() {
850        // Non-PL/SQL: identical to plsql_assignment_bind_names (empty here).
851        assert!(plsql_output_bind_names("select :a from dual")
852            .expect("scan")
853            .is_empty());
854
855        // PL/SQL assignment binds are included.
856        assert_eq!(
857            plsql_output_bind_names("begin :out := func(:in_value); end;").expect("scan"),
858            vec!["out".to_string()]
859        );
860
861        // PL/SQL SELECT ... INTO binds are appended (and deduplicated).
862        assert_eq!(
863            plsql_output_bind_names("begin select c1, c2 into :a, :b from t; end;").expect("scan"),
864            vec!["a".to_string(), "b".to_string()]
865        );
866
867        // RETURNING ... INTO inside PL/SQL contributes its INTO binds.
868        assert_eq!(
869            plsql_output_bind_names("begin update t set c = 1 returning id into :rid; end;")
870                .expect("scan"),
871            vec!["rid".to_string()]
872        );
873
874        // Assignment + INTO + RETURNING-INTO together, deduplicated case-insensitively.
875        assert_eq!(
876            plsql_output_bind_names(
877                "begin :out := 1; select c into :a from t; \
878                 update t set c = 2 returning id into :A; end;"
879            )
880            .expect("scan"),
881            vec!["out".to_string(), "a".to_string()]
882        );
883    }
884
885    #[test]
886    fn plsql_output_ignores_into_inside_string_literal() {
887        // bead rust-oracledb-l3z: an INTO/RETURNING keyword appearing inside a
888        // string literal must NOT be mistaken for a real clause. Before the
889        // tokenizer-aware fix, the substring search matched "into" inside the
890        // literal and misclassified an ordinary bind as a PL/SQL output bind.
891        assert!(
892            plsql_output_bind_names("begin proc('into :x', :realbind); end;")
893                .expect("scan")
894                .is_empty(),
895            "an INTO inside a string literal must not produce an output bind"
896        );
897        // A genuine INTO alongside a literal containing 'into' yields only the
898        // real bind.
899        assert_eq!(
900            plsql_output_bind_names("begin select 'into :x', c into :real from t; end;")
901                .expect("scan"),
902            vec!["real".to_string()]
903        );
904        // 'returning' inside a literal must not start a RETURNING-INTO scan.
905        assert!(
906            plsql_output_bind_names("begin proc('returning id into :x', :y); end;")
907                .expect("scan")
908                .is_empty(),
909            "a RETURNING inside a string literal must not produce an output bind"
910        );
911    }
912
913    #[test]
914    fn extracts_plsql_function_return_bind_name() {
915        assert_eq!(
916            plsql_function_return_bind_name("begin :ret := pkg.func(:arg); end;"),
917            Some("ret".to_string())
918        );
919        assert_eq!(
920            plsql_function_return_bind_name("begin pkg.proc(:arg); end;"),
921            None
922        );
923    }
924
925    #[test]
926    fn converts_public_bind_names_like_python_oracledb() {
927        assert_eq!(public_bind_name("abc"), "ABC");
928        assert_eq!(public_bind_name("\"MiX\""), "MiX");
929    }
930
931    /// Regression for a `sql_statement_surface` fuzz-target find: a bind
932    /// "name" that is a single `"` character trivially satisfies the naive
933    /// `starts_with('"') && ends_with('"')` check (same byte, both
934    /// conditions), which used to make `public_bind_name` slice `[1..0]` and
935    /// panic. `is_quoted_bind_name` must require at least two bytes, and
936    /// `public_bind_name` must fall back to the unquoted (upper-cased) path
937    /// for any string shorter than that instead of panicking.
938    #[test]
939    fn public_bind_name_never_panics_on_a_lone_quote_character() {
940        assert!(!is_quoted_bind_name("\""));
941        assert_eq!(public_bind_name("\""), "\"");
942        assert!(!is_quoted_bind_name(""));
943        assert_eq!(public_bind_name(""), "");
944        // Two quote characters back-to-back IS a valid (empty) quoted name.
945        assert!(is_quoted_bind_name("\"\""));
946        assert_eq!(public_bind_name("\"\""), "");
947    }
948
949    #[test]
950    fn rewrites_bind_placeholders_before_returning_only() {
951        assert_eq!(
952            generated_object_attr_bind_name("value-1", "attr"),
953            "ORADB_OBJ_VALUE_1_ATTR"
954        );
955        assert_eq!(
956            replace_input_bind_placeholder(
957                "insert into t values (:value, ':value') returning obj into :value",
958                "value",
959                "OBJ(:ORADB_OBJ_VALUE_ATTR)"
960            ),
961            "insert into t values (OBJ(:ORADB_OBJ_VALUE_ATTR), ':value') returning obj into :value"
962        );
963    }
964
965    #[test]
966    fn skips_comments_and_quoted_identifiers_like_reference_parser() {
967        assert_eq!(
968            public_unique_names(
969                "--begin :value2 := :a + :b + :c +:a +3; end;\n\
970                 begin :value2 := :a + :c +3; end; -- not a :bind_variable"
971            ),
972            vec!["VALUE2", "A", "C"]
973        );
974        assert_eq!(
975            public_unique_names(
976                "/*--select * from :a where :a = 1\n\
977                 select * from table_names where :a = 1*/\n\
978                 select :table_name, :value from dual"
979            ),
980            vec!["TABLE_NAME", "VALUE"]
981        );
982        assert_eq!(
983            public_unique_names(r#"select ":test", :a from dual"#),
984            vec!["A"]
985        );
986        assert_eq!(
987            public_unique_names(r#"select "/*_value1" + : "VaLue_2" + :"*/3VALUE" from dual"#),
988            vec!["VaLue_2", "*/3VALUE"]
989        );
990    }
991
992    #[test]
993    fn supports_reference_quoted_bind_names() {
994        assert_eq!(
995            public_unique_names(r#"select :"percent%" from dual"#),
996            vec!["percent%"]
997        );
998        assert_eq!(
999            public_unique_names(r#"select : "q?marks" from dual"#),
1000            vec!["q?marks"]
1001        );
1002        assert_eq!(
1003            public_unique_names(r#"select "col:nns", :"col:ons", :id from dual"#),
1004            vec!["col:ons", "ID"]
1005        );
1006    }
1007
1008    #[test]
1009    fn skips_qstrings_and_json_constant_colons() {
1010        assert_eq!(
1011            public_unique_names(
1012                "select :a, q'{This contains ' and \" and : just fine}', :b, \
1013                 q'[This contains ' and \" and : just fine]', :c, \
1014                 q'<This contains ' and \" and : just fine>', :d, \
1015                 q'(This contains ' and \" and : just fine)', :e, \
1016                 q'$This contains ' and \" and : just fine$', :f from dual"
1017            ),
1018            vec!["A", "B", "C", "D", "E", "F"]
1019        );
1020        assert_eq!(
1021            public_unique_names(
1022                "select json_object('foo':dummy), :bv1, json_object('foo'::bv2), \
1023                 :bv3, json { 'key1': 57, 'key2' : 58 }, :bv4 from dual"
1024            ),
1025            vec!["BV1", "BV2", "BV3", "BV4"]
1026        );
1027    }
1028
1029    #[test]
1030    fn reports_reference_qstring_errors() {
1031        assert_eq!(
1032            scan_bind_names("select q'[something from dual")
1033                .expect_err("unclosed q-string should be rejected"),
1034            SqlError::MissingEndingSingleQuote
1035        );
1036        assert_eq!(
1037            scan_bind_names("select q'[abc'], 5 from dual")
1038                .expect_err("unclosed q-string should be rejected"),
1039            SqlError::MissingEndingSingleQuote
1040        );
1041    }
1042
1043    fn public_unique_names(statement: &str) -> Vec<String> {
1044        unique_bind_names(statement)
1045            .expect("statement should parse")
1046            .iter()
1047            .map(|name| public_bind_name(name))
1048            .collect()
1049    }
1050
1051    #[test]
1052    fn simple_identifier_accepts_bare_rejects_quoted() {
1053        assert_eq!(simple_sql_identifier("MY_SCHEMA"), Some("MY_SCHEMA".into()));
1054        assert_eq!(simple_sql_identifier("a$b#c1"), Some("a$b#c1".into()));
1055        assert_eq!(simple_sql_identifier("needs space"), None);
1056        assert_eq!(simple_sql_identifier("has\"quote"), None);
1057    }
1058
1059    #[test]
1060    fn parses_alter_session_value_case_insensitively() {
1061        assert_eq!(
1062            parse_alter_session_value("ALTER SESSION SET CURRENT_SCHEMA = HR", "current_schema"),
1063            Some("HR".into())
1064        );
1065        assert_eq!(
1066            parse_alter_session_value("alter session set edition=ed1;", "edition"),
1067            Some("ed1".into())
1068        );
1069        // Wrong key, or not an alter-session-set, yields nothing.
1070        assert_eq!(
1071            parse_alter_session_value("alter session set current_schema = HR", "edition"),
1072            None
1073        );
1074        assert_eq!(
1075            parse_alter_session_value("select 1 from dual", "current_schema"),
1076            None
1077        );
1078    }
1079}