diff --git a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py index ac7ed14d08..987d801097 100644 --- a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py +++ b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py @@ -134,12 +134,14 @@ 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, NATIVE_TABLE_TYPE, NOT_FOUND_ERROR_CLASS_NAME, SELECT_STATEMENT_TYPE, + STRING_LITERAL_ESCAPES, TABLE_NAME_WILDCARD, TIMESTAMP_TYPE_TOKEN, ) @@ -1026,7 +1028,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 +1091,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 +1177,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 "`" + name.translate(IDENTIFIER_ESCAPES) + "`" 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 +3050,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..925e79dc17 100644 --- a/src/sqlbuild/adapters/bigquery/constants.py +++ b/src/sqlbuild/adapters/bigquery/constants.py @@ -52,3 +52,10 @@ + _CLONE_NAME + r"([^A-Za-z0-9_`-]|$)" ) + +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/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..283b9e99fc --- /dev/null +++ b/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py @@ -0,0 +1,94 @@ +"""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="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( + 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",