Fall back to the original string for complex MAP/STRUCT/ARRAY values instead of returning None - #763
Conversation
d178f72 to
faa836f
Compare
…TRUCT/ARRAY values instead of returning None
The native-format parsers for complex types give up when a value contains
nested structures (e.g. MAP<VARCHAR,VARCHAR> whose value holds a JSON
array), returning None. To callers this is indistinguishable from a NULL
cell, so the data appears silently lost - e.g. Superset over PyAthena
shows NULL for such columns even though Athena returned the value.
The existing comment in _to_map already stated the intent ('return None
to keep as string'), but returning None does not keep the string. This
change makes the give-up paths of _to_array/_to_map/_to_struct return the
original varchar value, preserving the data while keeping simple cases
parsed as before. CAST(col AS JSON) remains the recommended way to get
structured values.
Fixes pyathena-dev#764
faa836f to
8a75aea
Compare
| return None | ||
| # and keep the original string instead of silently dropping it | ||
| return varchar_value | ||
| return _parse_map_native(inner) |
There was a problem hiding this comment.
Thanks for the fix — the direction matches the comment's original intent. One class of silent None still slips through here, though: values that are nested structs/maps (braces only, no []()) pass the "()[]" check above, and _parse_map_native then skips every pair whose value contains {}=" and returns None when nothing is left:
_to_map('{a={b=1}}') # None — e.g. MAP<VARCHAR, ROW(...)> or MAP<VARCHAR, MAP(...)>
_to_map('{a={b=1, c=2}}') # NoneSince these are exactly the kind of complex values this PR is about, could you extend the fallback to cover the helper's give-up path as well? For example:
result = _parse_map_native(inner)
return result if result is not None else varchar_valueThe same pattern applies to _to_array and _to_struct — see the other comments.
There was a problem hiding this comment.
Good catch — fixed in f04125b. _to_map now falls back to the original string when _parse_map_native returns None, and added tests for {a={b=1}} / {a={b=1, c=2}}.
| # instead of silently dropping the value | ||
| return varchar_value | ||
| # Try native parsing (including struct arrays) | ||
| return _parse_array_native(inner) |
There was a problem hiding this comment.
Same remaining gap here: _parse_array_native returns None when every item is skipped by its safety check (items containing []="), and that None still propagates to the caller:
_to_array('[a=1, b=2]') # None — items containing '=' are skippedSuggest the same treatment as in _to_map:
result = _parse_array_native(inner)
return result if result is not None else varchar_valueThere was a problem hiding this comment.
Fixed in f04125b — same None → original-string fallback applied to _parse_array_native's result, with tests for [a=1, b=2].
| @@ -233,7 +241,8 @@ def _to_struct(varchar_value: str | None) -> dict[str, Any] | None: | |||
| # Unnamed struct: {Alice, 25} | |||
| return _parse_unnamed_struct(inner) | |||
There was a problem hiding this comment.
_parse_named_struct (the branch just above) can also return None when all pairs are skipped, which still propagates as a silent None:
_to_struct('{"a"=1}') # None — the key containing a quote is skippedWorth applying the same None → original-string fallback to its result as well (_parse_unnamed_struct always returns a dict, so only the named branch needs it).
There was a problem hiding this comment.
Fixed in f04125b — _parse_named_struct's result now gets the same fallback (left _parse_unnamed_struct as-is since it always returns a dict), with a test for {"a"=1}.
There was a problem hiding this comment.
One more case in the same class of silent data loss (pre-existing, but I'd like it folded into this PR since it directly undermines the "never silently lose data" goal): _parse_map_native silently drops individual pairs whose key/value contains {}=", so a partially parseable map returns a partial dict — which is arguably worse than None was, because the loss is invisible:
_to_map('{a="x", b=1}') # {'b': '1'} — the a="x" pair is silently dropped_parse_array_native has the same issue for skipped items (_to_array('[a, b=1]') → ['a']).
A clean way to handle this together with the other comments: make the _parse_*_native helpers return None whenever they had to skip anything, and let the outer functions fall back to the original string. That way the result is always either fully parsed or the intact raw string, never a partial value.
There was a problem hiding this comment.
Agreed that a partial dict/list is worse than None was — implemented exactly as you suggested in f04125b: _parse_map_native / _parse_array_native / _parse_named_struct now return None whenever any pair/item has to be skipped, and the outer functions fall back to the original string. The result is always either fully parsed or the intact raw string, never partial. Added tests for {a="x", b=1}, {a, b=1}, and [a, b=1] (previously {'b': '1'} / ['a']). All 137 unit tests pass, ruff/mypy clean.
…er give-up paths and forbid partial results Per review feedback on pyathena-dev#763: - _to_map/_to_array/_to_struct now fall back to the original string when the _parse_*_native helpers return None (e.g. nested braces-only values like '{a={b=1}}' that pass the '()[]' pre-check, or '[a=1, b=2]' where every item is skipped, or '{"a"=1}' where the quoted key is skipped). - _parse_map_native/_parse_array_native/_parse_named_struct now return None whenever any pair/item has to be skipped, instead of silently dropping it and returning a partial dict/list. Combined with the outer fallback, the result is always either fully parsed or the intact raw string - never a partial value.
…g fallback contract The integration tests in TestComplexDataTypes asserted the old contract (conversion returns a dict or None). With the raw-string fallback, values too complex to parse losslessly now return the original string instead of None, so accept that as a valid conversion result. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thank you for the report and the fix, and for the quick follow-up addressing all the review comments — much appreciated! Since fork PRs can't run the AWS-backed CI here, I ran the full test suites locally against this branch. Two integration tests in Final results on this branch:
Merging. Thanks again for the contribution! |
Closes #764
Problem
When a complex-type column contains values that are too complex for the native-format parsers,
_to_array/_to_map/_to_structreturnNone. To the caller this is indistinguishable from a NULL cell, so the data appears silently lost.Real-world example: a
MAP<VARCHAR, VARCHAR>column where one value holds a serialized JSON array:In BI tools built on PyAthena (e.g. Superset), simple maps display fine while complex ones display as NULL — even though Athena returned the value. Users reasonably conclude the data pipeline dropped their data. (We spent a full debugging session tracing Kafka → Flink → Iceberg before finding the converter.)
Notably, the existing comment in
_to_mapalready states the intent:# But for complex structures, return None to keep as string…but returning
Nonedoes not keep the string.Change
On the give-up paths (nested-structure skip and parse exceptions), return the original varchar value instead of
None:_to_array: nested-array skip / parse exception → original string_to_map: complex-structure skip / parse exception → original string_to_struct: parse exception → original stringparser.py: widenedstruct_parsertype annotation accordinglyBehavior is unchanged for:
Noneinput →NoneNonedict/listas beforeCAST(col AS JSON)remains the recommended way to get reliably structured values; this change only ensures the fallback is lossless.Tests
{push=Y})test_to_array_complex_nested_casesfromNoneto the original stringpytest tests/pyathena/test_parser.py tests/pyathena/test_converter.py: 130 passedruff check/ruff format --check/mypy: clean