Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/sqlalchemy.md
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ Athena parses DDL statements such as `CREATE TABLE` with Hive type syntax, and q

Complex types apply the same syntax to their nested types.
An `AthenaStruct` without fields raises `CompileError` in both.
`NullType`, including an unrecognized type that reflection reports as `NullType`, raises `CompileError` in DDL at any depth.

## Floating-point types

Expand Down Expand Up @@ -846,6 +847,10 @@ CREATE TABLE users (
`CREATE TABLE` renders `AthenaStruct` columns with Hive `STRUCT<name:type, ...>` syntax at every nesting depth, and `CAST` renders them as `ROW(name type, ...)`.
See [DDL and CAST types](#ddl-and-cast-types).

Reflected STRUCT and ROW columns use `AthenaStruct` with their field names and types.
A field type that the dialect does not recognize is reflected as `NullType` with a warning.
Selecting such a column returns the value from the cursor, as described under Data format support below; SQLAlchemy does not convert it to the reflected field types.

#### Querying STRUCT data

PyAthena automatically converts STRUCT data between different formats:
Expand Down Expand Up @@ -991,6 +996,10 @@ CREATE TABLE products (
`CREATE TABLE` renders integer MAP keys and values as `INT`.
`CAST` still spells those integers as `INTEGER`.

Reflected MAP columns use `AthenaMap` with their key and value types.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two (claims, callers, operations, evidence)

Base 6f258501a56124f6e29c6d530ce429b702ed2ab3, head 4217bbe65b6203f42522c63646bb2b5b06c02a7b. Result: FINDINGS (1, a PR body correction; no code change).

  • Finding: the PR body said "String comparator operators no longer apply". Measured: column + 'x' renders m || ... for String but m + ... for AthenaMap, while contains() and like() render the same. The body now states exactly that.
  • "Values unchanged": the live probe showed ({'1': '2', '3': '4'}, {'a': '1', 'b': '2'}) before the change, and test_reflect_select asserts the same row values with the new types (677 passed on AWS). The body now cites both.
  • Docs: "SQLAlchemy does not convert it to the reflected key and value types" holds; AthenaMap and AthenaStruct have no result processor. Links to the duplicated "Data format support" headings were avoided, because Sphinx renders the second one as #id6.
  • Callers: _get_column_type is called only from _column and from itself; the signature is unchanged.
  • AWS operations: unchanged (parsing only; no additional requests).
  • Evidence: just test sqla/sqla-async were not run locally; CI runs them on Ready.

A key or value type that the dialect does not recognize is reflected as `NullType` with a warning.
Selecting such a column returns the value from the cursor, as described under Data format support below; SQLAlchemy does not convert it to the reflected key and value types.

#### Querying MAP data

PyAthena automatically converts MAP data between different formats:
Expand Down
34 changes: 34 additions & 0 deletions pyathena/sqlalchemy/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,34 @@ def get_columns(self, connection: Connection, table_name: str, schema: str | Non
return self._get_columns(connection, table_name, schema=schema, **kw)

def _get_column_type(self, type_: str, _nested: bool = False):
"""Map an Athena column type string to a SQLAlchemy type.

Accepts both the Hive (``struct<a:int>``, ``map<int,int>``) and the
Trino (``row(a integer)``, ``map(integer, integer)``) spellings, and
parses the element, key, value, and field types of ARRAY, MAP, and
STRUCT/ROW types.

Args:
type_: The column type reported by Athena.
_nested: Whether ``type_`` is nested in another type. A nested MAP
or STRUCT/ROW that cannot be parsed raises, so that the
enclosing type is reported as unrecognized.

Returns:
The SQLAlchemy type. A type name that is not recognized, such as
``foo`` in ``struct<a:foo>``, becomes ``NullType`` in place with a
warning. A type that cannot be parsed, such as ``map<int>`` or
``varchar(x)``, makes its innermost enclosing ARRAY ``NullType``
with a warning; without an enclosing ARRAY, a top-level MAP or
STRUCT/ROW becomes ``NullType`` instead.

Raises:
ValueError: If a type cannot be parsed and neither an enclosing
ARRAY nor a top-level MAP or STRUCT/ROW handles it, for example
a top-level ``varchar(x)`` or a nested ``map<int>``.
TypeError: In the same case, for a DECIMAL type with more
arguments than SQLAlchemy's ``DECIMAL`` accepts.
"""
type_ = type_.strip()
match = self._pattern_column_type.match(type_)
if match:
Expand All @@ -710,6 +738,12 @@ def _get_column_type(self, type_: str, _nested: bool = False):
except (TypeError, ValueError):
util.warn(f"Did not recognize type '{type_}'")
return types.NullType()
if not _nested and name in ("map", "row", "struct") and length:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one (implementation behavior, public contracts, simplicity, regression coverage)

Base 6f258501a56124f6e29c6d530ce429b702ed2ab3, head 4217bbe65b6203f42522c63646bb2b5b06c02a7b. Result: CLEAN.

Covered: every file in git diff 6f258501a56124f6e29c6d530ce429b702ed2ab3..4217bbe65b6203f42522c63646bb2b5b06c02a7b.

  • Re-entry: a top-level map/row/struct with arguments calls _get_column_type(type_, _nested=True), which takes the existing nested MAP or STRUCT/ROW branch. Only the top-level call catches TypeError/ValueError.
  • Nested errors keep their old reporting. A bad type inside an ARRAY still turns the whole ARRAY into NullType; a bad type inside a top-level STRUCT turns the whole STRUCT into NullType. An unknown scalar nested in a STRUCT still warns and becomes a NullType field, without raising, as before.
  • Bare map/struct/row (no arguments) keep the ischema_names fallback.
  • Both reflection paths: _columns_from_metadata and _columns_from_information_schema (whose varchar to string substitution applies only to the whole type) both reach _column → _get_column_type. The live type strings for both paths are in the PR body.
  • Runtime effects, compared by a script for String() vs AthenaMap(Integer, Integer) and AthenaStruct() vs AthenaStruct(a, b): SELECT/INSERT/WHERE SQL, bind and result processors, and returned values are unchanged; python_type, literal rendering, +, and DDL differ, as listed in the PR body.
  • Simplicity: one guarded branch, no code moved. An earlier draft extracted a helper for the parser body and was reverted, because the re-entry gives the same behavior with a smaller diff.
  • Tests: the 8 new or extended offline cases fail with master's pyathena/, and the AWS assertions cover the live table and its CREATE TABLE rendering.

try:
return self._get_column_type(type_, _nested=True)
except (TypeError, ValueError):
util.warn(f"Did not recognize type '{type_}'")
return types.NullType()
if _nested and name == "map" and length:
key, value = _split_type_arguments(length)
return AthenaMap(
Expand Down
8 changes: 2 additions & 6 deletions pyathena/sqlalchemy/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ class AthenaTypeCompiler(GenericTypeCompiler):
without a length render as STRING; with a length, CHAR, NCHAR, VARCHAR,
and NVARCHAR render as CHAR(n) or VARCHAR(n). Complex
types render as ``STRUCT<name:type>``, ``MAP<key, value>``, and
``ARRAY<item>``. TIME, JSON, and a STRUCT without fields have no Athena
DDL type and raise ``CompileError``.
``ARRAY<item>``. TIME, JSON, a STRUCT without fields, and ``NullType``

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design consultation (relayed Claude Fable 5.1 opinion, requested by the maintainer)

  • claude -p, model claude-fable-5-1, effort high, profile max (first-party Max), session 17845510-8bb0-4015-a88b-a0d3617e0de5. Run on a clean detached snapshot of e06653de, read-only (MCP and hooks disabled; uv/python/pip denied). The snapshot was verified unchanged afterwards.
  • The question was whether (1) an unrecognized MAP key/value or STRUCT field type should stay NullType in place, and (2) AthenaTypeCompiler.visit_null should be removed so that DDL raises CompileError.

Opinion:

  1. Keep in-place NullType. It matches ARRAY elements and top-level scalars, and it keeps the known fields. A String fallback would let CREATE TABLE succeed with a wrong schema. The PostgreSQL dialect makes a whole array NULLTYPE, but it has no field names to keep.
  2. Remove visit_null. It was added in 19efdaa for BizarroCharacterTest.test_fk_ref (an untyped FK column), which requirements.py now skips through the unsupported foreign_key_constraint_reflection. No code in pyathena/ or tests/ relies on DDL NULL; pyathena/pandas/util.py to_sql uses its own type mapping; and no built-in SQLAlchemy dialect overrides visit_null.
  3. Alternatives (an Athena-specific message, or a NullType subclass keeping the raw type string) were rejected as not worth the complexity.

Open points it raised, with their status:

  • Release note: done in the PR body.
  • Docs: the reflected NullType leaves and the DDL CompileError are now documented.
  • Alembic autogenerate with a reflected NullType: not verified, because Alembic is not a dependency.
  • ischema_names["map"] now applies only to a bare map without arguments: unchanged, as behavior for bare types is out of scope.
  • DML CAST(x AS NULL): unchanged, out of scope.

Author verification before implementing: the 19efdaa commit message (the other.ref NullType error) and requirements.py (foreign_key_ddl and foreign_key_constraint_reflection unsupported) were checked. The implementation is db6be39f06803b1fd0e851f20598eefd2512ed61.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review of e06653de..db6be39f (removing DDL visit_null, plus docs and tests)

Round-one perspective: CLEAN.

  • AthenaTypeCompiler is reached with a NullType only through DDL (get_column_specification, compiler.py:1474) and TypeEngine.compile(). The statement compiler renders CAST through AthenaDMLTypeCompiler, whose visit_null remains. test_null_type_cast_is_unchanged asserts CAST(x AS NULL).
  • The 58-type comparison differs from the previous head only in the two DDL NullType lines, which now raise.
  • New tests: test_null_type_is_rejected_in_ddl (top level and in ARRAY/MAP/STRUCT) and test_unrecognized_field_type_reflects_null_type_and_blocks_ddl, which reflects struct<a:int,b:timestamp(3) with time zone> and asserts the column-qualified CompileError.

Round-two perspective: CLEAN.

  • The docs sentences and the class docstring match the tests.
  • The PR body lists the DDL NullType behavior change, including columns declared without a type and ARRAY<NULL>.
  • The claim that the compliance suite skips test_fk_ref is confirmed by just test sqla on db6be39f: 589 passed, 759 skipped, the same as before.
  • AWS tests/pyathena/sqlalchemy/ + aio on db6be39f: 683 passed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of e06653de..db6be39f (relayed Codex result): Codex CLI 0.160.0, gpt-6-astra, session 01a10131-ae3b-7243-b086-2e47be732d74. Run with codex exec --sandbox read-only on a detached snapshot of db6be39f, with the whole branch diff as context. Because this change widens the DDL contract, the review covered its full scope. Static review; the snapshot was verified unchanged.

Result: CLEAN. Covered:

  • every dialect (sync, aio, pandas, Arrow, Polars, S3FS) sharing type_compiler_cls
  • DDL, TypeEngine.compile(), variants and decorators, nested ARRAY/MAP/STRUCT, and both reflection paths
  • SELECT/DML, binds, CAST, projections, indexing, slicing, and partial updates, including _ArrayAssignmentType
  • the installed SQLAlchemy 2.0.46 compliance suite: test_fk_ref is skipped by requirements.py:76, the CTE fixture supplies Integer, and the normalized-name and cross-schema FK fixtures are gated off
  • the tests (they fail with the override restored) and the docs/docstring

have no Athena DDL type and raise ``CompileError``.

See Also:
AWS Athena Data Types:
Expand Down Expand Up @@ -238,10 +238,6 @@ def visit_unicode(self, type_, **kw):
def visit_unicode_text(self, type_, **kw):
return "STRING"

@override
def visit_null(self, type_, **kw):
return "NULL"

def visit_tinyint(self, type_, **kw):
"""Render a tinyint type through ``visit_TINYINT``.

Expand Down
80 changes: 72 additions & 8 deletions tests/pyathena/sqlalchemy/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ def open_cursor(cursor_class, converter=None):
assert [column["name"] for column in columns] == ["id", "payload", "label", "dt"]
assert isinstance(columns[0]["type"], types.INTEGER)
assert isinstance(columns[1]["type"], AthenaStruct)
assert list(columns[1]["type"].fields) == ["a", "b"]
assert type(columns[2]["type"]) is types.String
assert type(columns[3]["type"]) is types.String
assert [column["comment"] for column in columns] == ["identifier", None, None, None]
Expand All @@ -196,6 +197,62 @@ def open_cursor(cursor_class, converter=None):
assert "WHERE table_schema = 'my_schema' AND table_name = 'o''neil'" in operation
assert kwargs == {"result_reuse_enable": False}

@pytest.mark.parametrize(
("map_type", "struct_type"),
[
# Metadata API (Hive) spellings.
("map<int,string>", "struct<a:int,`b c`:array<struct<x:int>>>"),
# information_schema (Trino) spellings.
("map(integer, varchar)", 'row(a integer, "b c" array(row(x integer)))'),
],
)
def test_top_level_map_and_struct_reflect_their_types(self, map_type, struct_type):
dialect = AthenaDialect()
map_ = dialect._get_column_type(map_type)
struct = dialect._get_column_type(struct_type)

assert isinstance(map_, AthenaMap)
assert isinstance(map_.key_type, types.INTEGER)
assert isinstance(map_.value_type, (types.String, types.VARCHAR))
assert isinstance(struct, AthenaStruct)
assert list(struct.fields) == ["a", "b c"]
assert isinstance(struct.fields["a"], types.INTEGER)
assert isinstance(struct.fields["b c"], AthenaArray)
assert list(struct.fields["b c"].item_type.fields) == ["x"]

table = Table(
"t",
MetaData(),
Column("m", map_),
Column("s", struct),
awsathena_location="s3://bucket/path/",
)
ddl = str(CreateTable(table).compile(dialect=dialect))
assert "\tm MAP<INT, STRING>,\n" in ddl
assert "\ts STRUCT<a:INT, `b c`:ARRAY<STRUCT<x:INT>>>\n" in ddl

def test_unrecognized_field_type_reflects_null_type_and_blocks_ddl(self):
dialect = AthenaDialect()
with pytest.warns(sqlalchemy.exc.SAWarning, match="Did not recognize type"):
struct = dialect._get_column_type("struct<a:int,b:timestamp(3) with time zone>")
assert isinstance(struct.fields["a"], types.INTEGER)
assert isinstance(struct.fields["b"], types.NullType)
table = Table(
"t", MetaData(), Column("payload", struct), awsathena_location="s3://bucket/path/"
)
with pytest.raises(
sqlalchemy.exc.CompileError,
match=r"column 'payload'.*Can't generate DDL for NullType",
):
CreateTable(table).compile(dialect=dialect)

@pytest.mark.parametrize(
"type_", ["map<int>", "struct<a>", "row(a)", "struct<a:map<int>>", "map<int,struct<a>>"]
)
def test_unrecognized_top_level_map_or_struct_reflects_null_type(self, type_):
with pytest.warns(sqlalchemy.exc.SAWarning, match="Did not recognize type"):
assert isinstance(AthenaDialect()._get_column_type(type_), types.NullType)

def test_empty_metadata_comment_is_no_comment(self):
# Glue can carry an empty comment, so the metadata path must agree with
# the information_schema path rather than reflecting it as a comment.
Expand Down Expand Up @@ -1499,10 +1556,15 @@ def test_reflect_select(self, engine):
assert isinstance(one_row_complex.c.col_binary.type, types.BINARY)
assert isinstance(one_row_complex.c.col_array.type, AthenaArray)
assert isinstance(one_row_complex.c.col_array.type.item_type, types.INTEGER)
assert isinstance(one_row_complex.c.col_map.type, types.String)
# With struct support, col_struct should now be recognized as AthenaStruct

assert isinstance(one_row_complex.c.col_map.type, AthenaMap)
assert isinstance(one_row_complex.c.col_map.type.key_type, types.INTEGER)
assert isinstance(one_row_complex.c.col_map.type.value_type, types.INTEGER)
assert isinstance(one_row_complex.c.col_struct.type, AthenaStruct)
assert list(one_row_complex.c.col_struct.type.fields) == ["a", "b"]
assert isinstance(one_row_complex.c.col_struct.type.fields["a"], types.INTEGER)
ddl = str(CreateTable(one_row_complex).compile(dialect=engine.dialect))
assert "\tcol_map MAP<INT, INT>,\n" in ddl
assert "\tcol_struct STRUCT<a:INT, b:INT>,\n" in ddl
assert isinstance(
one_row_complex.c.col_decimal.type,
types.DECIMAL,
Expand Down Expand Up @@ -1552,11 +1614,13 @@ def test_get_column_type(self, engine):
assert isinstance(dialect._get_column_type("date"), types.DATE)
assert isinstance(dialect._get_column_type("binary"), types.BINARY)
assert isinstance(dialect._get_column_type("array<integer>"), AthenaArray)
assert isinstance(dialect._get_column_type("map<int, int>"), types.String)
# With struct support, struct types should be recognized as AthenaStruct

assert isinstance(dialect._get_column_type("struct<a: int, b: int>"), AthenaStruct)
assert isinstance(dialect._get_column_type("row<name: string, age: int>"), AthenaStruct)
assert isinstance(dialect._get_column_type("map<int, int>"), AthenaMap)
struct = dialect._get_column_type("struct<a: int, b: int>")
assert isinstance(struct, AthenaStruct)
assert list(struct.fields) == ["a", "b"]
row = dialect._get_column_type("row<name: string, age: int>")
assert isinstance(row, AthenaStruct)
assert list(row.fields) == ["name", "age"]
decimal_with_args = dialect._get_column_type("decimal(10,1)")
assert isinstance(decimal_with_args, types.DECIMAL)
assert decimal_with_args.precision == 10
Expand Down
19 changes: 19 additions & 0 deletions tests/pyathena/sqlalchemy/test_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,25 @@ def test_ddl_and_cast_types(self, type_, ddl, cast_type):
assert dialect.type_compiler_instance.process(type_) == ddl
assert str(cast(column("x"), type_).compile(dialect=dialect)) == f"CAST(x AS {cast_type})"

@pytest.mark.parametrize(
"type_",
[
types.NullType(),
AthenaArray(types.NullType()),
AthenaMap(Integer, types.NullType()),
AthenaStruct(("a", Integer), ("b", types.NullType())),
],
)
def test_null_type_is_rejected_in_ddl(self, type_):
dialect = AthenaDialect()
with pytest.raises(exc.CompileError, match="Can't generate DDL for NullType"):
dialect.type_compiler_instance.process(type_)

def test_null_type_cast_is_unchanged(self):
assert str(cast(column("x"), types.NullType()).compile(dialect=AthenaDialect())) == (
"CAST(x AS NULL)"
)

@pytest.mark.parametrize(
"type_", [types.JSON(), AthenaArray(types.JSON), AthenaMap(String, types.JSON)]
)
Expand Down
Loading