Skip to content

Commit ebd2cdd

Browse files
Niels-bclaude
andauthored
fix(bigquery): escape all single quotes in literal_string (r-605) (#2791)
BigQuerySqlDialect.literal_string wraps string values in triple quotes ('''...''') but only escaped exact ''' runs. That left a bare ''' inside the content whenever a value ends in a single quote (its trailing quote merges with the closing ''' -> '''') or contains a run of 4/5/7/8 single quotes. The stray ''' prematurely closes the string, so BigQuery sees two adjacent literals and rejects the statement with "Syntax error: concatenated string literals must be separated by whitespace or comments". This surfaced on the AST-fallback check-results INSERT: the failed_rows_query value embeds the scan-id as its own literal ('''<scan_id>'''), and the shipped escaping doubled those quotes into a six-quote run. Escape the backslash first, then EVERY single quote as \', keeping the triple-quote wrapper so multi-line check SQL still needs no newline escaping. Escaping every quote guarantees no bare ''' can appear in the content, for all inputs (verified with a round-trip decode across boundary quotes, runs 1-8, multiline and backslashes). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b3a2a9d commit ebd2cdd

2 files changed

Lines changed: 97 additions & 4 deletions

File tree

soda-bigquery/src/soda_bigquery/common/data_sources/bigquery_data_source.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,16 @@ def sql_expr_timestamp_add_day(self, timestamp_literal: str) -> str:
230230
def literal_string(self, value: str) -> Optional[str]:
231231
if value is None:
232232
return None
233-
# BigQuery triple-quoted strings ('''...''') allow multiline content and
234-
# embedded single quotes without escaping. The only sequence that needs
235-
# escaping inside triple-quoted strings is ''' itself and backslashes.
236-
escaped = value.replace("\\", "\\\\").replace("'''", "\\'''")
233+
# BigQuery triple-quoted strings ('''...''') allow multiline content, so the
234+
# wrapper keeps raw newlines in multi-line check SQL from needing escaping.
235+
# Escape the backslash first, then EVERY single quote as \'. Escaping every
236+
# quote guarantees that no unescaped ''' can appear inside the content and
237+
# prematurely close the string (which would make BigQuery see two adjacent
238+
# string literals -> "concatenated string literals must be separated by
239+
# whitespace or comments"). Only escaping ''' runs was not enough: values
240+
# ending in a quote, or containing runs of 4/5/7/8 quotes, still produced a
241+
# bare ''' at or near the wrapper boundary.
242+
escaped = value.replace("\\", "\\\\").replace("'", "\\'")
237243
return "'''" + escaped + "'''"
238244

239245
def build_cte_values_sql(self, values: VALUES, alias_columns: list[COLUMN] | None) -> str:

soda-bigquery/tests/unit/test_bigquery_dialect.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import pytest
12
from soda_bigquery.common.data_sources.bigquery_data_source import BigQuerySqlDialect
23
from soda_core.common.sql_dialect import FROM, RANDOM, SELECT
34

@@ -50,3 +51,89 @@ def test_extract_numeric_precision_case_insensitive():
5051
assert dialect.extract_numeric_precision(row, columns=[]) == 38
5152
row = ("col", "BIGNUMERIC")
5253
assert dialect.extract_numeric_precision(row, columns=[]) == 76
54+
55+
56+
# ---------------------------------------------------------------------------
57+
# BigQuery string literal escaping — literal_string wraps values in triple
58+
# single quotes ('''...'''). If a bare ''' survives inside the content it
59+
# prematurely closes the string and BigQuery rejects the statement with
60+
# "Syntax error: concatenated string literals must be separated by whitespace
61+
# or comments". This happened for values ending in a quote (boundary merges
62+
# with the closing ''') and for internal runs of 4/5/7/8 quotes. Regression
63+
# for the AST-fallback check-result INSERT reported for BigQuery.
64+
# ---------------------------------------------------------------------------
65+
66+
67+
def _decode_bq_triple_quoted(sql_literal: str) -> tuple[bool, str]:
68+
"""Tokenise a BigQuery '''...''' string literal the way BigQuery does.
69+
70+
A backslash escapes the next character; a ``'''`` closes the string.
71+
Returns ``(closes_exactly_at_end, decoded_value)`` — ``closes_exactly_at_end``
72+
is False when the literal never closes OR closes before the final character
73+
(i.e. trailing content would be parsed as a second, adjacent literal).
74+
"""
75+
assert sql_literal.startswith("'''") and len(sql_literal) >= 6
76+
escapes = {"n": "\n", "t": "\t", "r": "\r"}
77+
i, n, out = 3, len(sql_literal), []
78+
while i < n:
79+
c = sql_literal[i]
80+
if c == "\\":
81+
nxt = sql_literal[i + 1] if i + 1 < n else ""
82+
out.append(escapes.get(nxt, nxt))
83+
i += 2
84+
continue
85+
if c == "'" and sql_literal[i : i + 3] == "'''":
86+
return (i + 3 == n, "".join(out))
87+
out.append(c)
88+
i += 1
89+
return (False, "".join(out))
90+
91+
92+
@pytest.mark.parametrize(
93+
"value",
94+
[
95+
"",
96+
"no quotes here",
97+
"'",
98+
"''",
99+
"'''",
100+
"abc'", # trailing single quote -> boundary '''' before the fix
101+
"abc''", # trailing double quote -> boundary ''''' before the fix
102+
"'abc", # leading quote
103+
"SELECT 'x'", # ends in a quoted literal (the common customer trigger)
104+
"x'y",
105+
"x''y",
106+
"x'''y",
107+
"x''''y", # internal run of 4 -> broke before the fix
108+
"x'''''y", # 5
109+
"x''''''y", # 6
110+
"x'''''''y", # 7
111+
"x''''''''y", # 8
112+
"multi\nline 'quoted'\nvalue", # multiline must survive the triple-quote wrapper
113+
"back\\slash and 'quote'", # backslash + quote
114+
"WITH a AS (SELECT 'Zakenadres') SELECT 'missing'", # customer-style query
115+
],
116+
)
117+
def test_literal_string_is_well_formed_and_roundtrips(value: str):
118+
dialect = BigQuerySqlDialect()
119+
sql_literal = dialect.literal_string(value)
120+
closes_at_end, decoded = _decode_bq_triple_quoted(sql_literal)
121+
assert closes_at_end, f"literal not closed exactly at end (premature '''): {sql_literal!r}"
122+
assert decoded == value, f"round-trip mismatch: {decoded!r} != {value!r} (literal {sql_literal!r})"
123+
124+
125+
def test_literal_string_embedded_scan_id_query_roundtrips():
126+
"""A failed-rows query that itself embeds a scan-id literal ('''...''') is a
127+
real check-result value; wrapping it again must stay well-formed."""
128+
dialect = BigQuerySqlDialect()
129+
scan_id = "481214e3-a379-49f2-9edf-87fdb0d4e73b"
130+
inner = dialect.literal_string(scan_id) # '''481214...'''
131+
failed_rows_query = f"SELECT * FROM `t` AS `FAILED_ROWS` WHERE `__soda_scan_id` = {inner};"
132+
sql_literal = dialect.literal_string(failed_rows_query)
133+
closes_at_end, decoded = _decode_bq_triple_quoted(sql_literal)
134+
assert closes_at_end, f"premature ''' in {sql_literal!r}"
135+
assert decoded == failed_rows_query
136+
137+
138+
def test_literal_string_none_returns_none():
139+
assert BigQuerySqlDialect().literal_string(None) is None

0 commit comments

Comments
 (0)