Skip to content

Commit 8cf6499

Browse files
chore: sync with upstream/main and resolve merge conflicts
2 parents 3886bb9 + 427110c commit 8cf6499

5 files changed

Lines changed: 266 additions & 195 deletions

File tree

awswrangler/athena/_write_iceberg.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@
2424
_logger: logging.Logger = logging.getLogger(__name__)
2525

2626

27+
def _escape_athena_string_literal(value: Any) -> str:
28+
# Used for caller-supplied values spliced inside SQL string literals, e.g.
29+
# COMMENT '<value>' or TBLPROPERTIES ('<key>'='<value>').
30+
#
31+
# The wrapping single quotes around the splice site are *delimiters* — they tell
32+
# Athena "this is a string literal" and are part of the DDL grammar, not escaping.
33+
# A naive splice f"'{value}'" lets a value containing ' close the literal and
34+
# append arbitrary DDL (e.g. LOCATION '...').
35+
#
36+
# Athena/Trino string literals have exactly one escape mechanism: a single quote
37+
# inside the literal must be doubled. Pre-doubling caller quotes here means the
38+
# whole value parses as one literal regardless of what it contains.
39+
return str(value).replace("'", "''")
40+
41+
2742
def _create_iceberg_table(
2843
df: pd.DataFrame,
2944
database: str,
@@ -50,13 +65,19 @@ def _create_iceberg_table(
5065
[
5166
f"{k} {v}"
5267
if (columns_comments is None or columns_comments.get(k) is None)
53-
else f"{k} {v} COMMENT '{columns_comments[k]}'"
68+
else f"{k} {v} COMMENT '{_escape_athena_string_literal(columns_comments[k])}'"
5469
for k, v in columns_types.items()
5570
]
5671
)
5772
partition_cols_str: str = f"PARTITIONED BY ({', '.join([col for col in partition_cols])})" if partition_cols else ""
5873
table_properties_str: str = (
59-
", " + ", ".join([f"'{key}'='{value}'" for key, value in additional_table_properties.items()])
74+
", "
75+
+ ", ".join(
76+
[
77+
f"'{_escape_athena_string_literal(key)}'='{_escape_athena_string_literal(value)}'"
78+
for key, value in additional_table_properties.items()
79+
]
80+
)
6081
if additional_table_properties
6182
else ""
6283
)

awswrangler/neptune/_gremlin_init.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,15 @@
88
from gremlin_python.process.anonymous_traversal import traversal
99
from gremlin_python.process.graph_traversal import GraphTraversalSource, __
1010
from gremlin_python.process.translator import Translator
11-
from gremlin_python.process.traversal import Cardinality, T
11+
from gremlin_python.process.traversal import Cardinality, T, TraversalStrategies
1212
from gremlin_python.structure.graph import Edge, Graph, Path, Property, Vertex, VertexProperty
1313

14+
def local_traversal_source() -> "GraphTraversalSource":
15+
# gremlinpython 3.8 removed Graph().traversal() and traversal().withGraph(Graph()).
16+
# We only need a traversal source to build bytecode that the Translator turns into
17+
# a query string — no remote connection or strategies are needed.
18+
return GraphTraversalSource(Graph(), TraversalStrategies())
19+
1420
__all__ = [
1521
"__",
1622
"Cardinality",
@@ -22,6 +28,8 @@
2228
"Property",
2329
"T",
2430
"Translator",
31+
"TraversalStrategies",
32+
"local_traversal_source",
2533
"traversal",
2634
"Vertex",
2735
"VertexProperty",

awswrangler/neptune/_neptune.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ def to_property_graph(
170170
... )
171171
"""
172172
# check if ~id and ~label column exist and if not throw error
173-
g = gremlin.Graph().traversal()
173+
g = gremlin.local_traversal_source()
174174
is_edge_df = False
175175
is_update_df = True
176176
if "~id" in df.columns:
@@ -198,7 +198,7 @@ def to_property_graph(
198198
if index > 0 and index + 1 % batch_size == 0:
199199
res = _run_gremlin_insert(client, g)
200200
if res:
201-
g = gremlin.Graph().traversal()
201+
g = gremlin.local_traversal_source()
202202

203203
return _run_gremlin_insert(client, g)
204204

tests/unit/test_moto.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,48 @@ def mock_make_api_call(self, operation_name, kwarg):
804804
assert describe_table_calls == 1
805805

806806

807+
def test_create_iceberg_table_escapes_single_quotes_in_columns_comments() -> None:
808+
# Single quotes in caller-supplied columns_comments / additional_table_properties
809+
# values must be doubled so they cannot terminate the surrounding 'literal' and
810+
# change the structure of the generated DDL.
811+
from awswrangler.athena import _write_iceberg
812+
813+
captured: list[str] = []
814+
815+
def fake_start(*, sql: str, **_) -> str:
816+
captured.append(sql)
817+
return "qid"
818+
819+
df = pd.DataFrame({"id": pd.Series(dtype="int64"), "user_name": pd.Series(dtype="string")})
820+
wg_config = mock.MagicMock()
821+
wg_config.enforce_workgroup_location = False
822+
823+
with mock.patch.object(_write_iceberg, "_start_query_execution", side_effect=fake_start), mock.patch.object(
824+
_write_iceberg, "wait_query"
825+
):
826+
_write_iceberg._create_iceberg_table(
827+
df=df,
828+
database="db",
829+
table="t",
830+
path="s3://intended/output/",
831+
wg_config=wg_config,
832+
partition_cols=None,
833+
additional_table_properties={"prop": "val') LOCATION 's3://other/' --"},
834+
index=False,
835+
boto3_session=mock.MagicMock(),
836+
columns_comments={"user_name": "') LOCATION 's3://other/' TBLPROPERTIES ('x'='y"},
837+
)
838+
839+
sql = captured[0]
840+
# Quotes were doubled in both splices, so unescaped caller content stays inside the
841+
# COMMENT / TBLPROPERTIES string literals and does not open a new DDL clause.
842+
assert "COMMENT ''') LOCATION ''s3://other/'' TBLPROPERTIES (''x''=''y'" in sql
843+
assert "'prop'='val'') LOCATION ''s3://other/'' --'" in sql
844+
# The intended LOCATION (un-doubled quotes) is the only top-level clause.
845+
assert "LOCATION 's3://intended/output/'" in sql
846+
assert "LOCATION 's3://other/'" not in sql
847+
848+
807849
def test_csv_pandas_mode_append(moto_s3_client: "S3Client") -> None:
808850
path = "s3://bucket/test_append.csv"
809851
df1 = pd.DataFrame({"col": [1, 2, 3]})

0 commit comments

Comments
 (0)