From 18d93e0b0cf62aed622ea13b9edbc793f2ba2bfd Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:21:38 +0000 Subject: [PATCH 1/2] fix(bigquery): render GoogleSQL column type changes and escapes ALTER COLUMN now uses SET DATA TYPE. String literals and quoted identifiers escape quotes, backticks, backslashes and line breaks with a backslash, as GoogleSQL requires, instead of doubling the quote. --- .../bigquery/classes/bigquery_adapter.py | 11 ++- src/sqlbuild/adapters/bigquery/constants.py | 4 + .../adapter/contract/test_loader_types.py | 4 +- .../test_typed_sql_rendering.py | 4 +- .../sqlbuild/adapters/bigquery/_test_types.py | 16 ++++ .../adapters/bigquery/test_sql_literals.py | 89 +++++++++++++++++++ .../adapters/test_relation_max_cursor.py | 4 +- 7 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py diff --git a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py index ac7ed14d08..e49aabdd81 100644 --- a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py +++ b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py @@ -140,6 +140,7 @@ NATIVE_TABLE_TYPE, NOT_FOUND_ERROR_CLASS_NAME, SELECT_STATEMENT_TYPE, + STRING_LITERAL_ESCAPES, TABLE_NAME_WILDCARD, TIMESTAMP_TYPE_TOKEN, ) @@ -1026,7 +1027,7 @@ def render_alter_column_types( ) -> tuple[str, ...]: return tuple( f"ALTER TABLE {destination} ALTER COLUMN " - f"{self.render_identifier(col.name)} TYPE {col.type}" + f"{self.render_identifier(col.name)} SET DATA TYPE {col.type}" for col in columns ) @@ -1089,6 +1090,8 @@ def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str: def render_typed_scalar(self, *, value: SqlValue) -> str: if value.kind == SqlValueKind.DECIMAL: return f"NUMERIC {self._quote_sql_string(str(_typed_scalar_payload(value)))}" + if value.kind == SqlValueKind.STRING: + return self._quote_sql_string(cast(str, _typed_scalar_payload(value))) return _render_ansi_typed_scalar(value=value) def render_typed_value_list(self, *, value: SqlValue) -> str: @@ -1173,13 +1176,13 @@ def _bigquery_loader_rows_projection_sql( return f"CAST({literal} AS {self._loader_row_sql_type(sql_type)}) AS {quoted_column}" def render_identifier(self, name: str) -> str: - return "`" + name.replace("`", "``") + "`" + return "`" + self._escape_sql_string(name).replace("`", "\\`") + "`" def render_exact_identifier(self, name: str) -> str: return self.render_identifier(name) def _quote_sql_string(self, value: str) -> str: - return "'" + value.replace("'", "''") + "'" + return "'" + self._escape_sql_string(value) + "'" def render_create_initial_snapshot_destination( self, @@ -3046,7 +3049,7 @@ def _table_storage_information_schema(cls, *, database: str | None, location: st @staticmethod def _escape_sql_string(value: str) -> str: - return value.replace("'", "''") + return value.translate(STRING_LITERAL_ESCAPES) @staticmethod def _strip_identifier_quotes(value: str) -> str: diff --git a/src/sqlbuild/adapters/bigquery/constants.py b/src/sqlbuild/adapters/bigquery/constants.py index 1e20b22b27..65c2a45def 100644 --- a/src/sqlbuild/adapters/bigquery/constants.py +++ b/src/sqlbuild/adapters/bigquery/constants.py @@ -52,3 +52,7 @@ + _CLONE_NAME + r"([^A-Za-z0-9_`-]|$)" ) + +STRING_LITERAL_ESCAPES: dict[int, str] = str.maketrans( + {"\\": "\\\\", "'": "\\'", "\n": "\\n", "\r": "\\r"} +) diff --git a/tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py b/tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py index df7520904e..4b4caa4651 100644 --- a/tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py +++ b/tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py @@ -124,7 +124,7 @@ def test_given_builtin_adapter_when_rendering_loader_types_then_maps_every_logic description="bigquery renders backtick-quoted loader identifiers", adapter_name=BuiltinAdapter.BIGQUERY.value, raw_identifier="order `id`", - expected_identifier="`order ``id```", + expected_identifier=r"`order \`id\``", ), AdapterIdentifierRenderingTestCase( description="databricks renders backtick-quoted loader identifiers", @@ -180,7 +180,7 @@ def test_given_builtin_adapter_when_rendering_identifier_then_uses_adapter_quote adapter_name=BuiltinAdapter.BIGQUERY.value, value={"source": "loader's api"}, logical_type=LoaderLogicalType.JSON, - expected_literal="JSON '{\"source\": \"loader''s api\"}'", + expected_literal=r"""JSON '{"source": "loader\'s api"}'""", ), AdapterLoaderValueLiteralTestCase( description="databricks renders json as quoted string literal", diff --git a/tests/unit/src/sqlbuild/adapter/contract/typed_sql_rendering/test_typed_sql_rendering.py b/tests/unit/src/sqlbuild/adapter/contract/typed_sql_rendering/test_typed_sql_rendering.py index 7a65929f2e..c6ee68af5e 100644 --- a/tests/unit/src/sqlbuild/adapter/contract/typed_sql_rendering/test_typed_sql_rendering.py +++ b/tests/unit/src/sqlbuild/adapter/contract/typed_sql_rendering/test_typed_sql_rendering.py @@ -80,7 +80,7 @@ AdapterTypedSqlRenderingTestCase( description="bigquery", adapter_factory=BigQueryAdapter, - expected_string="'O''Brien'", + expected_string="'O\\'Brien'", expected_true="TRUE", expected_false="FALSE", expected_integer="-7", @@ -88,7 +88,7 @@ expected_decimal="NUMERIC '2.4700'", expected_null="NULL", expected_value_list="('GB', 'FR')", - expected_object='JSON \'{"amount":2.4700,"label":"O\'\'Brien"}\'', + expected_object='JSON \'{"amount":2.4700,"label":"O\\\'Brien"}\'', ), AdapterTypedSqlRenderingTestCase( description="databricks", diff --git a/tests/unit/src/sqlbuild/adapters/bigquery/_test_types.py b/tests/unit/src/sqlbuild/adapters/bigquery/_test_types.py index 68c49b8df1..471a294490 100644 --- a/tests/unit/src/sqlbuild/adapters/bigquery/_test_types.py +++ b/tests/unit/src/sqlbuild/adapters/bigquery/_test_types.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime @@ -9,6 +10,7 @@ RowDiffSampleRow, SchemaDiffResult, ) +from sqlbuild.adapters.bigquery.classes.bigquery_adapter import BigQueryAdapter @dataclass(frozen=True) @@ -266,3 +268,17 @@ class BigQueryCloneStatementTestCase: description: str statement: str expected_clone: bool + + +@dataclass(frozen=True) +class BigQueryStringLiteralTestCase: + description: str + value: str + expected_literal: str + + +@dataclass(frozen=True) +class BigQueryGoogleSqlRenderingTestCase: + description: str + render: Callable[[BigQueryAdapter], str | tuple[str, ...]] + expected_sql: str | tuple[str, ...] diff --git a/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py b/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py new file mode 100644 index 0000000000..447df1c2de --- /dev/null +++ b/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py @@ -0,0 +1,89 @@ +"""BigQuery string literals, quoted identifiers and column type changes use GoogleSQL syntax.""" + +from __future__ import annotations + +import pytest + +from sqlbuild.adapter.contract.models import ColumnInfo +from sqlbuild.adapters.bigquery.classes.bigquery_adapter import BigQueryAdapter +from tests.unit.src.sqlbuild.adapter.contract.typed_sql_rendering.helpers import typed_sql_value +from tests.unit.src.sqlbuild.adapters.bigquery._test_types import ( + BigQueryGoogleSqlRenderingTestCase, + BigQueryStringLiteralTestCase, +) + + +@pytest.mark.parametrize( + "test_case", + [ + BigQueryStringLiteralTestCase( + description="single quote is backslash-escaped", + value="it's", + expected_literal=r"'it\'s'", + ), + BigQueryStringLiteralTestCase( + description="backslash is escaped before the quote", + value="C:\\orders\\'", + expected_literal=r"'C:\\orders\\\''", + ), + BigQueryStringLiteralTestCase( + description="line breaks become escape sequences", + value="first\r\nsecond", + expected_literal=r"'first\r\nsecond'", + ), + ], + ids=lambda case: case.description, +) +def test_given_special_characters_when_rendering_string_literal_then_uses_backslash_escapes( + test_case: BigQueryStringLiteralTestCase, +) -> None: + adapter: BigQueryAdapter = BigQueryAdapter() + + assert adapter.render_typed_scalar(value=typed_sql_value(test_case.value)) == ( + test_case.expected_literal + ) + assert adapter.render_loader_value_literal(value=test_case.value, logical_type=None) == ( + test_case.expected_literal + ) + + +@pytest.mark.parametrize( + "test_case", + [ + BigQueryGoogleSqlRenderingTestCase( + description="json text keeps its backslashes escaped", + render=lambda adapter: adapter.render_typed_object( + value=typed_sql_value({"note": 'say "hi"'}) + ), + expected_sql=r"""JSON '{"note":"say \\"hi\\""}'""", + ), + BigQueryGoogleSqlRenderingTestCase( + description="quoted identifier escapes backticks and backslashes", + render=lambda adapter: adapter.render_identifier("order`s\\total"), + expected_sql=r"`order\`s\\total`", + ), + BigQueryGoogleSqlRenderingTestCase( + description="column type change uses set data type", + render=lambda adapter: adapter.render_alter_column_types( + destination="`analytics.orders`", + columns=( + ColumnInfo(name="amount", type="NUMERIC"), + ColumnInfo(name="note", type="STRING"), + ), + ), + expected_sql=( + "ALTER TABLE `analytics.orders` ALTER COLUMN `amount` SET DATA TYPE NUMERIC", + "ALTER TABLE `analytics.orders` ALTER COLUMN `note` SET DATA TYPE STRING", + ), + ), + ], + ids=lambda case: case.description, +) +def test_given_bigquery_statement_parts_when_rendering_then_uses_googlesql_syntax( + test_case: BigQueryGoogleSqlRenderingTestCase, +) -> None: + assert test_case.render(BigQueryAdapter()) == test_case.expected_sql + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"]) diff --git a/tests/unit/src/sqlbuild/adapters/test_relation_max_cursor.py b/tests/unit/src/sqlbuild/adapters/test_relation_max_cursor.py index e702ae026b..b4d82ad7b3 100644 --- a/tests/unit/src/sqlbuild/adapters/test_relation_max_cursor.py +++ b/tests/unit/src/sqlbuild/adapters/test_relation_max_cursor.py @@ -50,8 +50,8 @@ "event`time", "2026-09-01", True, - "SELECT MAX(`event``time`) FROM analytics.events " - "WHERE `event``time` <= CAST('2026-09-01' AS DATE)", + r"SELECT MAX(`event\`time`) FROM analytics.events " + r"WHERE `event\`time` <= CAST('2026-09-01' AS DATE)", ), AdapterEligibleMaxCursorSqlTestCase( "sqlserver date predicate", From 063c49902d33ca9c50b7a5c73bd75015e0ddd5cd Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:40:10 +0000 Subject: [PATCH 2/2] fix(bigquery): keep apostrophes raw in quoted identifiers BigQuery column names may contain an apostrophe, and stored compatibility view definitions spell it raw. Escaping it changed the rendered SQL, so the janitor no longer recognised existing views. Identifiers now escape only backslashes, backticks and line breaks. --- src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py | 3 ++- src/sqlbuild/adapters/bigquery/constants.py | 3 +++ .../unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py | 5 +++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py index e49aabdd81..987d801097 100644 --- a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py +++ b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py @@ -134,6 +134,7 @@ DECIMAL_METADATA_TYPE_NAMES, FLOAT_METADATA_TYPE_NAME, FLOAT_WIRE_TYPE_NAME, + IDENTIFIER_ESCAPES, INTEGER_METADATA_TYPE_NAME, INTEGER_TYPE_TOKEN, INTEGER_WIRE_TYPE_NAME, @@ -1176,7 +1177,7 @@ def _bigquery_loader_rows_projection_sql( return f"CAST({literal} AS {self._loader_row_sql_type(sql_type)}) AS {quoted_column}" def render_identifier(self, name: str) -> str: - return "`" + self._escape_sql_string(name).replace("`", "\\`") + "`" + return "`" + name.translate(IDENTIFIER_ESCAPES) + "`" def render_exact_identifier(self, name: str) -> str: return self.render_identifier(name) diff --git a/src/sqlbuild/adapters/bigquery/constants.py b/src/sqlbuild/adapters/bigquery/constants.py index 65c2a45def..925e79dc17 100644 --- a/src/sqlbuild/adapters/bigquery/constants.py +++ b/src/sqlbuild/adapters/bigquery/constants.py @@ -56,3 +56,6 @@ STRING_LITERAL_ESCAPES: dict[int, str] = str.maketrans( {"\\": "\\\\", "'": "\\'", "\n": "\\n", "\r": "\\r"} ) +IDENTIFIER_ESCAPES: dict[int, str] = str.maketrans( + {"\\": "\\\\", "`": "\\`", "\n": "\\n", "\r": "\\r"} +) diff --git a/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py b/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py index 447df1c2de..283b9e99fc 100644 --- a/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py +++ b/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py @@ -62,6 +62,11 @@ def test_given_special_characters_when_rendering_string_literal_then_uses_backsl render=lambda adapter: adapter.render_identifier("order`s\\total"), expected_sql=r"`order\`s\\total`", ), + BigQueryGoogleSqlRenderingTestCase( + description="quoted identifier keeps an apostrophe as stored view definitions spell it", + render=lambda adapter: adapter.render_identifier("customer's id"), + expected_sql="`customer's id`", + ), BigQueryGoogleSqlRenderingTestCase( description="column type change uses set data type", render=lambda adapter: adapter.render_alter_column_types(