Skip to content

Commit d3fc0e0

Browse files
authored
fix(adapters): escape cursor bounds and UDF packages as dialect string literals (#1346)
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.
1 parent f393b8c commit d3fc0e0

13 files changed

Lines changed: 194 additions & 55 deletions

File tree

‎src/sqlbuild/adapter/contract/classes/base_adapter.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ def render_max_cursor_at_or_before(
405405

406406
quoted_cursor: str = self.render_identifier(cursor_column)
407407
maximum_literal: str = (
408-
f"CAST('{maximum_allowed}' AS DATE)"
408+
f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)"
409409
if is_date
410410
else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type)
411411
)
@@ -425,9 +425,11 @@ def build_cursor_filter(
425425

426426
if cursor_column is None or start_cursor is None:
427427
return ""
428-
clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"]
428+
clauses: list[str] = [
429+
f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}"
430+
]
429431
if end_cursor is not None:
430-
clauses.append(f"{cursor_column} < '{end_cursor.value}'")
432+
clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}")
431433
return " AND ".join(clauses)
432434

433435
def schema_exists(
@@ -710,8 +712,9 @@ def render_delete_insert_cursor(
710712
) -> tuple[str, ...]:
711713
delete_sql: str = (
712714
f"DELETE FROM {destination} "
713-
f"WHERE {self.render_identifier(cursor_column)} >= '{cursor_start}' "
714-
f"AND {self.render_identifier(cursor_column)} < '{cursor_end}'"
715+
f"WHERE {self.render_identifier(cursor_column)} >= "
716+
f"{self._quote_sql_string(cursor_start)} "
717+
f"AND {self.render_identifier(cursor_column)} < {self._quote_sql_string(cursor_end)}"
715718
)
716719
insert_stmts: tuple[str, ...] = self.render_append(
717720
destination=destination, sql=sql, columns=columns
@@ -2482,14 +2485,19 @@ def expression_inference_profile(self) -> ExpressionInferenceProfile:
24822485

24832486
return ExpressionInferenceProfile(sql_analysis_dialect=self.sql_analysis_dialect())
24842487

2488+
def _quote_sql_string(self, value: str) -> str:
2489+
"""Quote one string literal in this adapter's dialect; adapters override the escaping."""
2490+
2491+
return _quote_sql_string(value)
2492+
24852493
def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> str:
24862494
"""Render one generic cursor bound literal from a normalized string value."""
24872495

24882496
if cursor_type == CursorKind.INTEGER:
24892497
return value
24902498
if cursor_type == CursorKind.TIMESTAMP:
2491-
return f"TIMESTAMP '{value}'"
2492-
return f"'{value}'"
2499+
return f"TIMESTAMP {self._quote_sql_string(value)}"
2500+
return self._quote_sql_string(value)
24932501

24942502
def default_table_promotion_mode(self) -> TablePromotionMode:
24952503
"""Return staged as the generic default promotion mode."""

‎src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,8 +1075,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) ->
10751075
if cursor_type == CursorKind.INTEGER:
10761076
return value
10771077
if cursor_type == CursorKind.TIMESTAMP:
1078-
return f"TIMESTAMP '{value}'"
1079-
return f"'{value}'"
1078+
return f"TIMESTAMP {self._quote_sql_string(value)}"
1079+
return self._quote_sql_string(value)
10801080

10811081
def connect(self, config: dict[str, Any]) -> Any:
10821082
"""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(
11971197
) -> str:
11981198
quoted_cursor: str = self.render_identifier(cursor_column)
11991199
maximum_literal: str = (
1200-
f"CAST('{maximum_allowed}' AS DATE)"
1200+
f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)"
12011201
if is_date
12021202
else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type)
12031203
)
@@ -1217,9 +1217,11 @@ def build_cursor_filter(
12171217

12181218
if cursor_column is None or start_cursor is None:
12191219
return ""
1220-
clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"]
1220+
clauses: list[str] = [
1221+
f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}"
1222+
]
12211223
if end_cursor is not None:
1222-
clauses.append(f"{cursor_column} < '{end_cursor.value}'")
1224+
clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}")
12231225
return " AND ".join(clauses)
12241226

12251227
def duckdb_build_attach_sql(self, attach_entry: dict[str, object]) -> str:
@@ -1604,8 +1606,9 @@ def render_delete_insert_cursor(
16041606
) -> tuple[str, ...]:
16051607
delete_sql: str = (
16061608
f"DELETE FROM {destination} "
1607-
f"WHERE {self.render_identifier(cursor_column)} >= '{cursor_start}' "
1608-
f"AND {self.render_identifier(cursor_column)} < '{cursor_end}'"
1609+
f"WHERE {self.render_identifier(cursor_column)} >= "
1610+
f"{self._quote_sql_string(cursor_start)} "
1611+
f"AND {self.render_identifier(cursor_column)} < {self._quote_sql_string(cursor_end)}"
16091612
)
16101613
insert_stmts: tuple[str, ...] = self.render_append(
16111614
destination=destination, sql=sql, columns=columns

‎src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1691,8 +1691,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) ->
16911691
if cursor_type == CursorKind.INTEGER:
16921692
return value
16931693
if cursor_type == CursorKind.TIMESTAMP:
1694-
return f"TIMESTAMP '{value}'"
1695-
return f"'{value}'"
1694+
return f"TIMESTAMP {self._quote_sql_string(value)}"
1695+
return self._quote_sql_string(value)
16961696

16971697
def default_table_promotion_mode(self) -> TablePromotionMode:
16981698
return TablePromotionMode.STAGED
@@ -1759,7 +1759,9 @@ def render_create_function(
17591759
)
17601760
package_sql: str = ""
17611761
if packages:
1762-
package_values: str = ", ".join(f"'{package}'" for package in packages)
1762+
package_values: str = ", ".join(
1763+
self._quote_sql_string(package) for package in packages
1764+
)
17631765
package_sql = f",\n packages = [{package_values}]"
17641766
return (
17651767
"CREATE OR REPLACE FUNCTION "
@@ -2354,7 +2356,7 @@ def render_max_cursor_at_or_before(
23542356
) -> str:
23552357
quoted_cursor: str = self.render_identifier(cursor_column)
23562358
maximum_literal: str = (
2357-
f"CAST('{maximum_allowed}' AS DATE)"
2359+
f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)"
23582360
if is_date
23592361
else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type)
23602362
)
@@ -3094,7 +3096,7 @@ def _to_bigquery_type(column_type: str) -> str:
30943096
def _render_cursor_value_literal(cursor_value: CursorValue) -> str:
30953097
if cursor_value.kind == CursorKind.INTEGER:
30963098
return str(cursor_value.value)
3097-
return f"TIMESTAMP '{cursor_value.value}'"
3099+
return f"TIMESTAMP '{str(cursor_value.value).translate(STRING_LITERAL_ESCAPES)}'"
30983100

30993101
@staticmethod
31003102
def _to_int(value: object) -> int:
@@ -3107,5 +3109,5 @@ def _render_cursor_bound_string(value: str) -> str:
31073109
try:
31083110
int(value)
31093111
except ValueError:
3110-
return f"TIMESTAMP '{value}'"
3112+
return f"TIMESTAMP '{value.translate(STRING_LITERAL_ESCAPES)}'"
31113113
return value

‎src/sqlbuild/adapters/databricks/classes/databricks_adapter.py‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,22 @@
1111
from datetime import date, datetime
1212
from decimal import Decimal
1313
from pathlib import Path
14-
from typing import Any, ClassVar
14+
from typing import Any, ClassVar, cast
1515

1616
from sqlbuild._native import (
1717
SeedCsvSettings,
1818
SourceFreshnessRecord,
1919
SqlLexicalSyntax,
2020
SqlValue,
21+
SqlValueKind,
2122
render_insert_source_freshness_records_sql,
2223
)
2324
from sqlbuild.adapter.contract.classes.base_adapter import (
2425
BaseAdapter,
2526
_encode_typed_json,
2627
_render_ansi_typed_scalar,
2728
_render_typed_value_list,
29+
_typed_scalar_payload,
2830
)
2931
from sqlbuild.adapter.contract.classes.consumption import ConsumptionMixin
3032
from sqlbuild.adapter.contract.classes.historical_check_snapshot_sql import (
@@ -132,6 +134,7 @@
132134
DETAILED_TABLE_INFORMATION_MARKER,
133135
NON_ROW_RESULT_COLUMN_NAMES,
134136
RELATION_NOT_FOUND_ERROR_CLASSES,
137+
STRING_LITERAL_ESCAPES,
135138
TABLE_RELATION_METADATA_TYPES,
136139
TEMPORARY_FUNCTION_PREFIX,
137140
UNDEFINED_TABLE_SQLSTATE,
@@ -884,6 +887,8 @@ def render_loader_logical_type(self, type_name: LoaderLogicalType) -> str:
884887
return "STRING"
885888

886889
def render_typed_scalar(self, *, value: SqlValue) -> str:
890+
if value.kind == SqlValueKind.STRING:
891+
return self._quote_sql_string(cast(str, _typed_scalar_payload(value)))
887892
return _render_ansi_typed_scalar(value=value)
888893

889894
def render_typed_value_list(self, *, value: SqlValue) -> str:
@@ -961,7 +966,7 @@ def _loader_row_sql_type(self, column_type: str | None) -> str:
961966
return self._to_databricks_type(column_type)
962967

963968
def _quote_sql_string(self, value: str) -> str:
964-
return "'" + value.replace("'", "''") + "'"
969+
return "'" + value.translate(STRING_LITERAL_ESCAPES) + "'"
965970

966971
def render_create_initial_snapshot_destination(
967972
self,
@@ -1830,8 +1835,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) ->
18301835
if cursor_type == CursorKind.INTEGER:
18311836
return value
18321837
if cursor_type == CursorKind.TIMESTAMP:
1833-
return f"TIMESTAMP '{value}'"
1834-
return f"'{value}'"
1838+
return f"TIMESTAMP {self._quote_sql_string(value)}"
1839+
return self._quote_sql_string(value)
18351840

18361841
def render_drop(self, *, destination: str, if_exists: bool = True) -> tuple[str, ...]:
18371842
exists_clause: str = " IF EXISTS" if if_exists else ""
@@ -1960,13 +1965,9 @@ def read_view_definition(
19601965
connection=connection,
19611966
sql=(
19621967
f"SELECT view_definition FROM {views} WHERE lower(table_schema) = lower("
1963-
+ "'"
1964-
+ schema.replace("'", "''")
1965-
+ "'"
1968+
+ self._quote_sql_string(schema)
19661969
+ ") AND lower(table_name) = lower("
1967-
+ "'"
1968-
+ name.replace("'", "''")
1969-
+ "'"
1970+
+ self._quote_sql_string(name)
19701971
+ ")"
19711972
),
19721973
).fetchall()
@@ -2444,7 +2445,7 @@ def render_max_cursor_at_or_before(
24442445
) -> str:
24452446
quoted_cursor: str = self.render_identifier(cursor_column)
24462447
maximum_literal: str = (
2447-
f"CAST('{maximum_allowed}' AS DATE)"
2448+
f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)"
24482449
if is_date
24492450
else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type)
24502451
)
@@ -3191,8 +3192,7 @@ def _normalize_session_value(value: object | None) -> str | None:
31913192

31923193
@staticmethod
31933194
def _string_literal(value: str) -> str:
3194-
escaped_value: str = value.replace("'", "''")
3195-
return f"'{escaped_value}'"
3195+
return "'" + value.translate(STRING_LITERAL_ESCAPES) + "'"
31963196

31973197
@staticmethod
31983198
def _information_schema(database: str) -> str:
@@ -3221,13 +3221,13 @@ def _to_databricks_type(column_type: str) -> str:
32213221
def _render_cursor_value_literal(cursor_value: CursorValue) -> str:
32223222
if cursor_value.kind == CursorKind.INTEGER:
32233223
return str(cursor_value.value)
3224-
return f"TIMESTAMP '{cursor_value.value}'"
3224+
return f"TIMESTAMP '{str(cursor_value.value).translate(STRING_LITERAL_ESCAPES)}'"
32253225

32263226
@staticmethod
32273227
def _render_cursor_bound_string(value: str) -> str:
32283228
if value.isdigit():
32293229
return value
3230-
return f"TIMESTAMP '{value}'"
3230+
return f"TIMESTAMP '{value.translate(STRING_LITERAL_ESCAPES)}'"
32313231

32323232
@staticmethod
32333233
def _to_int(value: object) -> int:

‎src/sqlbuild/adapters/databricks/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@
3535
USAGE_UNAVAILABLE_SQLSTATES: frozenset[str] = frozenset({"42501", "42P01", "42704"})
3636
LINEAGE_VIEW_SOURCE_TYPES: tuple[str, ...] = ("VIEW", "MATERIALIZED_VIEW", "METRIC_VIEW")
3737
CURRENT_CATALOG_INFORMATION_SCHEMA: str = "information_schema"
38+
STRING_LITERAL_ESCAPES: dict[int, str] = str.maketrans({"\\": "\\\\", "'": "\\'"})

‎src/sqlbuild/adapters/postgres/classes/postgres_adapter.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ def render_max_cursor_at_or_before(
300300
) -> str:
301301
quoted_cursor: str = self.render_identifier(cursor_column)
302302
maximum_literal: str = (
303-
f"CAST('{maximum_allowed}' AS DATE)"
303+
f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)"
304304
if is_date
305305
else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type)
306306
)
@@ -320,9 +320,11 @@ def build_cursor_filter(
320320

321321
if cursor_column is None or start_cursor is None:
322322
return ""
323-
clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"]
323+
clauses: list[str] = [
324+
f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}"
325+
]
324326
if end_cursor is not None:
325-
clauses.append(f"{cursor_column} < '{end_cursor.value}'")
327+
clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}")
326328
return " AND ".join(clauses)
327329

328330
def schema_exists(
@@ -632,8 +634,9 @@ def render_delete_insert_cursor(
632634
) -> tuple[str, ...]:
633635
quoted_cursor_column: str = self.render_identifier(cursor_column)
634636
delete_sql: str = (
635-
f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= '{cursor_start}' "
636-
f"AND {quoted_cursor_column} < '{cursor_end}'"
637+
f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= "
638+
f"{self._quote_sql_string(cursor_start)} "
639+
f"AND {quoted_cursor_column} < {self._quote_sql_string(cursor_end)}"
637640
)
638641
insert_stmts: tuple[str, ...] = self.render_append(
639642
destination=destination, sql=sql, columns=columns
@@ -1932,8 +1935,8 @@ def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) ->
19321935
if cursor_type == CursorKind.INTEGER:
19331936
return value
19341937
if cursor_type == CursorKind.TIMESTAMP:
1935-
return f"TIMESTAMP '{value}'"
1936-
return f"'{value}'"
1938+
return f"TIMESTAMP {self._quote_sql_string(value)}"
1939+
return self._quote_sql_string(value)
19371940

19381941
def default_table_promotion_mode(self) -> TablePromotionMode:
19391942
"""Return staged as the generic default promotion mode."""

‎src/sqlbuild/adapters/snowflake/classes/snowflake_adapter.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,8 +1164,9 @@ def render_delete_insert_cursor(
11641164
) -> tuple[str, ...]:
11651165
quoted_cursor_column: str = self.render_identifier(cursor_column)
11661166
delete_sql: str = (
1167-
f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= '{cursor_start}' "
1168-
f"AND {quoted_cursor_column} < '{cursor_end}'"
1167+
f"DELETE FROM {destination} WHERE {quoted_cursor_column} >= "
1168+
f"{self._quote_sql_string(cursor_start)} "
1169+
f"AND {quoted_cursor_column} < {self._quote_sql_string(cursor_end)}"
11691170
)
11701171
insert_stmts: tuple[str, ...] = self.render_append(
11711172
destination=destination, sql=sql, columns=columns
@@ -3079,10 +3080,7 @@ def render_create_temporary_function(
30793080
def render_cursor_bound_literal(self, *, value: str, cursor_type: str | None) -> str:
30803081
if cursor_type == CursorKind.INTEGER:
30813082
return value
3082-
if cursor_type == CursorKind.TIMESTAMP:
3083-
escaped_value: str = value.replace("'", "''")
3084-
return f"'{escaped_value}'"
3085-
return f"'{value}'"
3083+
return self._quote_sql_string(value)
30863084

30873085
def interrupt_connection(self, connection: Any) -> bool:
30883086
"""Abort the connection's running statements through Snowflake's cancel request."""
@@ -3462,7 +3460,7 @@ def render_max_cursor_at_or_before(
34623460
) -> str:
34633461
quoted_cursor: str = self.render_identifier(cursor_column)
34643462
maximum_literal: str = (
3465-
f"CAST('{maximum_allowed}' AS DATE)"
3463+
f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)"
34663464
if is_date
34673465
else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type)
34683466
)
@@ -3975,9 +3973,11 @@ def build_cursor_filter(
39753973

39763974
if cursor_column is None or start_cursor is None:
39773975
return ""
3978-
clauses: list[str] = [f"{cursor_column} >= '{start_cursor.value}'"]
3976+
clauses: list[str] = [
3977+
f"{cursor_column} >= {self._quote_sql_string(str(start_cursor.value))}"
3978+
]
39793979
if end_cursor is not None:
3980-
clauses.append(f"{cursor_column} < '{end_cursor.value}'")
3980+
clauses.append(f"{cursor_column} < {self._quote_sql_string(str(end_cursor.value))}")
39813981
return " AND ".join(clauses)
39823982

39833983
def _build_information_schema_type(

‎src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ def render_max_cursor_at_or_before(
287287
) -> str:
288288
quoted_cursor: str = self.render_identifier(cursor_column)
289289
maximum_literal: str = (
290-
f"CAST('{maximum_allowed}' AS DATE)"
290+
f"CAST({self._quote_sql_string(maximum_allowed)} AS DATE)"
291291
if is_date
292292
else self.render_cursor_bound_literal(value=maximum_allowed, cursor_type=cursor_type)
293293
)

‎tests/unit/src/sqlbuild/adapter/contract/test_loader_types.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def test_given_builtin_adapter_when_rendering_identifier_then_uses_adapter_quote
187187
adapter_name=BuiltinAdapter.DATABRICKS.value,
188188
value={"source": "loader's api"},
189189
logical_type=LoaderLogicalType.JSON,
190-
expected_literal="'{\"source\": \"loader''s api\"}'",
190+
expected_literal=r"""'{"source": "loader\'s api"}'""",
191191
),
192192
AdapterLoaderValueLiteralTestCase(
193193
description="duckdb escapes string literals",

‎tests/unit/src/sqlbuild/adapter/contract/typed_sql_rendering/test_typed_sql_rendering.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,15 @@
9393
AdapterTypedSqlRenderingTestCase(
9494
description="databricks",
9595
adapter_factory=DatabricksAdapter,
96-
expected_string="'O''Brien'",
96+
expected_string="'O\\'Brien'",
9797
expected_true="TRUE",
9898
expected_false="FALSE",
9999
expected_integer="-7",
100100
expected_float="1.25",
101101
expected_decimal="2.4700",
102102
expected_null="NULL",
103103
expected_value_list="('GB', 'FR')",
104-
expected_object='parse_json(\'{"amount":2.4700,"label":"O\'\'Brien"}\')',
104+
expected_object='parse_json(\'{"amount":2.4700,"label":"O\\\'Brien"}\')',
105105
),
106106
AdapterTypedSqlRenderingTestCase(
107107
description="sqlserver",

0 commit comments

Comments
 (0)