Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/sqlbuild/adapters/bigquery/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
4 changes: 2 additions & 2 deletions tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,15 @@
AdapterTypedSqlRenderingTestCase(
description="bigquery",
adapter_factory=BigQueryAdapter,
expected_string="'O''Brien'",
expected_string="'O\\'Brien'",
expected_true="TRUE",
expected_false="FALSE",
expected_integer="-7",
expected_float="1.25",
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",
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/src/sqlbuild/adapters/bigquery/_test_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime

Expand All @@ -9,6 +10,7 @@
RowDiffSampleRow,
SchemaDiffResult,
)
from sqlbuild.adapters.bigquery.classes.bigquery_adapter import BigQueryAdapter


@dataclass(frozen=True)
Expand Down Expand Up @@ -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, ...]
94 changes: 94 additions & 0 deletions tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py
Original file line number Diff line number Diff line change
@@ -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"])
4 changes: 2 additions & 2 deletions tests/unit/src/sqlbuild/adapters/test_relation_max_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading