Skip to content

Read tables with multi-argument transforms as unknown transforms - #3630

Open
moomindani wants to merge 4 commits into
apache:mainfrom
moomindani:moomindani/multi-arg-transforms
Open

Read tables with multi-argument transforms as unknown transforms#3630
moomindani wants to merge 4 commits into
apache:mainfrom
moomindani:moomindani/multi-arg-transforms

Conversation

@moomindani

Copy link
Copy Markdown
Contributor

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 in source-ids, so loading such tables failed entirely.

  • Model source-ids on PartitionField / SortField and treat multi-argument transforms as UnknownTransform, which already gives the spec behavior (no partition pruning: project returns None).
  • Serialize per spec: source-id only for single-argument transforms, source-ids only for multi-argument ones, so specs round-trip faithfully instead of being silently rewritten on the next commit.
  • Fix two latent tolerance bugs found along the way: unknown transform names sharing a prefix with known ones (e.g. bucketv2[4]) failed parsing instead of becoming UnknownTransform, and str(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 requires source-id), though its transform parsing already matches names exactly and UnknownTransform.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. The source-ids handling here follows the spec text directly (partition/sort field JSON: single-argument transforms write only source-id, multi-argument only source-ids).

Are these changes tested?

Yes: eight new tests covering multi-argument parse tolerance (partition and sort), round-trip serialization (source-ids preserved, source-id omitted, transform name intact), single-element normalization, partition_type resolution, prefix-colliding unknown transform names, and str(UnknownTransform) name preservation. All fail without the fix. No new failures in tests/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.

Comment thread pyiceberg/partitioning.py
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})"

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.

Could you also update SortField.__str__?

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.

Updated SortField.__str__ in 818e6f9 to render all source ids the same way as PartitionField.__str__.

Comment thread pyiceberg/table/sorting.py Outdated
Comment on lines +126 to +131
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)

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.

Why does this block contain underscore fields that the specification doesn't include? We should delete them in my opinion. Same for partitioning.py‎.

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

@moomindani

Copy link
Copy Markdown
Contributor Author

Not stale — waiting for review.

CI is green (17/17) and mergeable. Both of @ebyhr's comments from July 10 are addressed in 818e6f99: SortField.__str__ now renders all source ids the same way as PartitionField.__str__, and the defensive alias handling was removed.

This is a read-path correctness fix — PyIceberg currently raises Multi argument transforms are not yet supported on any partition or sort field with multiple source-ids, so a v3 table using one cannot be loaded at all. Closes #3628, part of #1818.

@ebyhr would you mind confirming the two threads, and @Fokko / @sungwy a review from either of you would be welcome.

@moomindani
moomindani force-pushed the moomindani/multi-arg-transforms branch from 818e6f9 to 9a99f17 Compare August 17, 2026 07:26
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.
@moomindani
moomindani force-pushed the moomindani/multi-arg-transforms branch from 9a99f17 to a4a7360 Compare August 20, 2026 04:05
Comment thread pyiceberg/partitioning.py Outdated
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

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

Comment thread pyiceberg/partitioning.py Outdated
"""

source_id: int = Field(alias="source-id")
source_ids: list[int] | None = Field(alias="source-ids", default=None, repr=False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

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.

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_ids when 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.

@rambleraptor

Copy link
Copy Markdown
Collaborator

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

Support multi-argument transforms

3 participants