From 9ca4ab8c6ced0b7beda3cd0465014bc40a95f08f Mon Sep 17 00:00:00 2001 From: ordinary being Date: Tue, 6 Oct 2026 22:44:52 +0800 Subject: [PATCH] fix(sql): keep Oracle q-quotes whole when splitting statements --- crates/dbx-sql-core/src/sql.rs | 178 ++++++++++++++++++++++++++++++++- 1 file changed, 176 insertions(+), 2 deletions(-) diff --git a/crates/dbx-sql-core/src/sql.rs b/crates/dbx-sql-core/src/sql.rs index fd0762b9ae..da3612ab23 100644 --- a/crates/dbx-sql-core/src/sql.rs +++ b/crates/dbx-sql-core/src/sql.rs @@ -155,6 +155,11 @@ struct SqlDialectProfile { /// PostgreSQL escape string literals (`E'...'`), which keep backslash escapes /// even where ordinary `'...'` literals do not. supports_postgres_escape_strings: bool, + /// Oracle alternative quoting: `q'[text]'`, `q'{text}'`, `q'(text)'`, + /// `q''` or `q'XtextX'`. The literal exists so that an apostrophe does + /// not end the string, so reading it as an ordinary `'...'` string ends the + /// text early and splits the statement at a semicolon inside it. + supports_oracle_q_quotes: bool, supports_oracle_plsql_blocks: bool, supports_oracle_style_routine_bodies: bool, supports_slash_line_block_delimiter: bool, @@ -175,6 +180,7 @@ impl Default for SqlDialectProfile { Self { supports_hash_line_comments: false, supports_nested_block_comments: false, + supports_oracle_q_quotes: false, supports_backslash_escaped_quotes: true, supports_postgres_escape_strings: false, supports_oracle_plsql_blocks: false, @@ -274,7 +280,12 @@ impl SqlDialectProfile { } fn oracle_like() -> Self { - Self { supports_oracle_plsql_blocks: true, supports_slash_line_block_delimiter: true, ..Self::default() } + Self { + supports_oracle_plsql_blocks: true, + supports_oracle_q_quotes: true, + supports_slash_line_block_delimiter: true, + ..Self::default() + } } /// PostgreSQL and openGauss, which are also the connection types users pick for a @@ -516,6 +527,36 @@ fn dash_dash_starts_line_comment(profile: SqlDialectProfile, char_after_dashes: char_after_dashes.is_none_or(|ch| ch.is_whitespace() || ch.is_control()) } +/// Tracks an Oracle `q'...'` literal across characters, so a semicolon inside its +/// text never splits the statement. +#[derive(Default, Clone, Copy)] +struct OracleQQuoteState { + /// 1 while the introducer's quote is expected, 2 while its delimiter is. + awaiting: u8, + closer: Option, +} + +impl OracleQQuoteState { + fn is_open(&self) -> bool { + self.awaiting > 0 || self.closer.is_some() + } + + /// Consumes one character of an open literal; `previous` is the character + /// before it, as tracked by the caller. + fn consume(&mut self, ch: char, previous: Option) { + if self.awaiting == 1 { + self.awaiting = if ch == '\'' { 2 } else { 0 }; + } else if self.awaiting == 2 { + self.closer = oracle_q_quote_closer(ch); + self.awaiting = 0; + } else if let Some(closer) = self.closer { + if previous == Some(closer) && ch == '\'' { + self.closer = None; + } + } + } +} + #[derive(Default)] pub struct SqlStatementSplitter { buffer: String, @@ -534,6 +575,8 @@ pub struct SqlStatementSplitter { postgres_dollar_quoted_routine: bool, previous: Option, pending_mysql_line_comment_dashes: bool, + /// Open Oracle `q'...'` literal, if any. + oracle_q_quote: OracleQQuoteState, custom_delimiter: Option, stop_on_error: bool, pending_psql_command: Option, @@ -642,7 +685,26 @@ impl SqlStatementSplitter { continue; } + if self.oracle_q_quote.is_open() { + self.oracle_q_quote.consume(ch, self.previous); + self.buffer.push(ch); + self.previous = Some(ch); + i += 1; + continue; + } + if !self.in_single_quote && !self.in_double_quote && !self.in_backtick { + if self.options.profile.supports_oracle_q_quotes + && matches!(ch, 'q' | 'Q') + && next == Some('\'') + && !self.previous.is_some_and(is_identifier_continue_char) + { + self.oracle_q_quote.awaiting = 1; + self.buffer.push(ch); + self.previous = Some(ch); + i += 1; + continue; + } if self.previous == Some('-') && ch == '-' { if self.options.profile.requires_whitespace_after_line_comment_dashes && next.is_none() { self.pending_mysql_line_comment_dashes = true; @@ -930,7 +992,38 @@ fn ends_with_escape_string_prefix(text: &str) -> bool { if !matches!(chars.next(), Some('E' | 'e')) { return false; } - !chars.next().is_some_and(|ch| ch.is_alphanumeric() || ch == '_' || ch == '$') + !chars.next().is_some_and(is_identifier_continue_char) +} + +/// Whether `ch` can continue an unquoted identifier, which decides whether a +/// one-letter introducer (`E'...'`, `q'...'`) starts its own token. +fn is_identifier_continue_char(ch: char) -> bool { + ch.is_alphanumeric() || ch == '_' || ch == '$' +} + +/// The closing character of an Oracle alternative-quoting delimiter: brackets +/// pair up (`q'[a]'`) and every other character delimits itself (`q'!a!'`). +/// A quote or whitespace cannot delimit the literal. +fn oracle_q_quote_closer(opening: char) -> Option { + match opening { + '[' => Some(']'), + '{' => Some('}'), + '(' => Some(')'), + '<' => Some('>'), + '\'' | ' ' | '\t' | '\n' | '\r' => None, + other => Some(other), + } +} + +/// Byte index just past the closing quote of the `q'...'` literal whose opening +/// quote sits at `quote_index`, or `None` when the literal is not well formed. +fn oracle_q_quote_end(sql: &str, quote_index: usize) -> Option { + let opening = next_char_at(sql, quote_index + 1)?; + let closer = oracle_q_quote_closer(opening)?; + let content_start = quote_index + 1 + opening.len_utf8(); + let closing = format!("{closer}'"); + let offset = sql.get(content_start..)?.find(&closing)?; + Some(content_start + offset + closing.len()) } pub fn split_sql_statements(sql: &str) -> Vec { @@ -1222,6 +1315,16 @@ fn split_sql_statement_ranges_with_options(sql: &str, options: SqlParsingOptions } if !in_single_quote && !in_double_quote && !in_backtick { + if options.profile.supports_oracle_q_quotes + && matches!(ch, 'q' | 'Q') + && next == Some('\'') + && !sql[..i].chars().next_back().is_some_and(is_identifier_continue_char) + { + if let Some(end) = oracle_q_quote_end(sql, i + ch.len_utf8()) { + i = end; + continue; + } + } if ch == '-' && next == Some('-') && dash_dash_starts_line_comment(options.profile, next_char_at(sql, i + 2)) @@ -2296,6 +2399,7 @@ struct SqlScanner { in_backtick: bool, in_line_comment: bool, block_comment_depth: usize, + oracle_q_quote: OracleQQuoteState, dollar_quote_tag: Option, previous: Option, } @@ -2335,7 +2439,22 @@ impl SqlScanner { return; } + if self.oracle_q_quote.is_open() { + self.oracle_q_quote.consume(ch, self.previous); + self.previous = Some(ch); + return; + } + if !self.in_single_quote && !self.in_double_quote && !self.in_backtick { + if self.profile.supports_oracle_q_quotes + && matches!(ch, 'q' | 'Q') + && next == Some('\'') + && !self.previous.is_some_and(is_identifier_continue_char) + { + self.oracle_q_quote.awaiting = 1; + self.previous = Some(ch); + return; + } if (ch == '-' && next == Some('-')) || (self.profile.supports_hash_line_comments && ch == '#') { self.in_line_comment = true; } else if ch == '/' && next == Some('*') { @@ -2371,6 +2490,7 @@ impl SqlScanner { || self.in_backtick || self.in_line_comment || self.block_comment_depth > 0 + || self.oracle_q_quote.is_open() || self.dollar_quote_tag.is_some() } } @@ -3733,6 +3853,57 @@ mod tests { assert!(!statement.contains("UPDATE prod.orders"), "{statement}"); } + #[test] + fn oracle_alternative_quoting_keeps_apostrophes_and_semicolons_inside_the_literal() { + let sql = "SELECT q'[it's; still one value]' FROM dual; SELECT 1 FROM dual;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Oracle), + vec!["SELECT q'[it's; still one value]' FROM dual", "SELECT 1 FROM dual"] + ); + } + + #[test] + fn oracle_alternative_quoting_supports_every_delimiter_form() { + for oracle_literal in ["q'{a;b}'", "q'(a;b)'", "q''", "q'!a;b!'", "q'Aa;bA'", "Q'[i's; x]'"] { + let sql = format!("SELECT {oracle_literal} FROM dual; SELECT 1 FROM dual;"); + + assert_eq!( + split_sql_statements_for_database(&sql, DatabaseType::Oracle), + vec![format!("SELECT {oracle_literal} FROM dual"), "SELECT 1 FROM dual".to_string()], + "{oracle_literal}" + ); + } + } + + #[test] + fn dameng_alternative_quoting_matches_oracle() { + let sql = "SELECT q'[it's; x]' FROM dual; SELECT 1 FROM dual;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Dameng), + vec!["SELECT q'[it's; x]' FROM dual", "SELECT 1 FROM dual"] + ); + } + + #[test] + fn mysql_treats_q_quotes_as_plain_strings() { + let sql = "SELECT q'[a;b]' FROM t; SELECT 1;"; + + assert_eq!( + split_sql_statements_for_database(sql, DatabaseType::Mysql), + vec!["SELECT q'[a;b]' FROM t", "SELECT 1"] + ); + } + + #[test] + fn cursor_statement_keeps_oracle_alternative_quoting_together() { + let sql = "SELECT q'[it's; still one value]' FROM dual;\nSELECT 1 FROM dual;"; + let cursor = sql.find("SELECT 1").unwrap() + 3; + + assert_eq!(find_statement_at_cursor_for_database(sql, cursor, DatabaseType::Oracle), "SELECT 1 FROM dual"); + } + #[test] fn emits_trailing_statement_without_semicolon() { assert_eq!( @@ -4338,6 +4509,9 @@ SELECT 2;"; assert!(!default.supports_hash_line_comments); assert!(default.supports_backslash_escaped_quotes); assert!(!default.supports_postgres_escape_strings); + assert!(!default.supports_oracle_q_quotes); + assert!(SqlDialectProfile::for_database_type(DatabaseType::Oracle).supports_oracle_q_quotes); + assert!(SqlDialectProfile::for_database_type(DatabaseType::Dameng).supports_oracle_q_quotes); assert!(!default.supports_nested_block_comments); assert!(!default.supports_mysql_routine_blocks); assert!(!default.supports_oracle_plsql_blocks);