Skip to content

Commit 0832727

Browse files
feat(bigquery): resolve backslash escapes in backtick identifiers
GoogleSQL quoted (backtick) identifiers accept the same backslash escape sequences as string literals. Add a dialect flag supports_identifier_backslash_escape (true for BigQuery) and resolve the escapes in the tokenizer; an unrecognized escape is an "Illegal escape sequence" tokenizer error instead of a stripped backslash.
1 parent c80969f commit 0832727

3 files changed

Lines changed: 119 additions & 4 deletions

File tree

src/dialect/bigquery.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ impl Dialect for BigQueryDialect {
102102
true
103103
}
104104

105+
// See https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#quoted_identifiers
106+
fn supports_identifier_backslash_escape(&self) -> bool {
107+
true
108+
}
109+
105110
/// See [doc](https://cloud.google.com/bigquery/docs/reference/standard-sql/window-function-calls#ref_named_window)
106111
fn supports_window_clause_named_window_reference(&self) -> bool {
107112
true

src/dialect/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,16 @@ pub trait Dialect: Debug + Any {
283283
false
284284
}
285285

286+
/// Determine if the dialect recognizes backslash escape sequences inside
287+
/// quoted (delimited) identifiers, as GoogleSQL does. When true, the
288+
/// escapes are resolved (e.g. `` \` `` becomes a literal backtick) and an
289+
/// unrecognized escape is a tokenizer error rather than a stripped
290+
/// backslash.
291+
/// [BigQuery](https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#quoted_identifiers)
292+
fn supports_identifier_backslash_escape(&self) -> bool {
293+
false
294+
}
295+
286296
/// Determine whether the dialect strips the backslash when escaping LIKE wildcards (%, _).
287297
///
288298
/// [MySQL] has a special case when escaping single quoted strings which leaves these unescaped
@@ -2002,6 +2012,10 @@ mod tests {
20022012
self.0.supports_string_literal_backslash_escape()
20032013
}
20042014

2015+
fn supports_identifier_backslash_escape(&self) -> bool {
2016+
self.0.supports_identifier_backslash_escape()
2017+
}
2018+
20052019
fn supports_filter_during_aggregation(&self) -> bool {
20062020
self.0.supports_filter_during_aggregation()
20072021
}

src/tokenizer.rs

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,7 +2069,7 @@ impl<'a> Tokenizer<'a> {
20692069
let error_loc = chars.location();
20702070
chars.next(); // consume the opening quote
20712071
let quote_end = Word::matching_end_quote(quote_start);
2072-
let (s, last_char) = self.parse_quoted_ident(chars, quote_end);
2072+
let (s, last_char) = self.parse_quoted_ident(chars, quote_end)?;
20732073

20742074
if last_char == Some(quote_end) {
20752075
Ok(s)
@@ -2368,11 +2368,17 @@ impl<'a> Tokenizer<'a> {
23682368
}
23692369
}
23702370

2371-
fn parse_quoted_ident(&self, chars: &mut State, quote_end: char) -> (String, Option<char>) {
2371+
fn parse_quoted_ident(
2372+
&self,
2373+
chars: &mut State,
2374+
quote_end: char,
2375+
) -> Result<(String, Option<char>), TokenizerError> {
2376+
let backslash_escape = self.dialect.supports_identifier_backslash_escape();
23722377
let mut last_char = None;
23732378
let mut s = String::new();
2374-
while let Some(ch) = chars.next() {
2379+
while let Some(&ch) = chars.peek() {
23752380
if ch == quote_end {
2381+
chars.next();
23762382
if chars.peek() == Some(&quote_end) {
23772383
chars.next();
23782384
s.push(ch);
@@ -2384,11 +2390,35 @@ impl<'a> Tokenizer<'a> {
23842390
last_char = Some(quote_end);
23852391
break;
23862392
}
2393+
} else if ch == '\\' && backslash_escape {
2394+
let escape_loc = chars.location();
2395+
chars.next(); // consume the backslash
2396+
let Some(escaped) = chars.next() else {
2397+
// EOF right after the backslash: leave the loop so the
2398+
// caller reports the missing close delimiter.
2399+
break;
2400+
};
2401+
if self.unescape {
2402+
match unescape_identifier_escape(escaped) {
2403+
Some(resolved) => s.push(resolved),
2404+
None => {
2405+
return self.tokenizer_error(
2406+
escape_loc,
2407+
format!("Syntax error: Illegal escape sequence: \\{escaped}"),
2408+
);
2409+
}
2410+
}
2411+
} else {
2412+
// In no-escape mode, keep the query text verbatim.
2413+
s.push('\\');
2414+
s.push(escaped);
2415+
}
23872416
} else {
2417+
chars.next();
23882418
s.push(ch);
23892419
}
23902420
}
2391-
(s, last_char)
2421+
Ok((s, last_char))
23922422
}
23932423

23942424
#[allow(clippy::unnecessary_wraps)]
@@ -2405,6 +2435,24 @@ impl<'a> Tokenizer<'a> {
24052435
/// Read from `chars` until `predicate` returns `false` or EOF is hit.
24062436
/// Return the characters read as String, and keep the first non-matching
24072437
/// char available as `chars.next()`.
2438+
/// Resolve a single-character GoogleSQL escape sequence inside a quoted
2439+
/// identifier. Returns `None` for an unrecognized escape, which the caller
2440+
/// reports as an illegal escape sequence.
2441+
/// See <https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#escape_sequences>
2442+
fn unescape_identifier_escape(ch: char) -> Option<char> {
2443+
match ch {
2444+
'a' => Some('\u{7}'),
2445+
'b' => Some('\u{8}'),
2446+
'f' => Some('\u{c}'),
2447+
'n' => Some('\n'),
2448+
'r' => Some('\r'),
2449+
't' => Some('\t'),
2450+
'v' => Some('\u{b}'),
2451+
'\\' | '?' | '"' | '\'' | '`' => Some(ch),
2452+
_ => None,
2453+
}
2454+
}
2455+
24082456
fn peeking_take_while(chars: &mut State, mut predicate: impl FnMut(char) -> bool) -> String {
24092457
let mut s = String::new();
24102458
while let Some(&ch) = chars.peek() {
@@ -3714,6 +3762,54 @@ mod tests {
37143762
compare(expected, tokens);
37153763
}
37163764

3765+
#[test]
3766+
fn tokenize_bigquery_quoted_identifier_backslash_escape() {
3767+
// GoogleSQL resolves backslash escapes inside backtick identifiers:
3768+
// `\`` -> a literal backtick, raw double quotes pass through.
3769+
let sql = r#"`a "very" weird \`name\``"#;
3770+
let dialect = BigQueryDialect {};
3771+
let tokens = Tokenizer::new(&dialect, sql).tokenize().unwrap();
3772+
let expected = vec![Token::make_word(r#"a "very" weird `name`"#, Some('`'))];
3773+
compare(expected, tokens);
3774+
}
3775+
3776+
#[test]
3777+
fn tokenize_bigquery_quoted_identifier_backslash_escape_no_unescape() {
3778+
// With unescaping disabled the identifier text is preserved verbatim.
3779+
let sql = r#"`a \`b`"#;
3780+
let dialect = BigQueryDialect {};
3781+
let tokens = Tokenizer::new(&dialect, sql)
3782+
.with_unescape(false)
3783+
.tokenize()
3784+
.unwrap();
3785+
let expected = vec![Token::make_word(r#"a \`b"#, Some('`'))];
3786+
compare(expected, tokens);
3787+
}
3788+
3789+
#[test]
3790+
fn tokenize_bigquery_quoted_identifier_illegal_escape() {
3791+
let sql = r#"`bad\qescape`"#;
3792+
let dialect = BigQueryDialect {};
3793+
let mut tokenizer = Tokenizer::new(&dialect, sql);
3794+
assert_eq!(
3795+
tokenizer.tokenize(),
3796+
Err(TokenizerError {
3797+
message: r#"Syntax error: Illegal escape sequence: \q"#.to_string(),
3798+
location: Location { line: 1, column: 5 },
3799+
})
3800+
);
3801+
}
3802+
3803+
#[test]
3804+
fn tokenize_generic_quoted_identifier_no_backslash_escape() {
3805+
// Dialects without GoogleSQL escapes leave the backslash untouched.
3806+
let sql = r#"`a\b`"#;
3807+
let dialect = GenericDialect {};
3808+
let tokens = Tokenizer::new(&dialect, sql).tokenize().unwrap();
3809+
let expected = vec![Token::make_word(r"a\b", Some('`'))];
3810+
compare(expected, tokens);
3811+
}
3812+
37173813
#[test]
37183814
fn tokenize_with_location() {
37193815
let sql = "SELECT a,\n b";

0 commit comments

Comments
 (0)