@@ -39,6 +39,27 @@ def _escape_athena_string_literal(value: Any) -> str:
3939 return str (value ).replace ("'" , "''" )
4040
4141
42+ def _escape_athena_dml_identifier (name : Any ) -> str :
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.
47+ #
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.
51+ return str (name ).replace ('"' , '""' )
52+
53+
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+
4263def _create_iceberg_table (
4364 df : pd .DataFrame ,
4465 database : str ,
@@ -63,12 +84,14 @@ def _create_iceberg_table(
6384 columns_types , _ = catalog .extract_athena_types (df = df , index = index , dtype = dtype )
6485 cols_str : str = ", " .join (
6586 [
66- f"{ k } { v } "
87+ f"` { _escape_athena_ddl_identifier ( k ) } ` { v } "
6788 if (columns_comments is None or columns_comments .get (k ) is None )
68- 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 ])} '"
6990 for k , v in columns_types .items ()
7091 ]
7192 )
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.
7295 partition_cols_str : str = f"PARTITIONED BY ({ ', ' .join ([col for col in partition_cols ])} )" if partition_cols else ""
7396 table_properties_str : str = (
7497 ", "
@@ -83,9 +106,9 @@ def _create_iceberg_table(
83106 )
84107
85108 create_sql : str = (
86- f"CREATE TABLE IF NOT EXISTS `{ table } ` ({ cols_str } ) "
109+ f"CREATE TABLE IF NOT EXISTS `{ _escape_athena_ddl_identifier ( table ) } ` ({ cols_str } ) "
87110 f"{ partition_cols_str } "
88- f"LOCATION '{ path } ' "
111+ f"LOCATION '{ _escape_athena_string_literal ( path ) } ' "
89112 f"TBLPROPERTIES ('table_type' ='ICEBERG', 'format'='parquet'{ table_properties_str } )"
90113 )
91114
@@ -216,9 +239,11 @@ def _alter_iceberg_table_add_columns_sql(
216239 table : str ,
217240 columns_to_add : dict [str , str ],
218241) -> list [str ]:
219- add_cols_str = ", " .join ([f"{ col_name } { columns_to_add [col_name ]} " for col_name in columns_to_add ])
242+ add_cols_str = ", " .join (
243+ [f"`{ _escape_athena_ddl_identifier (col_name )} ` { columns_to_add [col_name ]} " for col_name in columns_to_add ]
244+ )
220245
221- return [f"ALTER TABLE { table } ADD COLUMNS ({ add_cols_str } )" ]
246+ return [f"ALTER TABLE ` { _escape_athena_ddl_identifier ( table ) } ` ADD COLUMNS ({ add_cols_str } )" ]
222247
223248
224249def _alter_iceberg_table_change_columns_sql (
@@ -228,7 +253,9 @@ def _alter_iceberg_table_change_columns_sql(
228253 sql_statements = []
229254
230255 for col_name , col_type in columns_to_change .items ():
231- sql_statements .append (f"ALTER TABLE { table } CHANGE COLUMN { col_name } { col_name } { 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 } " )
232259
233260 return sql_statements
234261
@@ -290,7 +317,9 @@ def _build_order_by_clause(partition_cols: list[str] | None) -> str:
290317 if not partition_cols :
291318 return ""
292319
293- order_cols = [f'"{ _extract_column_from_partition_transform (col )} "' for col in partition_cols ]
320+ order_cols = [
321+ f'"{ _escape_athena_dml_identifier (_extract_column_from_partition_transform (col ))} "' for col in partition_cols
322+ ]
294323 return f"ORDER BY { ', ' .join (order_cols )} "
295324
296325
@@ -359,34 +388,40 @@ def _merge_iceberg(
359388 """
360389 wg_config : _WorkGroupConfig = _get_workgroup_config (session = boto3_session , workgroup = workgroup )
361390
391+ esc_database = _escape_athena_dml_identifier (database )
392+ esc_table = _escape_athena_dml_identifier (table )
393+ esc_source_table = _escape_athena_dml_identifier (source_table )
394+ esc_columns = [_escape_athena_dml_identifier (x ) for x in df .columns ]
395+
362396 sql_statement : str
363397 if merge_cols :
398+ esc_merge_cols = [_escape_athena_dml_identifier (x ) for x in merge_cols ]
364399 if merge_condition == "update" :
365400 match_condition = f"""WHEN MATCHED THEN
366- UPDATE SET { ", " .join ([f'"{ x } " = source."{ x } "' for x in df . columns ])} """
401+ UPDATE SET { ", " .join ([f'"{ x } " = source."{ x } "' for x in esc_columns ])} """
367402 else :
368403 match_condition = ""
369404
370405 if merge_match_nulls :
371- merge_conditions = [f'(target."{ x } " IS NOT DISTINCT FROM source."{ x } ")' for x in merge_cols ]
406+ merge_conditions = [f'(target."{ x } " IS NOT DISTINCT FROM source."{ x } ")' for x in esc_merge_cols ]
372407 else :
373- merge_conditions = [f'(target."{ x } " = source."{ x } ")' for x in merge_cols ]
408+ merge_conditions = [f'(target."{ x } " = source."{ x } ")' for x in esc_merge_cols ]
374409
375410 sql_statement = f"""
376- MERGE INTO "{ database } "."{ table } " target
377- USING "{ database } "."{ source_table } " source
411+ MERGE INTO "{ esc_database } "."{ esc_table } " target
412+ USING "{ esc_database } "."{ esc_source_table } " source
378413 ON { " AND " .join (merge_conditions )}
379414 { match_condition }
380415 WHEN NOT MATCHED THEN
381- INSERT ({ ", " .join ([f'"{ x } "' for x in df . columns ])} )
382- VALUES ({ ", " .join ([f'source."{ x } "' for x in df . columns ])} )
416+ INSERT ({ ", " .join ([f'"{ x } "' for x in esc_columns ])} )
417+ VALUES ({ ", " .join ([f'source."{ x } "' for x in esc_columns ])} )
383418 """
384419 else :
385420 order_by_clause = _build_order_by_clause (partition_cols )
386421 sql_statement = f"""
387- INSERT INTO "{ database } "."{ table } " ({ ", " .join ([f'"{ x } "' for x in df . columns ])} )
388- SELECT { ", " .join ([f'"{ x } "' for x in df . columns ])}
389- FROM "{ database } "."{ source_table } "
422+ INSERT INTO "{ esc_database } "."{ esc_table } " ({ ", " .join ([f'"{ x } "' for x in esc_columns ])} )
423+ SELECT { ", " .join ([f'"{ x } "' for x in esc_columns ])}
424+ FROM "{ esc_database } "."{ esc_source_table } "
390425 { order_by_clause }
391426 """
392427
@@ -644,7 +679,7 @@ def to_iceberg( # noqa: PLR0913
644679 )
645680 # if mode == "overwrite", delete whole data from table (but not table itself)
646681 elif mode == "overwrite" :
647- delete_sql_statement = f" DELETE FROM { table } "
682+ delete_sql_statement = f' DELETE FROM " { _escape_athena_dml_identifier ( table ) } "'
648683 delete_query_execution_id : str = _start_query_execution (
649684 sql = delete_sql_statement ,
650685 workgroup = workgroup ,
@@ -829,10 +864,14 @@ def delete_from_iceberg_table(
829864 index = False ,
830865 )
831866
867+ esc_database = _escape_athena_dml_identifier (database )
868+ esc_table = _escape_athena_dml_identifier (table )
869+ esc_temp_table = _escape_athena_dml_identifier (temp_table )
870+ esc_merge_cols = [_escape_athena_dml_identifier (x ) for x in merge_cols ]
832871 sql_statement = f"""
833- MERGE INTO "{ database } "."{ table } " target
834- USING "{ database } "."{ temp_table } " source
835- ON { " AND " .join ([f'target."{ x } " = source."{ x } "' for x in merge_cols ])}
872+ MERGE INTO "{ esc_database } "."{ esc_table } " target
873+ USING "{ esc_database } "."{ esc_temp_table } " source
874+ ON { " AND " .join ([f'target."{ x } " = source."{ x } "' for x in esc_merge_cols ])}
836875 WHEN MATCHED THEN
837876 DELETE
838877 """
0 commit comments