Read tables with multi-argument transforms as unknown transforms - #3630
Read tables with multi-argument transforms as unknown transforms#3630moomindani wants to merge 4 commits into
Conversation
| sources = ", ".join(str(s) for s in self.source_ids) | ||
| else: | ||
| sources = str(self.source_id) | ||
| return f"{self.field_id}: {self.name}: {self.transform}({sources})" |
There was a problem hiding this comment.
Could you also update SortField.__str__?
There was a problem hiding this comment.
Updated SortField.__str__ in 818e6f9 to render all source ids the same way as PartitionField.__str__.
| if self.source_ids is not None and len(self.source_ids) > 1: | ||
| serialized.pop("source-id", None) | ||
| serialized.pop("source_id", None) | ||
| else: | ||
| serialized.pop("source-ids", None) | ||
| serialized.pop("source_ids", None) |
There was a problem hiding this comment.
Why does this block contain underscore fields that the specification doesn't include? We should delete them in my opinion. Same for partitioning.py.
There was a problem hiding this comment.
Agreed, removed them in 818e6f9. For context, they were a defensive measure for model_dump(by_alias=False), where pydantic emits field names (source_id) instead of aliases (source-id) — but since IcebergBaseModel always forces by_alias=True for metadata serialization, that case never produces spec JSON and the extra pops were unnecessary.
|
Not stale — waiting for review. CI is green (17/17) and mergeable. Both of @ebyhr's comments from July 10 are addressed in This is a read-path correctness fix — PyIceberg currently raises @ebyhr would you mind confirming the two threads, and @Fokko / @sungwy a review from either of you would be welcome. |
818e6f9 to
9a99f17
Compare
Per the V3 spec, readers must read tables with unknown transforms, ignoring them. PyIceberg raised on partition or sort fields with more than one entry in source-ids, so such tables failed to load. Model source-ids on PartitionField and SortField, treat multi-argument transforms as UnknownTransform (null partition values, always-true projection), and serialize per spec: source-id for single-argument transforms, source-ids otherwise. Also fix two latent tolerance bugs: unknown transform names sharing a prefix with known ones (e.g. bucketv2[4]) failed to parse, and str(UnknownTransform) returned "unknown" instead of the original name, corrupting metadata on rewrite. Closes apache#3628
A multi-argument field without a transform previously fabricated
UnknownTransform('None') and masked the missing required field; raise
a clear error instead. Assert explicitly that a single-element
source-ids is normalized onto source-id, and only use the list form
of __str__ for genuinely multi-argument fields.
9a99f17 to
a4a7360
Compare
| raise ValueError("Empty source-ids is not allowed") | ||
| if len(source_ids) > 1: | ||
| raise ValueError("Multi argument transforms are not yet supported") | ||
| if data.get("transform") is None: |
There was a problem hiding this comment.
This block of code doesn't get run if source-id and source-ids are both set.
In general, this set of code is way too deeply-nested and it's hard to understand.
There was a problem hiding this comment.
Good catch on both counts.
You're right that the block is skipped when both keys are present — the guard was "source-id" not in data and "source-ids" in data. The consequence is worse than just losing the normalization: a multi-argument field written with both keys keeps its real transform instead of being replaced with UnknownTransform, so we would evaluate e.g. bucket[4] against only the first source column rather than treating it as unknown.
The spec only ever writes one of the two keys ("For partition fields with a transform with a single argument, only source-id is written. In case of a multi-argument transform, only source-ids is written." — same wording for sort fields), so both-present is non-conformant input. I went with the lenient read: source-ids is authoritative whenever it is present, and a source-id next to it is ignored. That also means an empty source-ids is now rejected even when a source-id is present. There is no Java implementation of source-ids to align with — the string does not appear anywhere in apache/iceberg's Java tree — so the spec text is the only reference here.
The nesting is gone as well: the validator is now a flat sequence of guards with early returns. New tests cover the both-present case for multi-argument, single-element and empty source-ids, on partition fields and sort fields.
| """ | ||
|
|
||
| source_id: int = Field(alias="source-id") | ||
| source_ids: list[int] | None = Field(alias="source-ids", default=None, repr=False) |
There was a problem hiding this comment.
Is it possible for us to add a "source-id" getter to this method? We've got places in the code that work off data["source-id"] and need to be updated for source-ids.
If we can consolidate where source-id is accessed, it makes it a lot easier to handle the source-id vs. source_ids thing.
Unfortunate downside of our dependency on Pydantic.
There was a problem hiding this comment.
Done, and this was the right call — the same three pieces (before-validator, model serializer, __str__) were duplicated between PartitionField and SortField.
Added TransformSourceMixin in pyiceberg/transforms.py, which both classes now inherit. It owns the source-id/source-ids fields, the validator, the serializer, and two accessors:
transform_arguments -> list[int]— the source column ids the transform is applied to (source_idswhen multi-argument, otherwise[source_id])is_multi_argument -> bool— derived from the above, so the arity check has a single definition
__str__ in both classes is now a single line over transform_arguments, and the only code that touches the raw "source-id" / "source-ids" keys is the mixin. Both files shrank by 46 lines.
It lives in transforms.py because UnknownTransform is already there and both call sites import from it, so this needed no new module and no new dependency edge. Happy to move it, or rename the accessors, if you would rather have it elsewhere.
|
Thanks a lot for this contribution! I think we should hold off until this is merged into the Java repository. I wouldn't want to have to merge changes after this reaches Java. |
Extract the source-id/source-ids pair, its before-validator, its serializer and a transform_arguments accessor into TransformSourceMixin, inherited by PartitionField and SortField, replacing the near-identical copies in both. The validator is now flat and treats source-ids as authoritative, so a field carrying both keys (which the spec never writes) is still normalized. Before, the whole block was skipped for such input and a multi-argument transform stayed evaluable instead of becoming UnknownTransform.
Closes #3628 (part of #1818)
Rationale for this change
The V3 spec requires readers to read tables with unknown transforms, ignoring them. PyIceberg raised (
Multi argument transforms are not yet supported) on partition or sort fields with more than one entry insource-ids, so loading such tables failed entirely.source-idsonPartitionField/SortFieldand treat multi-argument transforms asUnknownTransform, which already gives the spec behavior (no partition pruning:projectreturns None).source-idonly for single-argument transforms,source-idsonly for multi-argument ones, so specs round-trip faithfully instead of being silently rewritten on the next commit.bucketv2[4]) failed parsing instead of becomingUnknownTransform, andstr(UnknownTransform)returned"unknown"instead of the original name, corrupting the transform name when metadata is rewritten.Evaluating multi-argument transforms on write stays out of scope until the spec defines concrete ones.
Note on other implementations: none currently satisfies the spec's tolerance requirement in full. Java has not implemented
source-ids(its parser requiressource-id), though its transform parsing already matches names exactly andUnknownTransform.toString()preserves the original name — the two tolerance fixes here align PyIceberg with that behavior. iceberg-rust and iceberg-cpp reject unrecognized transform names outright. Thesource-idshandling here follows the spec text directly (partition/sort field JSON: single-argument transforms write onlysource-id, multi-argument onlysource-ids).Are these changes tested?
Yes: eight new tests covering multi-argument parse tolerance (partition and sort), round-trip serialization (
source-idspreserved,source-idomitted, transform name intact), single-element normalization,partition_typeresolution, prefix-colliding unknown transform names, andstr(UnknownTransform)name preservation. All fail without the fix. No new failures intests/table,tests/catalog,tests/utils.Are there any user-facing changes?
Yes: tables using multi-argument transforms now load and scan (without pruning on those fields) instead of raising.
This pull request and its description were written by Claude Fable 5.