Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/iceberg/public-api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3249,7 +3249,9 @@ pub fn iceberg::transaction::FastAppendAction::with_check_duplicate(self, v: boo
pub struct iceberg::transaction::ReplaceSortOrderAction
impl iceberg::transaction::ReplaceSortOrderAction
pub fn iceberg::transaction::ReplaceSortOrderAction::asc(self, name: &str, null_order: iceberg::spec::NullOrder) -> Self
pub fn iceberg::transaction::ReplaceSortOrderAction::asc_with_transform(self, name: &str, transform: iceberg::spec::Transform, null_order: iceberg::spec::NullOrder) -> Self
pub fn iceberg::transaction::ReplaceSortOrderAction::desc(self, name: &str, null_order: iceberg::spec::NullOrder) -> Self
pub fn iceberg::transaction::ReplaceSortOrderAction::desc_with_transform(self, name: &str, transform: iceberg::spec::Transform, null_order: iceberg::spec::NullOrder) -> Self
pub struct iceberg::transaction::Transaction
impl iceberg::transaction::Transaction
pub async fn iceberg::transaction::Transaction::commit(self, catalog: &dyn iceberg::Catalog) -> iceberg::Result<iceberg::table::Table>
Expand Down
47 changes: 20 additions & 27 deletions crates/iceberg/src/spec/sort.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ impl SortOrderBuilder {
match schema.field_by_id(sort_field.source_id) {
None => {
return Err(Error::new(
ErrorKind::Unexpected,
ErrorKind::DataInvalid,
format!("Cannot find source column for sort field: {sort_field}"),
));
}
Expand All @@ -189,20 +189,13 @@ impl SortOrderBuilder {

if !source_type.is_primitive() {
return Err(Error::new(
ErrorKind::Unexpected,
ErrorKind::DataInvalid,
format!("Cannot sort by non-primitive source field: {source_type}"),
));
}

let field_transform = sort_field.transform;
if field_transform.result_type(source_type).is_err() {
return Err(Error::new(
ErrorKind::Unexpected,
format!(
"Invalid source type {source_type} for transform {field_transform}"
),
));
}
field_transform.result_type(source_type)?;

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.

This is exactly the change I asked for last round — the transform branch reports DataInvalid now and the test pins it.

The one thing I'd tidy while we're in here: the two branches just above (the "Cannot find source column" and "Cannot sort by non-primitive source field" arms around 183/192) still return Unexpected for what's really the same class of user mistake, so the function now hands back two different kinds depending on which check trips. They're pre-existing and untouched by this PR, so I won't hold on it — but flipping both to DataInvalid here would make the whole function consistent for callers matching on .kind().

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.

Done in 9082bf7. Both the missing-column and non-primitive-source branches now return DataInvalid. Updated the existing tests for missing columns, list fields, and variant fields to assert the error kind as well as the message.

}
}
}
Expand Down Expand Up @@ -403,12 +396,12 @@ mod tests {
)
.build(&schema);

let err = sort_order_builder_result.expect_err("Expected an Err value");
assert_eq!(err.kind(), ErrorKind::DataInvalid);
assert_eq!(
sort_order_builder_result
.expect_err("Expected an Err value")
.message(),
err.message(),
"Cannot find source column for sort field: SortField { source_id: 2, transform: identity, direction: ascending, null_order: first }"
)
);
}

#[test]
Expand Down Expand Up @@ -444,12 +437,12 @@ mod tests {
)
.build(&schema);

let err = sort_order_builder_result.expect_err("Expected an Err value");
assert_eq!(err.kind(), ErrorKind::DataInvalid);
assert_eq!(
sort_order_builder_result
.expect_err("Expected an Err value")
.message(),
err.message(),
"Cannot sort by non-primitive source field: list"
)
);
}

#[test]
Expand All @@ -473,12 +466,12 @@ mod tests {
)
.build(&schema);

let err = sort_order_builder_result.expect_err("Expected an Err value");
assert_eq!(err.kind(), ErrorKind::DataInvalid);
assert_eq!(
sort_order_builder_result
.expect_err("Expected an Err value")
.message(),
err.message(),
"Cannot sort by non-primitive source field: variant"
)
);
}

#[test]
Expand All @@ -502,12 +495,12 @@ mod tests {
)
.build(&schema);

let err = sort_order_builder_result.expect_err("Expected an Err value");
assert_eq!(err.kind(), ErrorKind::DataInvalid);
assert_eq!(
sort_order_builder_result
.expect_err("Expected an Err value")
.message(),
"Invalid source type int for transform year"
)
err.message(),
"int is not a valid input type of year transform"
);
}

#[test]
Expand Down
196 changes: 188 additions & 8 deletions crates/iceberg/src/transaction/sort_order.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use crate::{Error, ErrorKind, TableRequirement, TableUpdate};
#[derive(Debug, PartialEq, Eq, Clone)]
struct PendingSortField {
name: String,
transform: Transform,
direction: SortDirection,
null_order: NullOrder,
}
Expand All @@ -44,9 +45,18 @@ impl PendingSortField {
)
})?;

// This action intentionally refuses to author all-null (void) sort keys;
// this policy does not restrict reading or preserving existing metadata.
if matches!(self.transform, Transform::Unknown | Transform::Void) {

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.

The guard does exactly what I wanted on the action path.

One gap: it lives in to_sort_field, so a caller building a SortOrder directly via SortOrder::builder().build() still gets Void/Unknown through unchecked. As the write path grows that second entry point starts to matter. Not blocking here, but if we ever want the invariant to be uniform, check_compatibility is the spot both paths funnel through. wdyt?

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.

Agreed that check_compatibility is the shared validation point for both paths. For this PR, I'd keep the guard scoped to the replace-sort-order action as an explicit policy for what this API authors.

Given your note about Java accepting Void, moving the guard into the shared builder would broaden that restriction to existing callers. I'd leave that as a follow-up where we can decide the builder's behavior for Unknown and Void separately.

return Err(Error::new(
ErrorKind::DataInvalid,
format!("Cannot sort by transform {}", self.transform),
));
}

Ok(SortField::builder()
.source_id(field_id)
.transform(Transform::Identity)
.transform(self.transform)
.direction(self.direction)
.null_order(self.null_order)
.build())
Expand All @@ -65,24 +75,64 @@ impl ReplaceSortOrderAction {
}
}

/// Adds a field for sorting in ascending order.
/// Adds a field for sorting in ascending order, sorting by the column's raw value
/// (an identity transform). To sort by a transform of the column instead (e.g.
/// `bucket[N]`, `year`, `truncate[W]`), use [`Self::asc_with_transform`].
pub fn asc(self, name: &str, null_order: NullOrder) -> Self {
self.add_sort_field(name, SortDirection::Ascending, null_order)
self.asc_with_transform(name, Transform::Identity, null_order)
}

/// Adds a field for sorting in descending order.
/// Adds a field for sorting in descending order, sorting by the column's raw value
/// (an identity transform). To sort by a transform of the column instead (e.g.
/// `bucket[N]`, `year`, `truncate[W]`), use [`Self::desc_with_transform`].
pub fn desc(self, name: &str, null_order: NullOrder) -> Self {
self.add_sort_field(name, SortDirection::Descending, null_order)
self.desc_with_transform(name, Transform::Identity, null_order)
}

/// Adds a field for sorting in ascending order by a transform of the column's value
/// (e.g. `Transform::Bucket(16)`, `Transform::Year`, `Transform::Truncate(4)`).
///
/// Whether the transform is valid for the column's type is checked at commit time,
/// once the table schema is available (mirroring Java's `SortOrder.Builder.build()`).
/// `Transform::Unknown` and `Transform::Void` are rejected at commit time with

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.

One wrinkle on the Void half of what I asked for, and this is new to me: Java's VoidTransform.canTransform returns true for every type, so SortOrder.Builder.build() there happily accepts a void sort field — a Java-authored table can legitimately carry one (e.g. after a partition field was voided). Rejecting Void outright means we can't round-trip that.

I still think rejecting it is defensible as a deliberate "we won't author these" policy, and Unknown clearly should go regardless. But if we keep the Void rejection I'd drop a one-line comment here noting it's intentionally stricter than the spec. wdyt — keep it strict, or match Java and let Void through?

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.

I'd keep the action strict. Added a comment and API documentation in 9082bf7 explaining that rejecting Void is intentional: an all-null sort key adds no ordering information.

To clarify the scope, the guard only applies when declaring a replacement sort order through this action. It does not affect reading or serializing existing metadata containing Void, though recreating that order through this action would be rejected.

/// [`ErrorKind::DataInvalid`]. Rejecting `Void` is an intentional authoring policy
/// for this action: an all-null sort key adds no ordering information.
///
/// Note: `Term` is currently a plain column reference. Once it becomes
/// transform-carrying (#2665), sort-order declaration is expected to converge on
/// Term-based `asc`/`desc` (as in Java's `SortOrderBuilder`), at which point the
/// `_with_transform` variants can be deprecated in its favor.
pub fn asc_with_transform(

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.

One footgun the new public surface opens up: asc_with_transform(name, Transform::Unknown, ...) and Transform::Void both sail through commit-time validation. Unknown.result_type() returns Ok(String) and Void.result_type() returns Ok(input_type), so check_compatibility accepts them and we happily write "transform": "unknown" / "transform": "void" to metadata.

Unknown is our read-side forward-compat sentinel — there's no spec string for it, and no other client can execute it (Java's UnknownTransform.apply() throws, PyIceberg can't apply it at all). Java's builder never lets you construct one; you only reach UnknownTransform by parsing existing metadata, so this is a path Java deliberately doesn't expose. Void loads everywhere but produces an all-null sort key — a silent no-op.

I'd guard both with a DataInvalid in add_sort_field (or to_sort_field), something like matches!(transform, Transform::Unknown | Transform::Void). Cheap, and it keeps us from minting sort orders no client can actually order by. wdyt?

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.

Fixed in 0fba334. Added the guard in PendingSortField::to_sort_field, returning DataInvalid at commit time while preserving the builder API. Added tests covering both transforms through both ascending and descending methods.

self,
name: &str,
transform: Transform,
null_order: NullOrder,
) -> Self {
self.add_sort_field(name, transform, SortDirection::Ascending, null_order)
}

/// Adds a field for sorting in descending order by a transform of the column's value.
///
/// See [`Self::asc_with_transform`] for validation and future API direction.
pub fn desc_with_transform(
self,
name: &str,
transform: Transform,
null_order: NullOrder,
) -> Self {
self.add_sort_field(name, transform, SortDirection::Descending, null_order)
}

fn add_sort_field(
mut self,
name: &str,
transform: Transform,
sort_direction: SortDirection,
null_order: NullOrder,
) -> Self {
self.pending_sort_fields.push(PendingSortField {
name: name.to_string(),
transform,
direction: sort_direction,
null_order,
});
Expand Down Expand Up @@ -127,12 +177,17 @@ impl TransactionAction for ReplaceSortOrderAction {

#[cfg(test)]
mod tests {
use std::sync::Arc;

use as_any::Downcast;

use crate::spec::{NullOrder, SortDirection};
use crate::catalog::Catalog;
use crate::memory::tests::new_memory_catalog;
use crate::spec::{NullOrder, SortDirection, Transform};
use crate::transaction::sort_order::{PendingSortField, ReplaceSortOrderAction};
use crate::transaction::tests::make_v2_table;
use crate::transaction::{ApplyTransactionAction, Transaction};
use crate::transaction::tests::{make_v2_table, make_v3_minimal_table_in_catalog};
use crate::transaction::{ApplyTransactionAction, Transaction, TransactionAction};
use crate::{ErrorKind, TableUpdate};

#[test]
fn test_replace_sort_order() {
Expand All @@ -153,14 +208,139 @@ mod tests {
assert_eq!(replace_sort_order.pending_sort_fields, vec![
PendingSortField {
name: String::from("x"),
transform: Transform::Identity,
direction: SortDirection::Ascending,
null_order: NullOrder::First,
},
PendingSortField {
name: String::from("y"),
transform: Transform::Identity,
direction: SortDirection::Descending,
null_order: NullOrder::Last,
}
]);
}

#[test]
fn test_replace_sort_order_with_transform() {
let table = make_v2_table();
let tx = Transaction::new(&table);
let replace_sort_order = tx.replace_sort_order();

let tx = replace_sort_order
.asc_with_transform("x", Transform::Bucket(16), NullOrder::First)
.desc_with_transform("y", Transform::Truncate(4), NullOrder::Last)
.apply(tx)
.unwrap();

let replace_sort_order = (*tx.actions[0])
.downcast_ref::<ReplaceSortOrderAction>()
.unwrap();

assert_eq!(replace_sort_order.pending_sort_fields, vec![
PendingSortField {
name: String::from("x"),
transform: Transform::Bucket(16),
direction: SortDirection::Ascending,
null_order: NullOrder::First,
},
PendingSortField {
name: String::from("y"),
transform: Transform::Truncate(4),
direction: SortDirection::Descending,
null_order: NullOrder::Last,
}
]);
}

#[tokio::test]
async fn test_replace_sort_order_with_transform_commits() {
let table = make_v2_table();
let action = Arc::new(ReplaceSortOrderAction::new().asc_with_transform(
"x",
Transform::Bucket(16),
NullOrder::First,
));

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.


assert_eq!(updates.len(), 2);
let TableUpdate::AddSortOrder { sort_order } = &updates[0] else {
panic!("expected AddSortOrder, got {:?}", updates[0]);
};
assert_eq!(sort_order.fields.len(), 1);
assert_eq!(sort_order.fields[0].transform, Transform::Bucket(16));
}

#[tokio::test]
async fn test_replace_sort_order_rejects_incompatible_transform() {
let table = make_v2_table();
// `x` is a `long` column; `year` only accepts date/timestamp types.
let action = Arc::new(ReplaceSortOrderAction::new().asc_with_transform(
"x",
Transform::Year,
NullOrder::First,
));

let err = TransactionAction::commit(action, &table)
.await
.err()
.expect("year transform on a long column should be rejected");
assert_eq!(err.kind(), ErrorKind::DataInvalid);
}

#[tokio::test]
async fn test_replace_sort_order_rejects_unknown_and_void_transforms() {
let table = make_v2_table();
for transform in [Transform::Unknown, Transform::Void] {
for action in [
ReplaceSortOrderAction::new().asc_with_transform("x", transform, NullOrder::First),
ReplaceSortOrderAction::new().desc_with_transform("x", transform, NullOrder::Last),
] {
let err = TransactionAction::commit(Arc::new(action), &table)
.await
.err()
.expect("unknown and void sort transforms should be rejected");
assert_eq!(err.kind(), ErrorKind::DataInvalid);
assert_eq!(
err.message(),
format!("Cannot sort by transform {transform}")
);
}
}
}

#[tokio::test]
async fn test_sort_order_transform_survives_metadata_json_round_trip() {
// Commit a transform-based sort order through a real catalog: the memory
// catalog serializes the updated table metadata to a metadata.json file
// (`TableMetadata::write_to`), and `load_table` reads that file back and
// parses it (`TableMetadata::read_from`). This exercises the full
// JSON round-trip of the transform (e.g. `"bucket[16]"`), not just the
// in-memory `TableUpdate`.
let catalog = new_memory_catalog().await;
let table = make_v3_minimal_table_in_catalog(&catalog).await;

let tx = Transaction::new(&table);
let tx = tx
.replace_sort_order()
.asc_with_transform("x", Transform::Bucket(16), NullOrder::First)
.desc_with_transform("y", Transform::Truncate(4), NullOrder::Last)
.apply(tx)
.unwrap();
let committed = tx.commit(&catalog).await.unwrap();

// Reload from the catalog: this parses the metadata.json written above.
let reloaded = catalog.load_table(committed.identifier()).await.unwrap();
let sort_order = reloaded.metadata().default_sort_order();

assert_eq!(sort_order.fields.len(), 2);
assert_eq!(sort_order.fields[0].transform, Transform::Bucket(16));
assert_eq!(sort_order.fields[0].direction, SortDirection::Ascending);

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.

Since this one's billed as the full round-trip, I'd assert null_order survives too — right now we check transform and direction but not that First/Last came back. One more assert_eq! per field closes the gap.

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.

Done in 0fba334. The round-trip test now also asserts NullOrder::First for the first field and NullOrder::Last for the second field after reloading the metadata.

assert_eq!(sort_order.fields[0].null_order, NullOrder::First);
assert_eq!(sort_order.fields[1].transform, Transform::Truncate(4));
assert_eq!(sort_order.fields[1].direction, SortDirection::Descending);
assert_eq!(sort_order.fields[1].null_order, NullOrder::Last);
}
}
Loading