Skip to content

Commit 64433bb

Browse files
Support Amazon S3 Tables in the SQLAlchemy dialect
Athena exposes S3 Tables through a catalog named s3tablescatalog/<table-bucket> whose namespace is the database, and such tables use managed storage. Athena rejects a three-part catalog.namespace.table identifier in DDL ("Unsupported ddl with 2 catalogs"), so the catalog is selected on the connection via catalog_name and the namespace is used as the table schema. - Omit the LOCATION clause for Iceberg tables when the connection catalog_name names an S3 Tables catalog (managed storage), instead of raising or building an s3_staging_dir fallback. - Add no-AWS compiler unit tests and AWS-gated E2E tests (CREATE TABLE, partition transform, CTAS with is_external=false), an S3 Tables docs section, and optional AWS_ATHENA_S3_TABLES_* test env vars. - Provision the S3 Tables test infrastructure in CloudFormation: table bucket, namespace, the federated s3tablescatalog Glue catalog (IAM-based access), OIDC-role IAM permissions, and 1-day unreferenced file removal to bound storage cost. Wire the env vars into the test workflow. Refs #710 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d2cc374 commit 64433bb

8 files changed

Lines changed: 353 additions & 1 deletion

File tree

.github/workflows/test-suite.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ jobs:
1818
AWS_ATHENA_WORKGROUP: pyathena
1919
AWS_ATHENA_SPARK_WORKGROUP: pyathena-spark
2020
AWS_ATHENA_MANAGED_WORKGROUP: pyathena-managed
21+
# Registered S3 Tables catalog (s3tablescatalog/<table-bucket>) and namespace
22+
# for the SQLAlchemy S3 Tables tests; the table bucket, namespace, and the
23+
# AWS analytics-services integration are provisioned out of band.
24+
AWS_ATHENA_S3_TABLES_CATALOG: s3tablescatalog/laughingman7743-pyathena-s3-tables
25+
AWS_ATHENA_S3_TABLES_NAMESPACE: pyathena
2126

2227
strategy:
2328
fail-fast: false

docs/sqlalchemy.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,61 @@ If you want to limit the column options to specific table names only, specify th
363363
awsathena+rest://:@athena.us-west-2.amazonaws.com:443/default?partition=table1.column1%2Ctable1.column2&cluster=table2.column1%2Ctable2.column2&...
364364
```
365365

366+
## Amazon S3 Tables
367+
368+
[Amazon S3 Tables](https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables.html) are Iceberg-backed
369+
tables stored in a dedicated table bucket. Once the table bucket is integrated with the AWS analytics services,
370+
Athena registers it as a catalog named `s3tablescatalog/<table-bucket>`, with the S3 Tables namespace as the
371+
database.
372+
373+
Athena does not accept a three-part `catalog.namespace.table` identifier in DDL, so select the catalog on the
374+
**connection** with `catalog_name` and use the namespace as the table `schema`. Because S3 Tables use managed
375+
storage, the dialect omits the `LOCATION` clause automatically (so `awsathena_location` is not required).
376+
377+
```python
378+
engine = create_engine(
379+
"awsathena+rest://athena.us-west-2.amazonaws.com:443/my_namespace"
380+
"?s3_staging_dir=s3://my-bucket/athena-results/"
381+
"&catalog_name=s3tablescatalog/my-table-bucket"
382+
)
383+
384+
table = Table(
385+
"some_table",
386+
MetaData(schema="my_namespace"),
387+
Column("id", types.Integer),
388+
Column("dt", types.Date, awsathena_partition=True, awsathena_partition_transform="day"),
389+
awsathena_tblproperties={"table_type": "ICEBERG"},
390+
)
391+
with engine.connect() as conn:
392+
table.create(bind=conn)
393+
```
394+
395+
which builds the following statement:
396+
397+
```sql
398+
CREATE TABLE my_namespace.some_table (
399+
id INT,
400+
dt DATE
401+
)
402+
PARTITIONED BY (
403+
day(dt)
404+
)
405+
TBLPROPERTIES (
406+
'table_type' = 'ICEBERG'
407+
)
408+
```
409+
410+
All Iceberg partition transforms (`year`, `month`, `day`, `hour`, `bucket`, `truncate`) are supported, the same as
411+
for other Iceberg tables. CTAS (`CREATE TABLE ... AS SELECT`) is not modeled as a SQLAlchemy construct; issue it as
412+
raw SQL via `text()`. Managed Iceberg CTAS requires `is_external = false`:
413+
414+
```python
415+
conn.execute(text(
416+
'CREATE TABLE "my_namespace"."some_table" '
417+
"WITH (table_type = 'ICEBERG', is_external = false) AS SELECT 1 AS id"
418+
))
419+
```
420+
366421
## Temporal/Time-travel with Iceberg
367422

368423
Athena supports time-travel queries on Iceberg tables by either a version_id or a timestamp. The `FOR TIMESTAMP AS OF`

pyathena/sqlalchemy/compiler.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@
3939
_DialectArgDict = Mapping[str, Any]
4040
CreateColumn = Any
4141

42+
# Prefix of the Athena data catalog name registered for an Amazon S3 Tables
43+
# table bucket (e.g. ``s3tablescatalog/my-bucket``). It is selected via the
44+
# connection ``catalog_name``. S3 Tables are Iceberg-backed and use managed
45+
# storage, so their CREATE TABLE statements must not include a LOCATION clause.
46+
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-tables-integrations-query-athena.html
47+
S3_TABLES_CATALOG_PREFIX = "s3tablescatalog/"
48+
4249

4350
class AthenaTypeCompiler(GenericTypeCompiler):
4451
"""Type compiler for Amazon Athena SQL types.
@@ -305,6 +312,10 @@ class AthenaDDLCompiler(DDLCompiler):
305312
306313
- External table creation (EXTERNAL keyword for Hive-style tables)
307314
- Iceberg table creation (managed tables with ACID support)
315+
- Amazon S3 Tables (Iceberg-backed, managed storage): set the connection
316+
``catalog_name`` to ``s3tablescatalog/<table-bucket>`` and use the
317+
namespace as the table ``schema``. The LOCATION clause is omitted since
318+
storage is managed.
308319
- File formats: PARQUET, ORC, TEXTFILE, JSON, AVRO, etc.
309320
- Row formats with SerDe specifications
310321
- Compression settings for various file formats
@@ -445,6 +456,28 @@ def _get_serde_properties_specification(
445456
text.append(")")
446457
return "\n".join(text)
447458

459+
@staticmethod
460+
def _is_s3_tables_catalog(connect_opts: Mapping[str, Any]) -> bool:
461+
"""Return whether the connection targets an Amazon S3 Tables catalog.
462+
463+
S3 Tables are queried by setting the connection ``catalog_name`` to
464+
``s3tablescatalog/<table-bucket>`` and using the namespace as the table
465+
``schema`` (a two-part ``namespace.table`` identifier). Athena rejects a
466+
three-part ``catalog.namespace.table`` identifier in DDL, so the catalog
467+
must be selected at the connection level. Such tables use managed
468+
storage, so their CREATE TABLE statement must omit the LOCATION clause.
469+
470+
Args:
471+
connect_opts: The dialect connection options.
472+
473+
Returns:
474+
True if ``catalog_name`` names an S3 Tables catalog.
475+
"""
476+
if not connect_opts:
477+
return False
478+
catalog = connect_opts.get("catalog_name") or ""
479+
return catalog.startswith(S3_TABLES_CATALOG_PREFIX)
480+
448481
def _get_table_location(
449482
self, table: Table, dialect_opts: _DialectArgDict, connect_opts: Mapping[str, Any]
450483
) -> str | None:
@@ -466,6 +499,9 @@ def _get_table_location(
466499
def _get_table_location_specification(
467500
self, table: Table, dialect_opts: _DialectArgDict, connect_opts: Mapping[str, Any]
468501
) -> str:
502+
if self._is_s3_tables_catalog(connect_opts):
503+
# S3 Tables use managed storage; CREATE TABLE must not set a LOCATION.
504+
return ""
469505
location = self._get_table_location(table, dialect_opts, connect_opts)
470506
text = []
471507
if location:

scripts/cloudformation/github_actions_oidc.yaml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ Parameters:
2020
ManagedWorkGroupName:
2121
Type: String
2222
Default: "pyathena-managed"
23+
S3TablesBucketName:
24+
Type: String
25+
Default: "laughingman7743-pyathena-s3-tables"
26+
Description: Name of the Amazon S3 Tables table bucket used by the SQLAlchemy S3 Tables tests.
27+
S3TablesNamespaceName:
28+
Type: String
29+
Default: "pyathena"
30+
Description: Namespace created in the S3 Tables table bucket for the SQLAlchemy S3 Tables tests.
2331
OIDCProviderArn:
2432
Type: String
2533
Default: ""
@@ -120,6 +128,30 @@ Resources:
120128
Resource: [
121129
!Sub "arn:aws:s3:::*"
122130
]
131+
- PolicyName: s3tables-access
132+
PolicyDocument:
133+
Version: "2012-10-17"
134+
Statement:
135+
# Amazon S3 Tables data plane: create/query/drop namespaces and
136+
# tables in the table bucket used by the SQLAlchemy S3 Tables tests.
137+
- Effect: Allow
138+
Action: [
139+
"s3tables:*"
140+
]
141+
Resource: [
142+
!Sub "arn:aws:s3tables:${AWS::Region}:${AWS::AccountId}:bucket/*"
143+
]
144+
# Resolve the federated `s3tablescatalog` Glue catalog and obtain
145+
# Lake Formation data access when querying S3 Tables from Athena.
146+
- Effect: Allow
147+
Action: [
148+
"glue:GetCatalog",
149+
"glue:GetCatalogs",
150+
"lakeformation:GetDataAccess"
151+
]
152+
Resource: [
153+
"*"
154+
]
123155
SparkRole:
124156
Type: AWS::IAM::Role
125157
Properties:
@@ -269,6 +301,47 @@ Resources:
269301
ManagedQueryResultsConfiguration:
270302
Enabled: true
271303

304+
# Amazon S3 Tables infrastructure for the SQLAlchemy S3 Tables tests.
305+
# UnreferencedFileRemoval garbage-collects files left behind by dropped test
306+
# tables; 1 day is the most aggressive setting to keep test storage costs low.
307+
S3TablesBucket:
308+
Type: AWS::S3Tables::TableBucket
309+
Properties:
310+
TableBucketName: !Sub "${S3TablesBucketName}"
311+
UnreferencedFileRemoval:
312+
Status: Enabled
313+
UnreferencedDays: 1
314+
NoncurrentDays: 1
315+
316+
S3TablesNamespace:
317+
Type: AWS::S3Tables::Namespace
318+
Properties:
319+
TableBucketARN: !GetAtt S3TablesBucket.TableBucketARN
320+
Namespace: !Sub "${S3TablesNamespaceName}"
321+
322+
# One-time, per-Region integration: register S3 Tables into the Glue Data
323+
# Catalog as the federated `s3tablescatalog` catalog so Athena can query them.
324+
# IAM_ALLOWED_PRINCIPALS default permissions select IAM-based access control
325+
# (no Lake Formation grants needed; the OIDC role's IAM policy governs access).
326+
S3TablesCatalog:
327+
Type: AWS::Glue::Catalog
328+
Properties:
329+
Name: s3tablescatalog
330+
Description: Federated catalog that maps S3 Tables into the Glue Data Catalog
331+
FederatedCatalog:
332+
Identifier: !Sub "arn:aws:s3tables:${AWS::Region}:${AWS::AccountId}:bucket/*"
333+
ConnectionName: aws:s3tables
334+
CreateDatabaseDefaultPermissions:
335+
- Principal:
336+
DataLakePrincipalIdentifier: IAM_ALLOWED_PRINCIPALS
337+
Permissions:
338+
- ALL
339+
CreateTableDefaultPermissions:
340+
- Principal:
341+
DataLakePrincipalIdentifier: IAM_ALLOWED_PRINCIPALS
342+
Permissions:
343+
- ALL
344+
272345
GithubOidc:
273346
Type: AWS::IAM::OIDCProvider
274347
Condition: CreateOIDCProvider

tests/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ def __init__(self):
3131
)
3232
self.default_work_group = os.getenv("AWS_ATHENA_DEFAULT_WORKGROUP", "primary")
3333
self.managed_work_group = os.getenv("AWS_ATHENA_MANAGED_WORKGROUP")
34+
# Optional Amazon S3 Tables configuration for the SQLAlchemy dialect tests.
35+
# `s3tables_catalog` is the registered table-bucket catalog, e.g.
36+
# "s3tablescatalog/<table-bucket>"; `s3tables_namespace` is a namespace that
37+
# already exists in it. These are optional so the suite stays runnable
38+
# without an S3 Tables bucket; the S3 Tables tests skip when they are unset.
39+
self.s3tables_catalog = os.getenv("AWS_ATHENA_S3_TABLES_CATALOG")
40+
self.s3tables_namespace = os.getenv("AWS_ATHENA_S3_TABLES_NAMESPACE")
3441
self.schema = "pyathena_test_" + "".join(
3542
random.choices(string.ascii_lowercase + string.digits, k=10)
3643
)

tests/pyathena/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def create_engine(**kwargs):
7676
conn_str = SQLALCHEMY_CONNECTION_STRING.replace("+rest", f"+{driver}")
7777
for arg in [
7878
"bucket_count",
79+
"catalog_name",
7980
"cluster",
8081
"compression",
8182
"duration_seconds",

tests/pyathena/sqlalchemy/test_base.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import re
23
import textwrap
34
import uuid
@@ -26,6 +27,25 @@
2627
)
2728
from tests.pyathena.conftest import ENV
2829

30+
# Amazon S3 Tables tests need a pre-provisioned table-bucket catalog and namespace.
31+
# Skip them unless AWS_ATHENA_S3_TABLES_CATALOG / AWS_ATHENA_S3_TABLES_NAMESPACE are set.
32+
requires_s3_tables = pytest.mark.skipif(
33+
not ENV.s3tables_catalog or not ENV.s3tables_namespace,
34+
reason="AWS_ATHENA_S3_TABLES_CATALOG / AWS_ATHENA_S3_TABLES_NAMESPACE are not configured",
35+
)
36+
37+
38+
def unique_s3tables_table_name(base: str) -> str:
39+
"""Return a per-run-unique S3 Tables table name.
40+
41+
Other integration tests isolate themselves with a random per-process
42+
``ENV.schema``, but the S3 Tables tests share one fixed namespace
43+
(``ENV.s3tables_namespace``). The CI matrix runs ``tests/pyathena`` once per
44+
Python version in parallel against the same account, so a fixed table name
45+
would collide across those concurrent jobs; a random suffix keeps them apart.
46+
"""
47+
return f"{base}_{uuid.uuid4().hex[:8]}"
48+
2949

3050
class TestSQLAlchemyAthena:
3151
@pytest.mark.parametrize(
@@ -2019,6 +2039,104 @@ def test_create_iceberg_table_with_multiple_partition_transform(self, engine):
20192039
tblproperties = actual.dialect_options["awsathena"]["tblproperties"]
20202040
assert tblproperties["table_type"] == "ICEBERG"
20212041

2042+
@requires_s3_tables
2043+
@pytest.mark.parametrize("engine", [{"catalog_name": ENV.s3tables_catalog}], indirect=True)
2044+
def test_create_s3tables_iceberg_table(self, engine):
2045+
engine, conn = engine
2046+
# S3 Tables select the catalog via the connection ``catalog_name`` and use
2047+
# the namespace as the schema, so the DDL is a two-part ``namespace.table``
2048+
# identifier with no LOCATION (managed storage).
2049+
schema = ENV.s3tables_namespace
2050+
table_name = unique_s3tables_table_name("test_create_s3tables_iceberg_table")
2051+
table = Table(
2052+
table_name,
2053+
MetaData(schema=schema),
2054+
Column("col_1", types.String),
2055+
Column("col_2", types.Integer),
2056+
awsathena_tblproperties={"table_type": "ICEBERG"},
2057+
)
2058+
ddl = CreateTable(table).compile(bind=conn)
2059+
2060+
assert str(ddl) == textwrap.dedent(
2061+
f"""
2062+
CREATE TABLE {ENV.s3tables_namespace}.{table_name} (
2063+
\tcol_1 STRING,
2064+
\tcol_2 INT
2065+
)
2066+
TBLPROPERTIES (
2067+
\t'table_type' = 'ICEBERG'
2068+
)
2069+
"""
2070+
)
2071+
2072+
table.create(bind=conn)
2073+
try:
2074+
fqtn = f'"{ENV.s3tables_namespace}"."{table_name}"'
2075+
conn.execute(text(f"SELECT col_1, col_2 FROM {fqtn} LIMIT 0"))
2076+
finally:
2077+
with contextlib.suppress(Exception):
2078+
table.drop(bind=conn)
2079+
2080+
@requires_s3_tables
2081+
@pytest.mark.parametrize("engine", [{"catalog_name": ENV.s3tables_catalog}], indirect=True)
2082+
def test_create_s3tables_iceberg_table_with_partition_transform(self, engine):
2083+
engine, conn = engine
2084+
schema = ENV.s3tables_namespace
2085+
table_name = unique_s3tables_table_name("test_create_s3tables_partition_transform")
2086+
table = Table(
2087+
table_name,
2088+
MetaData(schema=schema),
2089+
Column("col_1", types.String),
2090+
Column("dt", types.Date, awsathena_partition=True, awsathena_partition_transform="day"),
2091+
awsathena_tblproperties={"table_type": "ICEBERG"},
2092+
)
2093+
ddl = CreateTable(table).compile(bind=conn)
2094+
2095+
assert str(ddl) == textwrap.dedent(
2096+
f"""
2097+
CREATE TABLE {ENV.s3tables_namespace}.{table_name} (
2098+
\tcol_1 STRING,
2099+
\tdt DATE
2100+
)
2101+
PARTITIONED BY (
2102+
\tday(dt)
2103+
)
2104+
TBLPROPERTIES (
2105+
\t'table_type' = 'ICEBERG'
2106+
)
2107+
"""
2108+
)
2109+
2110+
table.create(bind=conn)
2111+
with contextlib.suppress(Exception):
2112+
table.drop(bind=conn)
2113+
2114+
@requires_s3_tables
2115+
@pytest.mark.parametrize("engine", [{"catalog_name": ENV.s3tables_catalog}], indirect=True)
2116+
def test_create_s3tables_table_as_select(self, engine):
2117+
engine, conn = engine
2118+
# CTAS is not modeled as a SQLAlchemy construct; exercise it as raw SQL.
2119+
# With the catalog selected on the connection the identifier is two-part
2120+
# (namespace.table); managed Iceberg CTAS requires is_external=false.
2121+
# Identifiers are left unquoted: DROP TABLE is Hive DDL and rejects the
2122+
# double quotes that the Trino CTAS/SELECT statements accept.
2123+
table_name = unique_s3tables_table_name("test_create_s3tables_table_as_select")
2124+
fqtn = f"{ENV.s3tables_namespace}.{table_name}"
2125+
conn.execute(text(f"DROP TABLE IF EXISTS {fqtn}"))
2126+
try:
2127+
conn.execute(
2128+
text(
2129+
f"CREATE TABLE {fqtn} "
2130+
"WITH (table_type = 'ICEBERG', is_external = false) AS "
2131+
"SELECT 1 AS id, 'a' AS name"
2132+
)
2133+
)
2134+
rows = conn.execute(text(f"SELECT id, name FROM {fqtn}")).fetchall()
2135+
assert rows == [(1, "a")]
2136+
finally:
2137+
with contextlib.suppress(Exception):
2138+
conn.execute(text(f"DROP TABLE IF EXISTS {fqtn}"))
2139+
20222140
def test_insert_from_select_cte_follows_insert_one(self, engine):
20232141
engine, conn = engine
20242142
metadata = MetaData(schema=ENV.schema)

0 commit comments

Comments
 (0)