Skip to content

Commit 8d80f2b

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. - Import the existing S3 staging bucket into the CloudFormation stack with DeletionPolicy: Retain, capturing its lifecycle, encryption, ownership, and public-access settings (drift-free import). Refs #710 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d2cc374 commit 8d80f2b

8 files changed

Lines changed: 387 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: 106 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,80 @@ Resources:
269301
ManagedQueryResultsConfiguration:
270302
Enabled: true
271303

304+
# S3 staging bucket for Athena query results and test data. Created outside
305+
# CloudFormation and imported into the stack. Retain on delete/replace so the
306+
# bucket and its data survive stack operations.
307+
S3StagingBucket:
308+
Type: AWS::S3::Bucket
309+
DeletionPolicy: Retain
310+
UpdateReplacePolicy: Retain
311+
Properties:
312+
BucketName: !Sub "${BucketName}"
313+
PublicAccessBlockConfiguration:
314+
BlockPublicAcls: true
315+
IgnorePublicAcls: true
316+
BlockPublicPolicy: true
317+
RestrictPublicBuckets: true
318+
OwnershipControls:
319+
Rules:
320+
- ObjectOwnership: BucketOwnerEnforced
321+
BucketEncryption:
322+
ServerSideEncryptionConfiguration:
323+
- ServerSideEncryptionByDefault:
324+
SSEAlgorithm: AES256
325+
BucketKeyEnabled: true
326+
LifecycleConfiguration:
327+
Rules:
328+
- Id: delete
329+
Status: Enabled
330+
ExpirationInDays: 1
331+
NoncurrentVersionExpiration:
332+
NoncurrentDays: 1
333+
NewerNoncurrentVersions: 1
334+
AbortIncompleteMultipartUpload:
335+
DaysAfterInitiation: 1
336+
337+
# Amazon S3 Tables infrastructure for the SQLAlchemy S3 Tables tests.
338+
# UnreferencedFileRemoval garbage-collects files left behind by dropped test
339+
# tables; 1 day is the most aggressive setting to keep test storage costs low.
340+
S3TablesBucket:
341+
Type: AWS::S3Tables::TableBucket
342+
Properties:
343+
TableBucketName: !Sub "${S3TablesBucketName}"
344+
UnreferencedFileRemoval:
345+
Status: Enabled
346+
UnreferencedDays: 1
347+
NoncurrentDays: 1
348+
349+
S3TablesNamespace:
350+
Type: AWS::S3Tables::Namespace
351+
Properties:
352+
TableBucketARN: !GetAtt S3TablesBucket.TableBucketARN
353+
Namespace: !Sub "${S3TablesNamespaceName}"
354+
355+
# One-time, per-Region integration: register S3 Tables into the Glue Data
356+
# Catalog as the federated `s3tablescatalog` catalog so Athena can query them.
357+
# IAM_ALLOWED_PRINCIPALS default permissions select IAM-based access control
358+
# (no Lake Formation grants needed; the OIDC role's IAM policy governs access).
359+
S3TablesCatalog:
360+
Type: AWS::Glue::Catalog
361+
Properties:
362+
Name: s3tablescatalog
363+
Description: Federated catalog that maps S3 Tables into the Glue Data Catalog
364+
FederatedCatalog:
365+
Identifier: !Sub "arn:aws:s3tables:${AWS::Region}:${AWS::AccountId}:bucket/*"
366+
ConnectionName: aws:s3tables
367+
CreateDatabaseDefaultPermissions:
368+
- Principal:
369+
DataLakePrincipalIdentifier: IAM_ALLOWED_PRINCIPALS
370+
Permissions:
371+
- ALL
372+
CreateTableDefaultPermissions:
373+
- Principal:
374+
DataLakePrincipalIdentifier: IAM_ALLOWED_PRINCIPALS
375+
Permissions:
376+
- ALL
377+
272378
GithubOidc:
273379
Type: AWS::IAM::OIDCProvider
274380
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",

0 commit comments

Comments
 (0)