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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 9e9bd525c24 branch-4.2: [improvement](zonemap) Support 
column-vs-column expression pruning (#67774) (#69659)
9e9bd525c24 is described below

commit 9e9bd525c24b43067f49168c206791cc72996c9e
Author: HappenLee <[email protected]>
AuthorDate: Wed Oct 28 09:23:16 2026 +0800

    branch-4.2: [improvement](zonemap) Support column-vs-column expression 
pruning (#67774) (#69659)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #67774
    
    Problem Summary:
    
    Backport #67774 to `branch-4.2`. Comparisons such as `a != b` and `a <
    b` can use both columns' zone-map ranges to discard native segments and
    Parquet V2 row groups that cannot match. The existing slot-vs-literal
    path shares the same six interval rules; dictionary and Bloom-filter
    gates remain unchanged.
    
    The backport preserves branch-4.2's `BloomFilterProbe` interfaces
    instead of importing the unrelated master metadata-probe/array-contains
    refactor. It also retains the original NULL, NaN, string-bound,
    unsupported-type and legacy Parquet safeguards, plus the unit tests and
    remaining regression coverage.
    
    **Branch-specific adaptation:** master already reads hidden version
    columns through a constant reader with substituted zone maps. Branch-4.2
    still substitutes `__DORIS_VERSION_COL__` after reading a single-version
    rowset, so this backport excludes that column's on-disk placeholder from
    both segment- and page-level expression zone-map pruning. The original
    enabled/disabled hidden-version regression is retained.
    
    **Deferred VARIANT regression:** omit the VARIANT-path fixture and its
    two result blocks until Variant V2 is integrated into branch-4.2. On
    this branch, the legacy VARIANT parser returns max-subcolumns 0 while
    fuzzy table defaults can differ, so INSERT fails before the
    path-comparison checks. The VARIANT exclusion in the production zone-map
    gate remains intact. The hidden-version regression is retained.
    
    ### Release note
    
    Support column-to-column comparisons in expression zone-map pruning for
    native segments and Parquet V2 row groups.
    
    ### Check List (For Author)
    
    - Test:
      - [ ] Regression test (not executed locally)
      - [ ] Unit Test (local configuration blocked)
    - [x] Manual test: static backport review, formatting and golden-label
    consistency checks only; no runtime execution claimed.
    - Passed: `build-support/clang-format.sh` and
    `build-support/check-format.sh` with clang-format 16.0.6; `git diff
    --check`.
    - Carried the original unit tests and remaining regression tests. All 41
    remaining `qt_` labels match the golden file. Only the two deferred
    VARIANT result blocks were removed from the runner-generated source PR
    output; all other result blocks are unchanged.
    - Attempted `BUILD_TYPE=ASAN ./build.sh --be -j48`. The cached
    third-party package lacks Paimon-Rust headers/archive; stopped the
    automatic full dependency rebuild.
    - Attempted `BUILD_TYPE=ASAN ./run-be-ut.sh -j48 --run
    --filter='ExprZonemapFilterTest.*'`. Configuration stopped because the
    checkout lacks the `contrib/openblas` and `contrib/faiss` CMake sources.
    No local unit-test or regression runtime pass is claimed; CI validation
    is required.
    - Behavior changed:
    - [x] Yes. Additional predicates can prune; unsupported metadata stays
    conservative, and hidden-version placeholders are excluded on this
    branch.
    - Does this need documentation:
      - [x] No. Backport of an existing feature.
    
    No FE/BE protocol, storage-format, configuration, transaction or
    data-write changes.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases and CI results
    - [ ] Confirm document
    - [ ] Add branch pick label
    
    ---------
    
    Co-authored-by: YangJie <[email protected]>
---
 be/src/exprs/expr_zonemap_filter.cpp               |  33 ++
 be/src/exprs/expr_zonemap_filter.h                 |  25 ++
 be/src/exprs/function/functions_comparison.h       | 186 ++++++--
 be/src/format/parquet/vparquet_reader.cpp          |  41 +-
 be/src/format_v2/parquet/parquet_statistics.cpp    |   5 +-
 .../storage/index/zone_map/zonemap_eval_context.h  |  17 +
 be/src/storage/segment/segment.cpp                 |   6 +
 be/src/storage/segment/segment_iterator.cpp        |   6 +
 be/test/exprs/expr_zonemap_filter_test.cpp         | 465 +++++++++++++++++++
 .../expr_zonemap/test_expr_zonemap_pruning.out     | 124 +++++
 .../expr_zonemap/test_expr_zonemap_pruning.groovy  | 500 +++++++++++++++++++--
 11 files changed, 1327 insertions(+), 81 deletions(-)

diff --git a/be/src/exprs/expr_zonemap_filter.cpp 
b/be/src/exprs/expr_zonemap_filter.cpp
index 20e12a8c8d7..91b2af4e724 100644
--- a/be/src/exprs/expr_zonemap_filter.cpp
+++ b/be/src/exprs/expr_zonemap_filter.cpp
@@ -265,6 +265,39 @@ std::optional<SlotLiteral> extract_slot_and_literal(const 
VExprSPtrs& args) {
     return std::nullopt;
 }
 
+std::optional<SlotSlot> extract_slot_and_slot(const VExprSPtrs& args) {
+    if (args.size() != 2) {
+        return std::nullopt;
+    }
+    auto left = std::dynamic_pointer_cast<VSlotRef>(args[0]);
+    auto right = std::dynamic_pointer_cast<VSlotRef>(args[1]);
+    if (left == nullptr || right == nullptr) {
+        return std::nullopt;
+    }
+    return SlotSlot {.left_slot_index = left->column_id(),
+                     .left_type = left->data_type(),
+                     .right_slot_index = right->column_id(),
+                     .right_type = right->data_type()};
+}
+
+bool contains_slot_slot_comparison(const VExprSPtr& expr) {
+    if (expr == nullptr) {
+        return false;
+    }
+    // A comparison node's two operands are its children, so a slot-vs-slot 
leaf is a node whose
+    // children are two VSlotRef. extract_slot_and_slot accepts a same-column 
pair too, which is the
+    // point: `a < a` has one column id but is still slot-vs-slot.
+    if (extract_slot_and_slot(expr->children()).has_value()) {
+        return true;
+    }
+    for (const auto& child : expr->children()) {
+        if (contains_slot_slot_comparison(child)) {
+            return true;
+        }
+    }
+    return false;
+}
+
 std::optional<BloomFilterProbe> extract_bloom_filter_probe(const VExprSPtr& 
expr) {
     if (expr == nullptr || expr->data_type() == nullptr) {
         return std::nullopt;
diff --git a/be/src/exprs/expr_zonemap_filter.h 
b/be/src/exprs/expr_zonemap_filter.h
index ee95da8ec46..d247ef57484 100644
--- a/be/src/exprs/expr_zonemap_filter.h
+++ b/be/src/exprs/expr_zonemap_filter.h
@@ -100,6 +100,21 @@ struct SlotLiteral {
     bool literal_on_left;
 };
 
+// A comparison whose both operands are plain slot references, e.g. `WHERE a 
!= b`. Unlike
+// SlotLiteral there is no flag to flip the operator: with two slots the 
argument order already is
+// the expression order, so evaluators use the comparison operator as written.
+struct SlotSlot {
+    // Slot ordinals of the left and right operand. Both are keys used to look 
up the corresponding
+    // reader-schema type and zone map from ZoneMapEvalContext, same as 
SlotLiteral::slot_index.
+    int left_slot_index;
+    // Type carried by the left slot expression. Kept separately for the same 
reason as
+    // SlotLiteral::slot_type: evaluation first has to verify the expression 
binding is compatible
+    // with the reader-schema type held by the context.
+    DataTypePtr left_type;
+    int right_slot_index;
+    DataTypePtr right_type;
+};
+
 enum class BloomFilterPathKind {
     STRUCT_FIELD,
     LIST_ELEMENT,
@@ -123,6 +138,16 @@ struct BloomFilterProbe {
 
 std::optional<SlotLiteral> extract_slot_and_literal(const VExprSPtrs& args);
 
+// Both operands must be bare VSlotRef. A cast around a slot is deliberately 
rejected: the zone map
+// holds raw stored values, so comparing across a cast would need the cast 
applied to the bounds.
+std::optional<SlotSlot> extract_slot_and_slot(const VExprSPtrs& args);
+
+// True if the expression tree contains a slot-vs-slot comparison leaf (two 
VSlotRef operands),
+// at the root or nested under AND/OR, including two references to the same 
column (`a < a`). The v1
+// Parquet reader uses this to exclude slot-vs-slot pruning entirely: counting 
distinct columns is
+// not enough because a same-column pair still has one column id.
+bool contains_slot_slot_comparison(const VExprSPtr& expr);
+
 std::optional<BloomFilterProbe> extract_bloom_filter_probe(const VExprSPtr& 
expr);
 
 std::optional<BloomFilterProbe> extract_bloom_filter_predicate_probe(const 
VExprSPtr& expr);
diff --git a/be/src/exprs/function/functions_comparison.h 
b/be/src/exprs/function/functions_comparison.h
index a1f681f2e48..affff29d724 100644
--- a/be/src/exprs/function/functions_comparison.h
+++ b/be/src/exprs/function/functions_comparison.h
@@ -49,6 +49,7 @@
 #include "exprs/vexpr.h"
 #include "runtime/runtime_state.h"
 #include "storage/index/index_reader_helper.h"
+#include "storage/olap_define.h"
 
 namespace doris {
 #include "common/compile_check_begin.h"
@@ -300,16 +301,43 @@ inline Op symmetric_op(Op op) {
     __builtin_unreachable();
 }
 
-inline ZoneMapFilterResult evaluate(const ZoneMapEvalContext& ctx, const 
VExprSPtrs& arguments,
-                                    Op op) {
-    auto slot_literal = expr_zonemap::extract_slot_and_literal(arguments);
+// Return true when `L <op> R` cannot be TRUE for any row, given that every 
non-null value of the
+// left operand lies in [lmin, lmax] and every non-null value of the right 
operand lies in
+// [rmin, rmax]. This only compares the four bounds: callers must already have 
rejected zone maps
+// whose bounds are unusable and columns that hold no non-null value at all.
+//
+// The slot-vs-literal path passes the literal as the degenerate range 
[literal, literal], which
+// reduces every branch below to the plain slot-vs-literal test it replaced.
+inline bool range_vs_range_no_match(const Field& lmin, const Field& lmax, 
const Field& rmin,
+                                    const Field& rmax, Op op) {
+    switch (op) {
+    case Op::EQ:
+        // Disjoint ranges cannot share a value.
+        return lmin > rmax || rmin > lmax;
+    case Op::NE:
+        // Only provable when both sides collapse to the same single value.
+        return lmin == lmax && rmin == rmax && lmin == rmin;
+    case Op::LT:
+        return lmin >= rmax;
+    case Op::LE:
+        return lmin > rmax;
+    case Op::GT:
+        return rmin >= lmax;
+    case Op::GE:
+        return rmin > lmax;
+    }
+    __builtin_unreachable();
+}
 
-    auto slot_type = expr_zonemap::fetch_compatible_slot_type(ctx, 
slot_literal->slot_index,
-                                                              
slot_literal->slot_type);
+inline ZoneMapFilterResult evaluate_slot_literal(const ZoneMapEvalContext& ctx,
+                                                 const 
expr_zonemap::SlotLiteral& slot_literal,
+                                                 Op op) {
+    auto slot_type = expr_zonemap::fetch_compatible_slot_type(ctx, 
slot_literal.slot_index,
+                                                              
slot_literal.slot_type);
     if (slot_type == nullptr) {
         return unsupported_zonemap_filter(ctx);
     }
-    auto zone_map_ptr = ctx.zone_map(slot_literal->slot_index);
+    auto zone_map_ptr = ctx.zone_map(slot_literal.slot_index);
     if (zone_map_ptr == nullptr) {
         return unsupported_zonemap_filter(ctx);
     }
@@ -321,42 +349,95 @@ inline ZoneMapFilterResult evaluate(const 
ZoneMapEvalContext& ctx, const VExprSP
         return unsupported_zonemap_filter(ctx);
     }
 
-    const auto effective_op = slot_literal->literal_on_left ? symmetric_op(op) 
: op;
-    const auto& literal = slot_literal->literal;
+    const auto effective_op = slot_literal.literal_on_left ? symmetric_op(op) 
: op;
+    const auto& literal = slot_literal.literal;
     const bool literal_is_nan = literal.is_nan();
     const bool hidden_nan_can_match = (effective_op == Op::EQ && 
literal_is_nan) ||
                                       (effective_op == Op::NE && 
!literal_is_nan) ||
                                       (effective_op == Op::GT && 
!literal_is_nan) ||
                                       effective_op == Op::GE;
-    if (ctx.floating_nan_count_unknown(slot_literal->slot_index) && 
hidden_nan_can_match) {
+    if (ctx.floating_nan_count_unknown(slot_literal.slot_index) && 
hidden_nan_can_match) {
         // Parquet bounds omit NaNs, so only operators that cannot match a 
hidden NaN may prune.
         return unsupported_zonemap_filter(ctx);
     }
-    switch (effective_op) {
-    case Op::EQ:
-        return literal < zone_map.min_value || zone_map.max_value < literal
-                       ? ZoneMapFilterResult::kNoMatch
-                       : ZoneMapFilterResult::kMayMatch;
-    case Op::NE:
-        return zone_map.min_value == literal && zone_map.max_value == literal
-                       ? ZoneMapFilterResult::kNoMatch
-                       : ZoneMapFilterResult::kMayMatch;
-    case Op::LT:
-        return zone_map.min_value >= literal ? ZoneMapFilterResult::kNoMatch
-                                             : ZoneMapFilterResult::kMayMatch;
-    case Op::LE:
-        return zone_map.min_value > literal ? ZoneMapFilterResult::kNoMatch
-                                            : ZoneMapFilterResult::kMayMatch;
-    case Op::GT:
-        return zone_map.max_value <= literal ? ZoneMapFilterResult::kNoMatch
-                                             : ZoneMapFilterResult::kMayMatch;
-    case Op::GE:
-        return zone_map.max_value < literal ? ZoneMapFilterResult::kNoMatch
-                                            : ZoneMapFilterResult::kMayMatch;
+    return range_vs_range_no_match(zone_map.min_value, zone_map.max_value, 
literal, literal,
+                                   effective_op)
+                   ? ZoneMapFilterResult::kNoMatch
+                   : ZoneMapFilterResult::kMayMatch;
+}
+
+inline ZoneMapFilterResult evaluate_slot_slot(const ZoneMapEvalContext& ctx,
+                                              const expr_zonemap::SlotSlot& 
slot_slot, Op op) {
+    const auto left_type = expr_zonemap::fetch_compatible_slot_type(ctx, 
slot_slot.left_slot_index,
+                                                                    
slot_slot.left_type);
+    const auto right_type = expr_zonemap::fetch_compatible_slot_type(
+            ctx, slot_slot.right_slot_index, slot_slot.right_type);
+    if (left_type == nullptr || right_type == nullptr) {
+        // The context skips a slot entirely when the segment cannot apply 
predicates on it.
+        return unsupported_zonemap_filter(ctx);
+    }
+    const auto left_zone_map = ctx.zone_map(slot_slot.left_slot_index);
+    const auto right_zone_map = ctx.zone_map(slot_slot.right_slot_index);
+    if (left_zone_map == nullptr || right_zone_map == nullptr) {
+        // A slot can be present with a data type but no zone map, so this is 
a separate check.
+        return unsupported_zonemap_filter(ctx);
+    }
+    // A column holding no non-null value makes the comparison NULL on every 
row, which never
+    // satisfies a WHERE conjunct. This must run before the range checks 
below: an all-null zone map
+    // leaves min/max default-constructed as TYPE_NULL, which the range checks 
would fatal on.
+    if (!left_zone_map->has_not_null || !right_zone_map->has_not_null) {
+        return ZoneMapFilterResult::kNoMatch;
+    }
+    if (!expr_zonemap::range_stats_usable_for_zonemap(*left_zone_map, 
left_type) ||
+        !expr_zonemap::range_stats_usable_for_zonemap(*right_zone_map, 
right_type)) {
+        return unsupported_zonemap_filter(ctx);
+    }
+    // A native string zone-map max of exactly MAX_ZONE_MAP_INDEX_SIZE bytes 
was bumped upward,
+    // and may also have been truncated (segments written before the carry fix 
may hold a bump
+    // that wrapped on 0xff), so a bound of that length is not a reliable 
fence for a two-sided
+    // proof. A shorter bound is the exact value; the bump never changes the 
length, so this also
+    // rejects those wrapped bounds.
+    // The test only decides whether to trust the bound as a fence. A 
Parquet-sourced string bound
+    // shorter than the cap is not necessarily exact, but it is a 
spec-conservative min/max, which
+    // is all a range proof needs; do not tighten this to assume exactness 
below the cap.
+    // data_types_compatible pairs strings only with strings, so checking one 
side's type is enough
+    // to know all four bounds are strings.
+    if (is_string_type(remove_nullable(left_type)->get_primitive_type())) {
+        auto is_untruncated = [](const Field& f) {
+            return f.get<TYPE_STRING>().size() < MAX_ZONE_MAP_INDEX_SIZE;
+        };
+        if (!is_untruncated(left_zone_map->min_value) ||
+            !is_untruncated(left_zone_map->max_value) ||
+            !is_untruncated(right_zone_map->min_value) ||
+            !is_untruncated(right_zone_map->max_value)) {
+            return unsupported_zonemap_filter(ctx);
+        }
+    }
+    // Parquet bounds omit NaN without recording how many were skipped, so a 
zone map that looks
+    // like a single point may still hide one, which would flip the NE rule 
from false to true. The
+    // slot-vs-literal path can reason per operator because it knows whether 
the literal is NaN;
+    // with two slots there is no literal, so bail out for every operator 
instead.
+    if (ctx.floating_nan_count_unknown(slot_slot.left_slot_index) ||
+        ctx.floating_nan_count_unknown(slot_slot.right_slot_index)) {
+        return unsupported_zonemap_filter(ctx);
     }
+    return range_vs_range_no_match(left_zone_map->min_value, 
left_zone_map->max_value,
+                                   right_zone_map->min_value, 
right_zone_map->max_value, op)
+                   ? ZoneMapFilterResult::kNoMatch
+                   : ZoneMapFilterResult::kMayMatch;
+}
 
-    // keep this to avoid compile failure with g++.
-    __builtin_unreachable();
+inline ZoneMapFilterResult evaluate(const ZoneMapEvalContext& ctx, const 
VExprSPtrs& arguments,
+                                    Op op) {
+    if (auto slot_literal = expr_zonemap::extract_slot_and_literal(arguments);
+        slot_literal.has_value()) {
+        return evaluate_slot_literal(ctx, *slot_literal, op);
+    }
+    auto slot_slot = expr_zonemap::extract_slot_and_slot(arguments);
+    // can_evaluate_zonemap_filter accepts exactly the two shapes handled 
above, so reaching this
+    // point means the capability gate and this evaluator went out of sync.
+    DORIS_CHECK(slot_slot.has_value());
+    return evaluate_slot_slot(ctx, *slot_slot, op);
 }
 
 inline bool can_evaluate(const VExprSPtrs& arguments) {
@@ -384,6 +465,40 @@ inline bool can_evaluate(const VExprSPtrs& arguments) {
     return true;
 }
 
+// Accept a comparison whose both operands are slot references. Kept separate 
from can_evaluate on
+// purpose: can_evaluate also gates dictionary filtering and 
can_evaluate_equality, and both of
+// those dereference extract_slot_and_literal behind a DORIS_CHECK, so 
widening it would abort on a
+// slot-vs-slot expression. Only can_evaluate_zonemap_filter ORs this in.
+inline bool can_evaluate_slot_slot(const VExprSPtrs& arguments) {
+    auto slot_slot = expr_zonemap::extract_slot_and_slot(arguments);
+    if (!slot_slot.has_value()) {
+        return false;
+    }
+    DORIS_CHECK(slot_slot->left_type != nullptr);
+    DORIS_CHECK(slot_slot->right_type != nullptr);
+    // Reject types whose slot-slot bounds cannot be compared safely:
+    // - VARBINARY: v1 Parquet produces a TYPE_STRING bound for it, which
+    //   range_stats_usable_for_zonemap aborts on instead of falling back.
+    // - VARIANT: a segment types a path slot by its inferred leaf type (a 
typed `v.a` is INT), so
+    //   fetch_compatible_slot_type's check against the VARIANT expression 
type would fail.
+    // - ARRAY/MAP/STRUCT, JSONB, aggregate states: Field orders every pair of 
them as equal.
+    // A truncated string bound is rejected later, in evaluate_slot_slot, by 
its length.
+    const auto excluded = [](const DataTypePtr& type) {
+        const auto primitive_type = 
remove_nullable(type)->get_primitive_type();
+        return is_varbinary(primitive_type) || primitive_type == TYPE_VARIANT 
||
+               primitive_type == TYPE_JSONB || is_complex_type(primitive_type) 
||
+               is_var_len_object(primitive_type);
+    };
+    if (excluded(slot_slot->left_type) || excluded(slot_slot->right_type)) {
+        return false;
+    }
+    // The two zone maps' Fields are compared directly and Field comparison 
throws on mismatched
+    // non-string types, so reject incompatible column pairs here. A pair 
differing only by width or
+    // decimal scale never reaches this point anyway, because the optimizer 
inserts a cast and a
+    // cast is not a VSlotRef.
+    return expr_zonemap::data_types_compatible(slot_slot->left_type, 
slot_slot->right_type);
+}
+
 inline bool can_evaluate_equality(const VExprSPtrs& arguments, Op op) {
     if (op != Op::EQ || !can_evaluate(arguments)) {
         return false;
@@ -657,8 +772,11 @@ public:
     }
 
     bool can_evaluate_zonemap_filter(const VExprSPtrs& arguments) const 
override {
-        return comparison_zonemap_detail::op_from_name(name).has_value() &&
-               comparison_zonemap_detail::can_evaluate(arguments);
+        if (!comparison_zonemap_detail::op_from_name(name).has_value()) {
+            return false;
+        }
+        return comparison_zonemap_detail::can_evaluate(arguments) ||
+               comparison_zonemap_detail::can_evaluate_slot_slot(arguments);
     }
 
     ZoneMapFilterResult evaluate_dictionary_filter(const 
DictionaryEvalContext& ctx,
diff --git a/be/src/format/parquet/vparquet_reader.cpp 
b/be/src/format/parquet/vparquet_reader.cpp
index 9ec55437c33..d06e8556f4e 100644
--- a/be/src/format/parquet/vparquet_reader.cpp
+++ b/be/src/format/parquet/vparquet_reader.cpp
@@ -1018,6 +1018,11 @@ bool ParquetReader::_expr_zonemap_page_slot_index(const 
VExprContextSPtr& conjun
     if (column_ids.size() != 1) {
         return false;
     }
+    // The v1 reader does not take on slot-vs-slot pruning. A same-column pair 
like `a < a` still has
+    // one column id, so exclude any slot-vs-slot leaf explicitly rather than 
by column count.
+    if (expr_zonemap::contains_slot_slot_comparison(conjunct->root())) {
+        return false;
+    }
 
     const int slot_index = *column_ids.begin();
     if (slot_index < 0 || static_cast<size_t>(slot_index) >= 
_tuple_descriptor->slots().size()) {
@@ -1274,6 +1279,11 @@ Status ParquetReader::_process_expr_zonemap_page_filter(
         if (!cached_page_index->get_stat_func(&stat, cid) || stat == nullptr 
|| !stat->available) {
             continue;
         }
+        // Same domain requirement as the row-group path above.
+        if (stat->col_schema == nullptr ||
+            !expr_zonemap::data_types_compatible(stat->col_schema->data_type, 
slot->type())) {
+            continue;
+        }
         RowRanges expr_ranges;
         ZoneMapEvalStats page_stats;
         for (int64_t page_id = 0; page_id < stat->num_of_pages; ++page_id) {
@@ -1285,7 +1295,7 @@ Status ParquetReader::_process_expr_zonemap_page_filter(
 
             ZoneMapEvalContext ctx;
             ZoneMapEvalContext::SlotZoneMap slot_zone_map;
-            slot_zone_map.data_type = slot->type();
+            slot_zone_map.set_data_type_from_parquet(slot->type());
             segment_v2::ZoneMap zone_map;
             zone_map.has_null = stat->has_null[page_id];
             zone_map.has_not_null = !stat->is_all_null[page_id];
@@ -1520,11 +1530,24 @@ Status 
ParquetReader::_process_expr_zonemap_filter(const tparquet::RowGroup& row
         return Status::OK();
     }
 
+    // The v1 Parquet reader is being removed, so it does not take on the new 
slot-vs-slot pruning.
+    // Restrict this path to single-column conjuncts that carry no 
slot-vs-slot leaf: counting
+    // distinct columns alone is not enough, because a same-column pair like 
`a < a` still has one
+    // column id. A two-slot shape falls back to no pruning here. Native and 
v2 keep it.
     std::set<int> column_ids;
+    VExprContextSPtrs single_slot_conjuncts;
     for (const auto& conjunct : all_conjuncts) {
-        if (conjunct->root() != nullptr && 
conjunct->root()->can_evaluate_zonemap_filter()) {
-            conjunct->root()->collect_slot_column_ids(column_ids);
+        if (conjunct->root() == nullptr || 
!conjunct->root()->can_evaluate_zonemap_filter()) {
+            continue;
         }
+        std::set<int> conjunct_column_ids;
+        conjunct->root()->collect_slot_column_ids(conjunct_column_ids);
+        if (conjunct_column_ids.size() != 1 ||
+            expr_zonemap::contains_slot_slot_comparison(conjunct->root())) {
+            continue;
+        }
+        single_slot_conjuncts.emplace_back(conjunct);
+        column_ids.insert(*conjunct_column_ids.begin());
     }
     if (column_ids.empty()) {
         return Status::OK();
@@ -1537,7 +1560,7 @@ Status ParquetReader::_process_expr_zonemap_filter(const 
tparquet::RowGroup& row
         }
         auto* slot = _tuple_descriptor->slots()[cid];
         ZoneMapEvalContext::SlotZoneMap slot_zone_map;
-        slot_zone_map.data_type = slot->type();
+        slot_zone_map.set_data_type_from_parquet(slot->type());
         if (!_exists_in_file(slot->col_name()) || !_type_matches(cid)) {
             ctx.slots.emplace(cid, std::move(slot_zone_map));
             continue;
@@ -1545,6 +1568,14 @@ Status ParquetReader::_process_expr_zonemap_filter(const 
tparquet::RowGroup& row
         const auto& file_col_name =
                 
_table_info_node_ptr->children_file_column_name(slot->col_name());
         const FieldSchema* col_schema = 
_file_metadata->schema().get_column(file_col_name);
+        // parse_min_max_value decodes the bounds in the file's own logical 
type, while _type_matches
+        // only compares primitive types. A DECIMAL bound decoded at the 
file's scale would then be
+        // compared against the table's scale as if the payloads shared a 
domain, so leave the zone
+        // map out unless the two types agree exactly.
+        if (!expr_zonemap::data_types_compatible(col_schema->data_type, 
slot->type())) {
+            ctx.slots.emplace(cid, std::move(slot_zone_map));
+            continue;
+        }
         int parquet_col_id = col_schema->physical_column_index;
         if (parquet_col_id < 0) {
             // Complex parent fields do not map to a physical Parquet column.
@@ -1578,7 +1609,7 @@ Status ParquetReader::_process_expr_zonemap_filter(const 
tparquet::RowGroup& row
         ctx.slots.emplace(cid, std::move(slot_zone_map));
     }
 
-    const auto result = VExprContext::evaluate_zonemap_filter(all_conjuncts, 
ctx);
+    const auto result = 
VExprContext::evaluate_zonemap_filter(single_slot_conjuncts, ctx);
     ctx.stats.accumulate_to(&_reader_statistics);
     if (result == ZoneMapFilterResult::kNoMatch) {
         *filter_group = true;
diff --git a/be/src/format_v2/parquet/parquet_statistics.cpp 
b/be/src/format_v2/parquet/parquet_statistics.cpp
index e4928e65a7c..6895c9743c7 100644
--- a/be/src/format_v2/parquet/parquet_statistics.cpp
+++ b/be/src/format_v2/parquet/parquet_statistics.cpp
@@ -904,11 +904,8 @@ void add_slot_zonemap(ZoneMapEvalContext* ctx, int 
slot_index, const DataTypePtr
                       std::shared_ptr<segment_v2::ZoneMap> zone_map) {
     DORIS_CHECK(ctx != nullptr);
     ZoneMapEvalContext::SlotZoneMap slot_zone_map;
-    slot_zone_map.data_type = data_type;
+    slot_zone_map.set_data_type_from_parquet(data_type);
     slot_zone_map.zone_map = std::move(zone_map);
-    const auto primitive_type = 
remove_nullable(data_type)->get_primitive_type();
-    slot_zone_map.floating_nan_count_unknown =
-            primitive_type == TYPE_FLOAT || primitive_type == TYPE_DOUBLE;
     ctx->slots.emplace(slot_index, std::move(slot_zone_map));
 }
 
diff --git a/be/src/storage/index/zone_map/zonemap_eval_context.h 
b/be/src/storage/index/zone_map/zonemap_eval_context.h
index 7c1afab8780..25eb935ac44 100644
--- a/be/src/storage/index/zone_map/zonemap_eval_context.h
+++ b/be/src/storage/index/zone_map/zonemap_eval_context.h
@@ -22,6 +22,7 @@
 #include <memory>
 
 #include "core/data_type/data_type.h"
+#include "core/data_type/data_type_nullable.h"
 #include "storage/index/zone_map/zone_map_index.h"
 #include "storage/index/zone_map/zonemap_filter_result.h"
 
@@ -54,6 +55,22 @@ public:
         std::shared_ptr<const segment_v2::ZoneMap> zone_map;
         // Parquet min/max does not expose whether a floating chunk also 
contains NaNs.
         bool floating_nan_count_unknown = false;
+
+        // Every caller that fills this from Parquet statistics must go 
through here. Setting
+        // data_type without the flag silently re-enables pruning that Parquet 
bounds cannot
+        // support, so the two fields are assigned together on purpose.
+        void set_data_type_from_parquet(const DataTypePtr& type) {
+            data_type = type;
+            // SlotDescriptor's test-only default constructor leaves the type 
null, and the callers
+            // used to just copy it through.
+            floating_nan_count_unknown = false;
+            if (type == nullptr) {
+                return;
+            }
+            const auto primitive_type = 
remove_nullable(type)->get_primitive_type();
+            floating_nan_count_unknown =
+                    primitive_type == TYPE_FLOAT || primitive_type == 
TYPE_DOUBLE;
+        }
     };
 
     std::shared_ptr<const segment_v2::ZoneMap> zone_map(int slot_index) const;
diff --git a/be/src/storage/segment/segment.cpp 
b/be/src/storage/segment/segment.cpp
index ca7db7148b7..90bddf887f6 100644
--- a/be/src/storage/segment/segment.cpp
+++ b/be/src/storage/segment/segment.cpp
@@ -118,6 +118,12 @@ Status build_segment_zonemap_context(Segment* segment, 
const Schema& schema,
             continue;
         }
         const auto column_id = schema.column_id(cast_set<size_t>(slot_index));
+        // Single-version rowsets substitute the version after reading in 4.2. 
Its on-disk zone
+        // map holds only a placeholder, so it cannot prove that the 
expression will not match.
+        if (read_options.version.first == read_options.version.second &&
+            cast_set<int32_t>(column_id) == schema.version_col_idx()) {
+            continue;
+        }
         const auto* tablet_column = schema.column(column_id);
         DORIS_CHECK(tablet_column != nullptr);
         if (!segment->can_apply_predicate_safely(
diff --git a/be/src/storage/segment/segment_iterator.cpp 
b/be/src/storage/segment/segment_iterator.cpp
index 2ec15cb8533..6213aa265c4 100644
--- a/be/src/storage/segment/segment_iterator.cpp
+++ b/be/src/storage/segment/segment_iterator.cpp
@@ -3723,6 +3723,12 @@ Status 
SegmentIterator::_apply_expr_zonemap_to_row_ranges(const VExprContextSPtr
             continue;
         }
         const auto cid = _schema->column_id(cast_set<size_t>(slot_index));
+        // Match the segment-level exclusion: the version is substituted after 
the storage read,
+        // while page zone maps still describe the placeholder stored on disk.
+        if (_opts.version.first == _opts.version.second &&
+            cast_set<int32_t>(cid) == _schema->version_col_idx()) {
+            continue;
+        }
         if (!_segment->can_apply_predicate_safely(cid, *_schema,
                                                   
_opts.target_cast_type_for_variants, _opts)) {
             continue;
diff --git a/be/test/exprs/expr_zonemap_filter_test.cpp 
b/be/test/exprs/expr_zonemap_filter_test.cpp
index 03b5159262b..1e3909d1308 100644
--- a/be/test/exprs/expr_zonemap_filter_test.cpp
+++ b/be/test/exprs/expr_zonemap_filter_test.cpp
@@ -33,13 +33,17 @@
 #include "common/object_pool.h"
 #include "core/column/column_vector.h"
 #include "core/data_type/data_type_array.h"
+#include "core/data_type/data_type_bitmap.h"
 #include "core/data_type/data_type_date_or_datetime_v2.h"
 #include "core/data_type/data_type_decimal.h"
+#include "core/data_type/data_type_factory.hpp"
+#include "core/data_type/data_type_jsonb.h"
 #include "core/data_type/data_type_nullable.h"
 #include "core/data_type/data_type_number.h"
 #include "core/data_type/data_type_string.h"
 #include "core/data_type/data_type_struct.h"
 #include "core/data_type/data_type_varbinary.h"
+#include "core/data_type/data_type_variant.h"
 #include "core/field.h"
 #include "core/string_ref.h"
 #include "core/value/vdatetime_value.h"
@@ -142,6 +146,23 @@ ZoneMapEvalContext make_context(segment_v2::ZoneMap 
zone_map, const DataTypePtr&
     return ctx;
 }
 
+// Two slots in one context, which is what the segment-level and Parquet 
row-group-level callers
+// build for a compound expression. Slot 0 is the left operand, slot 1 the 
right.
+ZoneMapEvalContext make_two_slot_context(segment_v2::ZoneMap left, 
segment_v2::ZoneMap right,
+                                         const DataTypePtr& left_type,
+                                         const DataTypePtr& right_type) {
+    ZoneMapEvalContext ctx;
+    ZoneMapEvalContext::SlotZoneMap left_slot;
+    left_slot.data_type = left_type;
+    left_slot.zone_map = 
std::make_shared<segment_v2::ZoneMap>(std::move(left));
+    ctx.slots.emplace(0, std::move(left_slot));
+    ZoneMapEvalContext::SlotZoneMap right_slot;
+    right_slot.data_type = right_type;
+    right_slot.zone_map = 
std::make_shared<segment_v2::ZoneMap>(std::move(right));
+    ctx.slots.emplace(1, std::move(right_slot));
+    return ctx;
+}
+
 DictionaryEvalContext make_dictionary_context(std::vector<Field> values,
                                               const DataTypePtr& data_type) {
     DictionaryEvalContext ctx;
@@ -308,6 +329,28 @@ private:
     std::string _expr_name = "unsupported_single_slot_expr";
 };
 
+// A stand-in for a binary comparison predicate node (`a < b`, `a = 5`, ...).
+// contains_slot_slot_comparison inspects only children(), so the concrete 
node type does not matter:
+// a comparison's two operands are its children.
+class BinaryLeafExpr final : public VExpr {
+public:
+    BinaryLeafExpr(VExprSPtr left, VExprSPtr right)
+            : VExpr(std::make_shared<DataTypeUInt8>(), false) {
+        add_child(std::move(left));
+        add_child(std::move(right));
+    }
+
+    const std::string& expr_name() const override { return _expr_name; }
+
+    Status execute_column_impl(VExprContext*, const Block*, const Selector*, 
size_t,
+                               ColumnPtr&) const override {
+        return Status::InternalError("BinaryLeafExpr is only used by zonemap 
tests");
+    }
+
+private:
+    std::string _expr_name = "binary_leaf_expr";
+};
+
 VExprSPtr make_fixed_zonemap_expr(ZoneMapFilterResult result) {
     return std::make_shared<FixedZonemapExpr>(result);
 }
@@ -1478,4 +1521,426 @@ TEST(ExprZonemapFilterTest, 
ExprContextZonemapEvaluationShortCircuitsOnNoMatch)
     EXPECT_EQ(0, ctx.stats.unusable_zonemap_eval_count);
 }
 
+TEST(ExprZonemapFilterTest, 
SlotSlotNotEqualsPrunesOnlyWhenBothCollapseToOneValue) {
+    auto type = int_type();
+    auto left = make_slot(0, type);
+    auto right = make_slot(1, type);
+    FunctionComparison<NotEqualsOp, NameNotEquals> not_equals;
+
+    // Both columns hold the single value 5, so `a != b` is false on every row.
+    auto same_point =
+            make_two_slot_context(make_int_zonemap(5, 5), make_int_zonemap(5, 
5), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              not_equals.evaluate_zonemap_filter(same_point, {left, right}));
+
+    // Two different single values: `a != b` is true everywhere, so nothing 
can be pruned.
+    auto other_point =
+            make_two_slot_context(make_int_zonemap(5, 5), make_int_zonemap(6, 
6), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              not_equals.evaluate_zonemap_filter(other_point, {left, right}));
+
+    // A non-degenerate range on either side leaves the outcome unknown.
+    auto left_range =
+            make_two_slot_context(make_int_zonemap(5, 6), make_int_zonemap(5, 
5), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              not_equals.evaluate_zonemap_filter(left_range, {left, right}));
+    auto right_range =
+            make_two_slot_context(make_int_zonemap(5, 5), make_int_zonemap(5, 
6), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              not_equals.evaluate_zonemap_filter(right_range, {left, right}));
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotCoversAllSixOperatorsAtTheBoundary) {
+    auto type = int_type();
+    auto left = make_slot(0, type);
+    auto right = make_slot(1, type);
+    // Left entirely above right, ranges touching at 10: [10, 20] vs [1, 10].
+    auto ctx = make_two_slot_context(make_int_zonemap(10, 20), 
make_int_zonemap(1, 10), type, type);
+
+    // a < b needs min_a >= max_b, which holds at the touching bound.
+    FunctionComparison<LessOp, NameLess> less;
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch, less.evaluate_zonemap_filter(ctx, 
{left, right}));
+    // a <= b needs min_a > max_b, which does not hold: a == b == 10 is 
possible.
+    FunctionComparison<LessOrEqualsOp, NameLessOrEquals> less_equals;
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              less_equals.evaluate_zonemap_filter(ctx, {left, right}));
+    FunctionComparison<GreaterOp, NameGreater> greater;
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch, 
greater.evaluate_zonemap_filter(ctx, {left, right}));
+    FunctionComparison<EqualsOp, NameEquals> equals;
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch, 
equals.evaluate_zonemap_filter(ctx, {left, right}));
+
+    // Fully disjoint: equality becomes impossible and so does a <= b.
+    auto disjoint =
+            make_two_slot_context(make_int_zonemap(10, 20), 
make_int_zonemap(1, 9), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              equals.evaluate_zonemap_filter(disjoint, {left, right}));
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              less_equals.evaluate_zonemap_filter(disjoint, {left, right}));
+
+    // Mirrored, so a > b and a >= b become impossible.
+    auto mirrored =
+            make_two_slot_context(make_int_zonemap(1, 9), make_int_zonemap(10, 
20), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              greater.evaluate_zonemap_filter(mirrored, {left, right}));
+    FunctionComparison<GreaterOrEqualsOp, NameGreaterOrEquals> greater_equals;
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              greater_equals.evaluate_zonemap_filter(mirrored, {left, right}));
+
+    // Partially overlapping ranges: every operator must keep the zone. These 
are the cases that
+    // pin down which end of each range the rule reads. Taking max_a instead 
of min_a for a < b, or
+    // max_b instead of min_b for a > b, still prunes here and would drop the 
overlapping rows.
+    // [10, 20] vs [5, 15] overlap on [10, 15]: a = 12, b = 14 satisfies a < b 
and a <= b.
+    auto left_high =
+            make_two_slot_context(make_int_zonemap(10, 20), 
make_int_zonemap(5, 15), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              less.evaluate_zonemap_filter(left_high, {left, right}));
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              less_equals.evaluate_zonemap_filter(left_high, {left, right}));
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              equals.evaluate_zonemap_filter(left_high, {left, right}));
+    // [5, 15] vs [10, 20]: a = 14, b = 12 satisfies a > b and a >= b.
+    auto right_high =
+            make_two_slot_context(make_int_zonemap(5, 15), 
make_int_zonemap(10, 20), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              greater.evaluate_zonemap_filter(right_high, {left, right}));
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              greater_equals.evaluate_zonemap_filter(right_high, {left, 
right}));
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              equals.evaluate_zonemap_filter(right_high, {left, right}));
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotGuardsCoverNullAndMissingStatistics) {
+    auto type = int_type();
+    auto left = make_slot(0, type);
+    auto right = make_slot(1, type);
+    FunctionComparison<LessOp, NameLess> less;
+
+    // A column with no non-null value makes the comparison NULL on every row. 
Production builds
+    // such a zone map with default-constructed min/max, which carry TYPE_NULL 
rather than the
+    // column type. That is why the has_not_null check has to run before the 
range check: the range
+    // check asserts the bounds match the slot type and would fatal on 
TYPE_NULL.
+    segment_v2::ZoneMap all_null_zonemap;
+    all_null_zonemap.has_null = true;
+    all_null_zonemap.has_not_null = false;
+    auto left_all_null =
+            make_two_slot_context(all_null_zonemap, make_int_zonemap(1, 9), 
type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              less.evaluate_zonemap_filter(left_all_null, {left, right}));
+    EXPECT_EQ(0, left_all_null.stats.unusable_zonemap_eval_count);
+    // The same on the right operand: both halves of the check are 
load-bearing.
+    auto right_all_null =
+            make_two_slot_context(make_int_zonemap(1, 9), all_null_zonemap, 
type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              less.evaluate_zonemap_filter(right_all_null, {left, right}));
+    EXPECT_EQ(0, right_all_null.stats.unusable_zonemap_eval_count);
+
+    // The right slot is absent from the context entirely.
+    auto missing_slot = make_context(make_int_zonemap(1, 9), type);
+    EXPECT_EQ(ZoneMapFilterResult::kUnsupported,
+              less.evaluate_zonemap_filter(missing_slot, {left, right}));
+    EXPECT_EQ(1, missing_slot.stats.unusable_zonemap_eval_count);
+
+    // The right slot is present with a type but without a zone map.
+    auto missing_zonemap = make_context(make_int_zonemap(1, 9), type);
+    ZoneMapEvalContext::SlotZoneMap slot_without_zonemap;
+    slot_without_zonemap.data_type = type;
+    missing_zonemap.slots.emplace(1, std::move(slot_without_zonemap));
+    EXPECT_EQ(ZoneMapFilterResult::kUnsupported,
+              less.evaluate_zonemap_filter(missing_zonemap, {left, right}));
+    EXPECT_EQ(1, missing_zonemap.stats.unusable_zonemap_eval_count);
+
+    // pass_all marks the bounds as unusable even though has_not_null is set.
+    auto pass_all_zonemap = make_int_zonemap(1, 9);
+    pass_all_zonemap.pass_all = true;
+    auto pass_all = make_two_slot_context(make_int_zonemap(10, 20), 
std::move(pass_all_zonemap),
+                                          type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kUnsupported,
+              less.evaluate_zonemap_filter(pass_all, {left, right}));
+    EXPECT_EQ(1, pass_all.stats.unusable_zonemap_eval_count);
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotHandlesTheSameSlotOnBothSides) {
+    // FE's SimplifySelfComparison rewrites `a != a` (parsed as Not(EqualTo(a, 
a))) by folding the
+    // inner equality, so it does not normally reach the BE as a slot-vs-slot 
NotEqualTo. This pins
+    // the conservative fallback if such a shape still reaches the evaluator: 
the generic point-range
+    // rule proves kNoMatch when a is a single value, and a range stays 
kMayMatch instead of being
+    // special-cased as self-comparison.
+    auto type = int_type();
+    auto slot = make_slot(0, type);
+    FunctionComparison<NotEqualsOp, NameNotEquals> not_equals;
+
+    auto single_value = make_context(make_int_zonemap(7, 7), type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              not_equals.evaluate_zonemap_filter(single_value, {slot, slot}));
+    auto range = make_context(make_int_zonemap(7, 8), type);
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              not_equals.evaluate_zonemap_filter(range, {slot, slot}));
+}
+
+TEST(ExprZonemapFilterTest, ContainsSlotSlotComparisonFindsSlotPairsAnywhere) {
+    auto type = int_type();
+    auto slot_a = make_slot(0, type);
+    auto slot_b = make_slot(1, type);
+
+    EXPECT_FALSE(expr_zonemap::contains_slot_slot_comparison(nullptr));
+
+    // `a < b`: two distinct slots at the root.
+    EXPECT_TRUE(expr_zonemap::contains_slot_slot_comparison(
+            std::make_shared<BinaryLeafExpr>(slot_a, slot_b)));
+
+    // `a < a`: same column on both sides. This is the case a distinct-column 
count misses -- one
+    // column id, still a slot-vs-slot leaf -- and the regression this gate 
has to catch.
+    EXPECT_TRUE(expr_zonemap::contains_slot_slot_comparison(
+            std::make_shared<BinaryLeafExpr>(slot_a, slot_a)));
+
+    // `a < 5`: slot versus literal is not a slot-vs-slot comparison.
+    EXPECT_FALSE(expr_zonemap::contains_slot_slot_comparison(
+            std::make_shared<BinaryLeafExpr>(slot_a, make_int_literal(5))));
+
+    // `a < 5 AND c < d`: the slot pair is nested on the second child of the 
AND.
+    auto slot_c = make_slot(2, type);
+    auto slot_d = make_slot(3, type);
+    auto and_pred =
+            
std::make_shared<VCompoundPred>(make_compound_node(TExprOpcode::COMPOUND_AND, 
2));
+    and_pred->add_child(std::make_shared<BinaryLeafExpr>(slot_a, 
make_int_literal(5)));
+    and_pred->add_child(std::make_shared<BinaryLeafExpr>(slot_c, slot_d));
+    EXPECT_TRUE(expr_zonemap::contains_slot_slot_comparison(and_pred));
+
+    // `a < 5 OR b < 6`: only slot-vs-literal leaves, so no slot pair anywhere 
in the tree.
+    auto or_pred = 
std::make_shared<VCompoundPred>(make_compound_node(TExprOpcode::COMPOUND_OR, 
2));
+    or_pred->add_child(std::make_shared<BinaryLeafExpr>(slot_a, 
make_int_literal(5)));
+    or_pred->add_child(std::make_shared<BinaryLeafExpr>(slot_b, 
make_int_literal(6)));
+    EXPECT_FALSE(expr_zonemap::contains_slot_slot_comparison(or_pred));
+}
+
+TEST(ExprZonemapFilterTest, 
SlotSlotWidensOnlyTheZonemapGateNotDictionaryOrBloom) {
+    // This is the regression guard for the trap in this change: 
comparison_zonemap_detail's
+    // can_evaluate is shared by dictionary filtering and 
can_evaluate_equality, and both dereference
+    // extract_slot_and_literal behind a DORIS_CHECK. Widening it would abort 
on `a != b`, so the
+    // slot-vs-slot shape must only ever be accepted by 
can_evaluate_zonemap_filter.
+    auto type = int_type();
+    auto left = make_slot(0, type);
+    auto right = make_slot(1, type);
+    FunctionComparison<NotEqualsOp, NameNotEquals> not_equals;
+
+    EXPECT_TRUE(not_equals.can_evaluate_zonemap_filter({left, right}));
+    EXPECT_FALSE(not_equals.can_evaluate_dictionary_filter({left, right}));
+
+    // The bloom gate has to be checked through EQ. can_evaluate_bloom_filter 
starts with
+    // `op == Op::EQ`, so asserting it on NE would pass no matter what the 
rest of the gate does.
+    FunctionComparison<EqualsOp, NameEquals> equals;
+    EXPECT_TRUE(equals.can_evaluate_zonemap_filter({left, right}));
+    EXPECT_FALSE(equals.can_evaluate_bloom_filter({left, right}));
+    EXPECT_TRUE(equals.can_evaluate_bloom_filter({left, make_int_literal(1)}));
+
+    // Slot vs literal keeps working on every gate.
+    EXPECT_TRUE(not_equals.can_evaluate_zonemap_filter({left, 
make_int_literal(1)}));
+    EXPECT_TRUE(not_equals.can_evaluate_dictionary_filter({left, 
make_int_literal(1)}));
+
+    // Two columns of incompatible types are rejected: the two Fields would be 
compared directly and
+    // Field comparison throws on mismatched non-string types.
+    auto bigint = std::make_shared<DataTypeInt64>();
+    EXPECT_FALSE(not_equals.can_evaluate_zonemap_filter({left, make_slot(1, 
bigint)}));
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotGateRejectsVarbinaryButAllowsStrings) {
+    // VARBINARY is rejected at the gate: the v1 Parquet path produces a 
TYPE_STRING bound for a
+    // VARBINARY slot, so evaluating it would abort in 
fetch_compatible_slot_type instead of falling
+    // back. String and int pairs are admitted; the string bound-length safety 
is in evaluation.
+    auto string_type = std::make_shared<DataTypeString>();
+    auto varbinary_type = std::make_shared<DataTypeVarbinary>();
+    FunctionComparison<GreaterOp, NameGreater> greater;
+
+    EXPECT_FALSE(greater.can_evaluate_zonemap_filter(
+            {make_slot(0, varbinary_type), make_slot(1, varbinary_type)}));
+
+    EXPECT_TRUE(greater.can_evaluate_zonemap_filter(
+            {make_slot(0, string_type), make_slot(1, string_type)}));
+    auto type = int_type();
+    EXPECT_TRUE(greater.can_evaluate_zonemap_filter({make_slot(0, type), 
make_slot(1, type)}));
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotGateRejectsVariantAndUnorderedTypes) {
+    // A segment types a VARIANT path slot by its inferred leaf type, so 
admitting a VARIANT pair
+    // would fail fetch_compatible_slot_type's check. Complex, JSONB and 
aggregate-state Fields all
+    // compare equal, so their bounds prove nothing. Each is rejected, 
nullable or not.
+    FunctionComparison<EqualsOp, NameEquals> equals;
+    const std::vector<DataTypePtr> excluded_types = {
+            std::make_shared<DataTypeVariant>(),
+            std::make_shared<DataTypeJsonb>(),
+            std::make_shared<DataTypeArray>(make_nullable(int_type())),
+            std::make_shared<DataTypeBitMap>(),
+    };
+    for (const auto& excluded : excluded_types) {
+        for (const auto& type : {excluded, make_nullable(excluded)}) {
+            EXPECT_FALSE(
+                    equals.can_evaluate_zonemap_filter({make_slot(0, type), 
make_slot(1, type)}))
+                    << type->get_name();
+        }
+    }
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotStringPrunesUntruncatedBoundsOnly) {
+    // A native string zone-map max of exactly 512 bytes was bumped upward and 
may also have been
+    // truncated, so it may not be a real upper bound. A shorter bound is the 
exact value.
+    // left in ["a","c"], right pinned above "c", so `left > right` cannot 
hold when the bounds
+    // are trusted.
+    auto string_type = std::make_shared<DataTypeString>();
+    auto left = make_slot(0, string_type);
+    auto right = make_slot(1, string_type);
+    FunctionComparison<GreaterOp, NameGreater> greater;
+
+    auto eval = [&](const std::string& r) {
+        auto ctx = make_two_slot_context(make_string_zonemap("a", "c"), 
make_string_zonemap(r, r),
+                                         string_type, string_type);
+        return greater.evaluate_zonemap_filter(ctx, {left, right});
+    };
+
+    // Short and 511-byte bounds are untruncated, so pruning stands.
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch, eval("m"));
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch, eval(std::string(511, 'x')));
+    // A 512-byte bound may be truncated/bumped, and a longer one is out of 
spec: fall back.
+    EXPECT_EQ(ZoneMapFilterResult::kUnsupported, eval(std::string(512, 'x')));
+    EXPECT_EQ(ZoneMapFilterResult::kUnsupported, eval(std::string(513, 'x')));
+    // Before the carry fix the writer bumped only the last byte, so a 0xff 
there wrapped to 0x00
+    // and left a 512-byte bound ending in '\0' below the data; still length 
512, still unusable.
+    std::string wrapped(511, 'x');
+    wrapped.push_back('\0');
+    EXPECT_EQ(ZoneMapFilterResult::kUnsupported, eval(wrapped));
+
+    // An embedded NUL counts toward .size(); a short bound with one stays 
usable.
+    std::string with_nul("a\0b", 3);
+    auto ctx_nul = make_two_slot_context(make_string_zonemap(with_nul, 
with_nul),
+                                         make_string_zonemap("m", "z"), 
string_type, string_type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              greater.evaluate_zonemap_filter(ctx_nul, {left, right}));
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotBailsOutWhenAFloatingNanCountIsUnknown) {
+    // Parquet bounds omit NaN without reporting how many were skipped, so a 
zone map that looks like
+    // a single point may still hide one. That would flip the NE rule from 
false to true, so the
+    // slot-vs-slot path refuses to prune for every operator once either side 
is marked unknown.
+    auto type = std::make_shared<DataTypeFloat64>();
+    auto left = make_slot(0, type);
+    auto right = make_slot(1, type);
+
+    auto make_double_zonemap = [](double value) {
+        segment_v2::ZoneMap zone_map;
+        zone_map.min_value = Field::create_field<TYPE_DOUBLE>(value);
+        zone_map.max_value = Field::create_field<TYPE_DOUBLE>(value);
+        zone_map.has_not_null = true;
+        return zone_map;
+    };
+
+    // Control group. Both sides are the same single value, so NE prunes 
without the guard; the
+    // contrast with the loop below is what shows the guard is what changes 
the outcome. The counter
+    // alone cannot show that, since every bail-out path in evaluate_slot_slot 
bumps the same one.
+    FunctionComparison<NotEqualsOp, NameNotEquals> not_equals;
+    auto known =
+            make_two_slot_context(make_double_zonemap(1.0), 
make_double_zonemap(1.0), type, type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              not_equals.evaluate_zonemap_filter(known, {left, right}));
+    EXPECT_EQ(0, known.stats.unusable_zonemap_eval_count);
+
+    auto expect_bails_out = [&](auto&& comparison, const char* predicate) {
+        for (int unknown_slot : {0, 1}) {
+            auto ctx = make_two_slot_context(make_double_zonemap(1.0), 
make_double_zonemap(1.0),
+                                             type, type);
+            ctx.slots[unknown_slot].floating_nan_count_unknown = true;
+            EXPECT_EQ(ZoneMapFilterResult::kUnsupported,
+                      comparison.evaluate_zonemap_filter(ctx, {left, right}))
+                    << predicate << " must stop pruning when slot " << 
unknown_slot
+                    << " has an unknown NaN count";
+            EXPECT_EQ(1, ctx.stats.unusable_zonemap_eval_count);
+        }
+    };
+
+    // All six operators bail out. On these bounds NE, LT and GT would 
otherwise prune and EQ, LE
+    // and GE would otherwise keep the zone, so none of the six is vacuously 
covered.
+    expect_bails_out(FunctionComparison<EqualsOp, NameEquals> {}, "a = b");
+    expect_bails_out(FunctionComparison<NotEqualsOp, NameNotEquals> {}, "a != 
b");
+    expect_bails_out(FunctionComparison<LessOp, NameLess> {}, "a < b");
+    expect_bails_out(FunctionComparison<LessOrEqualsOp, NameLessOrEquals> {}, 
"a <= b");
+    expect_bails_out(FunctionComparison<GreaterOp, NameGreater> {}, "a > b");
+    expect_bails_out(FunctionComparison<GreaterOrEqualsOp, 
NameGreaterOrEquals> {}, "a >= b");
+}
+
+TEST(ExprZonemapFilterTest, SlotSlotComparesDecimalBoundsInOneDomain) {
+    // Decimal bounds are unscaled payloads, so the rule is only meaningful 
when both sides share a
+    // scale. The v1 Parquet reader decodes row-group bounds in the file's own 
logical type while
+    // _type_matches compares only primitive types, so it now leaves the zone 
map out when the file
+    // and table types differ; this pins the behaviour the rule needs when 
they agree.
+    auto decimal_type = 
DataTypeFactory::instance().create_data_type(TYPE_DECIMAL64, false, 18, 2);
+    auto left = make_slot(0, decimal_type);
+    auto right = make_slot(1, decimal_type);
+    FunctionComparison<LessOp, NameLess> less;
+
+    auto make_decimal_zonemap = [](int64_t unscaled_min, int64_t unscaled_max) 
{
+        segment_v2::ZoneMap zone_map;
+        zone_map.min_value = 
Field::create_field<TYPE_DECIMAL64>(Decimal64(unscaled_min));
+        zone_map.max_value = 
Field::create_field<TYPE_DECIMAL64>(Decimal64(unscaled_max));
+        zone_map.has_not_null = true;
+        return zone_map;
+    };
+
+    // 10.00 .. 20.00 against 0.50 .. 1.00: a < b cannot hold.
+    auto separated =
+            make_two_slot_context(make_decimal_zonemap(1000, 2000), 
make_decimal_zonemap(50, 100),
+                                  decimal_type, decimal_type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch,
+              less.evaluate_zonemap_filter(separated, {left, right}));
+
+    // Touching at 10.00, which is what separates `lmin >= rmax` from `lmin > 
rmax`: a < b still
+    // cannot hold, so LT must prune while LE must not.
+    auto touching =
+            make_two_slot_context(make_decimal_zonemap(1000, 2000), 
make_decimal_zonemap(500, 1000),
+                                  decimal_type, decimal_type);
+    EXPECT_EQ(ZoneMapFilterResult::kNoMatch, 
less.evaluate_zonemap_filter(touching, {left, right}));
+    FunctionComparison<LessOrEqualsOp, NameLessOrEquals> less_equals;
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              less_equals.evaluate_zonemap_filter(touching, {left, right}));
+
+    // Overlapping in the same scale: nothing can be concluded.
+    auto overlapping =
+            make_two_slot_context(make_decimal_zonemap(1000, 2000),
+                                  make_decimal_zonemap(1500, 2500), 
decimal_type, decimal_type);
+    EXPECT_EQ(ZoneMapFilterResult::kMayMatch,
+              less.evaluate_zonemap_filter(overlapping, {left, right}));
+}
+
+TEST(ExprZonemapFilterTest, ParquetSlotZoneMapMarksFloatingNanCountUnknown) {
+    // The rule used to be spelled out in format_v2's add_slot_zonemap only, 
and the v1 Parquet
+    // reader assigned data_type directly and so left the flag false. Two 
double columns whose
+    // Parquet bounds both read [1.0, 1.0] then got pruned by `a != b` even 
when one of them hid a
+    // NaN. Both readers now go through this setter, so this test pins the 
rule down in one place.
+    ZoneMapEvalContext::SlotZoneMap double_slot;
+    
double_slot.set_data_type_from_parquet(std::make_shared<DataTypeFloat64>());
+    EXPECT_TRUE(double_slot.floating_nan_count_unknown);
+
+    ZoneMapEvalContext::SlotZoneMap float_slot;
+    float_slot.set_data_type_from_parquet(std::make_shared<DataTypeFloat32>());
+    EXPECT_TRUE(float_slot.floating_nan_count_unknown);
+
+    // Nullable wrappers must not hide the floating type.
+    ZoneMapEvalContext::SlotZoneMap nullable_double_slot;
+    nullable_double_slot.set_data_type_from_parquet(
+            
std::make_shared<DataTypeNullable>(std::make_shared<DataTypeFloat64>()));
+    EXPECT_TRUE(nullable_double_slot.floating_nan_count_unknown);
+
+    // Non-floating columns keep exact bounds, so they must stay prunable.
+    ZoneMapEvalContext::SlotZoneMap int_slot;
+    auto int_data_type = int_type();
+    int_slot.set_data_type_from_parquet(int_data_type);
+    EXPECT_FALSE(int_slot.floating_nan_count_unknown);
+    EXPECT_EQ(int_data_type, int_slot.data_type);
+
+    // A slot with no type at all is copied through as before rather than 
dereferenced. The type is
+    // null only for SlotDescriptor's test-only default constructor, and the 
evaluator treats a slot
+    // without a type as unusable anyway.
+    ZoneMapEvalContext::SlotZoneMap untyped_slot;
+    untyped_slot.set_data_type_from_parquet(nullptr);
+    EXPECT_EQ(nullptr, untyped_slot.data_type);
+    EXPECT_FALSE(untyped_slot.floating_nan_count_unknown);
+}
+
 } // namespace doris
diff --git 
a/regression-test/data/query_p0/expr_zonemap/test_expr_zonemap_pruning.out 
b/regression-test/data/query_p0/expr_zonemap/test_expr_zonemap_pruning.out
new file mode 100644
index 00000000000..9a0e3e3a4fb
--- /dev/null
+++ b/regression-test/data/query_p0/expr_zonemap/test_expr_zonemap_pruning.out
@@ -0,0 +1,124 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !matched_rows --
+4096
+
+-- !starts_with_pruned --
+0
+
+-- !in_pruned --
+0
+
+-- !is_null_pruned --
+0
+
+-- !comparison_pruned --
+0
+
+-- !page_reachability --
+4096
+
+-- !inferred_prefix_range --
+4096
+
+-- !inferred_date_range --
+4096
+
+-- !inferred_date_format --
+4096
+
+-- !inferred_date_format_equality --
+4096
+
+-- !inferred_direct_scan --
+4096
+
+-- !large_quarter_period --
+8192
+
+-- !char_pruned --
+0
+
+-- !is_not_null_pruned --
+0
+
+-- !two_column_gt --
+0
+
+-- !two_column_ge --
+0
+
+-- !two_column_lt --
+0
+
+-- !two_column_le --
+0
+
+-- !two_column_eq --
+0
+
+-- !two_column_ne --
+0
+
+-- !overlap_lo_lt_alt --
+2048
+
+-- !overlap_lo_ne_alt --
+2048
+
+-- !overlap_lo_eq_alt --
+2048
+
+-- !overlap_lo_le_alt --
+4096
+
+-- !overlap_lo_gt_alt --
+0
+
+-- !overlap_lo_ge_alt_shift --
+4096
+
+-- !overlap_lo_lt_cast_hi --
+4096
+
+-- !two_column_null --
+0
+
+-- !two_column_all_null --
+0
+
+-- !per_segment_ne --
+1024
+
+-- !per_segment_eq --
+2048
+
+-- !per_segment_ne_without --
+1024
+
+-- !per_segment_eq_without --
+2048
+
+-- !literal_still_prunes --
+0
+
+-- !column_predicate --
+0
+
+-- !double_pruned --
+0
+
+-- !nan_lo_gt_hi --
+3
+
+-- !nan_lo_ne_hi --
+6
+
+-- !nan_lo_eq_hi --
+0
+
+-- !nan_lo_lt_hi --
+3
+
+-- !hidden_version_or_miss --
+2048
+
diff --git 
a/regression-test/suites/query_p0/expr_zonemap/test_expr_zonemap_pruning.groovy 
b/regression-test/suites/query_p0/expr_zonemap/test_expr_zonemap_pruning.groovy
index 7c468a0d1bf..1e280bfb45e 100644
--- 
a/regression-test/suites/query_p0/expr_zonemap/test_expr_zonemap_pruning.groovy
+++ 
b/regression-test/suites/query_p0/expr_zonemap/test_expr_zonemap_pruning.groovy
@@ -49,16 +49,11 @@ suite("test_expr_zonemap_pruning") {
     def profileAction = new ProfileAction(context)
 
     def getProfileByToken = { String token ->
-        for (int retry = 0; retry < 10; ++retry) {
-            List profileData = profileAction.getProfileList()
-            for (final def profileItem in profileData) {
-                if (profileItem["Sql Statement"].toString().contains(token)) {
-                    return profileAction.getProfile(profileItem["Profile 
ID"].toString())
-                }
-            }
-            Thread.sleep(500)
-        }
-        throw new IllegalStateException("Missing profile for token: " + token)
+        // Wait for the profile to reach COMPLETE before reading counters. A 
profile can be listed and
+        // fetchable while still aggregating across instances, so an exact 
counter (the 2 / 1 / 3
+        // filtered-segment assertions below) could otherwise read a partial 
total. getProfileBySql
+        // polls Profile Completion State and returns only once it is COMPLETE.
+        return profileAction.getProfileBySql(token)
     }
 
     def counterSum = { String profile, String counterName ->
@@ -105,40 +100,50 @@ suite("test_expr_zonemap_pruning") {
         assertTrue(counterValue > 0)
     }
 
-    def matchedRows = sql """
+    qt_matched_rows """
         SELECT COUNT(*) FROM test_expr_zonemap_pruning WHERE starts_with(v, 
'a')
     """
-    assertEquals(4096L, matchedRows[0][0] as long)
 
+    qt_starts_with_pruned """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning WHERE starts_with(v, 
'z')
+    """
     def startsWithToken = "expr_zonemap_pruning_starts_with_" + 
UUID.randomUUID().toString()
-    def startsWithPrunedRows = sql """
+    sql """
         SELECT '${startsWithToken}', COUNT(*) FROM test_expr_zonemap_pruning 
WHERE starts_with(v, 'z')
     """
-    assertEquals(0L, startsWithPrunedRows[0][1] as long)
     assertExprZonemapPruned(startsWithToken)
 
+    qt_in_pruned """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning
+        WHERE v IN ('z0', 'z1') OR starts_with(v, 'zz')
+    """
     def inToken = "expr_zonemap_pruning_in_" + UUID.randomUUID().toString()
-    def inPrunedRows = sql """
+    sql """
         SELECT '${inToken}', COUNT(*) FROM test_expr_zonemap_pruning
         WHERE v IN ('z0', 'z1') OR starts_with(v, 'zz')
     """
-    assertEquals(0L, inPrunedRows[0][1] as long)
     assertExprZonemapPruned(inToken)
 
+    qt_is_null_pruned """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning
+        WHERE v IS NULL OR starts_with(v, 'zz')
+    """
     def nullToken = "expr_zonemap_pruning_is_null_" + 
UUID.randomUUID().toString()
-    def nullPrunedRows = sql """
+    sql """
         SELECT '${nullToken}', COUNT(*) FROM test_expr_zonemap_pruning
         WHERE v IS NULL OR starts_with(v, 'zz')
     """
-    assertEquals(0L, nullPrunedRows[0][1] as long)
     assertExprZonemapPruned(nullToken)
 
+    qt_comparison_pruned """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning
+        WHERE id > 5000 OR id < 0
+    """
     def comparisonToken = "expr_zonemap_pruning_comparison_" + 
UUID.randomUUID().toString()
-    def comparisonPrunedRows = sql """
+    sql """
         SELECT '${comparisonToken}', COUNT(*) FROM test_expr_zonemap_pruning
         WHERE id > 5000 OR id < 0
     """
-    assertEquals(0L, comparisonPrunedRows[0][1] as long)
     assertExprZonemapPruned(comparisonToken)
 
     sql """ DROP TABLE IF EXISTS test_expr_zonemap_page_reachability """
@@ -166,12 +171,15 @@ suite("test_expr_zonemap_pruning") {
     """
     sql """ sync """
 
+    qt_page_reachability """
+        SELECT COUNT(*) FROM test_expr_zonemap_page_reachability
+        WHERE starts_with(v, 'm_')
+    """
     def pageReachabilityToken = "expr_zonemap_pruning_page_reachability_" + 
UUID.randomUUID().toString()
-    def pageReachabilityRows = sql """
+    sql """
         SELECT '${pageReachabilityToken}', COUNT(*) FROM 
test_expr_zonemap_page_reachability
         WHERE starts_with(v, 'm_')
     """
-    assertEquals(4096L, pageReachabilityRows[0][1] as long)
     assertProfileCounterPositive(pageReachabilityToken, 
"ExprZoneMapFilteredPages")
 
     sql """ DROP TABLE IF EXISTS test_monotonic_zonemap """
@@ -221,37 +229,49 @@ suite("test_expr_zonemap_pruning") {
         contains "pushAggOp=COUNT_ON_INDEX"
     }
 
+    qt_inferred_prefix_range """
+        SELECT COUNT(*) FROM test_monotonic_zonemap
+        WHERE substring(v, 1, 1) = 'a'
+    """
     def inferredPrefixRangeToken = 
"expr_zonemap_pruning_inferred_prefix_range_" + UUID.randomUUID().toString()
-    def inferredPrefixRangeRows = sql """
+    sql """
         SELECT '${inferredPrefixRangeToken}', COUNT(*) FROM 
test_monotonic_zonemap
         WHERE substring(v, 1, 1) = 'a'
     """
-    assertEquals(4096L, inferredPrefixRangeRows[0][1] as long)
     assertProfileCounterPositive(inferredPrefixRangeToken, "RowsStatsFiltered")
 
+    qt_inferred_date_range """
+        SELECT COUNT(*) FROM test_monotonic_zonemap
+        WHERE date_trunc(dt, 'day') = '2026-07-28 00:00:00'
+    """
     def inferredDateRangeToken = "expr_zonemap_pruning_inferred_date_range_" + 
UUID.randomUUID().toString()
-    def inferredDateRangeRows = sql """
+    sql """
         SELECT '${inferredDateRangeToken}', COUNT(*) FROM 
test_monotonic_zonemap
         WHERE date_trunc(dt, 'day') = '2026-07-28 00:00:00'
     """
-    assertEquals(4096L, inferredDateRangeRows[0][1] as long)
     assertProfileCounterPositive(inferredDateRangeToken, "RowsStatsFiltered")
 
+    qt_inferred_date_format """
+        SELECT COUNT(*) FROM test_monotonic_zonemap
+        WHERE date_format(dt, '%Y-%m-%d') >= '2026-08-01'
+    """
     def inferredDateFormatToken = "expr_zonemap_pruning_inferred_date_format_" 
+ UUID.randomUUID().toString()
-    def inferredDateFormatRows = sql """
+    sql """
         SELECT '${inferredDateFormatToken}', COUNT(*) FROM 
test_monotonic_zonemap
         WHERE date_format(dt, '%Y-%m-%d') >= '2026-08-01'
     """
-    assertEquals(4096L, inferredDateFormatRows[0][1] as long)
     assertProfileCounterPositive(inferredDateFormatToken, "RowsStatsFiltered")
 
+    qt_inferred_date_format_equality """
+        SELECT COUNT(*) FROM test_monotonic_zonemap
+        WHERE date_format(dt, '%Y-%m-%d') = '2026-07-28'
+    """
     def inferredDateFormatEqualityToken =
             "expr_zonemap_pruning_inferred_date_format_equality_" + 
UUID.randomUUID().toString()
-    def inferredDateFormatEqualityRows = sql """
+    sql """
         SELECT '${inferredDateFormatEqualityToken}', COUNT(*) FROM 
test_monotonic_zonemap
         WHERE date_format(dt, '%Y-%m-%d') = '2026-07-28'
     """
-    assertEquals(4096L, inferredDateFormatEqualityRows[0][1] as long)
     assertProfileCounterPositive(inferredDateFormatEqualityToken, 
"RowsStatsFiltered")
 
     sql """ set enable_count_on_index_pushdown = false """
@@ -262,20 +282,22 @@ suite("test_expr_zonemap_pruning") {
         """
         contains "pushAggOp=NONE"
     }
+    qt_inferred_direct_scan """
+        SELECT COUNT(*) FROM test_monotonic_zonemap
+        WHERE date_trunc(dt, 'day') = '2026-07-28 00:00:00'
+    """
     def inferredDirectScanToken =
             "expr_zonemap_pruning_inferred_direct_scan_" + 
UUID.randomUUID().toString()
-    def inferredDirectScanRows = sql """
+    sql """
         SELECT '${inferredDirectScanToken}', COUNT(*) FROM 
test_monotonic_zonemap
         WHERE date_trunc(dt, 'day') = '2026-07-28 00:00:00'
     """
-    assertEquals(4096L, inferredDirectScanRows[0][1] as long)
     assertProfileCounterPositive(inferredDirectScanToken, "RowsStatsFiltered")
 
-    def largeQuarterPeriodRows = sql """
+    qt_large_quarter_period """
         SELECT COUNT(*) FROM test_monotonic_zonemap
         WHERE quarter_floor(dt, 1431655766) = '0001-01-01 00:00:00'
     """
-    assertEquals(8192L, largeQuarterPeriodRows[0][0] as long)
     sql """ set enable_count_on_index_pushdown = true """
     sql """ set enable_expr_zonemap_filter = true """
 
@@ -299,12 +321,15 @@ suite("test_expr_zonemap_pruning") {
     """
     sql """ sync """
 
+    qt_char_pruned """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_char
+        WHERE starts_with(c, 'z') OR c IN ('z0', 'z1') OR c = 'z2'
+    """
     def charToken = "expr_zonemap_pruning_char_" + UUID.randomUUID().toString()
-    def charPrunedRows = sql """
+    sql """
         SELECT '${charToken}', COUNT(*) FROM test_expr_zonemap_pruning_char
         WHERE starts_with(c, 'z') OR c IN ('z0', 'z1') OR c = 'z2'
     """
-    assertEquals(0L, charPrunedRows[0][1] as long)
     assertExprZonemapPruned(charToken)
 
     sql """ DROP TABLE IF EXISTS test_expr_zonemap_pruning_nulls """
@@ -323,11 +348,410 @@ suite("test_expr_zonemap_pruning") {
     sql """ INSERT INTO test_expr_zonemap_pruning_nulls VALUES (1, NULL), (2, 
NULL) """
     sql """ sync """
 
+    qt_is_not_null_pruned """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_nulls
+        WHERE v IS NOT NULL OR starts_with(v, 'zz')
+    """
     def isNotNullToken = "expr_zonemap_pruning_is_not_null_" + 
UUID.randomUUID().toString()
-    def isNotNullPrunedRows = sql """
+    sql """
         SELECT '${isNotNullToken}', COUNT(*) FROM 
test_expr_zonemap_pruning_nulls
         WHERE v IS NOT NULL OR starts_with(v, 'zz')
     """
-    assertEquals(0L, isNotNullPrunedRows[0][1] as long)
     assertExprZonemapPruned(isNotNullToken)
+
+    // Column-vs-column comparisons. A predicate over two columns of the same 
table never became a
+    // ColumnPredicate, so it reaches the scanner as a common expression and 
is evaluated against the
+    // segment zone map of both slots at once.
+    sql """ DROP TABLE IF EXISTS test_expr_zonemap_pruning_two_columns """
+    sql """
+        CREATE TABLE test_expr_zonemap_pruning_two_columns (
+            id INT,
+            lo INT,
+            hi INT,
+            alt INT,
+            expected INT,
+            actual INT
+        ) ENGINE=OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "disable_auto_compaction" = "true"
+        )
+    """
+    // lo lands in [0, 4095] and hi in [10000, 14095], so the two ranges are 
fully separated. alt
+    // lands in [0, 7095] and equals lo on even rows and lo + 3000 on odd 
ones, so lo vs alt cannot
+    // be decided from the bounds and every row has to be evaluated. expected 
and actual are both
+    // the constant 7.
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_two_columns
+        SELECT CAST(number AS INT),
+               CAST(number AS INT),
+               CAST(number + 10000 AS INT),
+               IF(number % 2 = 0, CAST(number AS INT), CAST(number + 3000 AS 
INT)),
+               7,
+               7
+        FROM numbers("number" = "4096")
+    """
+    sql """ sync """
+
+    // Runs the same query with expr zonemap pruning on and off and asserts 
the two agree. The
+    // counter only shows that pruning fired; this is what shows it fired 
correctly.
+    def assertSameWithAndWithoutPruning = { String predicate ->
+        sql """ set enable_expr_zonemap_filter = false """
+        def withoutPruning = sql """
+            SELECT COUNT(*) FROM test_expr_zonemap_pruning_two_columns WHERE 
${predicate}
+        """
+        sql """ set enable_expr_zonemap_filter = true """
+        def withPruning = sql """
+            SELECT COUNT(*) FROM test_expr_zonemap_pruning_two_columns WHERE 
${predicate}
+        """
+        assertEquals(withoutPruning[0][0] as long, withPruning[0][0] as long)
+        return withPruning[0][0] as long
+    }
+
+    def assertTwoColumnPruned = { String predicate, String label ->
+        def token = "expr_zonemap_pruning_two_columns_" + label + "_" + 
UUID.randomUUID().toString()
+        sql """
+            SELECT '${token}', COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns
+            WHERE ${predicate}
+        """
+        assertExprZonemapPruned(token)
+        assertSameWithAndWithoutPruning(predicate)
+    }
+
+    // lo > hi and lo >= hi: rejected because min(hi) is already above max(lo).
+    assertTwoColumnPruned("lo > hi", "gt")
+    qt_two_column_gt """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo > hi"""
+    assertTwoColumnPruned("lo >= hi", "ge")
+    qt_two_column_ge """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo >= hi"""
+    // hi < lo and hi <= lo: the mirrored rules.
+    assertTwoColumnPruned("hi < lo", "lt")
+    qt_two_column_lt """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE hi < lo"""
+    assertTwoColumnPruned("hi <= lo", "le")
+    qt_two_column_le """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE hi <= lo"""
+    // lo = hi: the ranges are disjoint, so no row can be equal.
+    assertTwoColumnPruned("lo = hi", "eq")
+    qt_two_column_eq """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo = hi"""
+    // expected != actual: both columns collapse to the single value 7, which 
is the only shape that
+    // lets != prune.
+    assertTwoColumnPruned("expected != actual", "ne")
+    qt_two_column_ne """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE expected != actual"""
+
+    // Overlapping ranges must survive, and the row counts must be exact. 
These are the cases that
+    // catch a rule reading the wrong end of a range: lo in [0, 4095] against 
alt in [0, 7095] cannot
+    // be separated by the bounds, so all of these have to fall through to 
per-row evaluation. The
+    // enabled-vs-disabled equality stays programmatic; the exact counts land 
in golden output.
+    assertSameWithAndWithoutPruning("lo < alt")
+    qt_overlap_lo_lt_alt """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo < alt"""
+    assertSameWithAndWithoutPruning("lo != alt")
+    qt_overlap_lo_ne_alt """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo != alt"""
+    assertSameWithAndWithoutPruning("lo = alt")
+    qt_overlap_lo_eq_alt """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo = alt"""
+    assertSameWithAndWithoutPruning("lo <= alt")
+    qt_overlap_lo_le_alt """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo <= alt"""
+    assertSameWithAndWithoutPruning("lo > alt")
+    qt_overlap_lo_gt_alt """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo > alt"""
+    assertSameWithAndWithoutPruning("lo >= alt - 3000")
+    qt_overlap_lo_ge_alt_shift """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo >= alt - 3000"""
+    // A cast on either side is rejected by the capability gate, so this must 
still return the right
+    // answer rather than being pruned on raw bounds.
+    assertSameWithAndWithoutPruning("lo < CAST(hi AS BIGINT)")
+    qt_overlap_lo_lt_cast_hi """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns WHERE lo < CAST(hi AS BIGINT)"""
+
+    // One side partially NULL. min/max summarize the non-null values only, 
and a NULL row makes the
+    // comparison NULL, which never satisfies the conjunct, so the separated 
ranges still prune.
+    sql """ DROP TABLE IF EXISTS test_expr_zonemap_pruning_two_columns_nulls 
"""
+    sql """
+        CREATE TABLE test_expr_zonemap_pruning_two_columns_nulls (
+            id INT,
+            lo INT,
+            hi INT
+        ) ENGINE=OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "disable_auto_compaction" = "true"
+        )
+    """
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_two_columns_nulls
+        SELECT CAST(number AS INT),
+               IF(number % 8 = 0, NULL, CAST(number AS INT)),
+               CAST(number + 10000 AS INT)
+        FROM numbers("number" = "4096")
+    """
+    sql """ sync """
+
+    qt_two_column_null """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_two_columns_nulls
+        WHERE lo > hi
+    """
+    def twoColumnNullToken =
+            "expr_zonemap_pruning_two_columns_null_" + 
UUID.randomUUID().toString()
+    sql """
+        SELECT '${twoColumnNullToken}', COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns_nulls
+        WHERE lo > hi
+    """
+    assertExprZonemapPruned(twoColumnNullToken)
+
+    // A column with no non-null value at all makes the comparison NULL on 
every row.
+    sql """ DROP TABLE IF EXISTS 
test_expr_zonemap_pruning_two_columns_all_null """
+    sql """
+        CREATE TABLE test_expr_zonemap_pruning_two_columns_all_null (
+            id INT,
+            lo INT,
+            hi INT
+        ) ENGINE=OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "disable_auto_compaction" = "true"
+        )
+    """
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_two_columns_all_null
+        SELECT CAST(number AS INT), NULL, CAST(number AS INT)
+        FROM numbers("number" = "4096")
+    """
+    sql """ sync """
+
+    qt_two_column_all_null """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_two_columns_all_null
+        WHERE lo < hi
+    """
+    def allNullToken =
+            "expr_zonemap_pruning_two_columns_all_null_" + 
UUID.randomUUID().toString()
+    sql """
+        SELECT '${allNullToken}', COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns_all_null
+        WHERE lo < hi
+    """
+    assertExprZonemapPruned(allNullToken)
+
+    // Same as assertExprZonemapPruned but returns the count, so a case can 
pin the exact number of
+    // segments that had to be dropped instead of only that pruning happened 
at all.
+    def filteredSegmentsOf = { String token ->
+        long filteredSegments = 0
+        for (int retry = 0; retry < 20; ++retry) {
+            String profile = getProfileByToken(token).toString()
+            filteredSegments = counterSum(profile, 
"ExprZoneMapFilteredSegments")
+            if (filteredSegments > 0) {
+                return filteredSegments
+            }
+            Thread.sleep(500)
+        }
+        return filteredSegments
+    }
+
+    // Three loads, one segment each, each segment holding a constant in both 
columns. This is the
+    // shape where a cross-column rule has to decide per segment rather than 
per table, so the exact
+    // number of dropped segments is the assertion that matters.
+    //
+    //   batch | a | b | a != b        | a = b
+    //   1     | a | a | both collapse | overlap, keep
+    //   2     | a | b | disjoint,keep | disjoint, drop
+    //   3     | b | b | both collapse | overlap, keep
+    sql """ DROP TABLE IF EXISTS test_expr_zonemap_pruning_per_segment """
+    sql """
+        CREATE TABLE test_expr_zonemap_pruning_per_segment (
+            id INT,
+            a VARCHAR(32),
+            b VARCHAR(32)
+        ) ENGINE=OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "disable_auto_compaction" = "true"
+        )
+    """
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_per_segment
+        SELECT CAST(number AS INT), 'a', 'a' FROM numbers("number" = "1024")
+    """
+    sql """ sync """
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_per_segment
+        SELECT CAST(number AS INT), 'a', 'b' FROM numbers("number" = "1024")
+    """
+    sql """ sync """
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_per_segment
+        SELECT CAST(number AS INT), 'b', 'b' FROM numbers("number" = "1024")
+    """
+    sql """ sync """
+
+    qt_per_segment_ne """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_per_segment WHERE a != b
+    """
+    def perSegmentNeToken = "expr_zonemap_pruning_per_segment_ne_" + 
UUID.randomUUID().toString()
+    sql """
+        SELECT '${perSegmentNeToken}', COUNT(*) FROM 
test_expr_zonemap_pruning_per_segment
+        WHERE a != b
+    """
+    assertEquals(2L, filteredSegmentsOf(perSegmentNeToken))
+
+    qt_per_segment_eq """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_per_segment WHERE a = b
+    """
+    def perSegmentEqToken = "expr_zonemap_pruning_per_segment_eq_" + 
UUID.randomUUID().toString()
+    sql """
+        SELECT '${perSegmentEqToken}', COUNT(*) FROM 
test_expr_zonemap_pruning_per_segment
+        WHERE a = b
+    """
+    assertEquals(1L, filteredSegmentsOf(perSegmentEqToken))
+
+    sql """ set enable_expr_zonemap_filter = false """
+    qt_per_segment_ne_without """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_per_segment WHERE a != b
+    """
+    qt_per_segment_eq_without """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_per_segment WHERE a = b
+    """
+    sql """ set enable_expr_zonemap_filter = true """
+
+    // The single-slot path on the same table, to show that widening the 
zonemap capability gate for
+    // two-slot shapes did not disturb the one-slot one it shares a gate with. 
It has to be a
+    // predicate that expression zone maps actually see: `a = 'zzz'` is turned 
into a ColumnPredicate
+    // and pruned by the ordinary olap path instead, which leaves 
ExprZoneMapFilteredSegments at 0.
+    qt_literal_still_prunes """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_per_segment WHERE 
starts_with(a, 'z')
+    """
+    def literalStillPrunesToken =
+            "expr_zonemap_pruning_per_segment_literal_" + 
UUID.randomUUID().toString()
+    sql """
+        SELECT '${literalStillPrunesToken}', COUNT(*) FROM 
test_expr_zonemap_pruning_per_segment
+        WHERE starts_with(a, 'z')
+    """
+    assertEquals(3L, filteredSegmentsOf(literalStillPrunesToken))
+
+    // And the ColumnPredicate shape still returns the right answer.
+    qt_column_predicate """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_per_segment WHERE a = 
'zzz'
+    """
+
+    // Two DOUBLE columns on a native table. Unlike Parquet, the segment 
writer records NaN presence
+    // in the zone map, so a float column with no NaN in it prunes normally.
+    sql """ DROP TABLE IF EXISTS test_expr_zonemap_pruning_two_columns_double 
"""
+    sql """
+        CREATE TABLE test_expr_zonemap_pruning_two_columns_double (
+            id INT,
+            lo DOUBLE,
+            hi DOUBLE
+        ) ENGINE=OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "disable_auto_compaction" = "true"
+        )
+    """
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_two_columns_double
+        SELECT CAST(number AS INT), CAST(number AS DOUBLE), CAST(number + 
10000 AS DOUBLE)
+        FROM numbers("number" = "4096")
+    """
+    sql """ sync """
+
+    qt_double_pruned """
+        SELECT COUNT(*) FROM test_expr_zonemap_pruning_two_columns_double
+        WHERE lo > hi
+    """
+    def doubleToken = "expr_zonemap_pruning_two_columns_double_" + 
UUID.randomUUID().toString()
+    sql """
+        SELECT '${doubleToken}', COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns_double
+        WHERE lo > hi
+    """
+    assertExprZonemapPruned(doubleToken)
+
+    // NaN and infinities make the bounds unusable for range pruning, so these 
cases only have to
+    // return the right rows. has_nan / has_positive_inf / has_negative_inf 
are what stops them.
+    sql """ DROP TABLE IF EXISTS test_expr_zonemap_pruning_two_columns_nan """
+    sql """
+        CREATE TABLE test_expr_zonemap_pruning_two_columns_nan (
+            id INT,
+            lo DOUBLE,
+            hi DOUBLE
+        ) ENGINE=OLAP
+        DUPLICATE KEY(id)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_allocation" = "tag.location.default: 1",
+            "disable_auto_compaction" = "true"
+        )
+    """
+    sql """
+        INSERT INTO test_expr_zonemap_pruning_two_columns_nan VALUES
+            (1, CAST('NaN' AS DOUBLE), 1.0),
+            (2, 1.0, CAST('NaN' AS DOUBLE)),
+            (3, CAST('infinity' AS DOUBLE), 1.0),
+            (4, CAST('-infinity' AS DOUBLE), 1.0),
+            (5, 1.0, 2.0),
+            (6, 2.0, 1.0)
+    """
+    sql """ sync """
+
+    def assertSameOnNanTable = { String predicate ->
+        sql """ set enable_expr_zonemap_filter = false """
+        def without = sql """
+            SELECT COUNT(*) FROM test_expr_zonemap_pruning_two_columns_nan 
WHERE ${predicate}
+        """
+        sql """ set enable_expr_zonemap_filter = true """
+        def with_ = sql """
+            SELECT COUNT(*) FROM test_expr_zonemap_pruning_two_columns_nan 
WHERE ${predicate}
+        """
+        assertEquals(without[0][0] as long, with_[0][0] as long)
+        return with_[0][0] as long
+    }
+
+    // Doris orders NaN above every other value and treats NaN = NaN as true. 
So lo > hi holds for
+    // rows 1, 3 and 6; lo < hi for rows 2, 4 and 5; and no row has the two 
columns equal. The
+    // enabled-vs-disabled equality stays programmatic; the exact counts land 
in golden output.
+    assertSameOnNanTable("lo > hi")
+    qt_nan_lo_gt_hi """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns_nan WHERE lo > hi"""
+    assertSameOnNanTable("lo != hi")
+    qt_nan_lo_ne_hi """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns_nan WHERE lo != hi"""
+    assertSameOnNanTable("lo = hi")
+    qt_nan_lo_eq_hi """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns_nan WHERE lo = hi"""
+    assertSameOnNanTable("lo < hi")
+    qt_nan_lo_lt_hi """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_two_columns_nan WHERE lo < hi"""
+
+    // __DORIS_VERSION_COL__ stores 0 on disk; the 4.2 reader substitutes the 
rowset version after
+    // reading a single-version segment. Expression zone-map pruning must not 
use the on-disk
+    // [0,0] placeholder at either segment or page level.
+    sql """ set show_hidden_columns = true """
+    sql """ DROP TABLE IF EXISTS test_expr_zonemap_pruning_hidden_col """
+    sql """
+        CREATE TABLE test_expr_zonemap_pruning_hidden_col (
+            k INT,
+            v INT
+        ) UNIQUE KEY(k)
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES (
+            "enable_unique_key_merge_on_write" = "true",
+            "replication_num" = "1",
+            "disable_auto_compaction" = "true"
+        )
+    """
+    sql """ INSERT INTO test_expr_zonemap_pruning_hidden_col SELECT number, 
number FROM numbers("number" = "2048") """
+    sql """ sync """
+    def hiddenVersionRows = sql """ SELECT DISTINCT __DORIS_VERSION_COL__ FROM 
test_expr_zonemap_pruning_hidden_col """
+    assertEquals(1, hiddenVersionRows.size())
+    long hiddenVersion = hiddenVersionRows[0][0] as long
+    assertTrue(hiddenVersion > 0)
+    def assertSameOnHiddenCol = { String predicate ->
+        sql """ set enable_expr_zonemap_filter = false """
+        def without = sql """ SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_hidden_col WHERE ${predicate} """
+        sql """ set enable_expr_zonemap_filter = true """
+        def with_ = sql """ SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_hidden_col WHERE ${predicate} """
+        assertEquals(without[0][0] as long, with_[0][0] as long)
+        return with_[0][0] as long
+    }
+    // Every row carries the version and `v = -1` matches nothing, so the 
compound is the full 2048
+    // rows. Pruning against the [0,0] placeholder would drop the segment.
+    assertSameOnHiddenCol("__DORIS_VERSION_COL__ = ${hiddenVersion} OR v = -1")
+    qt_hidden_version_or_miss """SELECT COUNT(*) FROM 
test_expr_zonemap_pruning_hidden_col WHERE __DORIS_VERSION_COL__ = 
${hiddenVersion} OR v = -1"""
+    sql """ set show_hidden_columns = false """
 }


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

Reply via email to