From bf777f41dd20ea7041a79d46cf9beb9cc51420a0 Mon Sep 17 00:00:00 2001 From: Raghvendra Yadav Date: Mon, 19 Oct 2026 22:03:03 -0700 Subject: [PATCH] API: Handle null partition values in ResidualEvaluator startsWith/notStartsWith ResidualEvaluator dereferenced the partition value in startsWith and notStartsWith without a null check, so computing a residual for a partition whose value is null threw NullPointerException. ManifestFilterManager computes a residual for every live file in a manifest it rewrites, so a delete by row filter failed whenever the manifest also held a file with a null partition value. Scans hit it too: with or(isNull(s), startsWith(s, "a")) the null-partition file survives planning through the isNull branch and FileScanTask.residual() then throws. Treat a null partition value as not matching, so startsWith returns alwaysFalse() and notStartsWith returns alwaysTrue(). This matches Evaluator, which already evaluates startsWith as `evalRes != null && ...` and notStartsWith as its negation, and mirrors the guard in and notIn received in #17014. Fixes #18412 Signed-off-by: Raghvendra Yadav --- .../expressions/ResidualEvaluator.java | 12 +++--- .../iceberg/transforms/TestResiduals.java | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/api/src/main/java/org/apache/iceberg/expressions/ResidualEvaluator.java b/api/src/main/java/org/apache/iceberg/expressions/ResidualEvaluator.java index 3b60a75e41a7..b03675fa18a2 100644 --- a/api/src/main/java/org/apache/iceberg/expressions/ResidualEvaluator.java +++ b/api/src/main/java/org/apache/iceberg/expressions/ResidualEvaluator.java @@ -212,16 +212,16 @@ public Expression notIn(BoundReference ref, Set literalSet) { @Override public Expression startsWith(BoundReference ref, Literal lit) { - return ((String) ref.eval(struct)).startsWith((String) lit.value()) - ? alwaysTrue() - : alwaysFalse(); + String value = (String) ref.eval(struct); + return value != null && value.startsWith((String) lit.value()) ? alwaysTrue() : alwaysFalse(); } @Override public Expression notStartsWith(BoundReference ref, Literal lit) { - return ((String) ref.eval(struct)).startsWith((String) lit.value()) - ? alwaysFalse() - : alwaysTrue(); + String value = (String) ref.eval(struct); + return value == null || !value.startsWith((String) lit.value()) + ? alwaysTrue() + : alwaysFalse(); } @Override diff --git a/api/src/test/java/org/apache/iceberg/transforms/TestResiduals.java b/api/src/test/java/org/apache/iceberg/transforms/TestResiduals.java index 61c662bbfbf9..b1e91428e56a 100644 --- a/api/src/test/java/org/apache/iceberg/transforms/TestResiduals.java +++ b/api/src/test/java/org/apache/iceberg/transforms/TestResiduals.java @@ -289,6 +289,44 @@ public void testNotInWithNullStringPartition() { assertThat(residual).as("null not in [a, b, c] => true").isEqualTo(alwaysTrue()); } + @Test + public void testStartsWithWithNullStringPartition() { + Schema schema = new Schema(Types.NestedField.optional(50, "category", Types.StringType.get())); + + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("category").build(); + + ResidualEvaluator resEval = + ResidualEvaluator.of(spec, Expressions.startsWith("category", "a"), true); + + Expression residual = resEval.residualFor(Row.of("abc")); + assertThat(residual).isEqualTo(alwaysTrue()); + + residual = resEval.residualFor(Row.of("bcd")); + assertThat(residual).isEqualTo(alwaysFalse()); + + residual = resEval.residualFor(Row.of((Object) null)); + assertThat(residual).as("null startsWith a => false").isEqualTo(alwaysFalse()); + } + + @Test + public void testNotStartsWithWithNullStringPartition() { + Schema schema = new Schema(Types.NestedField.optional(50, "category", Types.StringType.get())); + + PartitionSpec spec = PartitionSpec.builderFor(schema).identity("category").build(); + + ResidualEvaluator resEval = + ResidualEvaluator.of(spec, Expressions.notStartsWith("category", "a"), true); + + Expression residual = resEval.residualFor(Row.of("bcd")); + assertThat(residual).isEqualTo(alwaysTrue()); + + residual = resEval.residualFor(Row.of("abc")); + assertThat(residual).isEqualTo(alwaysFalse()); + + residual = resEval.residualFor(Row.of((Object) null)); + assertThat(residual).as("null notStartsWith a => true").isEqualTo(alwaysTrue()); + } + @Test public void testIsNaN() { Schema schema =