Do not convert ArrowCursor fallback values twice - #1023
Conversation
With no S3 result file, AthenaArrowResultSet builds its table from GetQueryResults rows that are already converted, and the fetch methods converted them again. TIME, VARBINARY, and JSON object columns raised TypeError, and JSON string scalars were decoded twice. The result set now chooses the conversion functions for the fetch methods once, and uses none for the GetQueryResults fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| self._table = pa.Table.from_pydict({}) | ||
| # The conversion functions that the fetch methods apply to the table values. | ||
| # GetQueryResults values are already converted. | ||
| self._row_converters = self.converters if self.output_location else {} |
There was a problem hiding this comment.
Self-review round one (implementation behavior): CLEAN
Base 33d3a0700b90f3fe73a98768bbc01d1581a17960, head 70a1b877bb8abb3d25f07fd1459ab9dc6ebbe548.
Covered: AthenaArrowResultSet.__init__ for the S3 CSV, UNLOAD Parquet, GetQueryResults fallback, and failed paths; _fetch(), fetchone()/fetchmany()/fetchall(), and close(); the sync, async, and aio Arrow cursors (all build this result set); the extended test_fetch_all_rows.
Checks:
_row_convertersis computed after_as_arrow()updates the UNLOAD metadata, so the S3 paths get the same converters that_fetch()used to build lazily (descriptiondoes not change after__init__).Converter.get()always returns a function for a described column, soc(v) if c else vonly skips conversion when the column has no converter, which happens only on the fallback path ({}).- The fallback table comes from
_fetch_all_rows()withDefaultTypeConverter, so the fetched values equal theas_arrow()values. Live:defaultandmanagedreturn the same tuple for TIME, VARBINARY, a JSON object, and a JSON string scalar.
Pre-existing, not changed: close() sets _batches = [] (a list, not an iterator), so a _fetch() after close() would raise TypeError: 'list' object is not an iterator. That is outside this diff.
| else: | ||
| dict_rows = rows.to_pydict() | ||
| column_names = dict_rows.keys() | ||
| converters = [self._row_converters.get(k) for k in dict_rows] |
There was a problem hiding this comment.
Self-review round two (claims, callers, operations): CLEAN
Base 33d3a0700b90f3fe73a98768bbc01d1581a17960, head 70a1b877bb8abb3d25f07fd1459ab9dc6ebbe548. Full pass over the PR body, the commit message, the comment, and #1021.
- "broken since v3.28.0":
83566ef1(Fetching results does not work with Athena managed query result storage #664, managed query result storage) is first in v3.28.0. - "
_fetch()rebuiltconvertersfor every cell": the base calls theconvertersproperty inside the per-value generator. The head looks up each column's converter once per record batch. - The ArrowCursor converts GetQueryResults fallback values twice in the fetch methods #1021 table (TIME, VARBINARY, and JSON object fail; DATE and DECIMAL pass; a JSON string scalar is decoded twice) matches the measured base behavior. The extended test covers each failing type plus the string scalar.
- Callers: no public signature changes. The
convertersproperty is unchanged and still public. No additional AWS requests. - No docs describe the fallback fetch values for
ArrowCursor, so none need changes.
Choosing the converters in __init__ ignored converter.set() calls made after execute(). The result set now only records whether the rows come from a result file, and builds the converters once per record batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| self._table = pa.Table.from_pydict({}) | ||
| # The fetch methods convert only the values read from a result file. | ||
| # GetQueryResults values are already converted. | ||
| self._convert_rows = bool(self.output_location) |
There was a problem hiding this comment.
Independent review (relayed): FINDINGS (1 regression, 3 pre-existing)
Reviewer: Codex CLI 0.160.0, model gpt-6-astra, reasoning effort high, sandbox read-only, session 01a101ff-9276-7291-8c1f-de87bbbae0cc. Static review only. Snapshot: detached worktree at head 70a1b877bb8abb3d25f07fd1459ab9dc6ebbe548, base 33d3a0700b90f3fe73a98768bbc01d1581a17960. The prompt contained the literal diff and the intended behavior only. Afterwards, the snapshot and the PR worktree were clean at 70a1b877.
Covered (reviewer): S3 CSV, UNLOAD Parquet and metadata replacement, API fallback, failed and empty construction, sync/async/aio callers, fetch batching, exhaustion, and close, column names, converters, and the changed test and comment.
Findings and author verification:
- Regression, P2
pyathena/arrow/result_set.py:151: cachingself.convertersin__init__ignoresconverter.set(...)calls made afterexecute(), whereas the base resolved the converters at fetch time. Verified by reading the code. Action: fix. - Pre-existing, P2
pyathena/result_set.py:748:_rows_to_columnar()keys the columns by name, so on the fallbackSELECT 1 AS x, 2 AS xcollapses into one column. Verified by reading the code. Action: report to the maintainer; out of scope. - Pre-existing, P2
pyathena/result_set.py:721:_fetch_all_rows()checks every page for a header row, while_pre_fetch()checks only the first page. A later page whose first row equals the column names loses that row. Verified by reading the code. Action: report to the maintainer; out of scope. - Pre-existing, P3
pyathena/arrow/result_set.py:432:close()assigns a list to_batches, so a later_fetch()raisesTypeError. Already noted in round one; out of scope.
There was a problem hiding this comment.
Repair: 808cc34 (finding 1)
__init__ now records only self._convert_rows = bool(self.output_location). _fetch() resolves self.converters once per record batch when it converts rows, and leaves the rows unchanged on the fallback. A converter.set() after execute() reaches the next fetched batch again, and the converters are still built once per batch instead of once per cell.
Validation:
just lintpassed.- Live, S3 path: with
DefaultArrowTypeConverter,execute(json_parse(...)), thenconverter.set("json", lambda v: ("raw", v)),fetchone()returned(('raw', '{"a":1}'),). tests/pyathena/arrowandtests/pyathena/aio/arrow: 105 passed, includingtest_fetch_all_rows[managed].
Self-review of the repair: round one (behavior) and round two (claims) found nothing. The comment ("The fetch methods convert only the values read from a result file") matches both branches. The fix is now one boolean, with no cached state that a converter change can miss.
There was a problem hiding this comment.
Independent follow-up (relayed): CLEAN for 70a1b877..808cc34ffaec7b2d433d7232966a6716bd0bdc5a.
Reviewer: Codex CLI 0.160.0, gpt-6-astra, effort high, read-only, session 01a10207-a155-73d2-a876-602195cfec74. Static review; afterwards, the snapshot was clean at 808cc34f. Covered: S3 CSV/TXT, UNLOAD Parquet and schema metadata, the GetQueryResults fallback, failed and empty construction, converter mutation, batch buffering, fetch methods, exhaustion, close, and the comment. The S3 converters are resolved per fetched batch, so a converter.set() after execute() applies again. Rows already buffered keep their conversion, as on master. Fallback rows get no second conversion. No regressions.
- _to_json returns None for an empty string, which CSV results use for SQL NULL, so PandasCursor and ArrowCursor no longer raise JSONDecodeError on a NULL json value. - S3FSCursor converts GetQueryResults rows with its own converter, as it does for the CSV result file, instead of DefaultTypeConverter. - The ArrowCursor, PandasCursor, and PolarsCursor fallback tests cover NULL json values and JSON string scalars (the ArrowCursor fallback conversion itself was fixed in #1023). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WHAT
AthenaArrowResultSetnow records in__init__whether its table was read from an S3 result file (_convert_rows)._fetch()converts the values with the converters for the result columns, as before. It resolves them when it fetches, so aconverter.set()afterexecute()still applies._fetch()returns the values unchanged, because_fetch_all_rows()has already converted them withDefaultTypeConverter. The fetch methods now return the values thatas_arrow()holds._fetch()also builds theconvertersdictionary once per record batch instead of once per cell.Release note (4.0.0, fix): with managed query result storage,
ArrowCursorfetches TIME, VARBINARY, and JSON object columns instead of raisingTypeError, and no longer decodes JSON string scalars twice (broken since v3.28.0).WHY
Closes #1021.
_as_arrow_from_api()builds the table from converted rows, and_fetch()converted them again with converters that expect strings.PolarsCursorhad the same defect and was fixed in #1005.TEST
Tested commit: 808cc34
just lint: passed.TestArrowCursor::test_fetch_all_rows(defaultandmanaged) now selects TIME, VARBINARY, ajson_parseobject, and aCAST(text AS JSON)string scalar, and expects the same tuple in both modes. With the source change reverted,managedfails (TypeError: strptime() argument 1 must be str, not datetime.time) anddefaultpasses; with it, both pass.uv run --env-file .env pytest -n 4 tests/pyathena/arrow tests/pyathena/aio/arrow: 105 passed.converter.set("json", ...)afterexecute()applies to the nextfetchone(), as on master.just test pyathenasuite; it runs in CI when the PR is marked Ready.🤖 Generated with Claude Code