You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ke...@apache.org on 2026/06/21 19:40:53 UTC
(iceberg-python) branch main updated: fix: correct NOT STARTS WITH projection for truncated partitions (#3528)
This is an automated email from the ASF dual-hosted git repository.
kevinjqliu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-python.git
The following commit(s) were added to refs/heads/main by this push:
new fe7c74922 fix: correct NOT STARTS WITH projection for truncated partitions (#3528)
fe7c74922 is described below
commit fe7c749229be6a439a13371348ab741bba6fc940
Author: GayathriSrividya <ga...@gmail.com>
AuthorDate: Mon Jun 22 01:10:46 2026 +0530
fix: correct NOT STARTS WITH projection for truncated partitions (#3528)
closes #3493
## Summary
Fixes incorrect projection of `NOT STARTS WITH` predicates for truncated
string/binary partition fields. The current implementation unsafely
truncates the filter literal without checking its length relative to the
truncate width.
## Root Cause
The `TruncateTransform.project` method calls `_truncate_array` which
blindly truncates the literal for both `STARTS WITH` and `NOT STARTS
WITH` predicates:
```python
elif isinstance(pred, BoundNotStartsWith):
return NotStartsWith(Reference(name), _transform_literal(transform, boundary))
```
For `NOT STARTS WITH "hello"` with `truncate[2]`, this produces:
- Current (unsafe): `NOT STARTS WITH "he"`
- Problem: The truncated partition contains all values starting with
"he" (from "hello", "heat", "hear", etc.), so we cannot safely exclude
all non-"hello" rows
## Solution
Add special handling for `BoundNotStartsWith` in the `project` method
following the Java/Go reference behavior:
- **prefix_length < truncate_width**: Keep original `NOT STARTS WITH`
literal (safe)
- **prefix_length == truncate_width**: Project to `!=` instead (safe
equality check)
- **prefix_length > truncate_width**: Return `None` (no inclusive
projection possible)
### pyiceberg/transforms.py
- Add explicit `NOT STARTS WITH` handling before calling
`_truncate_array`
- Check literal length vs truncate width and apply correct projection
rules
### tests/test_transforms.py
- Update `test_projection_truncate_string_not_starts_with` to expect
`None` (prefix_length > width is unsafe)
- Add `test_projection_truncate_string_not_starts_with_shorter_literal`
(prefix_length == width → `!=`)
- Add `test_projection_truncate_string_not_starts_with_original_literal`
(prefix_length < width → original)
## Validation
- `make lint` ✓ (all pre-commit hooks pass)
- `pytest tests/test_transforms.py` → 280 passed ✓
- All 13 string truncate projection tests pass
---------
Co-authored-by: Gayathri Srividya Rajavarapu <ga...@Gayathris-MacBook-Air.local>
Co-authored-by: Kevin Liu <ke...@users.noreply.github.com>
---
pyiceberg/transforms.py | 11 ++++++++++-
tests/test_transforms.py | 20 +++++++++++++++++---
2 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/pyiceberg/transforms.py b/pyiceberg/transforms.py
index 739e18a6e..1b603b6bb 100644
--- a/pyiceberg/transforms.py
+++ b/pyiceberg/transforms.py
@@ -813,7 +813,16 @@ class TruncateTransform(Transform[S, S]):
return _truncate_number(name, pred, self.transform(field_type))
elif isinstance(field_type, (BinaryType, StringType)):
if isinstance(pred, BoundLiteralPredicate):
- return _truncate_array(name, pred, self.transform(field_type))
+ if isinstance(pred, BoundNotStartsWith):
+ literal_width = len(pred.literal.value)
+ if literal_width < self.width:
+ return pred.as_unbound(name, pred.literal.value)
+ elif literal_width == self.width:
+ return NotEqualTo(name, pred.literal.value)
+ else:
+ return None
+ else:
+ return _truncate_array(name, pred, self.transform(field_type))
def strict_project(self, name: str, pred: BoundPredicate) -> UnboundPredicate | None:
field_type = pred.term.ref().field.field_type
diff --git a/tests/test_transforms.py b/tests/test_transforms.py
index 96500907c..381e93c68 100644
--- a/tests/test_transforms.py
+++ b/tests/test_transforms.py
@@ -1025,10 +1025,24 @@ def test_projection_truncate_string_starts_with(bound_reference_str: BoundRefere
) == StartsWith(term="name", literal=literal("he"))
-def test_projection_truncate_string_not_starts_with(bound_reference_str: BoundReference) -> None:
+def test_projection_truncate_string_not_starts_with_longer_literal(bound_reference_str: BoundReference) -> None:
+ # Not a valid projection: return None because pruning on "he" could drop qualifying rows like "help".
+ assert TruncateTransform(2).project("name", BoundNotStartsWith(term=bound_reference_str, literal=literal("hello"))) is None
+
+
+def test_projection_truncate_string_not_starts_with_shorter_literal(bound_reference_str: BoundReference) -> None:
+def test_projection_truncate_string_not_starts_with_equal_width_literal(bound_reference_str: BoundReference) -> None:
+ # Valid projection: improve NOT STARTS WITH "he" to partition != "he".
+ assert TruncateTransform(2).project(
+ "name", BoundNotStartsWith(term=bound_reference_str, literal=literal("he"))
+ ) == NotEqualTo(term="name", literal=literal("he"))
+
+
+def test_projection_truncate_string_not_starts_with_shorter_literal(bound_reference_str: BoundReference) -> None:
+ # Valid projection: pass the NOT STARTS WITH literal "h" through unchanged.
assert TruncateTransform(2).project(
- "name", BoundNotStartsWith(term=bound_reference_str, literal=literal("hello"))
- ) == NotStartsWith(term="name", literal=literal("he"))
+ "name", BoundNotStartsWith(term=bound_reference_str, literal=literal("h"))
+ ) == NotStartsWith(term="name", literal=literal("h"))
def _test_projection(lhs: UnboundPredicate | None, rhs: UnboundPredicate | None) -> None: