Skip to content

[Python] Build nested rows for struct values in pyarrow batch converters - #41201

Open
Thanatos9404 wants to merge 3 commits into
apache:masterfrom
Thanatos9404:parquet-rows-nested
Open

Thanatos9404 wants to merge 3 commits into
apache:masterfrom
Thanatos9404:parquet-rows-nested

Conversation

@Thanatos9404

Copy link
Copy Markdown
Contributor

Fixes #41100

ReadFromParquet(as_rows=True) declares struct columns as nested rows in its element type, but PyarrowBatchConverter.explode_batch built each element from pa.Scalar.as_py(), which returns structs as dicts. So row.address.city (or a Beam YAML MapToFields expression on a struct field) failed with AttributeError: 'dict' object has no attribute 'city'.

explode_batch now builds the declared row type for struct values, including rows in optional fields and in lists. PyarrowArrayBatchConverter.explode_batch had the same problem for row elements and gets the same fix. Each converter works out once which fields need converting, so rows without nested structs take the same path as before.

A struct read with only some of its columns (columns=['address.city']) gets None for the fields that were not read.

Behaviour change: nested struct values are rows now, so code that worked around this by indexing them as dicts (row.address['city']) has to use attribute access. The CHANGES.md entry says so.

Not changed here:

Tests:

  • New explode tests in arrow_type_compatibility_test.py, for:
    • schema-generated row types;
    • user NamedTuples;
    • a pa.Array batch.
  • One new test in parquetio_test.py. It reads a Parquet file with a struct column and a list of structs, and also does a partial struct read.
  • These four fail without the change and pass with it.
  • I also added a list-of-structs entry to the parameterized converter tests for round-trip coverage.
  • arrow_type_compatibility_test.py and batch_test.py pass, along with the schema'd tests in parquetio_test.py.
  • yapf, ruff and pyrefly are clean.

I also added a CHANGES.md entry under Bugfixes for 2.79.0.

PyarrowBatchConverter.explode_batch built each element from
pa.Scalar.as_py(), which returns struct values as dicts. With
ReadFromParquet(as_rows=True), the element type declares those fields as
nested rows, so code like row.address.city failed with AttributeError.
PyarrowArrayBatchConverter had the same problem for row elements.

Build the declared row type for struct values, including rows in
optional fields and lists. A struct read with only some of its columns
gets None for the others.

Fixes apache#41100
@github-actions github-actions Bot added python io core size/m M: 50-249 lines changed labels Nov 11, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📋 Review Summary

This PR updates PyarrowBatchConverter and PyarrowArrayBatchConverter to recursively reconstruct nested row types (RowTypeConstraint, NamedTuple, and @dataclass) from the dictionaries returned by PyArrow's Scalar.as_py(), including when nested inside optional or sequence fields. It also handles partial struct reads by defaulting omitted struct subfields to None and adds comprehensive unit and Parquet I/O tests.

🔍 General Feedback

  • The recursive converter pre-computation in _from_arrow_value_fn and _row_from_dict_fn is clean and avoids per-element type inspection overhead during explode_batch.
  • One edge case to address in _from_arrow_value_fn is handling Beam TypeConstraint objects (which BatchConverter.from_typehints produces via typehints.normalize()) and Python 3.10+ PEP 604 union types (X | None), where typing.get_origin() does not return typing.Union or standard collection origins.

💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.


origin = get_origin(typ)
args = get_args(typ)
if origin is Union:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High: _from_arrow_value_fn relies on typing.get_origin(typ) and origin is Union, which misses two cases:

  1. Normalized Beam TypeConstraints: BatchConverter.from_typehints normalizes element_type via typehints.normalize(element_type) before instantiating PyarrowArrayBatchConverter (and Beam typehints like beam.typehints.Optional[...] or beam.typehints.List[...] can also appear in row fields). For UnionConstraint and SequenceTypeConstraint subclasses (ListConstraint, ABCSequenceTypeConstraint, etc.), typing.get_origin(typ) returns None, so BatchConverter.from_typehints(element_type=Optional[Address], batch_type=pa.Array) or BatchConverter.from_typehints(element_type=List[Address], batch_type=pa.Array) will have self._convert = None and yield raw dicts instead of Address rows.
  2. PEP 604 union syntax (Address | None): On Python 3.10+, get_origin(Address | None) is types.UnionType rather than typing.Union.

Consider converting non-row TypeConstraints via convert_to_python_type(typ) first (as schemas.py does in typing_to_runner_api) and checking origin in (Union, types.UnionType).

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.

The first case was real: with batch_type=pa.Array, Optional[Address] and List[Address] elements still came out as dicts because from_typehints normalizes the hint. a42a60f handles the normalized union and sequence constraints and adds a test for both. I matched the constraints directly since convert_to_python_type raises for Collection[...]. A top-level Address | None is normalized to the same union, so it is covered too; PEP 604 unions inside a row field fail earlier in schema conversion, which this PR does not touch.

@github-actions

Copy link
Copy Markdown
Contributor

Assigning reviewers:

R: @shunping for label python.
Backup reviewer: damccorm (if the main reviewer is unresponsive, you can escalate to them by tagging their username)

This pull request likely touches a core component ("core" label). Please review with scrutiny.

Note: If you would like to opt out of this review, comment assign to next reviewer.

Available commands:

  • stop reviewer notifications - opt out of the automated review tooling
  • remind me after tests pass - tag the comment author after tests pass
  • waiting on author - shift the attention set back to the author (any comment or push by the author will return the attention set to the reviewers)

The PR bot will only process comments in the main thread (not review comments).

…types

BatchConverter.from_typehints normalizes the element type, so
PyarrowArrayBatchConverter saw Beam UnionConstraint and sequence
constraints, which typing.get_origin does not recognize, and returned
struct values as dicts for Optional[Row] and List[Row] elements.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: [Python] ReadFromParquet(as_rows=True) yields dicts for struct fields declared as Row

1 participant