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"])