This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 4c3b7f56b6 [common] Normalize row-id IN literals inside Range.toRanges 
(#9630)
4c3b7f56b6 is described below

commit 4c3b7f56b65a59f4065be884bd3aca24b978c55f
Author: YangJie <[email protected]>
AuthorDate: Fri Sep 11 03:15:57 2026 -0400

    [common] Normalize row-id IN literals inside Range.toRanges (#9630)
---
 .../paimon/predicate/RowIdPredicateVisitor.java    | 11 +++--
 .../main/java/org/apache/paimon/utils/Range.java   | 17 ++++++-
 .../predicate/RowIdPredicateVisitorTest.java       | 48 +++++++++++++++++++
 .../paimon/table/DataEvolutionTableTest.java       | 56 ++++++++++++++++++++++
 4 files changed, 127 insertions(+), 5 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/predicate/RowIdPredicateVisitor.java
 
b/paimon-common/src/main/java/org/apache/paimon/predicate/RowIdPredicateVisitor.java
index d0ccf6600f..6d145aa8b2 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/predicate/RowIdPredicateVisitor.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/predicate/RowIdPredicateVisitor.java
@@ -61,9 +61,14 @@ public class RowIdPredicateVisitor implements 
PredicateVisitor<Optional<List<Ran
                 return Optional.of(Range.toRanges(rowIds));
             } else if (function instanceof Between) {
                 List<Object> literals = predicate.literals();
-                return Optional.of(
-                        Collections.singletonList(
-                                new Range((Long) literals.get(0), (Long) 
literals.get(1))));
+                long from = (Long) literals.get(0);
+                long to = (Long) literals.get(1);
+                // BETWEEN with inverted bounds (SQL allows BETWEEN 10 AND 5) 
is empty.
+                if (from > to) {
+                    // Mutable: the Or union path accumulates into the 
returned list.
+                    return Optional.of(new ArrayList<>());
+                }
+                return Optional.of(Collections.singletonList(new Range(from, 
to)));
             }
         }
         return Optional.empty();
diff --git a/paimon-common/src/main/java/org/apache/paimon/utils/Range.java 
b/paimon-common/src/main/java/org/apache/paimon/utils/Range.java
index 4457a00254..cdebaa342c 100644
--- a/paimon-common/src/main/java/org/apache/paimon/utils/Range.java
+++ b/paimon-common/src/main/java/org/apache/paimon/utils/Range.java
@@ -121,7 +121,8 @@ public class Range implements Serializable {
 
     public static List<Range> sortAndMergeOverlap(List<Range> ranges, boolean 
adjacent) {
         if (ranges == null || ranges.isEmpty()) {
-            return Collections.emptyList();
+            // Mutable: callers accumulate into the result across children.
+            return new ArrayList<>();
         }
 
         if (ranges.size() == 1) {
@@ -184,9 +185,18 @@ public class Range implements Serializable {
         return result;
     }
 
+    /**
+     * Groups row ids into ascending ranges, merging consecutive ids. The ids 
may arrive in any
+     * order and may repeat; both are normalized here, since the returned list 
has to be sorted and
+     * non-overlapping for {@link #and(List, List)} to intersect it correctly.
+     */
     public static List<Range> toRanges(Iterable<Long> ids) {
+        List<Long> sorted = new ArrayList<>();
+        ids.forEach(sorted::add);
+        Collections.sort(sorted);
+
         List<Range> ranges = new ArrayList<>();
-        Iterator<Long> iterator = ids.iterator();
+        Iterator<Long> iterator = sorted.iterator();
 
         if (!iterator.hasNext()) {
             return ranges;
@@ -197,6 +207,9 @@ public class Range implements Serializable {
 
         while (iterator.hasNext()) {
             long current = iterator.next();
+            if (current == rangeEnd) {
+                continue;
+            }
             if (current != rangeEnd + 1) {
                 // Save the current range and start a new one
                 ranges.add(new Range(rangeStart, rangeEnd));
diff --git 
a/paimon-common/src/test/java/org/apache/paimon/predicate/RowIdPredicateVisitorTest.java
 
b/paimon-common/src/test/java/org/apache/paimon/predicate/RowIdPredicateVisitorTest.java
index 7d4db36ace..34d04cd6dd 100644
--- 
a/paimon-common/src/test/java/org/apache/paimon/predicate/RowIdPredicateVisitorTest.java
+++ 
b/paimon-common/src/test/java/org/apache/paimon/predicate/RowIdPredicateVisitorTest.java
@@ -26,6 +26,7 @@ import org.apache.paimon.utils.Range;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
@@ -82,6 +83,53 @@ public class RowIdPredicateVisitorTest {
         assertThat(unrecognized.visit(visitor)).isEmpty();
     }
 
+    @Test
+    public void testUnsortedInLiteralsIntersectCorrectly() {
+        // IN literals in descending engine order: unsorted input to 
Range.and's
+        // two-pointer intersection silently drops rows (e.g. only [5,5] 
instead of
+        // [1,2] and [5,6]).
+        // >20 literals so PredicateBuilder keeps a real In leaf (smaller INs 
become
+        // an OR of equals): descending 25..5 exercises the In branch unsorted.
+        List<Object> descending = new ArrayList<>();
+        for (long v = 25; v >= 5; v--) {
+            descending.add(v);
+        }
+        Predicate inUnsorted = builder.in(rowIdIndex, descending);
+        Predicate between1To6 = builder.between(rowIdIndex, 1L, 6L);
+        Predicate and = PredicateBuilder.and(inUnsorted, between1To6);
+        Optional<List<Range>> result = and.visit(visitor);
+        assertThat(result).isPresent();
+        assertThat(result.get()).containsExactly(new Range(5, 6));
+
+        // Duplicates in the IN list must not produce overlapping ranges.
+        List<Object> duplicates = new ArrayList<>();
+        for (int i = 0; i < 21; i++) {
+            duplicates.add(15L);
+        }
+        Predicate inDuplicates = builder.in(rowIdIndex, duplicates);
+        Predicate between10To20 = builder.between(rowIdIndex, 10L, 20L);
+        Optional<List<Range>> dedup =
+                PredicateBuilder.and(inDuplicates, 
between10To20).visit(visitor);
+        assertThat(dedup).isPresent();
+        assertThat(dedup.get()).containsExactly(new Range(15, 15));
+    }
+
+    @Test
+    public void testInvertedBetweenIsEmpty() {
+        // SQL allows BETWEEN 10 AND 5; it matches nothing and must not 
fabricate an
+        // inverted Range that breaks the from <= to invariant.
+        Predicate inverted = builder.between(rowIdIndex, 10L, 5L);
+        Optional<List<Range>> result = inverted.visit(visitor);
+        assertThat(result).isPresent();
+        assertThat(result.get()).isEmpty();
+
+        // Inverted BETWEEN under OR must not break the union accumulation.
+        Predicate equal7 = builder.equal(rowIdIndex, 7L);
+        Optional<List<Range>> orResult = PredicateBuilder.or(inverted, 
equal7).visit(visitor);
+        assertThat(orResult).isPresent();
+        assertThat(orResult.get()).containsExactly(new Range(7, 7));
+    }
+
     @Test
     public void testCompoundedPredicate() {
         // Test AND intersection
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
index 2cd688c9e9..c10e8ad575 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/DataEvolutionTableTest.java
@@ -601,6 +601,62 @@ public class DataEvolutionTableTest extends 
DataEvolutionTestBase {
         assertThat(plannedFirstRowIds(plan)).isEqualTo(Arrays.asList(0L, 1L));
     }
 
+    @Test
+    public void testDescendingRowIdInIntersectBetweenReadsCorrectRows() throws 
Exception {
+        // Table-level regression: a descending _ROW_ID IN list intersected 
with a BETWEEN used to
+        // drop ranges (Range.toRanges/Range.and need ascending, deduped 
input), and the dropped
+        // ranges are rows that are never read. This is the TableRead 
equivalent of
+        // RowIdPredicateVisitorTest#testUnsortedInLiteralsIntersectCorrectly.
+        write(30); // one batch per column group; row id i <-> f0 == i
+        Schema schema = schemaDefault();
+        PredicateBuilder pb = new PredicateBuilder(rowTypeWithRowId(schema));
+        int rowIdIndex = schema.rowType().getFieldCount();
+
+        // IN (25,24,...,5) is 21 descending literals (> 20, so 
PredicateBuilder keeps a real In
+        // leaf) intersected with BETWEEN 3 AND 8 -> {5,6,7,8}.
+        Predicate filter =
+                PredicateBuilder.and(
+                        pb.in(rowIdIndex, descendingRowIds(25L, 5L)),
+                        pb.between(rowIdIndex, 3L, 8L));
+        assertThat(readF0WithFilter(filter)).isEqualTo(Arrays.asList(5, 6, 7, 
8));
+    }
+
+    @Test
+    public void testEmptyRowIdIntersectionUnderOrReadsOtherBranch() throws 
Exception {
+        // The empty branch (disjoint IN ∩ BETWEEN) has to yield a mutable 
empty range list so the
+        // Or union can accumulate the other branch into it; before the fix 
this threw
+        // UnsupportedOperationException while planning the scan.
+        write(30);
+        Schema schema = schemaDefault();
+        PredicateBuilder pb = new PredicateBuilder(rowTypeWithRowId(schema));
+        int rowIdIndex = schema.rowType().getFieldCount();
+
+        Predicate emptyIntersection =
+                PredicateBuilder.and(
+                        pb.in(rowIdIndex, descendingRowIds(25L, 5L)),
+                        pb.between(rowIdIndex, 100L, 110L)); // disjoint from 
the IN -> empty
+        Predicate filter = PredicateBuilder.or(emptyIntersection, 
pb.between(rowIdIndex, 10L, 12L));
+        assertThat(readF0WithFilter(filter)).isEqualTo(Arrays.asList(10, 11, 
12));
+    }
+
+    private List<Integer> readF0WithFilter(Predicate filter) throws Exception {
+        ReadBuilder rb = getTableDefault().newReadBuilder().withFilter(filter);
+        List<Integer> f0 = new ArrayList<>();
+        try (RecordReader<InternalRow> reader = 
rb.newRead().createReader(rb.newScan().plan())) {
+            reader.forEachRemaining(r -> f0.add(r.getInt(0)));
+        }
+        Collections.sort(f0);
+        return f0;
+    }
+
+    private static List<Object> descendingRowIds(long hi, long lo) {
+        List<Object> ids = new ArrayList<>();
+        for (long v = hi; v >= lo; v--) {
+            ids.add(v);
+        }
+        return ids;
+    }
+
     @Test
     public void testLimitPushDownWithoutFilter() throws Exception {
         createTableDefault();

Reply via email to