This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git
The following commit(s) were added to refs/heads/main by this push:
new bd98ff1de PyArrow: Support view types when writing to a partitioned
table (#4121)
bd98ff1de is described below
commit bd98ff1de88febaca645d54004a83de37a2d5ce9
Author: Dhruv <[email protected]>
AuthorDate: Sat Oct 24 18:17:38 2026 +0000
PyArrow: Support view types when writing to a partitioned table (#4121)
* PyArrow: Support view types when writing to a partitioned table
Appending an Arrow table with string_view or binary_view columns, which
pa.table() produces from a Polars DataFrame, worked for unpartitioned
tables but failed for partitioned ones: PyArrow has no group by, filter
or take kernels for view types, and pyiceberg-core's bucket and truncate
transforms reject them. Cast view types, including nested ones, to their
large counterparts before splitting the table into partitions.
* Use PyArrowSchemaVisitor to replace view types
* Test appending Polars categorical columns to a partitioned table
Polars exports Categorical as dictionary<string_view> and List(Categorical)
as large_list<dictionary<string_view>>, which exercise the _keep_dictionary
paths in _ConvertViewTypes.
Co-authored-by: Copilot App <[email protected]>
---------
Co-authored-by: Kevin Liu <[email protected]>
Co-authored-by: Copilot App <[email protected]>
---
pyiceberg/io/pyarrow.py | 50 ++++++++++++++++++++++++++++++
tests/io/test_pyarrow.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 128 insertions(+), 1 deletion(-)
diff --git a/pyiceberg/io/pyarrow.py b/pyiceberg/io/pyarrow.py
index 874646b71..843dc0449 100644
--- a/pyiceberg/io/pyarrow.py
+++ b/pyiceberg/io/pyarrow.py
@@ -1607,6 +1607,51 @@ class
_ConvertToSmallTypes(PyArrowSchemaVisitor[IcebergType | pa.Schema]):
return primitive
+class _ConvertViewTypes(PyArrowSchemaVisitor[pa.Schema | pa.DataType]):
+ """Replace string_view and binary_view, including nested occurrences, with
large_string and large_binary.
+
+ Everything else is kept as is: list flavour, field names, nullability,
metadata and dictionary encoding.
+ """
+
+ def schema(self, schema: pa.Schema, struct_result: pa.StructType) ->
pa.Schema:
+ return pa.schema(struct_result, metadata=schema.metadata)
+
+ def struct(self, struct: pa.StructType, field_results:
builtins.list[pa.Field]) -> pa.StructType:
+ return pa.struct(field_results)
+
+ def field(self, field: pa.Field, field_result: pa.DataType) -> pa.Field:
+ return field.with_type(_keep_dictionary(field.type, field_result))
+
+ def list(self, list_type: pa.ListType | pa.LargeListType |
pa.FixedSizeListType, element_result: pa.DataType) -> pa.DataType:
+ value_field =
list_type.value_field.with_type(_keep_dictionary(list_type.value_type,
element_result))
+ if isinstance(list_type, pa.FixedSizeListType):
+ return pa.list_(value_field, list_type.list_size)
+ elif isinstance(list_type, pa.LargeListType):
+ return pa.large_list(value_field)
+ return pa.list_(value_field)
+
+ def map(self, map_type: pa.MapType, key_result: pa.DataType, value_result:
pa.DataType) -> pa.DataType:
+ return pa.map_(
+ map_type.key_field.with_type(_keep_dictionary(map_type.key_type,
key_result)),
+ map_type.item_field.with_type(_keep_dictionary(map_type.item_type,
value_result)),
+ keys_sorted=map_type.keys_sorted,
+ )
+
+ def primitive(self, primitive: pa.DataType) -> pa.DataType:
+ if pa.types.is_string_view(primitive):
+ return pa.large_string()
+ elif pa.types.is_binary_view(primitive):
+ return pa.large_binary()
+ return primitive
+
+
+def _keep_dictionary(original: pa.DataType, result: pa.DataType) ->
pa.DataType:
+ """Re-apply dictionary encoding that visit_pyarrow strips before visiting
the value type."""
+ if isinstance(original, pa.DictionaryType):
+ return pa.dictionary(original.index_type, result,
ordered=original.ordered)
+ return result
+
+
class _ConvertToIcebergWithoutIDs(_ConvertToIceberg):
"""
Converts PyArrowSchema to Iceberg Schema with all -1 ids.
@@ -3116,6 +3161,11 @@ def _determine_partitions(spec: PartitionSpec, schema:
Schema, arrow_table: pa.T
- Then we produce a set of partitions by filtering on each of the
combinations
- We combine the chunks to create a copy to avoid GIL congestion on the
original table
"""
+ # PyArrow has no group by, filter or take kernels for view types, so
replace them with their large counterparts
+ arrow_schema = visit_pyarrow(arrow_table.schema, _ConvertViewTypes())
+ if arrow_schema != arrow_table.schema:
+ arrow_table = arrow_table.cast(arrow_schema)
+
# Assign unique names to columns where the partition transform has been
applied
# to avoid conflicts
partition_fields = [f"_partition_{field.name}" for field in spec.fields]
diff --git a/tests/io/test_pyarrow.py b/tests/io/test_pyarrow.py
index e97b7813d..e6a085b8d 100644
--- a/tests/io/test_pyarrow.py
+++ b/tests/io/test_pyarrow.py
@@ -106,7 +106,7 @@ from pyiceberg.table.deletion_vector import (
from pyiceberg.table.metadata import TableMetadataV2, TableMetadataV3
from pyiceberg.table.name_mapping import create_mapping_from_schema
from pyiceberg.table.puffin import MAGIC_BYTES, PuffinFile
-from pyiceberg.transforms import HourTransform, IdentityTransform
+from pyiceberg.transforms import BucketTransform, HourTransform,
IdentityTransform, Transform, TruncateTransform
from pyiceberg.typedef import UTF8, Properties, Record, TableVersion
from pyiceberg.types import (
BinaryType,
@@ -3194,6 +3194,83 @@ def test_determine_partitions_identity_nan() -> None:
assert rows_by_partition == {"1.0": [1], "nan": [2, 4], "None": [3]}
[email protected]("transform", [IdentityTransform(),
BucketTransform(4), TruncateTransform(1)])
+def test_determine_partitions_with_view_types(transform: Transform[Any, Any])
-> None:
+ schema = Schema(
+ NestedField(id=1, name="region", field_type=StringType(),
required=False),
+ NestedField(id=2, name="payload", field_type=BinaryType(),
required=False),
+ NestedField(
+ id=3,
+ name="info",
+ field_type=StructType(
+ NestedField(id=4, name="tags", field_type=ListType(5,
StringType(), element_required=False), required=False),
+ NestedField(id=6, name="attrs", field_type=MapType(7,
StringType(), 8, BinaryType()), required=False),
+ ),
+ required=False,
+ ),
+ )
+ spec = PartitionSpec(PartitionField(source_id=1, field_id=1000,
transform=transform, name="region_part"))
+ arrow_schema = pa.schema(
+ [
+ pa.field("region", pa.string_view()),
+ pa.field("payload", pa.binary_view()),
+ pa.field(
+ "info",
+ pa.struct(
+ [
+ pa.field("tags", pa.list_(pa.string_view())),
+ pa.field("attrs", pa.map_(pa.string_view(),
pa.binary_view())),
+ ]
+ ),
+ ),
+ ]
+ )
+ rows = [
+ {"region": "us", "payload": b"a", "info": {"tags": ["x"], "attrs":
[("k", b"v")]}},
+ {"region": "eu", "payload": None, "info": None},
+ {"region": "us", "payload": b"b", "info": {"tags": None, "attrs":
None}},
+ {"region": None, "payload": b"c", "info": {"tags": ["y", None],
"attrs": []}},
+ ]
+ arrow_table = pa.Table.from_pylist(rows, schema=arrow_schema)
+
+ partitions = list(_determine_partitions(spec, schema, arrow_table))
+
+ assert {p.partition_key.partition[0] for p in partitions} == {
+ transform.transform(StringType())(region) for region in ("us", "eu",
None)
+ }
+ written = pa.concat_tables([p.arrow_table_partition for p in partitions])
+ assert sorted(written.to_pylist(), key=str) == sorted(rows, key=str)
+
+
+def test_append_view_types_to_partitioned_table(catalog: InMemoryCatalog) ->
None:
+ schema = Schema(
+ NestedField(id=1, name="id", field_type=LongType(), required=False),
+ NestedField(id=2, name="region", field_type=StringType(),
required=False),
+ NestedField(id=3, name="payload", field_type=BinaryType(),
required=False),
+ NestedField(id=4, name="category", field_type=StringType(),
required=False),
+ NestedField(id=5, name="labels", field_type=ListType(6, StringType(),
element_required=False), required=False),
+ )
+ spec = PartitionSpec(PartitionField(source_id=2, field_id=1000,
transform=IdentityTransform(), name="region"))
+ catalog.create_namespace("default")
+ table = catalog.create_table("default.test_append_view_types",
schema=schema, partition_spec=spec)
+
+ categories = pa.array(["a", "b", None],
type=pa.string_view()).dictionary_encode()
+ arrow_table = pa.table(
+ {
+ "id": pa.array([1, 2, 3], type=pa.int64()),
+ "region": pa.array(["us", "eu", "us"], type=pa.string_view()),
+ "payload": pa.array([b"a", None, b"c"], type=pa.binary_view()),
+ # Polars exports Categorical as dictionary<string_view> and
List(Categorical) as large_list<dictionary<string_view>>
+ "category": categories,
+ "labels": pa.LargeListArray.from_arrays([0, 1, 1, 3], categories),
+ }
+ )
+ table.append(arrow_table)
+
+ assert len(table.inspect.partitions()) == 2
+ assert sorted(table.scan().to_arrow().to_pylist(), key=lambda row:
row["id"]) == arrow_table.to_pylist()
+
+
def test_inspect_partition_for_nested_field(catalog: InMemoryCatalog) -> None:
schema = Schema(
NestedField(id=1, name="foo", field_type=StringType(), required=True),