Skip to content

Commit 63b113a

Browse files
committed
fix(athena): escape DDL identifiers (backticks) in CREATE/ALTER and overwrite DELETE
1 parent c8f3b53 commit 63b113a

2 files changed

Lines changed: 81 additions & 19 deletions

File tree

awswrangler/athena/_write_iceberg.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,26 @@ def _escape_athena_string_literal(value: Any) -> str:
4040

4141

4242
def _escape_athena_identifier(name: Any) -> str:
43-
# Used for identifiers (column/table names) spliced inside double-quote delimited
44-
# identifiers, e.g. "<name>". Column names can originate from the Glue Data Catalog
45-
# during automatic schema reconciliation, so they are not necessarily caller-trusted.
43+
# For identifiers (column/table names) spliced inside DOUBLE-QUOTE delimited
44+
# identifiers in DML statements (SELECT/INSERT/MERGE/DELETE), e.g. "<name>".
45+
# Column names can originate from the Glue Data Catalog during automatic schema
46+
# reconciliation, so they are not necessarily caller-trusted.
4647
#
47-
# Trino/Athena delimited identifiers escape an embedded double-quote by doubling it.
48-
# Without this, a name containing " closes the identifier and appends arbitrary SQL.
48+
# Athena's Trino-based DML engine escapes an embedded double-quote in a delimited
49+
# identifier by doubling it. Without this, a name containing " closes the
50+
# identifier and the remainder is parsed as SQL.
4951
return str(name).replace('"', '""')
5052

5153

54+
def _escape_athena_ddl_identifier(name: Any) -> str:
55+
# For identifiers spliced inside BACKTICK delimited identifiers in DDL statements
56+
# (CREATE TABLE / ALTER TABLE), e.g. `<name>`. Athena's Hive-based DDL engine uses
57+
# backticks (not double quotes) for identifier quoting and escapes an embedded
58+
# backtick by doubling it. Mixing the two families is a syntax error, so DDL and
59+
# DML splices use separate helpers.
60+
return str(name).replace("`", "``")
61+
62+
5263
def _create_iceberg_table(
5364
df: pd.DataFrame,
5465
database: str,
@@ -73,12 +84,14 @@ def _create_iceberg_table(
7384
columns_types, _ = catalog.extract_athena_types(df=df, index=index, dtype=dtype)
7485
cols_str: str = ", ".join(
7586
[
76-
f"{k} {v}"
87+
f"`{_escape_athena_ddl_identifier(k)}` {v}"
7788
if (columns_comments is None or columns_comments.get(k) is None)
78-
else f"{k} {v} COMMENT '{_escape_athena_string_literal(columns_comments[k])}'"
89+
else f"`{_escape_athena_ddl_identifier(k)}` {v} COMMENT '{_escape_athena_string_literal(columns_comments[k])}'"
7990
for k, v in columns_types.items()
8091
]
8192
)
93+
# partition_cols may be partition transform expressions (e.g. "day(ts)", "truncate(10, col)"),
94+
# not plain identifiers, so they are spliced verbatim rather than quoted as identifiers.
8295
partition_cols_str: str = f"PARTITIONED BY ({', '.join([col for col in partition_cols])})" if partition_cols else ""
8396
table_properties_str: str = (
8497
", "
@@ -93,9 +106,9 @@ def _create_iceberg_table(
93106
)
94107

95108
create_sql: str = (
96-
f"CREATE TABLE IF NOT EXISTS `{table}` ({cols_str}) "
109+
f"CREATE TABLE IF NOT EXISTS `{_escape_athena_ddl_identifier(table)}` ({cols_str}) "
97110
f"{partition_cols_str} "
98-
f"LOCATION '{path}' "
111+
f"LOCATION '{_escape_athena_string_literal(path)}' "
99112
f"TBLPROPERTIES ('table_type' ='ICEBERG', 'format'='parquet'{table_properties_str})"
100113
)
101114

@@ -227,10 +240,10 @@ def _alter_iceberg_table_add_columns_sql(
227240
columns_to_add: dict[str, str],
228241
) -> list[str]:
229242
add_cols_str = ", ".join(
230-
[f'"{_escape_athena_identifier(col_name)}" {columns_to_add[col_name]}' for col_name in columns_to_add]
243+
[f"`{_escape_athena_ddl_identifier(col_name)}` {columns_to_add[col_name]}" for col_name in columns_to_add]
231244
)
232245

233-
return [f'ALTER TABLE "{_escape_athena_identifier(table)}" ADD COLUMNS ({add_cols_str})']
246+
return [f"ALTER TABLE `{_escape_athena_ddl_identifier(table)}` ADD COLUMNS ({add_cols_str})"]
234247

235248

236249
def _alter_iceberg_table_change_columns_sql(
@@ -240,9 +253,9 @@ def _alter_iceberg_table_change_columns_sql(
240253
sql_statements = []
241254

242255
for col_name, col_type in columns_to_change.items():
243-
escaped_table = _escape_athena_identifier(table)
244-
escaped_col = _escape_athena_identifier(col_name)
245-
sql_statements.append(f'ALTER TABLE "{escaped_table}" CHANGE COLUMN "{escaped_col}" "{escaped_col}" {col_type}')
256+
escaped_table = _escape_athena_ddl_identifier(table)
257+
escaped_col = _escape_athena_ddl_identifier(col_name)
258+
sql_statements.append(f"ALTER TABLE `{escaped_table}` CHANGE COLUMN `{escaped_col}` `{escaped_col}` {col_type}")
246259

247260
return sql_statements
248261

@@ -666,7 +679,7 @@ def to_iceberg( # noqa: PLR0913
666679
)
667680
# if mode == "overwrite", delete whole data from table (but not table itself)
668681
elif mode == "overwrite":
669-
delete_sql_statement = f"DELETE FROM {table}"
682+
delete_sql_statement = f'DELETE FROM "{_escape_athena_identifier(table)}"'
670683
delete_query_execution_id: str = _start_query_execution(
671684
sql=delete_sql_statement,
672685
workgroup=workgroup,

tests/unit/test_athena_iceberg.py

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1287,18 +1287,27 @@ def test_build_order_by_clause_escapes_identifier() -> None:
12871287
assert result == 'ORDER BY "a""b"'
12881288

12891289

1290+
def test_escape_athena_ddl_identifier_doubles_backticks() -> None:
1291+
from awswrangler.athena._write_iceberg import _escape_athena_ddl_identifier
1292+
1293+
assert _escape_athena_ddl_identifier("name") == "name"
1294+
assert _escape_athena_ddl_identifier("a`b") == "a``b"
1295+
assert _escape_athena_ddl_identifier("x`) DROP TABLE t --") == "x``) DROP TABLE t --"
1296+
1297+
12901298
def test_alter_iceberg_add_columns_escapes_identifier() -> None:
1299+
# ALTER TABLE is Hive-based DDL: identifiers are backtick-quoted, escaped by doubling `.
12911300
from awswrangler.athena._write_iceberg import _alter_iceberg_table_add_columns_sql
12921301

1293-
result = _alter_iceberg_table_add_columns_sql(table='t"1', columns_to_add={'c") x --': "bigint"})
1294-
assert result == ['ALTER TABLE "t""1" ADD COLUMNS ("c"") x --" bigint)']
1302+
result = _alter_iceberg_table_add_columns_sql(table="t`1", columns_to_add={"c`) x --": "bigint"})
1303+
assert result == ["ALTER TABLE `t``1` ADD COLUMNS (`c``) x --` bigint)"]
12951304

12961305

12971306
def test_alter_iceberg_change_columns_escapes_identifier() -> None:
12981307
from awswrangler.athena._write_iceberg import _alter_iceberg_table_change_columns_sql
12991308

1300-
result = _alter_iceberg_table_change_columns_sql(table='t"1', columns_to_change={'c"x': "bigint"})
1301-
assert result == ['ALTER TABLE "t""1" CHANGE COLUMN "c""x" "c""x" bigint']
1309+
result = _alter_iceberg_table_change_columns_sql(table="t`1", columns_to_change={"c`x": "bigint"})
1310+
assert result == ["ALTER TABLE `t``1` CHANGE COLUMN `c``x` `c``x` bigint"]
13021311

13031312

13041313
def test_merge_iceberg_escapes_malicious_column_name() -> None:
@@ -1319,3 +1328,43 @@ def test_merge_iceberg_escapes_malicious_column_name() -> None:
13191328
# The injected identifier must appear only in doubled-quote form, never as a raw closing quote.
13201329
assert '"id"") ; DROP TABLE victim --"' in sql
13211330
assert '"id") ;' not in sql
1331+
1332+
1333+
def test_create_iceberg_table_escapes_ddl_identifiers() -> None:
1334+
"""Table/column names spliced into CREATE TABLE DDL must be backtick-escaped."""
1335+
from unittest import mock
1336+
1337+
from awswrangler.athena import _write_iceberg
1338+
1339+
df = pd.DataFrame({"c`0": [1]})
1340+
1341+
with mock.patch.object(
1342+
_write_iceberg.catalog,
1343+
"extract_athena_types",
1344+
return_value=({"c`0": "bigint"}, {}),
1345+
), mock.patch.object(_write_iceberg, "_start_query_execution", return_value="qid") as start, mock.patch.object(
1346+
_write_iceberg, "wait_query"
1347+
):
1348+
_write_iceberg._create_iceberg_table(
1349+
df=df,
1350+
database="db",
1351+
table="t`1",
1352+
path="s3://bucket/t/",
1353+
wg_config=mock.MagicMock(),
1354+
partition_cols=None,
1355+
additional_table_properties=None,
1356+
)
1357+
1358+
sql = start.call_args.kwargs["sql"]
1359+
assert "CREATE TABLE IF NOT EXISTS `t``1`" in sql
1360+
assert "`c``0` bigint" in sql
1361+
1362+
1363+
def test_delete_from_iceberg_overwrite_escapes_table() -> None:
1364+
"""The DELETE FROM issued for mode='overwrite' must quote/escape the table name."""
1365+
from awswrangler.athena._write_iceberg import _escape_athena_identifier
1366+
1367+
# Mirrors the splice in to_iceberg's overwrite branch.
1368+
table = 'v" ; DROP TABLE x --'
1369+
stmt = f'DELETE FROM "{_escape_athena_identifier(table)}"'
1370+
assert stmt == 'DELETE FROM "v"" ; DROP TABLE x --"'

0 commit comments

Comments
 (0)