Reflect the field and value types of top-level STRUCT and MAP columns - #995
Conversation
Column reflection parsed MAP and STRUCT/ROW type arguments only inside an ARRAY. A top-level map<...> column was reflected as String and a top-level struct<...> column as an AthenaStruct without fields, so CREATE TABLE compiled from a reflected table rendered MAP columns as STRING and failed for STRUCT columns. Top-level MAP and STRUCT/ROW columns now reflect as AthenaMap and AthenaStruct with their parsed types, from both the metadata API (Hive) and information_schema (Trino) spellings. A top-level type that cannot be parsed warns and reflects as NullType, as an ARRAY already does. Closes #886 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| 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: |
There was a problem hiding this comment.
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/structwith arguments calls_get_column_type(type_, _nested=True), which takes the existing nested MAP or STRUCT/ROW branch. Only the top-level call catchesTypeError/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 intoNullType. An unknown scalar nested in a STRUCT still warns and becomes aNullTypefield, without raising, as before. - Bare
map/struct/row(no arguments) keep theischema_namesfallback. - Both reflection paths:
_columns_from_metadataand_columns_from_information_schema(whosevarchartostringsubstitution 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()vsAthenaMap(Integer, Integer)andAthenaStruct()vsAthenaStruct(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 itsCREATE TABLErendering.
| `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. |
There was a problem hiding this comment.
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'rendersm || ...for String butm + ...forAthenaMap, whilecontains()andlike()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, andtest_reflect_selectasserts 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;
AthenaMapandAthenaStructhave no result processor. Links to the duplicated "Data format support" headings were avoided, because Sphinx renders the second one as#id6. - Callers:
_get_column_typeis called only from_columnand from itself; the signature is unchanged. - AWS operations: unchanged (parsing only; no additional requests).
- Evidence:
just test sqla/sqla-asyncwere not run locally; CI runs them on Ready.
An unrecognized element, key, value, or field type becomes NullType in place, while arguments that cannot be parsed make the whole type NullType. The docstring only described the second case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| enclosing type is reported as unrecognized. | ||
|
|
||
| Returns: | ||
| The SQLAlchemy type. A type name that is not recognized becomes |
There was a problem hiding this comment.
Independent review (relayed Codex result)
- Reviewer: Codex CLI 0.160.0, model
gpt-6-astra, session01a10100-05d3-7690-844b-e82cb56d13db. - Invocation:
codex exec --sandbox read-onlyon a detached snapshot of4217bbe65b6203f42522c63646bb2b5b06c02a7b(base6f258501a56124f6e29c6d530ce429b702ed2ab3). The prompt gave the diff range and measured type-string facts, and omitted the PR number, description, commits, and self-review findings. - Snapshot: tracked files are unchanged. An ignored
.serena/directory appeared; the read-only sandbox cannot write, and the same artifact appeared in the Split SQLAlchemy type rendering into Hive DDL and Trino DML compilers #961 snapshots, so it comes from local tooling rather than the reviewer. - Coverage, as reported: parsing (both spellings, quoting, nesting, whitespace, bare and malformed types, exception handling); the metadata/information_schema paths,
_column,get_columns,has_table, and async; types, comparators, binds/results, DDL/CAST, and cache keys; tests and docs. - Result: FINDINGS (4). Static review.
Dispositions (each reproduced with a script on 4217bbe65b6203f42522c63646bb2b5b06c02a7b):
- P2,
map<int,>→AthenaMap(INTEGER, NullType)andstruct<a:map<>>→ struct with aNullTypefield: accepted as a documentation finding, no code change. This is the existing nested-parser contract: an unrecognized leaf becomesNullTypein place with a warning. ARRAY behaves the same (array<foo>→AthenaArray(NullType),array<struct<a:foo>>→ struct with aNullTypefield). Keeping recognized fields is deliberate. The docstring (543d681f4c89376840f00ea349fe4798818b0917) and the PR body overstated whole-column fallback and now describe both cases. - P2, bare
map/struct/row: pre-existing, not changed. These are not parse failures, and Athena reports complex types with arguments. The PR body now states that bare types keep theirischema_namestypes. - P3, mismatched outer delimiters (
map<int,int)): pre-existing, not changed._pattern_column_typealready acceptedarray<int)with no warning. Athena does not emit such strings, and tightening the shared regex is outside this PR. - P3,
RecursionErrorat about 1,100 nesting levels: not changed. Recursive ARRAY parsing already had this limit, and Athena/Hive types do not nest that deep. CatchingRecursionErroronly for top-level MAP/STRUCT would diverge from ARRAY.
Repair 543d681f4c89376840f00ea349fe4798818b0917 changes only the _get_column_type docstring; lint passed.
There was a problem hiding this comment.
Independent follow-ups on the docstring repair (relayed Codex results; Codex CLI 0.160.0, gpt-6-astra, codex exec --sandbox read-only, snapshot verified unchanged after each run)
- Session
01a10108-5f42-76d2-82f5-6c2ef3c74763, diff4217bbe6..543d681f: FINDINGS. TheRaisessection omitted scalar argument errors: a top-levelvarchar(nope)raisesValueError, anddecimal(1,2,3,4,5)raisesTypeError. Reproduced; this is pre-existing behavior. Documented in035a3b9e. - Session
01a1010c-6636-74e3-b3ed-9a4372ae9194, diff543d681f..035a3b9e: FINDINGS. The "whole top-level type becomes NullType" wording was wrong when a nested ARRAY handles the error first:map<int,array<varchar(nope)>>→AthenaMap(INTEGER, NullType), andarray<array<map<int>>>→AthenaArray(NullType). Reproduced with 11 probe cases.e06653derewritesReturns/Raisesaround the single rule: the innermost enclosing ARRAY handles a parse error, or else a top-level MAP or STRUCT/ROW, and otherwise it is raised. - Session
01a1010f-f5c6-7641-a0d2-a42ef07923cf, diff035a3b9e..e06653deplus the complete docstring: CLEAN.
No code behavior changed in 543d681f, 035a3b9e, or e06653de (docstring only); lint passed on each.
Correction to the review prompts: the prompts for runs 1 and 2 called keeping the in-place NullType leaf behavior a "maintainer-side decision". It was the author's decision, recorded in the disposition above, and is open to the maintainer's review.
There was a problem hiding this comment.
Self-review of the docstring repairs (4217bbe6..e06653de): behavior CLEAN (no code statements changed; git diff 4217bbe6..e06653de touches only the _get_column_type docstring). Claims CLEAN: every Returns/Raises example was checked against a probe run on e06653de (varchar(x) raises; array<varchar(x)>, struct<a:varchar(x)>, and struct<a:map<int>> are NullType; map<int,array<varchar(x)>> keeps the MAP; struct<a:foo> keeps the field as NullType; a nested map<int> raises). The PR body states that the later commits change only the docstring.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A parse error is handled by the innermost enclosing ARRAY, or else by a top-level MAP or STRUCT/ROW, and raised otherwise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AthenaTypeCompiler rendered NullType as NULL, which Athena DDL rejects. The override existed for CAST, which now uses AthenaDMLTypeCompiler, and for an untyped foreign key column in a compliance test that the dialect's requirements now skip. Reflection can now produce NullType for an unrecognized MAP key or value or STRUCT field type, so CREATE TABLE from such a table raises SQLAlchemy's CompileError instead of emitting invalid DDL, as it already does for JSON and an empty STRUCT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| 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`` |
There was a problem hiding this comment.
Design consultation (relayed Claude Fable 5.1 opinion, requested by the maintainer)
claude -p, modelclaude-fable-5-1, efforthigh, profilemax(first-party Max), session17845510-8bb0-4015-a88b-a0d3617e0de5. Run on a clean detached snapshot ofe06653de, read-only (MCP and hooks disabled;uv/python/pipdenied). The snapshot was verified unchanged afterwards.- The question was whether (1) an unrecognized MAP key/value or STRUCT field type should stay
NullTypein place, and (2)AthenaTypeCompiler.visit_nullshould be removed so that DDL raisesCompileError.
Opinion:
- Keep in-place
NullType. It matches ARRAY elements and top-level scalars, and it keeps the known fields. AStringfallback would let CREATE TABLE succeed with a wrong schema. The PostgreSQL dialect makes a whole arrayNULLTYPE, but it has no field names to keep. - Remove
visit_null. It was added in 19efdaa forBizarroCharacterTest.test_fk_ref(an untyped FK column), whichrequirements.pynow skips through the unsupportedforeign_key_constraint_reflection. No code inpyathena/ortests/relies on DDLNULL;pyathena/pandas/util.pyto_sqluses its own type mapping; and no built-in SQLAlchemy dialect overridesvisit_null. - Alternatives (an Athena-specific message, or a
NullTypesubclass 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
NullTypeleaves and the DDLCompileErrorare now documented. - Alembic autogenerate with a reflected
NullType: not verified, because Alembic is not a dependency. ischema_names["map"]now applies only to a baremapwithout 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.
There was a problem hiding this comment.
Self-review of e06653de..db6be39f (removing DDL visit_null, plus docs and tests)
Round-one perspective: CLEAN.
AthenaTypeCompileris reached with aNullTypeonly through DDL (get_column_specification, compiler.py:1474) andTypeEngine.compile(). The statement compiler renders CAST throughAthenaDMLTypeCompiler, whosevisit_nullremains.test_null_type_cast_is_unchangedassertsCAST(x AS NULL).- The 58-type comparison differs from the previous head only in the two DDL
NullTypelines, which now raise. - New tests:
test_null_type_is_rejected_in_ddl(top level and in ARRAY/MAP/STRUCT) andtest_unrecognized_field_type_reflects_null_type_and_blocks_ddl, which reflectsstruct<a:int,b:timestamp(3) with time zone>and asserts the column-qualifiedCompileError.
Round-two perspective: CLEAN.
- The docs sentences and the class docstring match the tests.
- The PR body lists the DDL
NullTypebehavior change, including columns declared without a type andARRAY<NULL>. - The claim that the compliance suite skips
test_fk_refis confirmed byjust test sqlaondb6be39f: 589 passed, 759 skipped, the same as before. - AWS
tests/pyathena/sqlalchemy/+ aio ondb6be39f: 683 passed.
There was a problem hiding this comment.
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_refis skipped by requirements.py:76, the CTE fixture suppliesInteger, and the normalized-name and cross-schema FK fixtures are gated off - the tests (they fail with the override restored) and the docs/docstring
WHAT
Column reflection now parses the types of top-level MAP and STRUCT/ROW columns.
map<...>/map(...)column reflects asAthenaMap(key_type, value_type)instead ofString.struct<...>/row(...)column reflects asAthenaStructwith its field names and types, instead of anAthenaStructwithout fields.map<int,int>,struct<a:int,b:int>) and Trino from theinformation_schemafallback (map(integer, integer),row(a integer, b integer)). The parser is the one already used for MAP and STRUCT/ROW types nested in an ARRAY._get_column_typenow re-enters it for a top-level type, so the change is a single branch.struct<a>) warns "Did not recognize type" and reflects asNullType, as a top-level ARRAY already does. A nested type whose arguments cannot be parsed makes its enclosing ARRAY, MAP, or STRUCTNullType, as before.NullTypein place with a warning, as it already did inside an ARRAY (for examplestruct<a:foo>reflects as anAthenaStructwhose fieldaisNullType).map,struct, androwwithout arguments keep theirischema_namestypes (String,AthenaStruct()); Athena reports complex types with arguments.AthenaTypeCompilerno longer overridesvisit_null, soNullTypeat any depth in DDL (CREATE TABLE,TypeEngine.compile()) raises SQLAlchemy'sCompileError("Can't generate DDL for NullType(); ...") instead of emittingNULL, which Athena DDL rejects. The override was added in 19efdaa for an untyped foreign-key column in the compliance suite'sBizarroCharacterTest.test_fk_ref, which the dialect's requirements now skip (foreign_key_constraint_reflectionis unsupported), and it later served CAST, which usesAthenaDMLTypeCompilersince Split SQLAlchemy type rendering into Hive DDL and Trino DML compilers #961.CAST(x AS NULL)is unchanged.docs/sqlalchemy.mdstates the reflected types in the STRUCT and MAP sections, that an unrecognized key, value, or field type is reflected asNullTypewith a warning, that selecting such a column returns the cursor's value without conversion to the reflected types, and thatNullTyperaisesCompileErrorin DDL.Behavior changes for the 4.0.0 release notes:
AthenaMapinstead ofString.python_typeisdictinstead ofstr, and String-specific behavior no longer applies:column + 'x'renders+instead of||, andliteral_bindscannot render a value for the column.like()andcontains()render the same.CREATE TABLEcompiled from a reflected table renders these columns asMAP<...>andSTRUCT<...>. Before, a MAP column rendered asSTRING, and a STRUCT column raisedCompileErrorafter Split SQLAlchemy type rendering into Hive DDL and Trino DML compilers #961 (it renderedROW(), which Athena rejects, before that).NullTypewith a warning, instead ofStringor anAthenaStructwithout fields.NullTypein DDL raisesCompileErrorinstead of renderingNULL. This covers a column declared without a type,ARRAY<NULL>from a reflected array of an unrecognized type, and an unrecognized reflected MAP key or value or STRUCT field. Compiling such a table's CREATE TABLE now fails at compile time instead of in Athena.Unchanged, compared on
6f258501with the old and new types: compiled SELECT, INSERT, and WHERE SQL, bind and result processing, and the values returned for these columns. Neither type has bind or result processors, and no JSON projection is added for top-level MAP/STRUCT columns.WHY
Closes #886.
#961 made
AthenaTypeCompilerrender Hive DDL types and raiseCompileErrorfor anAthenaStructwithout fields.Reflection still produced that type for every top-level STRUCT column, and
Stringfor every top-level MAP column, so a reflected table could not be re-created withCREATE TABLE.Reflection already parsed these types when they were nested in an ARRAY (#774); top-level columns were outside that change's ARRAY scope.
Measured on Athena engine version 3 for the
one_row_complextest table:col_mapcol_structGetTableMetadatamap<int,int>struct<a:int,b:int>information_schema.columnsmap(integer, integer)row(a integer, b integer)Selecting both columns through the reflected table returned
({'1': '2', '3': '4'}, {'a': '1', 'b': '2'})before the change, andtest_reflect_selectasserts the same values with the new types.TEST
Tested commit: db6be39.
just lint,just docs lint: passed.TestAthenaDialect:test_top_level_map_and_struct_reflect_their_typescovers both spellings, quoted field names, and a nested ARRAY of STRUCT, and checks thatCREATE TABLErendersMAP<INT, STRING>andSTRUCT<a:INT, `b c`:ARRAY<STRUCT<x:INT>>>.test_unrecognized_top_level_map_or_struct_reflects_null_typecovers five malformed types.test_unrecognized_field_type_reflects_null_type_and_blocks_ddlreflectsstruct<a:int,b:timestamp(3) with time zone>and asserts the column-qualifiedCompileError.test_columns_from_information_schemanow asserts the STRUCT fields.TestAthenaTypeCompiler:test_null_type_is_rejected_in_ddl(top level and inside ARRAY/MAP/STRUCT), andtest_null_type_cast_is_unchanged.pyathena/(6f25850), the reflection tests fail (8, checked on 4217bbe). The DDLNullTypetests fail with thevisit_nulloverride restored (per the independent review; the 58-type comparison showsNULLbefore the change andCompileErrorafter).test_reflect_selectasserts the reflected MAP/STRUCT types, the unchanged row values, and theCREATE TABLErendering of the reflectedone_row_complextable;test_get_column_typeasserts the parsed types.uv run --env-file .env pytest -n 8 tests/pyathena/sqlalchemy/ tests/pyathena/aio/sqlalchemy/on db6be39: 683 passed.uv run --env-file .env just test sqlaon db6be39: 589 passed, 759 skipped (unchanged; the compliance test the override was added for is skipped).test,test-sqla, andtest-sqla-asyncon e06653d, before thevisit_nullchange.just test sqla-asyncand the non-SQLAlchemy suites on db6be39; CI runs them once the PR is Ready.🤖 Generated with Claude Code