From 4358324b21d5e42d4c9a513b6bcbf9451bf6a957 Mon Sep 17 00:00:00 2001 From: compiler-segfault <2671715549@qq.com> Date: Fri, 6 Nov 2026 17:53:47 +0800 Subject: [PATCH 1/2] fix(spec): compare truncate width in code points when projecting strings The truncate transform truncates strings by Unicode code points, but the projection compared the literal length in UTF-8 bytes. A prefix of two code points and three bytes looked as wide as truncate[3], so STARTS WITH was rewritten to an equality predicate and scans dropped matching rows. --- crates/iceberg/src/spec/transform.rs | 11 ++--- crates/iceberg/src/transform/truncate.rs | 56 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/crates/iceberg/src/spec/transform.rs b/crates/iceberg/src/spec/transform.rs index f52202b78b..35ec2e1828 100644 --- a/crates/iceberg/src/spec/transform.rs +++ b/crates/iceberg/src/spec/transform.rs @@ -497,7 +497,7 @@ impl Transform { self.truncate_number_strict(name, expr, &func) } else if expr.op() == PredicateOperator::StartsWith { let len = match expr.literal().literal() { - PrimitiveLiteral::String(s) => s.len(), + PrimitiveLiteral::String(s) => s.chars().count(), PrimitiveLiteral::Binary(b) => b.len(), _ => { return Err(invalid_data!( @@ -521,7 +521,7 @@ impl Transform { } } else if expr.op() == PredicateOperator::NotStartsWith { let len = match expr.literal().literal() { - PrimitiveLiteral::String(s) => s.len(), + PrimitiveLiteral::String(s) => s.chars().count(), PrimitiveLiteral::Binary(b) => b.len(), _ => { return Err(invalid_data!( @@ -848,7 +848,7 @@ impl Transform { PredicateOperator::StartsWith => match datum.literal() { PrimitiveLiteral::String(s) => { if let Some(w) = width - && s.len() == w as usize + && s.chars().count() == w as usize { return Some(PredicateOperator::Eq); }; @@ -860,12 +860,13 @@ impl Transform { PrimitiveLiteral::String(s) => { if let Some(w) = width { let w = w as usize; + let len = s.chars().count(); - if s.len() == w { + if len == w { return Some(PredicateOperator::NotEq); } - if s.len() < w { + if len < w { return Some(*op); } diff --git a/crates/iceberg/src/transform/truncate.rs b/crates/iceberg/src/transform/truncate.rs index 3efb09258a..d2dabe7085 100644 --- a/crates/iceberg/src/transform/truncate.rs +++ b/crates/iceberg/src/transform/truncate.rs @@ -299,6 +299,62 @@ mod test { Ok(()) } + #[test] + fn test_projection_truncate_string_compares_width_in_code_points() -> Result<()> { + // "Év" is two code points but three UTF-8 bytes, so it is shorter than + // truncate[3] and the prefix must not collapse to an equality predicate. + let fixture = TestProjectionFixture::new( + Transform::Truncate(3), + "city", + NestedField::required(1, "city", Primitive(PrimitiveType::String)), + ); + + fixture.assert_projection( + &fixture.binary_predicate(PredicateOperator::StartsWith, Datum::string("Év")), + Some(r#"city STARTS WITH "Év""#), + )?; + + fixture.assert_projection( + &fixture.binary_predicate(PredicateOperator::NotStartsWith, Datum::string("Év")), + Some(r#"city NOT STARTS WITH "Év""#), + )?; + + fixture.assert_projection( + &fixture.binary_predicate(PredicateOperator::StartsWith, Datum::string("Évr")), + Some(r#"city = "Évr""#), + )?; + + Ok(()) + } + + #[test] + fn test_strict_projection_truncate_string_compares_width_in_code_points() -> Result<()> { + let transform = Transform::Truncate(3); + let fixture = TestProjectionFixture::new( + transform.clone(), + "city", + NestedField::required(1, "city", Primitive(PrimitiveType::String)), + ); + + for (prefix, expected) in [ + ("Év", Some(r#"city STARTS WITH "Év""#)), + ("Évr", Some(r#"city = "Évr""#)), + ("Évry", None), + ] { + let predicate = + fixture.binary_predicate(PredicateOperator::StartsWith, Datum::string(prefix)); + assert_eq!( + expected, + transform + .strict_project("city", &predicate)? + .map(|p| p.to_string()) + .as_deref(), + ); + } + + Ok(()) + } + #[test] fn test_strict_projection_truncate_binary() -> Result<()> { let transform = Transform::Truncate(3); From 7a60129b83871747198254d5eb4431d2df595e13 Mon Sep 17 00:00:00 2001 From: compiler-segfault <2671715549@qq.com> Date: Sat, 7 Nov 2026 16:25:03 +0800 Subject: [PATCH 2/2] test: drop redundant clone of the Copy transform --- crates/iceberg/src/transform/truncate.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/iceberg/src/transform/truncate.rs b/crates/iceberg/src/transform/truncate.rs index d2dabe7085..348edc69d0 100644 --- a/crates/iceberg/src/transform/truncate.rs +++ b/crates/iceberg/src/transform/truncate.rs @@ -331,7 +331,7 @@ mod test { fn test_strict_projection_truncate_string_compares_width_in_code_points() -> Result<()> { let transform = Transform::Truncate(3); let fixture = TestProjectionFixture::new( - transform.clone(), + transform, "city", NestedField::required(1, "city", Primitive(PrimitiveType::String)), );