Skip to content

Fall back to the original string for complex MAP/STRUCT/ARRAY values instead of returning None - #763

Merged
laughingman7743 merged 3 commits into
pyathena-dev:masterfrom
seunggabi:fix/complex-type-raw-string-fallback
Sep 2, 2026
Merged

Fall back to the original string for complex MAP/STRUCT/ARRAY values instead of returning None#763
laughingman7743 merged 3 commits into
pyathena-dev:masterfrom
seunggabi:fix/complex-type-raw-string-fallback

Conversation

@seunggabi

@seunggabi seunggabi commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes #764

Problem

When a complex-type column contains values that are too complex for the native-format parsers, _to_array / _to_map / _to_struct return None. 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:

{items=[{"product_id":285,"option_id":6049}], url=/webview/checkout, brand_id=75}
_to_map('{push=Y}')                      # {'push': 'Y'}  -> shown
_to_map('{items=[{...}], brand_id=75}')  # None           -> shown as NULL

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_map already states the intent:

# But for complex structures, return None to keep as string

…but returning None does 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 string
  • parser.py: widened struct_parser type annotation accordingly

Behavior is unchanged for:

  • None input → None
  • values that don't look like the type at all → None
  • all simple/parsable cases → parsed dict / list as before

CAST(col AS JSON) remains the recommended way to get reliably structured values; this change only ensures the fallback is lossless.

Tests

  • Added tests for the raw-string fallback (map with nested array values incl. the real-world case above, native nested arrays)
  • Added regression tests that simple values still parse ({push=Y})
  • Updated 2 existing expectations in test_to_array_complex_nested_cases from None to the original string
  • pytest tests/pyathena/test_parser.py tests/pyathena/test_converter.py: 130 passed
  • ruff check / ruff format --check / mypy: clean

@seunggabi
seunggabi force-pushed the fix/complex-type-raw-string-fallback branch from d178f72 to faa836f Compare September 1, 2026 08:56
…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
@seunggabi
seunggabi force-pushed the fix/complex-type-raw-string-fallback branch from faa836f to 8a75aea Compare September 1, 2026 09:00
Comment thread pyathena/converter.py Outdated
return None
# and keep the original string instead of silently dropping it
return varchar_value
return _parse_map_native(inner)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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}}')  # None

Since 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_value

The same pattern applies to _to_array and _to_struct — see the other comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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}}.

Comment thread pyathena/converter.py Outdated
# instead of silently dropping the value
return varchar_value
# Try native parsing (including struct arrays)
return _parse_array_native(inner)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 skipped

Suggest the same treatment as in _to_map:

result = _parse_array_native(inner)
return result if result is not None else varchar_value

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f04125b — same None → original-string fallback applied to _parse_array_native's result, with tests for [a=1, b=2].

Comment thread pyathena/converter.py
@@ -233,7 +241,8 @@ def _to_struct(varchar_value: str | None) -> dict[str, Any] | None:
# Unnamed struct: {Alice, 25}
return _parse_unnamed_struct(inner)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_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 skipped

Worth 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).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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}.

Comment thread pyathena/converter.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

seunggabi and others added 2 commits September 2, 2026 09:27
…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>
@laughingman7743

Copy link
Copy Markdown
Member

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 tests/pyathena/test_cursor.py (special_chars_struct / special_map) still asserted the old "dict or None" contract, so I pushed cd3bd74 to update them to accept the raw-string fallback — the new behavior is actually an improvement there (the old code partially parsed {msg=Hello, world, ...} and dropped data).

Final results on this branch:

  • just test pyathena: 1293 passed, 11 skipped
  • just test sqla: 234 passed, 152 skipped
  • just test sqla-async: 234 passed, 152 skipped

Merging. Thanks again for the contribution!

@laughingman7743
laughingman7743 merged commit f84817e into pyathena-dev:master Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Complex MAP/STRUCT/ARRAY values are silently converted to None (looks like data loss)

2 participants