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

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


The following commit(s) were added to refs/heads/master by this push:
     new b9c68020aa5 [fix](partition) Preserve lexicographic range partition 
boundaries (#67888)
b9c68020aa5 is described below

commit b9c68020aa51cd603dde200adfeb44f33ce4cb07
Author: morrySnow <[email protected]>
AuthorDate: Sun Sep 20 17:13:10 2026 +0800

    [fix](partition) Preserve lexicographic range partition boundaries (#67888)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    Problem: Composite RANGE partition pruning can discard partitions
    containing matching tuples when suffix columns appear outside
    independently projected column bounds even though the full tuple is
    lexicographically within the partition.
    
    Root cause: Expanded and unexpanded pruning paths modeled composite
    tuple bounds independently. Input range construction made a non-terminal
    upper column exclusive, while predicate refinement kept scanning after
    the first decisive OTHER column, allowing later suffixes to overwrite
    it. Expanded equality folding could also remove the range entry needed
    by refinement.
    
    Reproduction: For a partition `[(1, 10, 100), (100, 20, 200))`, tuples
    such as `(1, 11, 50)`, `(100, 19, 250)`, and `(100, 20, 199)` are
    lexicographically valid. With different
    `partition_pruning_expand_threshold` values, the old logic could produce
    a zero-partition scan or empty result.
    
    Fix: Share explicit lower and upper lexicographic bound state across
    input-range construction and predicate refinement. Constrain only the
    first unresolved column, keep suffixes unbounded after a prefix
    diverges, use inclusive upper bounds for non-terminal columns and an
    exclusive bound only on the final column, stop at the first decisive
    OTHER column, and conservatively reuse the input range when a folded
    equality removes an expanded entry.
    
    Tests: Extend `PartitionPrunerTest` with three- and four-column
    lower/upper boundaries, first/middle/final divergence,
    expanded/unexpanded paths, and suffix witnesses. Add an end-to-end
    regression that asserts selected partitions and query results for
    expanded and unexpanded paths.
    
    ### Release note
    
    Fix incorrect pruning for composite RANGE partitions at lexicographic
    boundary transitions.
---
 .../rules/OneRangePartitionEvaluator.java          | 341 ++++++++++++++++-----
 .../nereids/rules/rewrite/PartitionPrunerTest.java | 199 ++++++++++++
 .../test_lexicographic_range_partition.out         |  21 ++
 .../test_lexicographic_range_partition.groovy      |  93 ++++++
 4 files changed, 585 insertions(+), 69 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/OneRangePartitionEvaluator.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/OneRangePartitionEvaluator.java
index f1e7d5dcf31..d5c6f7debdf 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/OneRangePartitionEvaluator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/expression/rules/OneRangePartitionEvaluator.java
@@ -23,7 +23,6 @@ import org.apache.doris.catalog.RangePartitionItem;
 import org.apache.doris.catalog.Type;
 import org.apache.doris.common.Pair;
 import org.apache.doris.nereids.CascadesContext;
-import org.apache.doris.nereids.exceptions.AnalysisException;
 import org.apache.doris.nereids.rules.expression.ExpressionRewriteContext;
 import 
org.apache.doris.nereids.rules.expression.rules.OneRangePartitionEvaluator.EvaluateRangeInput;
 import 
org.apache.doris.nereids.rules.expression.rules.OneRangePartitionEvaluator.EvaluateRangeResult;
@@ -52,9 +51,9 @@ import 
org.apache.doris.nereids.trees.expressions.visitor.ExpressionVisitor;
 import org.apache.doris.nereids.types.BooleanType;
 import org.apache.doris.nereids.util.ExpressionUtils;
 import org.apache.doris.nereids.util.Utils;
+import org.apache.doris.qe.SessionVariable;
 
 import com.google.common.base.Preconditions;
-import com.google.common.collect.BoundType;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMap.Builder;
@@ -92,8 +91,26 @@ public class OneRangePartitionEvaluator<K>
     // whether the Expression in partition range may be null.
     private final Map<Expression, Boolean> partitionSlotContainsNull;
     private final Map<Slot, PartitionSlotType> slotToType;
+    // An impossible expander/evaluator state makes pruning unsafe. Production 
keeps the partition;
+    // fe_debug throws at the point where the broken invariant is observed.
+    private boolean disablePruning;
 
-    /** OneRangePartitionEvaluator */
+    /**
+     * Create an evaluator for one RANGE partition.
+     *
+     * <p>The constructor converts the tuple endpoints to Nereids literals, 
classifies every
+     * partition coordinate as {@code CONST}, {@code RANGE}, or {@code OTHER}, 
and expands the
+     * enumerable prefix up to {@code expandThreshold}. It also records 
whether each coordinate can
+     * contain NULL so expression evaluation can fold null-sensitive 
predicates safely. An unknown
+     * coordinate type violates the expander/evaluator contract: fe_debug 
reports it immediately,
+     * while production marks this evaluator as non-prunable.
+     *
+     * @param partitionIdent identifier returned when this partition must be 
scanned
+     * @param partitionSlots partition-key slots in lexicographic order
+     * @param partitionItem inclusive-lower/exclusive-upper RANGE partition 
definition
+     * @param cascadesContext planner context used by expression rewrite rules
+     * @param expandThreshold maximum number of enumerable values expanded 
from the partition range
+     */
     public OneRangePartitionEvaluator(K partitionIdent, List<Slot> 
partitionSlots,
             RangePartitionItem partitionItem, CascadesContext cascadesContext, 
int expandThreshold) {
         this.partitionIdent = partitionIdent;
@@ -139,7 +156,10 @@ public class OneRangePartitionEvaluator<K>
                         maybeNull = true;
                         break;
                     default:
-                        throw new AnalysisException("Unknown partition slot 
type: " + partitionSlotType);
+                        disablePruningOnInvalidState(
+                                "Unknown partition slot type while deriving 
nullability: " + partitionSlotType);
+                        maybeNull = true;
+                        break;
                 }
                 partitionSlotContainsNull.put(slot, maybeNull);
             }
@@ -170,12 +190,28 @@ public class OneRangePartitionEvaluator<K>
         }
     }
 
+    /**
+     * Evaluate a partition predicate under one projected input row.
+     *
+     * <p>The projected ranges seed expression evaluation and are then 
narrowed by the predicate.
+     * If input construction or range refinement discovers an invalid internal 
state, returning any
+     * folded result could incorrectly remove data. In production this method 
therefore returns the
+     * original predicate, which {@link PartitionPruner} treats as unknown and 
keeps the partition.
+     * In fe_debug the invalid state is thrown before this fallback is reached.
+     *
+     * @param expression partition predicate to simplify
+     * @param currentInputs replacement expression and projected range for 
every partition slot
+     * @return the simplified predicate, or {@code expression} when pruning 
has been disabled
+     */
     @Override
     public Expression evaluate(Expression expression, Map<Slot, 
PartitionSlotInput> currentInputs) {
+        if (disablePruning) {
+            return expression;
+        }
         Map<Expression, ColumnRange> defaultColumnRanges = 
currentInputs.values().iterator().next().columnRanges;
         Map<Expression, ColumnRange> rangeMap = new 
HashMap<>(defaultColumnRanges);
         EvaluateRangeResult result = expression.accept(this, new 
EvaluateRangeInput(currentInputs, rangeMap));
-        return result.result;
+        return disablePruning ? expression : result.result;
     }
 
     @Override
@@ -407,6 +443,19 @@ public class OneRangePartitionEvaluator<K>
         return result;
     }
 
+    /**
+     * Evaluate a conjunction and refine the independent column ranges with 
the tuple boundary.
+     *
+     * <p>The normal child evaluation first intersects the ranges contributed 
by every conjunct.
+     * A composite RANGE partition needs one additional pass: a suffix column 
is constrained by a
+     * lower or upper endpoint only while every preceding coordinate is still 
equal to that endpoint.
+     * Applying this pass after merging the children also recovers prefix 
coordinates whose equality
+     * expression was folded to a literal and therefore disappeared from the 
child range map.
+     *
+     * @param and conjunction being evaluated
+     * @param context replacement values and projected ranges for the current 
expanded partition input
+     * @return the folded conjunction together with its lexicographically 
refined column ranges
+     */
     @Override
     public EvaluateRangeResult visitAnd(And and, EvaluateRangeInput context) {
         EvaluateRangeResult result = evaluateChildrenThenThis(and, context);
@@ -424,8 +473,8 @@ public class OneRangePartitionEvaluator<K>
         }
 
         // shrink range and prune the other type: if previous column is 
literal and equals to the bound
-        andResult = determinateRangeOfOtherType(andResult, lowers, true);
-        andResult = determinateRangeOfOtherType(andResult, uppers, false);
+        andResult = determinateRangeOfOtherType(andResult, lowers, true, 
context.rangeMap);
+        andResult = determinateRangeOfOtherType(andResult, uppers, false, 
context.rangeMap);
         return andResult;
     }
 
@@ -526,60 +575,83 @@ public class OneRangePartitionEvaluator<K>
         }
     }
 
+    /**
+     * Refine the first unresolved suffix column against one lexicographic 
partition endpoint.
+     *
+     * <p>Partition slot types have the shape {@code CONST*, RANGE, OTHER*}. 
The scan proceeds from
+     * left to right and keeps an endpoint active only while the observed 
singleton values equal its
+     * prefix. For the first unresolved {@code OTHER} coordinate, the active 
lower endpoint contributes
+     * {@code >= bound}; an active upper endpoint contributes {@code <= 
bound}, except that the final
+     * partition column uses {@code < bound} because RANGE partitions are 
upper-exclusive. The method
+     * returns immediately after that coordinate because once it may differ 
from the endpoint, later
+     * coordinates are lexicographically unconstrained.
+     *
+     * <p>Constant folding can remove an expanded {@code RANGE} slot from 
{@code context.columnRanges}.
+     * For that slot only, the method reads the singleton from {@code 
defaultColumnRanges} so the
+     * omitted equal-prefix coordinate can still participate in prefix 
comparison. A missing
+     * {@code OTHER} slot is different: its default range describes only the 
partition boundary, not
+     * the predicate. Adding it to the predicate result would let {@code NOT} 
complement a synthetic
+     * range and could incorrectly prune the partition, so a missing {@code 
OTHER} stops refinement.
+     *
+     * @param context result of merging all conjunct ranges
+     * @param partitionBound lower or upper endpoint of the composite partition
+     * @param isLowerBound whether {@code partitionBound} is the inclusive 
lower endpoint
+     * @param defaultColumnRanges projected ranges for the current expanded 
partition input
+     * @return {@code context} refined at the first decisive suffix 
coordinate, or an equivalent
+     *         false result if the refined range is empty
+     */
     private EvaluateRangeResult determinateRangeOfOtherType(
-            EvaluateRangeResult context, List<Literal> partitionBound, boolean 
isLowerBound) {
+            EvaluateRangeResult context, List<Literal> partitionBound, boolean 
isLowerBound,
+            Map<Expression, ColumnRange> defaultColumnRanges) {
         if (context.result instanceof Literal) {
             return context;
         }
 
-        Slot qualifiedSlot = null;
-        ColumnRange qualifiedRange = null;
+        LexicographicBoundState boundState = new LexicographicBoundState(
+                partitionBound, isLowerBound, partitionSlots.size());
         for (int i = 0; i < partitionSlotTypes.size(); i++) {
             PartitionSlotType partitionSlotType = partitionSlotTypes.get(i);
             Slot slot = partitionSlots.get(i);
-            if (!context.columnRanges.containsKey(slot)) {
-                return context;
-            }
+            ColumnRange columnRange = context.columnRanges.get(slot);
             switch (partitionSlotType) {
                 case CONST: continue;
                 case RANGE:
-                    ColumnRange columnRange = context.columnRanges.get(slot);
-                    if (!columnRange.isSingleton()
-                            || 
!columnRange.getLowerBound().getValue().equals(partitionBound.get(i))) {
+                    // Expanded RANGE literals can disappear after constant 
folding. Recover only this
+                    // equal-prefix coordinate from the partition projection.
+                    if (columnRange == null) {
+                        columnRange = defaultColumnRanges.get(slot);
+                    }
+                    if (columnRange == null || !columnRange.isSingleton()) {
+                        return context;
+                    }
+                    
boundState.observeLiteral(columnRange.getLowerBound().getValue(), i);
+                    if (!boundState.hasEqualPrefix()) {
                         return context;
                     }
                     continue;
                 case OTHER:
-                    columnRange = context.columnRanges.get(slot);
+                    // Do not expose a default-only partition range to the 
predicate tree. In
+                    // particular, visitNot() must never complement a range 
that the predicate did
+                    // not contribute.
+                    if (columnRange == null) {
+                        return context;
+                    }
                     if (columnRange.isSingleton()
-                            && 
columnRange.getLowerBound().getValue().equals(partitionBound.get(i))) {
+                            && 
columnRange.getLowerBound().getValue().equals(partitionBound.get(i))
+                            && i + 1 < partitionSlots.size()) {
                         continue;
                     }
-
-                    qualifiedSlot = slot;
-                    if (isLowerBound) {
-                        qualifiedRange = 
ColumnRange.atLeast(partitionBound.get(i));
-                    } else {
-                        qualifiedRange = i + 1 == partitionSlots.size()
-                                ? ColumnRange.lessThen(partitionBound.get(i))
-                                : ColumnRange.atMost(partitionBound.get(i));
-                    }
-                    break;
+                    ColumnRange newRange = 
boundState.constrainFirstUnresolvedColumn(columnRange, i);
+                    Map<Expression, ColumnRange> newRanges = replaceExprRange(
+                            context.columnRanges, slot, newRange);
+                    return newRange.isEmptyRange()
+                            ? new EvaluateRangeResult(BooleanLiteral.FALSE, 
newRanges, context.childrenResult)
+                            : new EvaluateRangeResult(context.result, 
newRanges, context.childrenResult);
                 default:
-                    throw new AnalysisException("Unknown partition slot type: 
" + partitionSlotType);
-            }
-        }
-
-        if (qualifiedSlot != null) {
-            ColumnRange origin = context.columnRanges.get(qualifiedSlot);
-            ColumnRange newRange = origin.intersect(qualifiedRange);
-
-            Map<Expression, ColumnRange> newRanges = 
replaceExprRange(context.columnRanges, qualifiedSlot, newRange);
-
-            if (newRange.isEmptyRange()) {
-                return new EvaluateRangeResult(BooleanLiteral.FALSE, 
newRanges, context.childrenResult);
-            } else {
-                return new EvaluateRangeResult(context.result, newRanges, 
context.childrenResult);
+                    disablePruningOnInvalidState(
+                            "Unknown partition slot type while refining a 
lexicographic bound: "
+                                    + partitionSlotType);
+                    return context;
             }
         }
         return context;
@@ -745,52 +817,69 @@ public class OneRangePartitionEvaluator<K>
         return ImmutableList.of(slotToInputs);
     }
 
+    /**
+     * Build evaluator inputs for every expanded representation of this 
composite RANGE partition.
+     *
+     * <p>Range expansion replaces enumerable coordinates with literals and 
leaves unexpanded
+     * coordinates as slots. Separate lower- and upper-bound states track 
whether the literal prefix
+     * of each generated input still equals the corresponding endpoint. Only 
the first unresolved
+     * coordinate while a state is active receives that endpoint's constraint; 
after it can diverge,
+     * all suffix coordinates remain unbounded. This preserves tuple ordering 
instead of incorrectly
+     * treating each partition column as an independent interval.
+     *
+     * <p>The returned {@link PartitionSlotInput}s all contain the complete 
projected range map for
+     * their generated input. Expression evaluation can therefore recover 
ranges for slots that were
+     * replaced by literals and removed by constant folding.
+     *
+     * <p>A {@code CONST} coordinate must have been expanded to a literal. If 
it is not, the input is
+     * structurally invalid: fe_debug throws, while production makes the 
coordinate unbounded and
+     * disables pruning for the whole evaluator so planning can continue 
without dropping data.
+     *
+     * @return one slot-to-input map for each Cartesian-product row produced 
by range expansion
+     */
     private List<Map<Slot, PartitionSlotInput>> 
commonComputeOnePartitionInputs() {
         List<Map<Slot, PartitionSlotInput>> onePartitionInputs = 
Lists.newArrayListWithCapacity(inputs.size());
         for (List<Expression> input : inputs) {
-            boolean previousIsLowerBoundLiteral = true;
-            boolean previousIsUpperBoundLiteral = true;
+            LexicographicBoundState lowerState = new LexicographicBoundState(
+                    lowers, true, partitionSlots.size());
+            LexicographicBoundState upperState = new LexicographicBoundState(
+                    uppers, false, partitionSlots.size());
             Builder<Slot, PartitionSlotInput> slotToInputs = 
ImmutableMap.builderWithExpectedSize(16);
             for (int i = 0; i < partitionSlots.size(); ++i) {
                 Slot partitionSlot = partitionSlots.get(i);
                 // partitionSlot will be replaced to this expression
                 Expression expression = input.get(i);
-                ColumnRange slotRange = null;
+                ColumnRange slotRange;
                 PartitionSlotType partitionSlotType = 
partitionSlotTypes.get(i);
                 if (expression instanceof Literal) {
                     // const or expanded range
                     slotRange = ColumnRange.singleton((Literal) expression);
-                    if (!expression.equals(lowers.get(i))) {
-                        previousIsLowerBoundLiteral = false;
-                    }
-                    if (!expression.equals(uppers.get(i))) {
-                        previousIsUpperBoundLiteral = false;
-                    }
+                    lowerState.observeLiteral(expression, i);
+                    upperState.observeLiteral(expression, i);
                 } else {
-                    // un expanded range
+                    // The first unresolved column carries every still-active 
lexicographic bound.
+                    // Once that column can diverge, every suffix column must 
remain unbounded.
                     switch (partitionSlotType) {
                         case RANGE:
-                            boolean isLastPartitionColumn = i + 1 == 
partitionSlots.size();
-                            BoundType rightBoundType = isLastPartitionColumn
-                                    ? BoundType.OPEN : BoundType.CLOSED;
-                            slotRange = ColumnRange.range(
-                                    lowers.get(i), BoundType.CLOSED, 
uppers.get(i), rightBoundType);
-                            break;
                         case OTHER:
-                            if (previousIsLowerBoundLiteral) {
-                                slotRange = ColumnRange.atLeast(lowers.get(i));
-                            } else if (previousIsUpperBoundLiteral) {
-                                slotRange = 
ColumnRange.lessThen(uppers.get(i));
-                            } else {
-                                // unknown range
-                                slotRange = ColumnRange.all();
-                            }
+                            slotRange = 
lowerState.constrainFirstUnresolvedColumn(ColumnRange.all(), i);
+                            slotRange = 
upperState.constrainFirstUnresolvedColumn(slotRange, i);
+                            break;
+                        case CONST:
+                            disablePruningOnInvalidState("CONST partition 
input must be a literal: slot="
+                                    + partitionSlot + ", expression=" + 
expression);
+                            slotRange = ColumnRange.all();
+                            lowerState.diverge();
+                            upperState.diverge();
                             break;
                         default:
-                            throw new AnalysisException("Unknown partition 
slot type: " + partitionSlotType);
+                            disablePruningOnInvalidState(
+                                    "Unknown partition slot type while 
building evaluator inputs: "
+                                            + partitionSlotType);
+                            slotRange = ColumnRange.all();
+                            lowerState.diverge();
+                            upperState.diverge();
                     }
-                    previousIsLowerBoundLiteral = false;
-                    previousIsUpperBoundLiteral = false;
                 }
                 ImmutableMap<Expression, ColumnRange> slotToRange = 
ImmutableMap.of(partitionSlot, slotRange);
                 slotToInputs.put(partitionSlot, new 
PartitionSlotInput(expression, slotToRange));
@@ -802,6 +891,120 @@ public class OneRangePartitionEvaluator<K>
         return onePartitionInputs;
     }
 
+    /**
+     * Handle a state that violates the contract between {@link 
PartitionRangeExpander} and this evaluator.
+     *
+     * <p>Tests and debugging sessions set {@code fe_debug=true}, so they fail 
immediately and expose
+     * the broken invariant. Production planning must remain available: after 
logging the problem,
+     * evaluation returns the original predicate for this partition, which 
conservatively keeps it.
+     *
+     * @param message description of the invalid state
+     */
+    private void disablePruningOnInvalidState(String message) {
+        SessionVariable.throwAnalysisExceptionWhenFeDebug(message);
+        disablePruning = true;
+    }
+
+    /** Describes whether the coordinates already consumed are still equal to 
an endpoint prefix. */
+    private enum BoundPrefixState {
+        /** No consumed coordinate differs from the tracked lower or upper 
endpoint. */
+        EQUAL_PREFIX,
+
+        /** An earlier coordinate can differ, so this endpoint cannot 
constrain any later coordinate. */
+        DIVERGED
+    }
+
+    /**
+     * Tracks one lower or upper endpoint while projected ranges are built or 
refined.
+     *
+     * <p>Lexicographic comparison makes an endpoint relevant only as long as 
all preceding
+     * coordinates equal its prefix. For example, with lower endpoint {@code 
(1, 10, 100)}, an input
+     * whose first coordinate is {@code 1} must constrain the first unresolved 
coordinate to
+     * {@code >= 10}. If the first coordinate is {@code 2}, the lower endpoint 
is already satisfied
+     * and neither the second nor third coordinate receives a lower constraint.
+     *
+     * <p>The state is monotonic: it starts at {@link 
BoundPrefixState#EQUAL_PREFIX} and can transition
+     * to {@link BoundPrefixState#DIVERGED} only once.
+     */
+    private static class LexicographicBoundState {
+        private final List<Literal> bound;
+        private final boolean lowerBound;
+        private final int columnCount;
+        private BoundPrefixState prefixState = BoundPrefixState.EQUAL_PREFIX;
+
+        /**
+         * Create state for one endpoint of a composite partition.
+         *
+         * @param bound endpoint values in partition-column order
+         * @param lowerBound true for the inclusive lower endpoint, false for 
the exclusive upper endpoint
+         * @param columnCount total number of partition columns, used to 
identify the final coordinate
+         */
+        private LexicographicBoundState(List<Literal> bound, boolean 
lowerBound, int columnCount) {
+            this.bound = bound;
+            this.lowerBound = lowerBound;
+            this.columnCount = columnCount;
+        }
+
+        /**
+         * Return whether every coordinate observed so far is equal to this 
endpoint's prefix.
+         *
+         * @return true while this endpoint may still constrain the next 
unresolved coordinate
+         */
+        private boolean hasEqualPrefix() {
+            return prefixState == BoundPrefixState.EQUAL_PREFIX;
+        }
+
+        /**
+         * Consume a literal coordinate and deactivate this endpoint if the 
literal differs from it.
+         *
+         * <p>Once a coordinate differs, later values cannot make the tuple 
equal to the endpoint
+         * again, so an already-diverged state is intentionally left unchanged.
+         *
+         * @param literal literal selected for the current partition coordinate
+         * @param index zero-based partition-column index of {@code literal}
+         */
+        private void observeLiteral(Expression literal, int index) {
+            if (hasEqualPrefix() && !literal.equals(bound.get(index))) {
+                diverge();
+            }
+        }
+
+        /**
+         * Intersect an unresolved coordinate with this endpoint when its 
prefix is still equal.
+         *
+         * <p>The lower endpoint is inclusive on every coordinate. The upper 
endpoint is inclusive on
+         * non-terminal coordinates because equality there leaves later 
coordinates to decide tuple
+         * membership; only the final coordinate is exclusive. Consuming an 
unresolved coordinate
+         * always deactivates the endpoint so no suffix coordinate is 
independently constrained.
+         *
+         * @param origin range already inferred for the unresolved coordinate
+         * @param index zero-based partition-column index of that coordinate
+         * @return {@code origin} intersected with the active endpoint, or 
unchanged if an earlier
+         *         coordinate has already diverged
+         */
+        private ColumnRange constrainFirstUnresolvedColumn(ColumnRange origin, 
int index) {
+            if (!hasEqualPrefix()) {
+                return origin;
+            }
+            diverge();
+            Literal boundary = bound.get(index);
+            ColumnRange boundaryRange;
+            if (lowerBound) {
+                boundaryRange = ColumnRange.atLeast(boundary);
+            } else if (index + 1 == columnCount) {
+                boundaryRange = ColumnRange.lessThen(boundary);
+            } else {
+                boundaryRange = ColumnRange.atMost(boundary);
+            }
+            return origin.intersect(boundaryRange);
+        }
+
+        /** Mark this endpoint as satisfied or violated by an earlier decisive 
coordinate. */
+        private void diverge() {
+            prefixState = BoundPrefixState.DIVERGED;
+        }
+    }
+
     public EvaluateRangeResult visitMonotonic(Expression monotonic, 
EvaluateRangeInput context) {
         EvaluateRangeResult rangeResult = evaluateChildrenThenThis(monotonic, 
context);
         if (!rangeResult.result.getClass().equals(monotonic.getClass())) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PartitionPrunerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PartitionPrunerTest.java
index 3fe07a49f9c..f26c4701ce7 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PartitionPrunerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/rules/rewrite/PartitionPrunerTest.java
@@ -23,11 +23,13 @@ import org.apache.doris.catalog.ListPartitionItem;
 import org.apache.doris.catalog.PartitionItem;
 import org.apache.doris.catalog.PartitionKey;
 import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.catalog.RangePartitionItem;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Pair;
 import org.apache.doris.nereids.CascadesContext;
 import 
org.apache.doris.nereids.rules.expression.rules.OneListPartitionEvaluator;
 import org.apache.doris.nereids.rules.expression.rules.OnePartitionEvaluator;
+import 
org.apache.doris.nereids.rules.expression.rules.OneRangePartitionEvaluator;
 import org.apache.doris.nereids.rules.expression.rules.PartitionPruner;
 import 
org.apache.doris.nereids.rules.expression.rules.PartitionPruner.PartitionPruneResult;
 import 
org.apache.doris.nereids.rules.expression.rules.PartitionPruner.PartitionTableType;
@@ -35,24 +37,31 @@ import org.apache.doris.nereids.trees.expressions.And;
 import org.apache.doris.nereids.trees.expressions.EqualTo;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.GreaterThan;
+import org.apache.doris.nereids.trees.expressions.GreaterThanEqual;
 import org.apache.doris.nereids.trees.expressions.InPredicate;
 import org.apache.doris.nereids.trees.expressions.IsNull;
+import org.apache.doris.nereids.trees.expressions.LessThan;
 import org.apache.doris.nereids.trees.expressions.Not;
 import org.apache.doris.nereids.trees.expressions.Or;
+import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
 import org.apache.doris.nereids.trees.expressions.literal.Literal;
 import org.apache.doris.nereids.trees.expressions.literal.NullLiteral;
 import org.apache.doris.nereids.types.IntegerType;
 import org.apache.doris.nereids.types.VarcharType;
+import org.apache.doris.nereids.util.ExpressionUtils;
 import org.apache.doris.utframe.TestWithFeService;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Range;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
+import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
@@ -64,6 +73,7 @@ public class PartitionPrunerTest extends TestWithFeService {
     private final SlotReference slotA = new SlotReference("a", 
IntegerType.INSTANCE);
     private final SlotReference slotB = new SlotReference("b", 
IntegerType.INSTANCE);
     private final SlotReference slotC = new SlotReference("c", 
IntegerType.INSTANCE);
+    private final SlotReference slotD = new SlotReference("d", 
IntegerType.INSTANCE);
 
     @Override
     protected void runBeforeAll() throws Exception {
@@ -344,6 +354,195 @@ public class PartitionPrunerTest extends 
TestWithFeService {
         Assertions.assertTrue(result.hasPartitionPredicate);
     }
 
+    /**
+     * Verify lower/upper boundary transitions for a three-column partition in 
both evaluator paths.
+     *
+     * <p>The low threshold leaves the RANGE coordinate unexpanded, while the 
high threshold expands
+     * it into literals. The witness tuples cover divergence in a suffix 
coordinate, an exactly equal
+     * lower endpoint, and tuples at or beyond the exclusive upper endpoint.
+     */
+    @Test
+    public void testThreeColumnLexicographicRangeBoundaries()
+            throws AnalysisException, InvocationTargetException, 
IllegalAccessException {
+        List<Column> columns = ImmutableList.of(
+                new Column("a", PrimitiveType.INT),
+                new Column("b", PrimitiveType.INT),
+                new Column("c", PrimitiveType.INT));
+        List<Slot> slots = ImmutableList.of(slotA, slotB, slotC);
+        RangePartitionItem partitionItem = createRangePartitionItem(
+                columns, new int[] {1, 10, 100}, new int[] {100, 20, 200});
+
+        for (int expandThreshold : new int[] {1, 200}) {
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 1, 11, 50);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 100, 19, 250);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 100, 20, 199);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 1, 10, 100);
+
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
true, 1, 10, 99);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
true, 100, 20, 200);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
true, 100, 20, 250);
+        }
+    }
+
+    /**
+     * Verify that only the first unresolved coordinate receives a 
lexicographic endpoint constraint.
+     *
+     * <p>Four columns exercise divergence at the first, middle, and final 
coordinates. Extreme suffix
+     * values demonstrate that once an earlier coordinate differs, later 
columns are intentionally
+     * unbounded; exact-prefix cases verify inclusive lower and exclusive 
upper semantics at the end.
+     */
+    @Test
+    public void testFourColumnLexicographicRangeBoundaries()
+            throws AnalysisException, InvocationTargetException, 
IllegalAccessException {
+        List<Column> columns = ImmutableList.of(
+                new Column("a", PrimitiveType.INT),
+                new Column("b", PrimitiveType.INT),
+                new Column("c", PrimitiveType.INT),
+                new Column("d", PrimitiveType.INT));
+        List<Slot> slots = ImmutableList.of(slotA, slotB, slotC, slotD);
+        RangePartitionItem partitionItem = createRangePartitionItem(
+                columns, new int[] {1, 10, 100, 1000}, new int[] {4, 20, 200, 
2000});
+
+        for (int expandThreshold : new int[] {1, 10}) {
+            // Once an earlier column diverges, all suffix columns are 
unbounded.
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 2, -1000, -1000, -1000);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 3, 9999, 9999, 9999);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 1, 11, -1, -1);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 4, 19, 9999, 9999);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 1, 10, 101, -1);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 4, 20, 199, 9999);
+
+            // An equal prefix leaves the final column to decide the inclusive 
lower and exclusive upper bounds.
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 1, 10, 100, 1000);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
false, 4, 20, 200, 1999);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
true, 1, 10, 100, 999);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
true, 4, 20, 200, 2000);
+            assertRangeTuplePruned(partitionItem, slots, expandThreshold, 
true, 4, 20, 200, 2001);
+        }
+    }
+
+    /**
+     * A range copied only from the partition projection must not become part 
of a predicate result.
+     *
+     * <p>This is the reduced tree produced by the Date-IN partition-pruning 
rewrite for
+     * {@code NOT(date(c) IN (day2, day3))}. For expanded {@code a = 1}, the 
predicate constrains only
+     * {@code c}; injecting the default {@code b >= 10} range would let {@code 
NOT} complement it and
+     * incorrectly turn the entire partition into false.
+     */
+    @Test
+    public void testNotDoesNotComplementDefaultOnlySuffixRange()
+            throws AnalysisException, InvocationTargetException, 
IllegalAccessException {
+        List<Column> columns = ImmutableList.of(
+                new Column("a", PrimitiveType.INT),
+                new Column("b", PrimitiveType.INT),
+                new Column("c", PrimitiveType.INT));
+        List<Slot> slots = ImmutableList.of(slotA, slotB, slotC);
+        RangePartitionItem partitionItem = createRangePartitionItem(
+                columns, new int[] {1, 10, 1}, new int[] {2, 20, 4});
+        Expression predicate = new Not(new Or(
+                new And(new GreaterThanEqual(slotC, Literal.of(2)), new 
LessThan(slotC, Literal.of(3))),
+                new And(new GreaterThanEqual(slotC, Literal.of(3)), new 
LessThan(slotC, Literal.of(4)))));
+        OneRangePartitionEvaluator<String> evaluator = new 
OneRangePartitionEvaluator<>(
+                "p1", slots, partitionItem, cascadesContext, 200);
+
+        Pair<Boolean, Boolean> result =
+                (Pair<Boolean, Boolean>) canBePrunedOutMethod.invoke(null, 
predicate, evaluator);
+
+        Assertions.assertFalse(result.first);
+    }
+
+    /**
+     * Invalid expander/evaluator states fail in fe_debug but conservatively 
keep the partition in production.
+     */
+    @Test
+    public void testInvalidConstInputUsesFeDebugPolicy() throws Exception {
+        List<Column> columns = ImmutableList.of(
+                new Column("a", PrimitiveType.INT),
+                new Column("b", PrimitiveType.INT));
+        List<Slot> slots = ImmutableList.of(slotA, slotB);
+        RangePartitionItem partitionItem = createRangePartitionItem(
+                columns, new int[] {1, 10}, new int[] {1, 20});
+        Expression predicate = new EqualTo(slotA, Literal.of(2));
+        boolean oldFeDebug = connectContext.getSessionVariable().feDebug;
+        try {
+            connectContext.getSessionVariable().feDebug = false;
+            OneRangePartitionEvaluator<String> productionEvaluator =
+                    createEvaluatorWithNonLiteralConstInput(partitionItem, 
slots);
+            Pair<Boolean, Boolean> productionResult = (Pair<Boolean, Boolean>) 
canBePrunedOutMethod.invoke(
+                    null, predicate, productionEvaluator);
+            Assertions.assertFalse(productionResult.first);
+
+            connectContext.getSessionVariable().feDebug = true;
+            OneRangePartitionEvaluator<String> debugEvaluator =
+                    createEvaluatorWithNonLiteralConstInput(partitionItem, 
slots);
+            InvocationTargetException exception = 
Assertions.assertThrows(InvocationTargetException.class,
+                    () -> canBePrunedOutMethod.invoke(null, predicate, 
debugEvaluator));
+            Assertions.assertInstanceOf(
+                    
org.apache.doris.nereids.exceptions.AnalysisException.class, 
exception.getCause());
+        } finally {
+            connectContext.getSessionVariable().feDebug = oldFeDebug;
+        }
+    }
+
+    private OneRangePartitionEvaluator<String> 
createEvaluatorWithNonLiteralConstInput(
+            RangePartitionItem partitionItem, List<Slot> slots) throws 
ReflectiveOperationException {
+        OneRangePartitionEvaluator<String> evaluator = new 
OneRangePartitionEvaluator<>(
+                "p1", slots, partitionItem, cascadesContext, 200);
+        Field inputsField = 
OneRangePartitionEvaluator.class.getDeclaredField("inputs");
+        inputsField.setAccessible(true);
+        inputsField.set(evaluator, ImmutableList.of(ImmutableList.of(slotA, 
slotB)));
+        return evaluator;
+    }
+
+    /**
+     * Evaluate an equality predicate for one tuple and assert whether the 
partition is pruned.
+     *
+     * @param partitionItem composite RANGE partition under test
+     * @param slots partition slots in tuple order
+     * @param expandThreshold threshold selecting expanded or unexpanded 
evaluator inputs
+     * @param expectedPruned expected result from {@code 
PartitionPruner.canBePrunedOut}
+     * @param values tuple values used to build one equality per partition slot
+     */
+    private void assertRangeTuplePruned(RangePartitionItem partitionItem, 
List<Slot> slots,
+            int expandThreshold, boolean expectedPruned, int... values)
+            throws InvocationTargetException, IllegalAccessException {
+        ImmutableList.Builder<Expression> equalities = 
ImmutableList.builderWithExpectedSize(values.length);
+        for (int i = 0; i < values.length; i++) {
+            equalities.add(new EqualTo(slots.get(i), Literal.of(values[i])));
+        }
+        Expression predicate = ExpressionUtils.and(equalities.build());
+        OneRangePartitionEvaluator<String> evaluator = new 
OneRangePartitionEvaluator<>(
+                "p1", slots, partitionItem, cascadesContext, expandThreshold);
+        Pair<Boolean, Boolean> result =
+                (Pair<Boolean, Boolean>) canBePrunedOutMethod.invoke(null, 
predicate, evaluator);
+        Assertions.assertEquals(expectedPruned, result.first,
+                "tuple=" + Arrays.toString(values) + ", expandThreshold=" + 
expandThreshold);
+    }
+
+    /**
+     * Construct a closed-open composite RANGE partition from integer tuple 
endpoints.
+     *
+     * @param columns partition columns in key order
+     * @param lowerValues inclusive lower endpoint values
+     * @param upperValues exclusive upper endpoint values
+     * @return partition item representing {@code [lowerValues, upperValues)}
+     * @throws AnalysisException if an endpoint cannot be converted to a typed 
partition key
+     */
+    private RangePartitionItem createRangePartitionItem(
+            List<Column> columns, int[] lowerValues, int[] upperValues) throws 
AnalysisException {
+        ImmutableList.Builder<PartitionValue> lower = 
ImmutableList.builderWithExpectedSize(lowerValues.length);
+        ImmutableList.Builder<PartitionValue> upper = 
ImmutableList.builderWithExpectedSize(upperValues.length);
+        for (int value : lowerValues) {
+            lower.add(new PartitionValue(Integer.toString(value)));
+        }
+        for (int value : upperValues) {
+            upper.add(new PartitionValue(Integer.toString(value)));
+        }
+        PartitionKey lowerKey = PartitionKey.createPartitionKey(lower.build(), 
columns);
+        PartitionKey upperKey = PartitionKey.createPartitionKey(upper.build(), 
columns);
+        return new RangePartitionItem(Range.closedOpen(lowerKey, upperKey));
+    }
+
     private ListPartitionItem createListPartitionItem(String... values) throws 
AnalysisException {
         ImmutableList.Builder<PartitionKey> partitionKeys = 
ImmutableList.builder();
         for (String value : values) {
diff --git 
a/regression-test/data/nereids_rules_p0/partition_prune/test_lexicographic_range_partition.out
 
b/regression-test/data/nereids_rules_p0/partition_prune/test_lexicographic_range_partition.out
new file mode 100644
index 00000000000..4cf8fb2485d
--- /dev/null
+++ 
b/regression-test/data/nereids_rules_p0/partition_prune/test_lexicographic_range_partition.out
@@ -0,0 +1,21 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !expanded_lower_suffix --
+1
+
+-- !expanded_upper_suffix --
+2
+
+-- !expanded_upper_equal_prefix --
+3
+
+-- !unexpanded_lower_suffix --
+1
+
+-- !unexpanded_upper_suffix --
+2
+
+-- !unexpanded_upper_equal_prefix --
+3
+
+-- !negated_multi_date_in --
+42
diff --git 
a/regression-test/suites/nereids_rules_p0/partition_prune/test_lexicographic_range_partition.groovy
 
b/regression-test/suites/nereids_rules_p0/partition_prune/test_lexicographic_range_partition.groovy
new file mode 100644
index 00000000000..91c2a36ad8c
--- /dev/null
+++ 
b/regression-test/suites/nereids_rules_p0/partition_prune/test_lexicographic_range_partition.groovy
@@ -0,0 +1,93 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_lexicographic_range_partition") {
+    sql "DROP TABLE IF EXISTS lexicographic_range_partition"
+    sql """
+        CREATE TABLE lexicographic_range_partition (
+            k1 INT NOT NULL,
+            k2 INT NOT NULL,
+            k3 INT NOT NULL,
+            v INT NOT NULL
+        )
+        DUPLICATE KEY(k1, k2, k3)
+        PARTITION BY RANGE(k1, k2, k3) (
+            PARTITION p_target VALUES [("1", "10", "100"), ("100", "20", 
"200"))
+        )
+        DISTRIBUTED BY HASH(k1) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+    """
+    sql """
+        INSERT INTO lexicographic_range_partition VALUES
+            (1, 11, 50, 1),
+            (100, 19, 250, 2),
+            (100, 20, 199, 3),
+            (1, 10, 100, 4)
+    """
+
+    // Verify the optimizer retains the only partition before checking the 
query result. A wrong
+    // empty result alone is ambiguous here because it could also come from 
execution or test data.
+    def assertTargetPartition = { String query ->
+        explain {
+            sql query
+            contains "partitions=1/1 (p_target)"
+        }
+    }
+
+    sql "SET partition_pruning_expand_threshold=200"
+    assertTargetPartition("SELECT v FROM lexicographic_range_partition WHERE 
k1=1 AND k2=11 AND k3=50")
+    assertTargetPartition("SELECT v FROM lexicographic_range_partition WHERE 
k1=100 AND k2=19 AND k3=250")
+    assertTargetPartition("SELECT v FROM lexicographic_range_partition WHERE 
k1=100 AND k2=20 AND k3=199")
+    qt_expanded_lower_suffix "SELECT v FROM lexicographic_range_partition 
WHERE k1=1 AND k2=11 AND k3=50"
+    qt_expanded_upper_suffix "SELECT v FROM lexicographic_range_partition 
WHERE k1=100 AND k2=19 AND k3=250"
+    qt_expanded_upper_equal_prefix "SELECT v FROM 
lexicographic_range_partition WHERE k1=100 AND k2=20 AND k3=199"
+
+    sql "SET partition_pruning_expand_threshold=1"
+    assertTargetPartition("SELECT v FROM lexicographic_range_partition WHERE 
k1=1 AND k2=11 AND k3=50")
+    assertTargetPartition("SELECT v FROM lexicographic_range_partition WHERE 
k1=100 AND k2=19 AND k3=250")
+    assertTargetPartition("SELECT v FROM lexicographic_range_partition WHERE 
k1=100 AND k2=20 AND k3=199")
+    qt_unexpanded_lower_suffix "SELECT v FROM lexicographic_range_partition 
WHERE k1=1 AND k2=11 AND k3=50"
+    qt_unexpanded_upper_suffix "SELECT v FROM lexicographic_range_partition 
WHERE k1=100 AND k2=19 AND k3=250"
+    qt_unexpanded_upper_equal_prefix "SELECT v FROM 
lexicographic_range_partition WHERE k1=100 AND k2=20 AND k3=199"
+
+    sql "DROP TABLE IF EXISTS lexicographic_range_not_date_in"
+    sql """
+        CREATE TABLE lexicographic_range_not_date_in (
+            k1 INT NOT NULL,
+            k2 INT NOT NULL,
+            k3 DATE NOT NULL,
+            v INT NOT NULL
+        )
+        DUPLICATE KEY(k1, k2, k3)
+        PARTITION BY RANGE(k1, k2, k3) (
+            PARTITION p_target VALUES [("1", "10", "2020-01-01"), ("2", "20", 
"2020-01-04"))
+        )
+        DISTRIBUTED BY HASH(k1) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+    """
+    sql "INSERT INTO lexicographic_range_not_date_in VALUES (1, 11, 
'2020-01-01', 42)"
+
+    // The Date-IN rewrite creates NOT(OR(day ranges)). Default-only k2 ranges 
must not leak into
+    // that predicate tree, otherwise NOT turns the valid partition into an 
empty set.
+    sql "SET partition_pruning_expand_threshold=200"
+    def negatedDateInQuery = """
+        SELECT v FROM lexicographic_range_not_date_in
+        WHERE NOT(DATE(k3) IN ('2020-01-02', '2020-01-03'))
+    """
+    assertTargetPartition(negatedDateInQuery)
+    qt_negated_multi_date_in negatedDateInQuery
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to