From 7bd35601c5dc8ced21027191bdc0ef67d37f889b Mon Sep 17 00:00:00 2001 From: Kevin Longe <273747162+kvlonge@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:57:34 +0000 Subject: [PATCH] fix(adapters): escape cursor bounds and UDF packages as dialect string literals Cursor bounds in delete+insert filters, eligible-max probes and bound literals, and BigQuery Python UDF package names, were interpolated into quotes without escaping. They now go through each adapter's own string quoting. Databricks string literals process backslash escapes and concatenate adjacent literals, so its quoting now backslash-escapes backslashes and quotes instead of doubling the quote. --- .../adapter/contract/classes/base_adapter.py | 22 +++-- .../contract/classes/duckdb_backed_adapter.py | 17 ++-- .../bigquery/classes/bigquery_adapter.py | 14 +-- .../databricks/classes/databricks_adapter.py | 30 +++--- src/sqlbuild/adapters/databricks/constants.py | 1 + .../postgres/classes/postgres_adapter.py | 17 ++-- .../snowflake/classes/snowflake_adapter.py | 18 ++-- .../sqlserver/classes/sqlserver_adapter.py | 2 +- .../adapter/contract/test_loader_types.py | 2 +- .../test_typed_sql_rendering.py | 4 +- .../unit/src/sqlbuild/adapters/_test_types.py | 8 ++ .../adapters/bigquery/test_sql_literals.py | 19 ++++ .../adapters/test_cursor_literal_escaping.py | 95 +++++++++++++++++++ 13 files changed, 194 insertions(+), 55 deletions(-) create mode 100644 tests/unit/src/sqlbuild/adapters/test_cursor_literal_escaping.py diff --git a/src/sqlbuild/adapter/contract/classes/base_adapter.py b/src/sqlbuild/adapter/contract/classes/base_adapter.py index dfc315a9e2..e6601010aa 100644 --- a/src/sqlbuild/adapter/contract/classes/base_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/base_adapter.py @@ -405,7 +405,7 @@ def render_max_cursor_at_or_before( quoted_cursor: str = self.render_identifier(cursor_column) maximum_literal: str = ( - f"CAST('{maximum_allowed}' AS DATE)" + f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)" if is_date else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type) ) @@ -425,9 +425,11 @@ def build_cursor_filter( if cursor_column is None or start_cursor is None: return "" - clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"] + clauses: list[str] = [ + f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}" + ] if end_cursor is not None: - clauses.append(f"{cursor_column} < '{end_cursor.value}'") + clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}") return " AND ".join(clauses) def schema_exists( @@ -710,8 +712,9 @@ def render_delete_insert_cursor( ) -> tuple[str, ...]: delete_sql: str = ( f"DELETE FROM {destination} " - f"WHERE {self.render_identifier(cursor_column)} >= '{cursor_start}' " - f"AND {self.render_identifier(cursor_column)} < '{cursor_end}'" + f"WHERE {self.render_identifier(cursor_column)} >= " + f"{self._quote_sql_string(cursor_start)} " + f"AND {self.render_identifier(cursor_column)} < {self._quote_sql_string(cursor_end)}" ) insert_stmts: tuple[str, ...] = self.render_append( destination=destination, sql=sql, columns=columns @@ -2482,14 +2485,19 @@ def expression_inference_profile(self) -> ExpressionInferenceProfile: return ExpressionInferenceProfile(sql_analysis_dialect=self.sql_analysis_dialect()) + def _quote_sql_string(self, value: str) -> str: + """Quote one string literal in this adapter's dialect; adapters override the escaping.""" + + return _quote_sql_string(value) + def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> str: """Render one generic cursor bound literal from a normalized string value.""" if cursor_type == CursorKind.INTEGER: return value if cursor_type == CursorKind.TIMESTAMP: - return f"TIMESTAMP '{value}'" - return f"'{value}'" + return f"TIMESTAMP {self._quote_sql_string(value)}" + return self._quote_sql_string(value) def default_table_promotion_mode(self) -> TablePromotionMode: """Return staged as the generic default promotion mode.""" diff --git a/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py b/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py index 1dde873e0c..95a55403ae 100644 --- a/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py +++ b/src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py @@ -1075,8 +1075,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> if cursor_type == CursorKind.INTEGER: return value if cursor_type == CursorKind.TIMESTAMP: - return f"TIMESTAMP '{value}'" - return f"'{value}'" + return f"TIMESTAMP {self._quote_sql_string(value)}" + return self._quote_sql_string(value) def connect(self, config: dict[str, Any]) -> Any: """Open DuckDB, waiting up to five seconds for another process's database file lock.""" @@ -1197,7 +1197,7 @@ def render_max_cursor_at_or_before( ) -> str: quoted_cursor: str = self.render_identifier(cursor_column) maximum_literal: str = ( - f"CAST('{maximum_allowed}' AS DATE)" + f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)" if is_date else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type) ) @@ -1217,9 +1217,11 @@ def build_cursor_filter( if cursor_column is None or start_cursor is None: return "" - clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"] + clauses: list[str] = [ + f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}" + ] if end_cursor is not None: - clauses.append(f"{cursor_column} < '{end_cursor.value}'") + clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}") return " AND ".join(clauses) def duckdb_build_attach_sql(self, attach_entry: dict[str, object]) -> str: @@ -1604,8 +1606,9 @@ def render_delete_insert_cursor( ) -> tuple[str, ...]: delete_sql: str = ( f"DELETE FROM {destination} " - f"WHERE {self.render_identifier(cursor_column)} >= '{cursor_start}' " - f"AND {self.render_identifier(cursor_column)} < '{cursor_end}'" + f"WHERE {self.render_identifier(cursor_column)} >= " + f"{self._quote_sql_string(cursor_start)} " + f"AND {self.render_identifier(cursor_column)} < {self._quote_sql_string(cursor_end)}" ) insert_stmts: tuple[str, ...] = self.render_append( destination=destination, sql=sql, columns=columns diff --git a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py index 987d801097..bfc30561b6 100644 --- a/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py +++ b/src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py @@ -1691,8 +1691,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> if cursor_type == CursorKind.INTEGER: return value if cursor_type == CursorKind.TIMESTAMP: - return f"TIMESTAMP '{value}'" - return f"'{value}'" + return f"TIMESTAMP {self._quote_sql_string(value)}" + return self._quote_sql_string(value) def default_table_promotion_mode(self) -> TablePromotionMode: return TablePromotionMode.STAGED @@ -1759,7 +1759,9 @@ def render_create_function( ) package_sql: str = "" if packages: - package_values: str = ", ".join(f"'{package}'" for package in packages) + package_values: str = ", ".join( + self._quote_sql_string(package) for package in packages + ) package_sql = f",\n packages = [{package_values}]" return ( "CREATE OR REPLACE FUNCTION " @@ -2354,7 +2356,7 @@ def render_max_cursor_at_or_before( ) -> str: quoted_cursor: str = self.render_identifier(cursor_column) maximum_literal: str = ( - f"CAST('{maximum_allowed}' AS DATE)" + f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)" if is_date else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type) ) @@ -3094,7 +3096,7 @@ def _to_bigquery_type(column_type: str) -> str: def _render_cursor_value_literal(cursor_value: CursorValue) -> str: if cursor_value.kind == CursorKind.INTEGER: return str(cursor_value.value) - return f"TIMESTAMP '{cursor_value.value}'" + return f"TIMESTAMP '{str(cursor_value.value).translate(STRING_LITERAL_ESCAPES)}'" @staticmethod def _to_int(value: object) -> int: @@ -3107,5 +3109,5 @@ def _render_cursor_bound_string(value: str) -> str: try: int(value) except ValueError: - return f"TIMESTAMP '{value}'" + return f"TIMESTAMP '{value.translate(STRING_LITERAL_ESCAPES)}'" return value diff --git a/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py b/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py index 59c4438f80..6a4533c9da 100644 --- a/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py +++ b/src/sqlbuild/adapters/databricks/classes/databricks_adapter.py @@ -11,13 +11,14 @@ from datetime import date, datetime from decimal import Decimal from pathlib import Path -from typing import Any, ClassVar +from typing import Any, ClassVar, cast from sqlbuild._native import ( SeedCsvSettings, SourceFreshnessRecord, SqlLexicalSyntax, SqlValue, + SqlValueKind, render_insert_source_freshness_records_sql, ) from sqlbuild.adapter.contract.classes.base_adapter import ( @@ -25,6 +26,7 @@ _encode_typed_json, _render_ansi_typed_scalar, _render_typed_value_list, + _typed_scalar_payload, ) from sqlbuild.adapter.contract.classes.consumption import ConsumptionMixin from sqlbuild.adapter.contract.classes.historical_check_snapshot_sql import ( @@ -132,6 +134,7 @@ DETAILED_TABLE_INFORMATION_MARKER, NON_ROW_RESULT_COLUMN_NAMES, RELATION_NOT_FOUND_ERROR_CLASSES, + STRING_LITERAL_ESCAPES, TABLE_RELATION_METADATA_TYPES, TEMPORARY_FUNCTION_PREFIX, UNDEFINED_TABLE_SQLSTATE, @@ -884,6 +887,8 @@ def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str: return "STRING" def render_typed_scalar(self, *, value: SqlValue) -> str: + 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: @@ -961,7 +966,7 @@ def _loader_row_sql_type(self, column_type: str | None) -> str: return self._to_databricks_type(column_type) def _quote_sql_string(self, value: str) -> str: - return "'" + value.replace("'", "''") + "'" + return "'" + value.translate(STRING_LITERAL_ESCAPES) + "'" def render_create_initial_snapshot_destination( self, @@ -1830,8 +1835,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> if cursor_type == CursorKind.INTEGER: return value if cursor_type == CursorKind.TIMESTAMP: - return f"TIMESTAMP '{value}'" - return f"'{value}'" + return f"TIMESTAMP {self._quote_sql_string(value)}" + return self._quote_sql_string(value) def render_drop(self, *, destination: str, if_exists: bool = True) -> tuple[str, ...]: exists_clause: str = " IF EXISTS" if if_exists else "" @@ -1960,13 +1965,9 @@ def read_view_definition( connection=connection, sql=( f"SELECT view_definition FROM {views} WHERE lower(table_schema) = lower(" - + "'" - + schema.replace("'", "''") - + "'" + + self._quote_sql_string(schema) + ") AND lower(table_name) = lower(" - + "'" - + name.replace("'", "''") - + "'" + + self._quote_sql_string(name) + ")" ), ).fetchall() @@ -2444,7 +2445,7 @@ def render_max_cursor_at_or_before( ) -> str: quoted_cursor: str = self.render_identifier(cursor_column) maximum_literal: str = ( - f"CAST('{maximum_allowed}' AS DATE)" + f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)" if is_date else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type) ) @@ -3191,8 +3192,7 @@ def _normalize_session_value(value: object | None) -> str | None: @staticmethod def _string_literal(value: str) -> str: - escaped_value: str = value.replace("'", "''") - return f"'{escaped_value}'" + return "'" + value.translate(STRING_LITERAL_ESCAPES) + "'" @staticmethod def _information_schema(database: str) -> str: @@ -3221,13 +3221,13 @@ def _to_databricks_type(column_type: str) -> str: def _render_cursor_value_literal(cursor_value: CursorValue) -> str: if cursor_value.kind == CursorKind.INTEGER: return str(cursor_value.value) - return f"TIMESTAMP '{cursor_value.value}'" + return f"TIMESTAMP '{str(cursor_value.value).translate(STRING_LITERAL_ESCAPES)}'" @staticmethod def _render_cursor_bound_string(value: str) -> str: if value.isdigit(): return value - return f"TIMESTAMP '{value}'" + return f"TIMESTAMP '{value.translate(STRING_LITERAL_ESCAPES)}'" @staticmethod def _to_int(value: object) -> int: diff --git a/src/sqlbuild/adapters/databricks/constants.py b/src/sqlbuild/adapters/databricks/constants.py index bc09f33568..dcf2f31f21 100644 --- a/src/sqlbuild/adapters/databricks/constants.py +++ b/src/sqlbuild/adapters/databricks/constants.py @@ -35,3 +35,4 @@ USAGE_UNAVAILABLE_SQLSTATES: frozenset[str] = frozenset({"42501", "42P01", "42704"}) LINEAGE_VIEW_SOURCE_TYPES: tuple[str, ...] = ("VIEW", "MATERIALIZED_VIEW", "METRIC_VIEW") CURRENT_CATALOG_INFORMATION_SCHEMA: str = "information_schema" +STRING_LITERAL_ESCAPES: dict[int, str] = str.maketrans({"\\": "\\\\", "'": "\\'"}) diff --git a/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py b/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py index e4d53e7a82..b55d163b15 100644 --- a/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py +++ b/src/sqlbuild/adapters/postgres/classes/postgres_adapter.py @@ -300,7 +300,7 @@ def render_max_cursor_at_or_before( ) -> str: quoted_cursor: str = self.render_identifier(cursor_column) maximum_literal: str = ( - f"CAST('{maximum_allowed}' AS DATE)" + f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)" if is_date else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type) ) @@ -320,9 +320,11 @@ def build_cursor_filter( if cursor_column is None or start_cursor is None: return "" - clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"] + clauses: list[str] = [ + f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}" + ] if end_cursor is not None: - clauses.append(f"{cursor_column} < '{end_cursor.value}'") + clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}") return " AND ".join(clauses) def schema_exists( @@ -632,8 +634,9 @@ def render_delete_insert_cursor( ) -> tuple[str, ...]: quoted_cursor_column: str = self.render_identifier(cursor_column) delete_sql: str = ( - f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= '{cursor_start}' " - f"AND {quoted_cursor_column} < '{cursor_end}'" + f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= " + f"{self._quote_sql_string(cursor_start)} " + f"AND {quoted_cursor_column} < {self._quote_sql_string(cursor_end)}" ) insert_stmts: tuple[str, ...] = self.render_append( destination=destination, sql=sql, columns=columns @@ -1932,8 +1935,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> if cursor_type == CursorKind.INTEGER: return value if cursor_type == CursorKind.TIMESTAMP: - return f"TIMESTAMP '{value}'" - return f"'{value}'" + return f"TIMESTAMP {self._quote_sql_string(value)}" + return self._quote_sql_string(value) def default_table_promotion_mode(self) -> TablePromotionMode: """Return staged as the generic default promotion mode.""" diff --git a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py index 5a5990ea81..ddf90d3385 100644 --- a/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py +++ b/src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py @@ -1164,8 +1164,9 @@ def render_delete_insert_cursor( ) -> tuple[str, ...]: quoted_cursor_column: str = self.render_identifier(cursor_column) delete_sql: str = ( - f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= '{cursor_start}' " - f"AND {quoted_cursor_column} < '{cursor_end}'" + f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= " + f"{self._quote_sql_string(cursor_start)} " + f"AND {quoted_cursor_column} < {self._quote_sql_string(cursor_end)}" ) insert_stmts: tuple[str, ...] = self.render_append( destination=destination, sql=sql, columns=columns @@ -3079,10 +3080,7 @@ def render_create_temporary_function( def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> str: if cursor_type == CursorKind.INTEGER: return value - if cursor_type == CursorKind.TIMESTAMP: - escaped_value: str = value.replace("'", "''") - return f"'{escaped_value}'" - return f"'{value}'" + return self._quote_sql_string(value) def interrupt_connection(self, connection: Any) -> bool: """Abort the connection's running statements through Snowflake's cancel request.""" @@ -3462,7 +3460,7 @@ def render_max_cursor_at_or_before( ) -> str: quoted_cursor: str = self.render_identifier(cursor_column) maximum_literal: str = ( - f"CAST('{maximum_allowed}' AS DATE)" + f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)" if is_date else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type) ) @@ -3975,9 +3973,11 @@ def build_cursor_filter( if cursor_column is None or start_cursor is None: return "" - clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"] + clauses: list[str] = [ + f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}" + ] if end_cursor is not None: - clauses.append(f"{cursor_column} < '{end_cursor.value}'") + clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}") return " AND ".join(clauses) def _build_information_schema_type( diff --git a/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py b/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py index 437434e9c9..2e50a2e916 100644 --- a/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py +++ b/src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py @@ -287,7 +287,7 @@ def render_max_cursor_at_or_before( ) -> str: quoted_cursor: str = self.render_identifier(cursor_column) maximum_literal: str = ( - f"CAST('{maximum_allowed}' AS DATE)" + f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)" if is_date else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type) ) 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 4b4caa4651..435ef9fcd2 100644 --- a/tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py +++ b/tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py @@ -187,7 +187,7 @@ def test_given_builtin_adapter_when_rendering_identifier_then_uses_adapter_quote adapter_name=BuiltinAdapter.DATABRICKS.value, value={"source": "loader's api"}, logical_type=LoaderLogicalType.JSON, - expected_literal="'{\"source\": \"loader''s api\"}'", + expected_literal=r"""'{"source": "loader\'s api"}'""", ), AdapterLoaderValueLiteralTestCase( description="duckdb escapes string literals", 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 c6ee68af5e..2d0fea9254 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 @@ -93,7 +93,7 @@ AdapterTypedSqlRenderingTestCase( description="databricks", adapter_factory=DatabricksAdapter, - expected_string="'O''Brien'", + expected_string="'O\\'Brien'", expected_true="TRUE", expected_false="FALSE", expected_integer="-7", @@ -101,7 +101,7 @@ expected_decimal="2.4700", expected_null="NULL", expected_value_list="('GB', 'FR')", - expected_object='parse_json(\'{"amount":2.4700,"label":"O\'\'Brien"}\')', + expected_object='parse_json(\'{"amount":2.4700,"label":"O\\\'Brien"}\')', ), AdapterTypedSqlRenderingTestCase( description="sqlserver", diff --git a/tests/unit/src/sqlbuild/adapters/_test_types.py b/tests/unit/src/sqlbuild/adapters/_test_types.py index d86173bd94..7e4be3ec47 100644 --- a/tests/unit/src/sqlbuild/adapters/_test_types.py +++ b/tests/unit/src/sqlbuild/adapters/_test_types.py @@ -187,3 +187,11 @@ class BigQueryQueuedJobTestCase: pending_polls: int poll_seconds: float expected_maximum_running_seconds: float + + +@dataclass(frozen=True) +class AdapterCursorLiteralEscapingTestCase: + description: str + adapter: StrictAdapter + expected_literal: str + expected_delete_bound: 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 index 283b9e99fc..9be4e9e65b 100644 --- a/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py +++ b/tests/unit/src/sqlbuild/adapters/bigquery/test_sql_literals.py @@ -6,6 +6,7 @@ from sqlbuild.adapter.contract.models import ColumnInfo from sqlbuild.adapters.bigquery.classes.bigquery_adapter import BigQueryAdapter +from sqlbuild.compiler.compile.types import FunctionLanguage 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, @@ -67,6 +68,24 @@ def test_given_special_characters_when_rendering_string_literal_then_uses_backsl render=lambda adapter: adapter.render_identifier("customer's id"), expected_sql="`customer's id`", ), + BigQueryGoogleSqlRenderingTestCase( + description="python udf packages are escaped string literals", + render=lambda adapter: next( + line + for line in adapter.render_create_function( + destination="demo.udfs.score", + arguments=(), + returns="INT64", + body_sql="def main():\n return 1", + language=FunctionLanguage.PYTHON, + runtime_version="3.11", + entry_point="main", + packages=("numpy", "o'k\\x\ny"), + )[0].splitlines() + if "packages" in line + ), + expected_sql=r" packages = ['numpy', 'o\'k\\x\ny']", + ), BigQueryGoogleSqlRenderingTestCase( description="column type change uses set data type", render=lambda adapter: adapter.render_alter_column_types( diff --git a/tests/unit/src/sqlbuild/adapters/test_cursor_literal_escaping.py b/tests/unit/src/sqlbuild/adapters/test_cursor_literal_escaping.py new file mode 100644 index 0000000000..83946d919e --- /dev/null +++ b/tests/unit/src/sqlbuild/adapters/test_cursor_literal_escaping.py @@ -0,0 +1,95 @@ +"""Cursor bounds reach SQL through each dialect's string escaping, whatever their text holds.""" + +from __future__ import annotations + +import pytest + +from sqlbuild.adapter.contract.classes.strict_adapter import StrictAdapter +from sqlbuild.adapters.bigquery.classes.bigquery_adapter import BigQueryAdapter +from sqlbuild.adapters.clickhouse.classes.clickhouse_adapter import ClickHouseAdapter +from sqlbuild.adapters.databricks.classes.databricks_adapter import DatabricksAdapter +from sqlbuild.adapters.duckdb.classes.duckdb_adapter import DuckDbAdapter +from sqlbuild.adapters.motherduck.classes.motherduck_adapter import MotherDuckAdapter +from sqlbuild.adapters.postgres.classes.postgres_adapter import PostgresAdapter +from sqlbuild.adapters.snowflake.classes.snowflake_adapter import SnowflakeAdapter +from sqlbuild.adapters.sqlserver.classes.sqlserver_adapter import SqlServerAdapter +from tests.unit.src.sqlbuild.adapters._test_types import AdapterCursorLiteralEscapingTestCase + +BOUND: str = "o'k\\x\ny" + + +@pytest.mark.parametrize( + "test_case", + ( + AdapterCursorLiteralEscapingTestCase( + "duckdb doubles quotes", DuckDbAdapter(), "'o''k\\x\ny'", "'o''k\\x\ny'" + ), + AdapterCursorLiteralEscapingTestCase( + "motherduck doubles quotes", MotherDuckAdapter(), "'o''k\\x\ny'", "'o''k\\x\ny'" + ), + AdapterCursorLiteralEscapingTestCase( + "postgres doubles quotes", PostgresAdapter(), "'o''k\\x\ny'", "'o''k\\x\ny'" + ), + AdapterCursorLiteralEscapingTestCase( + "sqlserver doubles quotes", SqlServerAdapter(), "'o''k\\x\ny'", "'o''k\\x\ny'" + ), + AdapterCursorLiteralEscapingTestCase( + "snowflake doubles quotes and escapes backslashes", + SnowflakeAdapter(), + "'o''k\\\\x\ny'", + "'o''k\\\\x\ny'", + ), + AdapterCursorLiteralEscapingTestCase( + "clickhouse backslash-escapes", + ClickHouseAdapter(), + "'o\\'k\\\\x\ny'", + "'o\\'k\\\\x\ny'", + ), + AdapterCursorLiteralEscapingTestCase( + "databricks backslash-escapes", + DatabricksAdapter(), + "'o\\'k\\\\x\ny'", + "TIMESTAMP 'o\\'k\\\\x\ny'", + ), + AdapterCursorLiteralEscapingTestCase( + "bigquery backslash-escapes line breaks too", + BigQueryAdapter(), + "'o\\'k\\\\x\\ny'", + "TIMESTAMP 'o\\'k\\\\x\\ny'", + ), + ), + ids=lambda case: case.description, +) +def test_given_cursor_bound_with_quote_backslash_and_newline_when_rendering_then_escapes_it( + test_case: AdapterCursorLiteralEscapingTestCase, +) -> None: + adapter: StrictAdapter = test_case.adapter + + assert adapter.render_cursor_bound_literal(value=BOUND, cursor_type=None) == ( + test_case.expected_literal + ) + assert test_case.expected_literal in adapter.render_cursor_bound_literal( + value=BOUND, cursor_type="timestamp" + ) + max_sql: str = adapter.render_max_cursor_at_or_before( + relation="analytics.events", + cursor_column="updated_at", + maximum_allowed=BOUND, + cursor_type="timestamp", + is_date=True, + ) + assert test_case.expected_literal in max_sql + delete_sql: str = "\n".join( + adapter.render_delete_insert_cursor( + destination="analytics.events", + sql="SELECT 1", + cursor_column="updated_at", + cursor_start=BOUND, + cursor_end=BOUND, + ) + ) + assert delete_sql.count(test_case.expected_delete_bound) == 2 + + +if __name__ == "__main__": + pytest.main([__file__, "-vv"])