Skip to content

Commit b770a04

Browse files
committed
PyArrow: Cast dictionary-encoded arrays to target schema during scan projection (#3260)
1 parent 7539661 commit b770a04

2 files changed

Lines changed: 47 additions & 0 deletions

File tree

pyiceberg/io/pyarrow.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1716,6 +1716,7 @@ def _task_to_record_batches(
17161716
downcast_ns_timestamp_to_us=downcast_ns_timestamp_to_us,
17171717
projected_missing_fields=projected_missing_fields,
17181718
allow_timestamp_tz_mismatch=True,
1719+
dictionary_columns=dictionary_columns,
17191720
)
17201721

17211722

@@ -1910,6 +1911,7 @@ def _to_requested_schema(
19101911
projected_missing_fields: dict[int, Any] = EMPTY_DICT,
19111912
allow_timestamp_tz_mismatch: bool = False,
19121913
format_model: FileFormatModel | None = None,
1914+
dictionary_columns: tuple[str, ...] = (),
19131915
) -> pa.RecordBatch:
19141916
# We could reuse some of these visitors
19151917
struct_array = visit_with_partner(
@@ -1922,6 +1924,7 @@ def _to_requested_schema(
19221924
projected_missing_fields=projected_missing_fields,
19231925
allow_timestamp_tz_mismatch=allow_timestamp_tz_mismatch,
19241926
format_model=format_model,
1927+
dictionary_columns=dictionary_columns,
19251928
),
19261929
ArrowAccessor(file_schema),
19271930
)
@@ -1935,6 +1938,7 @@ class ArrowProjectionVisitor(SchemaWithPartnerVisitor[pa.Array, pa.Array | None]
19351938
_projected_missing_fields: dict[int, Any]
19361939
_allow_timestamp_tz_mismatch: bool
19371940
_format_model: FileFormatModel | None
1941+
_dictionary_columns: tuple[str, ...]
19381942

19391943
def __init__(
19401944
self,
@@ -1944,6 +1948,7 @@ def __init__(
19441948
projected_missing_fields: dict[int, Any] = EMPTY_DICT,
19451949
allow_timestamp_tz_mismatch: bool = False,
19461950
format_model: FileFormatModel | None = None,
1951+
dictionary_columns: tuple[str, ...] = (),
19471952
) -> None:
19481953
if include_field_ids and format_model is None:
19491954
raise ValueError("format_model is required when include_field_ids=True")
@@ -1955,12 +1960,18 @@ def __init__(
19551960
# Allowed for reading (aligns with Spark); disallowed for writing to enforce Iceberg spec's strict typing.
19561961
self._allow_timestamp_tz_mismatch = allow_timestamp_tz_mismatch
19571962
self._format_model = format_model
1963+
self._dictionary_columns = dictionary_columns
19581964

19591965
def _cast_if_needed(self, field: NestedField, values: pa.Array) -> pa.Array:
19601966
file_field = self._file_schema.find_field(field.field_id)
19611967

19621968
if field.field_type.is_primitive:
19631969
if (target_type := schema_to_pyarrow(field.field_type, include_field_ids=self._include_field_ids)) != values.type:
1970+
if pa.types.is_dictionary(values.type):
1971+
if field.name not in self._dictionary_columns:
1972+
return values.cast(target_type)
1973+
return values
1974+
19641975
if field.field_type == TimestampType():
19651976
source_tz_compatible = values.type.tz is None or (
19661977
self._allow_timestamp_tz_mismatch and values.type.tz in UTC_ALIASES

tests/io/test_pyarrow.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5462,3 +5462,39 @@ def test_dictionary_columns_produces_dict_encoded_output(tmpdir: str) -> None:
54625462

54635463
# Values must be identical
54645464
assert result_plain.column("label").to_pylist() == result_dict.column("label").to_pylist()
5465+
5466+
5467+
def test_arrow_scan_mixed_dict_encoded_and_plain_strings(tmpdir: str) -> None:
5468+
schema_plain = pa.schema([pa.field("col", pa.string(), metadata={b"PARQUET:field_id": b"1"})])
5469+
table_plain = pa.Table.from_pylist([{"col": "plain_a"}, {"col": "plain_b"}], schema=schema_plain)
5470+
file_plain = _write_table_to_data_file(f"{tmpdir}/plain.parquet", schema_plain, table_plain)
5471+
file_plain.spec_id = 0
5472+
5473+
schema_dict = pa.schema([pa.field("col", pa.dictionary(pa.int32(), pa.string()), metadata={b"PARQUET:field_id": b"1"})])
5474+
table_dict = pa.Table.from_pylist([{"col": "dict_a"}, {"col": "dict_b"}], schema=schema_dict)
5475+
file_dict = _write_table_to_data_file(f"{tmpdir}/dict.parquet", schema_dict, table_dict)
5476+
file_dict.spec_id = 0
5477+
5478+
iceberg_schema = Schema(
5479+
NestedField(1, "col", StringType(), required=False),
5480+
)
5481+
table_metadata = TableMetadataV2(
5482+
location=f"file://{tmpdir}",
5483+
last_column_id=1,
5484+
format_version=2,
5485+
schemas=[iceberg_schema],
5486+
partition_specs=[PartitionSpec()],
5487+
)
5488+
io = PyArrowFileIO()
5489+
tasks = [FileScanTask(file_plain), FileScanTask(file_dict)]
5490+
5491+
scan = ArrowScan(
5492+
table_metadata=table_metadata,
5493+
io=io,
5494+
projected_schema=iceberg_schema,
5495+
row_filter=AlwaysTrue(),
5496+
)
5497+
5498+
result = scan.to_table(tasks)
5499+
assert result.schema.field("col").type == schema_to_pyarrow(StringType())
5500+
assert result.column("col").to_pylist() == ["plain_a", "plain_b", "dict_a", "dict_b"]

0 commit comments

Comments
 (0)