Skip to content

feat(transaction): support transform-based sort orders#2765

Open
viirya wants to merge 3 commits into
apache:mainfrom
viirya:feat/sort-order-transform
Open

feat(transaction): support transform-based sort orders#2765
viirya wants to merge 3 commits into
apache:mainfrom
viirya:feat/sort-order-transform

Conversation

@viirya

@viirya viirya commented Jul 3, 2026

Copy link
Copy Markdown
Member

What changes are included in this PR?

ReplaceSortOrderAction only supported sorting by a column's raw value (an implicit identity transform) — asc/desc hardcoded Transform::Identity and only accepted a column name. There was no way to declare a sort order using a transform (bucket[N], year, truncate[W], etc.), which Java's SortOrderBuilder.asc/desc(Term, NullOrder) supports.

Adds asc_with_transform / desc_with_transform:

tx.replace_sort_order()
    .asc_with_transform("event_time", Transform::Year, NullOrder::First)
    .desc_with_transform("id", Transform::Bucket(16), NullOrder::Last);

asc/desc are unchanged (same signature, same behavior) — they now delegate to the new methods with Transform::Identity, so no existing caller is affected.

Whether a transform is valid for the source column's type is checked at commit time (via the existing SortOrder::builder().build(schema)check_compatibility), matching the timing of Java's SortOrder.Builder.build() — an incompatible transform is only rejected once the table schema is available, not at the asc_with_transform call site.

Scope

This only extends the metadata-declaration API — which sort order (including which transform) can be recorded on a table. Whether the write path actually sorts a RecordBatch to match a table's declared sort order is a separate, pre-existing gap: no writer in crates/iceberg/src/writer/ reads SortOrder or sorts data today, for any sort order, including the pre-existing identity case. This PR does not touch that; it only makes a wider set of sort orders declarable.

Are these changes tested?

  • test_replace_sort_order_with_transform — the builder records the requested transform per field.
  • test_replace_sort_order_with_transform_commits — end-to-end commit; the resulting TableUpdate::AddSortOrder's SortField carries the requested transform.
  • test_replace_sort_order_rejects_incompatible_transformTransform::Year on a long column is rejected at commit time with ErrorKind::Unexpected, exercising the existing check_compatibility path.
  • Existing test_replace_sort_order (plain asc/desc) passes unchanged.

Full iceberg lib suite passes; clippy and rustfmt clean.

ReplaceSortOrderAction only supported sorting by a column's raw value:
`asc`/`desc` hardcoded Transform::Identity and only accepted a column name.
Java's SortOrderBuilder.asc/desc accept a Term, which can be a transform
expression (bucket[N], year, truncate[W], ...).

Add asc_with_transform / desc_with_transform. asc/desc are unchanged (same
signature and behavior) and now delegate to the new methods with
Transform::Identity, so no existing caller is affected.

Transform-compatibility with the source column's type is checked at commit
time via the existing SortOrder::builder().build(schema) -> check_compatibility
path, matching the timing of Java's SortOrder.Builder.build() -- the spec-level
type was already general (SortField.transform, check_compatibility already
validate arbitrary transforms); only the transaction-layer API was narrow.

Scope: this only extends the metadata-declaration API. Whether the write path
sorts data to match a table's declared sort order -- for any transform,
including the pre-existing identity case -- is a separate, pre-existing gap
untouched here.

Closes apache#2764
));

let mut action_commit = TransactionAction::commit(action, &table).await.unwrap();
let updates = action_commit.take_updates();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can we also add an integration test that would write a metadata.json with updated sort order, and then another operation would read this json and correctly parse the transform?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good idea — added in eee40b8: test_sort_order_transform_survives_metadata_json_round_trip.

It commits a transform-based sort order (bucket[16] asc + truncate[4] desc) through the in-crate memory catalog, which serializes the updated table metadata to an actual metadata.json file (TableMetadata::write_to on FileIO), then reloads the table via catalog.load_table — which reads and parses that file back (TableMetadata::read_from) — and asserts both transforms and directions survive. So the transform string ("bucket[16]" etc.) is exercised through the real serialize-file-parse path, not just the in-memory TableUpdate.

…rders

Per review: commit a transform-based sort order through the in-crate memory
catalog (which serializes the updated metadata to a metadata.json file via
TableMetadata::write_to), then reload the table (TableMetadata::read_from
parses that file) and assert the transforms survive the JSON round-trip.

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

LGTM

@viirya

viirya commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

@CTTY Could you take a look at this? Thanks!

@mbutrovich mbutrovich left a comment

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.

Thanks @viirya! One design question: should sort-order declaration converge on Term rather than growing _with_transform variants

Adding asc_with_transform/desc_with_transform alongside asc/desc doubles the builder surface, and it mirrors Java's overload (asc(Term, NullOrder)) rather than Java's type (Term). We already have Term in crates/iceberg/src/expr/term.rs, but it's currently pub type Term = Reference (a bare column ref, no transform). #2665 is expanding Term into a transform-carrying enum. Once that lands, asc(term, null_order) could subsume both the identity and transform cases with a single method, which is both closer to Java's API and more idiomatic than a parallel method pair.

I don't think this should block the PR: building on a Term enum that doesn't exist yet would be premature, and _with_transform is the minimal non-breaking step today. Mainly flagging it so we agree on direction and don't ship an API we'll want to deprecate right after #2665 merges. Could you add a short comment noting the intended convergence, or say if you see it differently?

Two minor nits:

  1. The new tests scatter use statements inside each test fn (use std::sync::Arc;, use crate::TableUpdate;, etc.), whereas the rest of the file imports at the mod tests top. Worth hoisting for consistency.
  2. test_replace_sort_order_with_transform_commits is largely subsumed by test_sort_order_transform_survives_metadata_json_round_trip (the round-trip also asserts the committed transform). Not worth removing, just noting the overlap.

Per review: document on asc_with_transform/desc_with_transform that once
Term becomes transform-carrying (apache#2665), sort-order declaration is expected
to converge on Term-based asc/desc (as in Java's SortOrderBuilder) and the
_with_transform variants can be deprecated. Also hoist the use statements
scattered inside test fns to the test module top for consistency.
@viirya

viirya commented Jul 13, 2026

Copy link
Copy Markdown
Member Author

Agreed on the direction, @mbutrovich — converging on a transform-carrying Term is the better end state. I checked #2665 and it is indeed turning Term (today just pub type Term = Reference) into an enum, so once that lands, a single asc(term, null_order) mirrors Java's actual type rather than its overload, and the _with_transform pair becomes redundant.

Done in afe2c27:

On nit 2 — agreed there's overlap; keeping both since the unit test pinpoints the ActionCommit output in isolation while the round-trip covers the serde path, and the unit test is where a failure would localize faster. Cheap to keep.

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.

4 participants