You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by al...@apache.org on 2022/09/26 10:32:00 UTC

[ignite] branch master updated: IGNITE-15425 SQL Calcite: Add native support for SEARCH/SARG operator - Fixes #10251.

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 20fbf9d519d IGNITE-15425 SQL Calcite: Add native support for SEARCH/SARG operator - Fixes #10251.
20fbf9d519d is described below

commit 20fbf9d519d4472b338e96241da80840ad773e86
Author: Aleksey Plekhanov <pl...@gmail.com>
AuthorDate: Mon Sep 26 13:19:31 2022 +0300

    IGNITE-15425 SQL Calcite: Add native support for SEARCH/SARG operator - Fixes #10251.
    
    Signed-off-by: Aleksey Plekhanov <pl...@gmail.com>
---
 .../query/calcite/exec/AbstractIndexScan.java      |  39 +-
 .../processors/query/calcite/exec/IndexScan.java   |  21 +-
 .../query/calcite/exec/LogicalRelImplementor.java  |  52 +--
 .../query/calcite/exec/RuntimeSortedIndex.java     |  46 ++-
 .../query/calcite/exec/SystemViewScan.java         |  18 +-
 .../processors/query/calcite/exec/TreeIndex.java   |   4 +-
 .../query/calcite/exec/exp/ExpressionFactory.java  |  13 +
 .../calcite/exec/exp/ExpressionFactoryImpl.java    | 241 +++++++++++-
 .../exp/RangeCondition.java}                       |  30 +-
 .../exp/RangeIterable.java}                        |  19 +-
 .../query/calcite/exec/rel/IndexSpoolNode.java     |   8 +-
 .../query/calcite/externalize/RelInputEx.java      |   9 +
 .../query/calcite/externalize/RelJson.java         |  62 ++-
 .../query/calcite/externalize/RelJsonReader.java   |   6 +
 .../calcite/metadata/IgniteMdSelectivity.java      |   7 +
 .../query/calcite/prepare/IgnitePrograms.java      |   2 +
 .../query/calcite/prepare/bounds/ExactBounds.java  |  70 ++++
 .../query/calcite/prepare/bounds/MultiBounds.java  |  73 ++++
 .../query/calcite/prepare/bounds/RangeBounds.java  | 113 ++++++
 .../query/calcite/prepare/bounds/SearchBounds.java |  56 +++
 .../query/calcite/rel/AbstractIndexScan.java       |  63 +--
 .../query/calcite/rel/IgniteIndexScan.java         |  14 +-
 .../query/calcite/rel/IgniteSortedIndexSpool.java  |  28 +-
 .../rel/ProjectableFilterableTableScan.java        |   6 +-
 .../rel/logical/IgniteLogicalIndexScan.java        |  12 +-
 .../rule/FilterSpoolMergeToHashIndexSpoolRule.java |   8 +-
 .../FilterSpoolMergeToSortedIndexSpoolRule.java    |  37 +-
 .../calcite/rule/LogicalScanConverterRule.java     |   2 +-
 .../calcite/rule/logical/FilterScanMergeRule.java  |  10 +-
 .../query/calcite/schema/CacheIndexImpl.java       |  16 +-
 .../query/calcite/schema/IgniteIndex.java          |   9 +-
 .../query/calcite/schema/SystemViewIndexImpl.java  |  17 +-
 .../processors/query/calcite/trait/TraitUtils.java |  12 +-
 .../query/calcite/util/IndexConditions.java        | 142 -------
 .../processors/query/calcite/util/RexUtils.java    | 397 ++++++++++++-------
 .../calcite/exec/LogicalRelImplementorTest.java    |   4 +-
 .../query/calcite/exec/RuntimeSortedIndexTest.java |   2 +-
 .../exec/rel/SortedIndexSpoolExecutionTest.java    |  52 ++-
 .../integration/AbstractBasicIntegrationTest.java  |  14 +-
 .../CalciteBasicSecondaryIndexIntegrationTest.java |   4 -
 .../integration/IndexScanlIntegrationTest.java     |   8 +-
 .../SearchSargOnIndexIntegrationTest.java          | 153 ++++++++
 .../query/calcite/planner/AbstractPlannerTest.java |   6 +-
 .../CorrelatedNestedLoopJoinPlannerTest.java       |  25 +-
 .../planner/ProjectFilterScanMergePlannerTest.java |  22 +-
 .../planner/SearchSargOnIndexPlannerTest.java      | 436 +++++++++++++++++++++
 .../planner/SortedIndexSpoolPlannerTest.java       |  76 ++--
 .../calcite/planner/StatisticsPlannerTest.java     |  28 +-
 .../query/calcite/rules/OrToUnionRuleTest.java     |  13 +-
 .../ignite/testsuites/IntegrationTestSuite.java    |   2 +
 .../apache/ignite/testsuites/PlannerTestSuite.java |   2 +
 51 files changed, 1856 insertions(+), 653 deletions(-)

diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/AbstractIndexScan.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/AbstractIndexScan.java
index ca12e493005..228680a9473 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/AbstractIndexScan.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/AbstractIndexScan.java
@@ -20,13 +20,15 @@ import java.util.Iterator;
 import java.util.NoSuchElementException;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
-
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.ignite.IgniteCheckedException;
 import org.apache.ignite.internal.cache.query.index.sorted.inline.IndexQueryContext;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeCondition;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.util.lang.GridCursor;
 import org.apache.ignite.internal.util.lang.GridIteratorAdapter;
+import org.apache.ignite.internal.util.typedef.F;
+import org.apache.ignite.lang.IgniteClosure;
 import org.jetbrains.annotations.NotNull;
 
 /**
@@ -39,11 +41,8 @@ public abstract class AbstractIndexScan<Row, IdxRow> implements Iterable<Row>, A
     /** Additional filters. */
     private final Predicate<Row> filters;
 
-    /** Lower index scan bound. */
-    private final Supplier<Row> lowerBound;
-
-    /** Upper index scan bound. */
-    private final Supplier<Row> upperBound;
+    /** Index scan bounds. */
+    private final RangeIterable<Row> ranges;
 
     /** */
     private final Function<Row, Row> rowTransformer;
@@ -58,33 +57,41 @@ public abstract class AbstractIndexScan<Row, IdxRow> implements Iterable<Row>, A
      * @param ectx Execution context.
      * @param idx Physical index.
      * @param filters Additional filters.
-     * @param lowerBound Lower index scan bound.
-     * @param upperBound Upper index scan bound.
+     * @param ranges Index scan bounds.
      */
     protected AbstractIndexScan(
         ExecutionContext<Row> ectx,
         RelDataType rowType,
         TreeIndex<IdxRow> idx,
         Predicate<Row> filters,
-        Supplier<Row> lowerBound,
-        Supplier<Row> upperBound,
+        RangeIterable<Row> ranges,
         Function<Row, Row> rowTransformer
     ) {
         this.ectx = ectx;
         this.rowType = rowType;
         this.idx = idx;
         this.filters = filters;
-        this.lowerBound = lowerBound;
-        this.upperBound = upperBound;
+        this.ranges = ranges;
         this.rowTransformer = rowTransformer;
     }
 
     /** {@inheritDoc} */
     @Override public synchronized Iterator<Row> iterator() {
-        IdxRow lower = lowerBound == null ? null : row2indexRow(lowerBound.get());
-        IdxRow upper = upperBound == null ? null : row2indexRow(upperBound.get());
+        if (ranges == null)
+            return new IteratorImpl(idx.find(null, null, true, true, indexQueryContext()));
+
+        IgniteClosure<RangeCondition<Row>, IteratorImpl> clo = range -> {
+            IdxRow lower = range.lower() == null ? null : row2indexRow(range.lower());
+            IdxRow upper = range.upper() == null ? null : row2indexRow(range.upper());
+
+            return new IteratorImpl(
+                idx.find(lower, upper, range.lowerInclude(), range.upperInclude(), indexQueryContext()));
+        };
+
+        if (ranges.size() == 1)
+            return clo.apply(ranges.iterator().next());
 
-        return new IteratorImpl(idx.find(lower, upper, indexQueryContext()));
+        return F.flat(F.iterator(ranges, clo, true));
     }
 
     /** */
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/IndexScan.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/IndexScan.java
index 8b9b2fcc731..a21308e2a44 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/IndexScan.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/IndexScan.java
@@ -23,7 +23,6 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.calcite.util.ImmutableIntList;
@@ -46,6 +45,7 @@ import org.apache.ignite.internal.processors.cache.distributed.dht.topology.Grid
 import org.apache.ignite.internal.processors.cache.distributed.dht.topology.GridDhtPartitionTopology;
 import org.apache.ignite.internal.processors.cache.mvcc.MvccSnapshot;
 import org.apache.ignite.internal.processors.query.calcite.exec.RowHandler.RowFactory;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.schema.CacheTableDescriptor;
 import org.apache.ignite.internal.processors.query.calcite.type.IgniteTypeFactory;
 import org.apache.ignite.internal.processors.query.calcite.util.TypeUtils;
@@ -100,8 +100,7 @@ public class IndexScan<Row> extends AbstractIndexScan<Row, IndexRow> {
      * @param idxFieldMapping Mapping from index keys to row fields.
      * @param idx Phisycal index.
      * @param filters Additional filters.
-     * @param lowerBound Lower index scan bound.
-     * @param upperBound Upper index scan bound.
+     * @param ranges Index scan bounds.
      */
     public IndexScan(
         ExecutionContext<Row> ectx,
@@ -110,8 +109,7 @@ public class IndexScan<Row> extends AbstractIndexScan<Row, IndexRow> {
         ImmutableIntList idxFieldMapping,
         int[] parts,
         Predicate<Row> filters,
-        Supplier<Row> lowerBound,
-        Supplier<Row> upperBound,
+        RangeIterable<Row> ranges,
         Function<Row, Row> rowTransformer,
         @Nullable ImmutableBitSet requiredColumns
     ) {
@@ -120,8 +118,7 @@ public class IndexScan<Row> extends AbstractIndexScan<Row, IndexRow> {
             desc.rowType(ectx.getTypeFactory(), requiredColumns),
             new TreeIndexWrapper(idx),
             filters,
-            lowerBound,
-            upperBound,
+            ranges,
             rowTransformer
         );
 
@@ -296,9 +293,15 @@ public class IndexScan<Row> extends AbstractIndexScan<Row, IndexRow> {
         }
 
         /** {@inheritDoc} */
-        @Override public GridCursor<IndexRow> find(IndexRow lower, IndexRow upper, IndexQueryContext qctx) {
+        @Override public GridCursor<IndexRow> find(
+            IndexRow lower,
+            IndexRow upper,
+            boolean lowerInclude,
+            boolean upperInclude,
+            IndexQueryContext qctx
+        ) {
             try {
-                return idx.find(lower, upper, true, true, qctx);
+                return idx.find(lower, upper, lowerInclude, upperInclude, qctx);
             }
             catch (IgniteCheckedException e) {
                 throw new IgniteException("Failed to find index rows", e);
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java
index 20f9def3eb3..829236d6c8b 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementor.java
@@ -40,6 +40,7 @@ import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.internal.processors.failure.FailureProcessor;
 import org.apache.ignite.internal.processors.query.calcite.exec.RowHandler.RowFactory;
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.ExpressionFactory;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AccumulatorWrapper;
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AggregateType;
 import org.apache.ignite.internal.processors.query.calcite.exec.rel.AbstractSetOpNode;
@@ -65,6 +66,7 @@ import org.apache.ignite.internal.processors.query.calcite.exec.rel.TableSpoolNo
 import org.apache.ignite.internal.processors.query.calcite.exec.rel.UnionAllNode;
 import org.apache.ignite.internal.processors.query.calcite.metadata.AffinityService;
 import org.apache.ignite.internal.processors.query.calcite.metadata.ColocationGroup;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteCollect;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteCorrelatedNestedLoopJoin;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteExchange;
@@ -295,22 +297,21 @@ public class LogicalRelImplementor<Row> implements IgniteRelVisitor<Node<Row>> {
         IgniteTypeFactory typeFactory = ctx.getTypeFactory();
 
         ImmutableBitSet requiredColumns = rel.requiredColumns();
-        List<RexNode> lowerCond = rel.lowerBound();
-        List<RexNode> upperCond = rel.upperBound();
+        List<SearchBounds> searchBounds = rel.searchBounds();
 
         RelDataType rowType = tbl.getRowType(typeFactory, requiredColumns);
 
         Predicate<Row> filters = condition == null ? null : expressionFactory.predicate(condition, rowType);
-        Supplier<Row> lower = lowerCond == null ? null : expressionFactory.rowSource(lowerCond);
-        Supplier<Row> upper = upperCond == null ? null : expressionFactory.rowSource(upperCond);
         Function<Row, Row> prj = projects == null ? null : expressionFactory.project(projects, rowType);
+        RangeIterable<Row> ranges = searchBounds == null ? null :
+            expressionFactory.ranges(searchBounds, rel.collation(), tbl.getRowType(typeFactory));
 
         ColocationGroup grp = ctx.group(rel.sourceId());
 
         IgniteIndex idx = tbl.getIndex(rel.indexName());
 
         if (idx != null && !tbl.isIndexRebuildInProgress()) {
-            Iterable<Row> rowsIter = idx.scan(ctx, grp, filters, lower, upper, prj, requiredColumns);
+            Iterable<Row> rowsIter = idx.scan(ctx, grp, filters, ranges, prj, requiredColumns);
 
             return new ScanNode<>(ctx, rowType, rowsIter);
         }
@@ -361,24 +362,15 @@ public class LogicalRelImplementor<Row> implements IgniteRelVisitor<Node<Row>> {
             }
 
             if (spoolNodeRequired) {
-                if (lowerCond != null || upperCond != null) {
-                    if (requiredColumns != null) {
-                        // Remap index find predicate according to rowType of the spool.
-                        int cardinality = requiredColumns.cardinality();
-                        List<RexNode> remappedLowerCond = lowerCond != null ? new ArrayList<>(cardinality) : null;
-                        List<RexNode> remappedUpperCond = upperCond != null ? new ArrayList<>(cardinality) : null;
-
-                        for (int i = requiredColumns.nextSetBit(0); i != -1; i = requiredColumns.nextSetBit(i + 1)) {
-                            if (remappedLowerCond != null)
-                                remappedLowerCond.add(lowerCond.get(i));
-
-                            if (remappedUpperCond != null)
-                                remappedUpperCond.add(upperCond.get(i));
-                        }
-
-                        lower = remappedLowerCond == null ? null : expressionFactory.rowSource(remappedLowerCond);
-                        upper = remappedUpperCond == null ? null : expressionFactory.rowSource(remappedUpperCond);
-                    }
+                if (searchBounds != null && requiredColumns != null) {
+                    // Remap index find predicate according to rowType of the spool.
+                    List<SearchBounds> remappedSearchBounds = new ArrayList<>(requiredColumns.cardinality());
+
+                    for (int i = requiredColumns.nextSetBit(0); i != -1; i = requiredColumns.nextSetBit(i + 1))
+                        remappedSearchBounds.add(searchBounds.get(i));
+
+                    // Collation and row type are already remapped taking into account requiredColumns.
+                    ranges = expressionFactory.ranges(remappedSearchBounds, collation, rowType);
                 }
 
                 IndexSpoolNode<Row> spoolNode = IndexSpoolNode.createTreeSpool(
@@ -387,8 +379,7 @@ public class LogicalRelImplementor<Row> implements IgniteRelVisitor<Node<Row>> {
                     collation,
                     expressionFactory.comparator(collation),
                     filterHasCorrelation ? filters : null, // Not correlated filter included into table scan.
-                    lower,
-                    upper
+                    ranges
                 );
 
                 spoolNode.register(node);
@@ -515,14 +506,10 @@ public class LogicalRelImplementor<Row> implements IgniteRelVisitor<Node<Row>> {
     @Override public Node<Row> visit(IgniteSortedIndexSpool rel) {
         RelCollation collation = rel.collation();
 
-        assert rel.indexCondition() != null : rel;
-
-        List<RexNode> lowerBound = rel.indexCondition().lowerBound();
-        List<RexNode> upperBound = rel.indexCondition().upperBound();
+        assert rel.searchBounds() != null : rel;
 
         Predicate<Row> filter = expressionFactory.predicate(rel.condition(), rel.getRowType());
-        Supplier<Row> lower = lowerBound == null ? null : expressionFactory.rowSource(lowerBound);
-        Supplier<Row> upper = upperBound == null ? null : expressionFactory.rowSource(upperBound);
+        RangeIterable<Row> ranges = expressionFactory.ranges(rel.searchBounds(), collation, rel.getRowType());
 
         IndexSpoolNode<Row> node = IndexSpoolNode.createTreeSpool(
             ctx,
@@ -530,8 +517,7 @@ public class LogicalRelImplementor<Row> implements IgniteRelVisitor<Node<Row>> {
             collation,
             expressionFactory.comparator(collation),
             filter,
-            lower,
-            upper
+            ranges
         );
 
         Node<Row> input = visit(rel.getInput());
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndex.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndex.java
index 88df20afb71..e58d17f2759 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndex.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndex.java
@@ -21,10 +21,10 @@ import java.util.Comparator;
 import java.util.List;
 import java.util.Objects;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.ignite.internal.cache.query.index.sorted.inline.IndexQueryContext;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.util.lang.GridCursor;
 import org.apache.ignite.internal.util.typedef.F;
 import org.jetbrains.annotations.Nullable;
@@ -74,7 +74,13 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
     }
 
     /** {@inheritDoc} */
-    @Override public GridCursor<Row> find(Row lower, Row upper, IndexQueryContext qctx) {
+    @Override public GridCursor<Row> find(
+        Row lower,
+        Row upper,
+        boolean lowerInclude,
+        boolean upperInclude,
+        IndexQueryContext qctx
+    ) {
         assert qctx == null;
 
         int firstCol = F.first(collation.getKeys());
@@ -85,7 +91,7 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
         Row lowerRow = (lowerBound == null) ? null : lower;
         Row upperRow = (upperBound == null) ? null : upper;
 
-        return new Cursor(rows, lowerRow, upperRow);
+        return new Cursor(rows, lowerRow, upperRow, lowerInclude, upperInclude);
     }
 
     /**
@@ -95,10 +101,9 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
         ExecutionContext<Row> ectx,
         RelDataType rowType,
         Predicate<Row> filter,
-        Supplier<Row> lowerBound,
-        Supplier<Row> upperBound
+        RangeIterable<Row> ranges
     ) {
-        return new IndexScan(rowType, this, filter, lowerBound, upperBound);
+        return new IndexScan(rowType, this, filter, ranges);
     }
 
     /**
@@ -111,6 +116,9 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
         /** Upper bound. */
         private final Row upper;
 
+        /** Include upper bound. */
+        private final boolean includeUpper;
+
         /** Current row. */
         private Row row;
 
@@ -119,14 +127,17 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
 
         /**
          * @param rows List of rows.
-         * @param lower Lower bound (inclusive).
-         * @param upper Upper bound (inclusive).
+         * @param lower Lower bound.
+         * @param upper Upper bound.
+         * @param lowerInclude {@code True} for inclusive lower bound.
+         * @param upperInclude {@code True} for inclusive upper bound.
          */
-        Cursor(List<Row> rows, @Nullable Row lower, @Nullable Row upper) {
+        Cursor(List<Row> rows, @Nullable Row lower, @Nullable Row upper, boolean lowerInclude, boolean upperInclude) {
             this.rows = rows;
             this.upper = upper;
+            this.includeUpper = upperInclude;
 
-            idx = lower == null ? 0 : lowerBound(rows, lower);
+            idx = lower == null ? 0 : lowerBound(rows, lower, lowerInclude);
         }
 
         /**
@@ -136,7 +147,7 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
          * @param bound Lower bound.
          * @return Lower bound position in the list.
          */
-        private int lowerBound(List<Row> rows, Row bound) {
+        private int lowerBound(List<Row> rows, Row bound, boolean includeBound) {
             int low = 0, high = rows.size() - 1, idx = -1;
 
             while (low <= high) {
@@ -145,7 +156,7 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
 
                 if (compRes > 0)
                     high = mid - 1;
-                else if (compRes == 0) {
+                else if (compRes == 0 && includeBound) {
                     idx = mid;
                     high = mid - 1;
                 }
@@ -158,7 +169,7 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
 
         /** {@inheritDoc} */
         @Override public boolean next() {
-            if (idx == rows.size() || (upper != null && comp.compare(upper, rows.get(idx)) < 0))
+            if (idx == rows.size() || (upper != null && comp.compare(upper, rows.get(idx)) < (includeUpper ? 0 : 1)))
                 return false;
 
             row = rows.get(idx++);
@@ -180,16 +191,15 @@ public class RuntimeSortedIndex<Row> implements RuntimeIndex<Row>, TreeIndex<Row
          * @param rowType Row type.
          * @param idx Physical index.
          * @param filter Additional filters.
-         * @param lowerBound Lower index scan bound.
-         * @param upperBound Upper index scan bound.
+         * @param ranges Index scan bounds.
          */
         IndexScan(
             RelDataType rowType,
             TreeIndex<Row> idx,
             Predicate<Row> filter,
-            Supplier<Row> lowerBound,
-            Supplier<Row> upperBound) {
-            super(RuntimeSortedIndex.this.ectx, rowType, idx, filter, lowerBound, upperBound, null);
+            RangeIterable<Row> ranges
+        ) {
+            super(RuntimeSortedIndex.this.ectx, rowType, idx, filter, ranges, null);
         }
 
         /** {@inheritDoc} */
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/SystemViewScan.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/SystemViewScan.java
index 89392be37e4..2fa944ae678 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/SystemViewScan.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/SystemViewScan.java
@@ -22,10 +22,11 @@ import java.util.Iterator;
 import java.util.Map;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.internal.processors.query.calcite.exec.RowHandler.RowFactory;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeCondition;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.schema.SystemViewColumnDescriptor;
 import org.apache.ignite.internal.processors.query.calcite.schema.SystemViewTableDescriptorImpl;
 import org.apache.ignite.internal.processors.query.calcite.util.TypeUtils;
@@ -48,7 +49,7 @@ public class SystemViewScan<Row, ViewRow> implements Iterable<Row> {
     private final RowFactory<Row> factory;
 
     /** */
-    private final Supplier<Row> searchRow;
+    private final RangeIterable<Row> ranges;
 
     /** */
     private final Predicate<Row> filters;
@@ -69,14 +70,14 @@ public class SystemViewScan<Row, ViewRow> implements Iterable<Row> {
     public SystemViewScan(
         ExecutionContext<Row> ectx,
         SystemViewTableDescriptorImpl<ViewRow> desc,
-        @Nullable Supplier<Row> searchRow,
+        @Nullable RangeIterable<Row> ranges,
         Predicate<Row> filters,
         Function<Row, Row> rowTransformer,
         @Nullable ImmutableBitSet requiredColumns
     ) {
         this.ectx = ectx;
         this.desc = desc;
-        this.searchRow = searchRow;
+        this.ranges = ranges;
         this.filters = filters;
         this.rowTransformer = rowTransformer;
         this.requiredColumns = requiredColumns;
@@ -104,10 +105,15 @@ public class SystemViewScan<Row, ViewRow> implements Iterable<Row> {
 
         Iterator<ViewRow> viewIter;
 
-        if (searchRow != null) {
+        if (ranges != null) {
             assert view instanceof FiltrableSystemView : view;
 
-            Row searchValues = searchRow.get();
+            Iterator<RangeCondition<Row>> rangesIter = ranges.iterator();
+            RangeCondition<Row> range = rangesIter.next();
+
+            assert !rangesIter.hasNext();
+
+            Row searchValues = range.lower(); // Lower bound for the hash index should be the same as upper bound.
 
             RowHandler<Row> rowHnd = ectx.rowHandler();
             Map<String, Object> filterMap = null;
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TreeIndex.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TreeIndex.java
index 91cb9127249..6b3e2c47d18 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TreeIndex.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/TreeIndex.java
@@ -30,8 +30,10 @@ public interface TreeIndex<R> {
      *
      * @param lower Lower bound.
      * @param upper Upper bound.
+     * @param lowerInclude Inclusive lower bound.
+     * @param upperInclude Inclusive upper bound.
      * @param qctx Index query context.
      * @return Cursor over the rows within bounds.
      */
-    public GridCursor<R> find(R lower, R upper, IndexQueryContext qctx);
+    public GridCursor<R> find(R lower, R upper, boolean lowerInclude, boolean upperInclude, IndexQueryContext qctx);
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactory.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactory.java
index 0893229c79b..a68078f6c42 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactory.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactory.java
@@ -32,6 +32,7 @@ import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AccumulatorWrapper;
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AggregateType;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 
 /**
  * Expression factory.
@@ -105,6 +106,18 @@ public interface ExpressionFactory<Row> {
      */
     Supplier<Row> rowSource(List<RexNode> values);
 
+    /**
+     * Creates iterable search bounds tuples (lower row/upper row) by search bounds expressions.
+     *
+     * @param searchBounds Search bounds.
+     * @param collation Collation.
+     * @param rowType Row type.
+     */
+    RangeIterable<Row> ranges(
+        List<SearchBounds> searchBounds,
+        RelCollation collation,
+        RelDataType rowType
+    );
 
     /**
      * Executes expression.
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java
index 2617ae576ff..f5c3f91705b 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/ExpressionFactoryImpl.java
@@ -20,9 +20,13 @@ package org.apache.ignite.internal.processors.query.calcite.exec.exp;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.Type;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.BitSet;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
 import java.util.function.BiPredicate;
@@ -63,6 +67,10 @@ import org.apache.ignite.internal.processors.query.calcite.exec.exp.RexToLixTran
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AccumulatorWrapper;
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AccumulatorsFactory;
 import org.apache.ignite.internal.processors.query.calcite.exec.exp.agg.AggregateType;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.MultiBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.RangeBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.type.IgniteTypeFactory;
 import org.apache.ignite.internal.processors.query.calcite.util.Commons;
 import org.apache.ignite.internal.processors.query.calcite.util.IgniteMethod;
@@ -270,6 +278,121 @@ public class ExpressionFactoryImpl<Row> implements ExpressionFactory<Row> {
         return rows;
     }
 
+    /** {@inheritDoc} */
+    @Override public RangeIterable<Row> ranges(
+        List<SearchBounds> searchBounds,
+        RelCollation collation,
+        RelDataType rowType
+    ) {
+        RowFactory<Row> rowFactory = ctx.rowHandler().factory(typeFactory, rowType);
+
+        List<RangeCondition<Row>> ranges = new ArrayList<>();
+
+        expandBounds(
+            ranges,
+            searchBounds,
+            rowType,
+            rowFactory,
+            collation.getKeys(),
+            0,
+            Arrays.asList(new RexNode[searchBounds.size()]),
+            Arrays.asList(new RexNode[searchBounds.size()]),
+            true,
+            true
+        );
+
+        return new RangeIterableImpl(ranges, comparator(collation));
+    }
+
+    /**
+     * Expand column-oriented {@link SearchBounds} to a row-oriented list of ranges ({@link RangeCondition}).
+     *
+     * @param ranges List of ranges.
+     * @param searchBounds Search bounds.
+     * @param rowType Row type.
+     * @param rowFactory Row factory.
+     * @param collationKeys Collation keys.
+     * @param collationKeyIdx Current collation key index (field to process).
+     * @param curLower Current lower row.
+     * @param curUpper Current upper row.
+     * @param lowerInclude Include current lower row.
+     * @param upperInclude Include current upper row.
+     */
+    private void expandBounds(
+        List<RangeCondition<Row>> ranges,
+        List<SearchBounds> searchBounds,
+        RelDataType rowType,
+        RowFactory<Row> rowFactory,
+        List<Integer> collationKeys,
+        int collationKeyIdx,
+        List<RexNode> curLower,
+        List<RexNode> curUpper,
+        boolean lowerInclude,
+        boolean upperInclude
+    ) {
+        if ((collationKeyIdx >= collationKeys.size()) || (!lowerInclude && !upperInclude) ||
+            searchBounds.get(collationKeys.get(collationKeyIdx)) == null) {
+            ranges.add(new RangeConditionImpl(
+                scalar(curLower, rowType),
+                scalar(curUpper, rowType),
+                lowerInclude,
+                upperInclude,
+                rowFactory
+            ));
+
+            return;
+        }
+
+        int fieldIdx = collationKeys.get(collationKeyIdx);
+        SearchBounds fieldBounds = searchBounds.get(fieldIdx);
+
+        Collection<SearchBounds> fieldMultiBounds = fieldBounds instanceof MultiBounds ?
+            ((MultiBounds)fieldBounds).bounds() : Collections.singleton(fieldBounds);
+
+        for (SearchBounds fieldSingleBounds : fieldMultiBounds) {
+            RexNode fieldLowerBound;
+            RexNode fieldUpperBound;
+            boolean fieldLowerInclude;
+            boolean fieldUpperInclude;
+
+            if (fieldSingleBounds instanceof ExactBounds) {
+                fieldLowerBound = fieldUpperBound = ((ExactBounds)fieldSingleBounds).bound();
+                fieldLowerInclude = fieldUpperInclude = true;
+            }
+            else if (fieldSingleBounds instanceof RangeBounds) {
+                RangeBounds fieldRangeBounds = (RangeBounds)fieldSingleBounds;
+                fieldLowerBound = fieldRangeBounds.lowerBound();
+                fieldUpperBound = fieldRangeBounds.upperBound();
+                fieldLowerInclude = fieldRangeBounds.lowerInclude();
+                fieldUpperInclude = fieldRangeBounds.upperInclude();
+            }
+            else
+                throw new IllegalStateException("Unexpected bounds: " + fieldSingleBounds);
+
+            if (lowerInclude)
+                curLower.set(fieldIdx, fieldLowerBound);
+
+            if (upperInclude)
+                curUpper.set(fieldIdx, fieldUpperBound);
+
+            expandBounds(
+                ranges,
+                searchBounds,
+                rowType,
+                rowFactory,
+                collationKeys,
+                collationKeyIdx + 1,
+                curLower,
+                curUpper,
+                lowerInclude && fieldLowerInclude,
+                upperInclude && fieldUpperInclude
+            );
+        }
+
+        curLower.set(fieldIdx, null);
+        curLower.set(fieldIdx, null);
+    }
+
     /**
      * Creates {@link SingleScalar}, a code-generated expressions evaluator.
      *
@@ -288,7 +411,7 @@ public class ExpressionFactoryImpl<Row> implements ExpressionFactory<Row> {
      * @param type Row type.
      * @return SingleScalar.
      */
-    public SingleScalar scalar(List<RexNode> nodes, RelDataType type) {
+    private SingleScalar scalar(List<RexNode> nodes, RelDataType type) {
         return (SingleScalar)SCALAR_CACHE.computeIfAbsent(digest(nodes, type, false),
             k -> compile(nodes, type, false));
     }
@@ -300,7 +423,7 @@ public class ExpressionFactoryImpl<Row> implements ExpressionFactory<Row> {
      * @param type Row type.
      * @return BiScalar.
      */
-    public BiScalar biScalar(RexNode node, RelDataType type) {
+    private BiScalar biScalar(RexNode node, RelDataType type) {
         ImmutableList<RexNode> nodes = ImmutableList.of(node);
 
         return (BiScalar)SCALAR_CACHE.computeIfAbsent(digest(nodes, type, true),
@@ -552,6 +675,120 @@ public class ExpressionFactoryImpl<Row> implements ExpressionFactory<Row> {
         }
     }
 
+    /** */
+    private class RangeConditionImpl implements RangeCondition<Row> {
+        /** */
+        private final SingleScalar lowerBound;
+
+        /** */
+        private final SingleScalar upperBound;
+
+        /** */
+        private final boolean lowerInclude;
+
+        /** */
+        private final boolean upperInclude;
+
+        /** */
+        private Row lowerRow;
+
+        /** */
+        private Row upperRow;
+
+        /** */
+        private final RowFactory<Row> factory;
+
+        /** */
+        private RangeConditionImpl(
+            SingleScalar lowerBound,
+            SingleScalar upperBound,
+            boolean lowerInclude,
+            boolean upperInclude,
+            RowFactory<Row> factory
+        ) {
+            this.lowerBound = lowerBound;
+            this.upperBound = upperBound;
+            this.lowerInclude = lowerInclude;
+            this.upperInclude = upperInclude;
+            this.factory = factory;
+        }
+
+        /** {@inheritDoc} */
+        @Override public Row lower() {
+            return lowerRow != null ? lowerRow : (lowerRow = getRow(lowerBound));
+        }
+
+        /** {@inheritDoc} */
+        @Override public Row upper() {
+            return upperRow != null ? upperRow : (upperRow = getRow(upperBound));
+        }
+
+        /** {@inheritDoc} */
+        @Override public boolean lowerInclude() {
+            return lowerInclude;
+        }
+
+        /** {@inheritDoc} */
+        @Override public boolean upperInclude() {
+            return upperInclude;
+        }
+
+        /** Compute row. */
+        private Row getRow(SingleScalar scalar) {
+            Row res = factory.create();
+            scalar.execute(ctx, null, res);
+
+            return res;
+        }
+
+        /** Clear cached rows. */
+        public void clearCache() {
+            lowerRow = upperRow = null;
+        }
+    }
+
+    /** */
+    private class RangeIterableImpl implements RangeIterable<Row> {
+        /** */
+        private final List<RangeCondition<Row>> ranges;
+
+        /** */
+        private final Comparator<Row> comparator;
+
+        /** */
+        private boolean sorted;
+
+        /** */
+        public RangeIterableImpl(List<RangeCondition<Row>> ranges, Comparator<Row> comparator) {
+            this.ranges = ranges;
+            this.comparator = comparator;
+        }
+
+        /** {@inheritDoc} */
+        @Override public int size() {
+            return ranges.size();
+        }
+
+        /** {@inheritDoc} */
+        @Override public Iterator<RangeCondition<Row>> iterator() {
+            ranges.forEach(b -> ((RangeConditionImpl)b).clearCache());
+
+            if (ranges.size() == 1)
+                return ranges.iterator();
+
+            // Sort ranges using collation comparator to produce sorted output. There should be no ranges
+            // intersection.
+            // Do not sort again if ranges already were sorted before, different values of correlated variables
+            // should not affect ordering.
+            if (!sorted) {
+                ranges.sort((o1, o2) -> comparator.compare(o1.lower(), o2.lower()));
+                sorted = true;
+            }
+
+            return ranges.iterator();
+        }
+    }
+
     /** */
     private class BiFieldGetter extends CommonFieldGetter {
         /** */
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RangeCondition.java
similarity index 56%
copy from modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java
copy to modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RangeCondition.java
index 221809cf67e..3b08962ce34 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RangeCondition.java
@@ -6,7 +6,7 @@
  * (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
+ *     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,
@@ -14,16 +14,24 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.ignite.internal.processors.query.calcite.externalize;
 
-import org.apache.calcite.rel.RelCollation;
-import org.apache.calcite.rel.RelInput;
+package org.apache.ignite.internal.processors.query.calcite.exec.exp;
 
-/** */
-public interface RelInputEx extends RelInput {
-    /**
-     * @param tag Tag.
-     * @return A collation value.
-     */
-    RelCollation getCollation(String tag);
+/**
+ * A range condition is a search condition which represents a comparison predicate or a BETWEEN predicate.
+ *
+ * <p>Used to define bounds of a range scan.
+ */
+public interface RangeCondition<Row> {
+    /** Lower search row. */
+    public Row lower();
+
+    /** Upper search row. */
+    public Row upper();
+
+    /** Inlusive search by lower row. */
+    public boolean lowerInclude();
+
+    /** Inlusive search by upper row. */
+    public boolean upperInclude();
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RangeIterable.java
similarity index 66%
copy from modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java
copy to modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RangeIterable.java
index 221809cf67e..7e0be826c19 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/exp/RangeIterable.java
@@ -6,7 +6,7 @@
  * (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
+ *     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,
@@ -14,16 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.ignite.internal.processors.query.calcite.externalize;
 
-import org.apache.calcite.rel.RelCollation;
-import org.apache.calcite.rel.RelInput;
+package org.apache.ignite.internal.processors.query.calcite.exec.exp;
 
-/** */
-public interface RelInputEx extends RelInput {
-    /**
-     * @param tag Tag.
-     * @return A collation value.
-     */
-    RelCollation getCollation(String tag);
+/**
+ * Iterable over range conditions.
+ */
+public interface RangeIterable<Row> extends Iterable<RangeCondition<Row>> {
+    /** Count of ranges in iterable. */
+    public int size();
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/IndexSpoolNode.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/IndexSpoolNode.java
index 93e8b714ba2..2ea72a5a6b6 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/IndexSpoolNode.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/IndexSpoolNode.java
@@ -20,7 +20,6 @@ package org.apache.ignite.internal.processors.query.calcite.exec.rel;
 import java.util.Comparator;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
-
 import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.util.ImmutableBitSet;
@@ -28,6 +27,7 @@ import org.apache.ignite.internal.processors.query.calcite.exec.ExecutionContext
 import org.apache.ignite.internal.processors.query.calcite.exec.RuntimeHashIndex;
 import org.apache.ignite.internal.processors.query.calcite.exec.RuntimeIndex;
 import org.apache.ignite.internal.processors.query.calcite.exec.RuntimeSortedIndex;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.util.typedef.F;
 import org.jetbrains.annotations.Nullable;
 
@@ -165,8 +165,7 @@ public class IndexSpoolNode<Row> extends AbstractNode<Row> implements SingleNode
         RelCollation collation,
         Comparator<Row> comp,
         Predicate<Row> filter,
-        Supplier<Row> lowerIdxBound,
-        Supplier<Row> upperIdxBound
+        RangeIterable<Row> ranges
     ) {
         RuntimeSortedIndex<Row> idx = new RuntimeSortedIndex<>(ctx, collation, comp);
 
@@ -177,8 +176,7 @@ public class IndexSpoolNode<Row> extends AbstractNode<Row> implements SingleNode
                 ctx,
                 rowType,
                 filter,
-                lowerIdxBound,
-                upperIdxBound
+                ranges
             )
         );
 
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java
index 221809cf67e..cb64b13e634 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelInputEx.java
@@ -16,8 +16,10 @@
  */
 package org.apache.ignite.internal.processors.query.calcite.externalize;
 
+import java.util.List;
 import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rel.RelInput;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 
 /** */
 public interface RelInputEx extends RelInput {
@@ -26,4 +28,11 @@ public interface RelInputEx extends RelInput {
      * @return A collation value.
      */
     RelCollation getCollation(String tag);
+
+    /**
+     *
+     * @param tag Tag.
+     * @return Search bounds.
+     */
+    List<SearchBounds> getSearchBounds(String tag);
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJson.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJson.java
index 04ddded380d..57048ad6c7c 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJson.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJson.java
@@ -100,6 +100,10 @@ import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.calcite.util.Util;
 import org.apache.ignite.IgniteException;
 import org.apache.ignite.internal.processors.query.calcite.prepare.BaseQueryContext;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.MultiBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.RangeBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteRel;
 import org.apache.ignite.internal.processors.query.calcite.trait.DistributionFunction;
 import org.apache.ignite.internal.processors.query.calcite.trait.DistributionTrait;
@@ -304,6 +308,8 @@ class RelJson {
             return toJson((RelDataTypeField)value);
         else if (value instanceof ByteString)
             return toJson((ByteString)value);
+        else if (value instanceof SearchBounds)
+            return toJson((SearchBounds)value);
         else
             throw new UnsupportedOperationException("type not serializable: "
                 + value + " (type " + value.getClass().getCanonicalName() + ")");
@@ -526,7 +532,7 @@ class RelJson {
                 else if (type.getSqlTypeName().getFamily() == SqlTypeFamily.BINARY)
                     literal = toByteString(literal);
 
-                return rexBuilder.makeLiteral(literal, type, false);
+                return rexBuilder.makeLiteral(literal, type, true);
             }
 
             throw new UnsupportedOperationException("cannot convert to rex " + o);
@@ -677,6 +683,37 @@ class RelJson {
         return list;
     }
 
+    /** */
+    private SearchBounds toSearchBound(RelInput input, Map<String, Object> map) {
+        if (map == null)
+            return null;
+
+        String type = (String)map.get("type");
+
+        if (SearchBounds.Type.EXACT.name().equals(type))
+            return new ExactBounds(null, toRex(input, map.get("bound")));
+        else if (SearchBounds.Type.MULTI.name().equals(type))
+            return new MultiBounds(null, toSearchBoundList(input, (List<Map<String, Object>>)map.get("bounds")));
+        else if (SearchBounds.Type.RANGE.name().equals(type)) {
+            return new RangeBounds(null,
+                toRex(input, map.get("lowerBound")),
+                toRex(input, map.get("upperBound")),
+                (Boolean)map.get("lowerInclude"),
+                (Boolean)map.get("upperInclude")
+            );
+        }
+
+        throw new IllegalStateException("Unsupported search bound type: " + type);
+    }
+
+    /** */
+    List<SearchBounds> toSearchBoundList(RelInput input, List<Map<String, Object>> bounds) {
+        if (bounds == null)
+            return null;
+
+        return bounds.stream().map(b -> toSearchBound(input, b)).collect(Collectors.toList());
+    }
+
     /** */
     private Object toJson(Enum<?> enum0) {
         String key = enum0.getDeclaringClass().getSimpleName() + "#" + enum0.name();
@@ -955,4 +992,27 @@ class RelJson {
     private Object toJson(ByteString val) {
         return val.toString();
     }
+
+    /** */
+    private Object toJson(SearchBounds val) {
+        Map map = map();
+        map.put("type", val.type().name());
+
+        if (val instanceof ExactBounds)
+            map.put("bound", toJson(((ExactBounds)val).bound()));
+        else if (val instanceof MultiBounds)
+            map.put("bounds", toJson(((MultiBounds)val).bounds()));
+        else {
+            assert val instanceof RangeBounds : val;
+
+            RangeBounds val0 = (RangeBounds)val;
+
+            map.put("lowerBound", val0.lowerBound() == null ? null : toJson(val0.lowerBound()));
+            map.put("upperBound", val0.upperBound() == null ? null : toJson(val0.upperBound()));
+            map.put("lowerInclude", val0.lowerInclude());
+            map.put("upperInclude", val0.upperInclude());
+        }
+
+        return map;
+    }
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJsonReader.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJsonReader.java
index 7847ef20fbc..522ec373125 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJsonReader.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/externalize/RelJsonReader.java
@@ -46,6 +46,7 @@ import org.apache.calcite.util.Pair;
 import org.apache.calcite.util.Util;
 import org.apache.ignite.IgniteException;
 import org.apache.ignite.internal.processors.query.calcite.prepare.BaseQueryContext;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.util.Commons;
 
 /** */
@@ -282,6 +283,11 @@ public class RelJsonReader {
             return relJson.toCollation((List)get(tag));
         }
 
+        /** {@inheritDoc} */
+        @Override public List<SearchBounds> getSearchBounds(String tag) {
+            return relJson.toSearchBoundList(this, (List<Map<String, Object>>)get(tag));
+        }
+
         /** {@inheritDoc} */
         @Override public RelDistribution getDistribution() {
             return relJson.toDistribution(get("distribution"));
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/metadata/IgniteMdSelectivity.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/metadata/IgniteMdSelectivity.java
index 85ceba2c16a..6377af09f09 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/metadata/IgniteMdSelectivity.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/metadata/IgniteMdSelectivity.java
@@ -38,6 +38,7 @@ import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexLocalRef;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.rex.RexSlot;
+import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.schema.Statistic;
 import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.SqlOperator;
@@ -207,6 +208,12 @@ public class IgniteMdSelectivity extends RelMdSelectivity {
 
                 continue;
             }
+            else if (predKind == SqlKind.SEARCH) {
+                sel *= getTablePredicateBasedSelectivity(rel, mq,
+                    RexUtil.expandSearch(rel.getCluster().getRexBuilder(), null, pred));
+
+                continue;
+            }
 
             RexSlot op = null;
 
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgnitePrograms.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgnitePrograms.java
index b4ba54ceee3..838fea05657 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgnitePrograms.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/IgnitePrograms.java
@@ -53,6 +53,8 @@ public class IgnitePrograms {
             final HepPlanner hepPlanner = new HepPlanner(builder.build(), Commons.context(rel), true,
                 null, Commons.context(rel).config().getCostFactory());
 
+            hepPlanner.setExecutor(planner.getExecutor());
+
             for (RelOptMaterialization materialization : materializations)
                 hepPlanner.addMaterialization(materialization);
 
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/ExactBounds.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/ExactBounds.java
new file mode 100644
index 00000000000..2e67a1d22a3
--- /dev/null
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/ExactBounds.java
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+package org.apache.ignite.internal.processors.query.calcite.prepare.bounds;
+
+import java.util.Objects;
+import org.apache.calcite.rex.RexNode;
+import org.apache.ignite.internal.util.typedef.internal.S;
+
+/**
+ * Exact (equals) bounds holder for search row.
+ */
+public class ExactBounds extends SearchBounds {
+    /** */
+    private final RexNode bound;
+
+    /**
+     */
+    public ExactBounds(RexNode condition, RexNode bound) {
+        super(condition);
+        this.bound = bound;
+    }
+
+    /**
+     * @return Search bound.
+     */
+    public RexNode bound() {
+        return bound;
+    }
+
+    /** {@inheritDoc} */
+    @Override public Type type() {
+        return Type.EXACT;
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean equals(Object o) {
+        if (this == o)
+            return true;
+
+        if (o == null || getClass() != o.getClass())
+            return false;
+
+        return bound.equals(((ExactBounds)o).bound);
+    }
+
+    /** {@inheritDoc} */
+    @Override public int hashCode() {
+        return Objects.hash(bound);
+    }
+
+    /** */
+    @Override public String toString() {
+        return S.toString(ExactBounds.class, this);
+    }
+}
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/MultiBounds.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/MultiBounds.java
new file mode 100644
index 00000000000..5101fe85b69
--- /dev/null
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/MultiBounds.java
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ */
+
+package org.apache.ignite.internal.processors.query.calcite.prepare.bounds;
+
+import java.util.List;
+import java.util.Objects;
+import org.apache.calcite.rex.RexNode;
+import org.apache.ignite.internal.util.tostring.GridToStringInclude;
+import org.apache.ignite.internal.util.typedef.internal.S;
+
+/**
+ * Multiple bounds holder for search row.
+ */
+public class MultiBounds extends SearchBounds {
+    /** */
+    @GridToStringInclude
+    private final List<SearchBounds> bounds;
+
+    /**
+     */
+    public MultiBounds(RexNode condition, List<SearchBounds> bounds) {
+        super(condition);
+        this.bounds = bounds;
+    }
+
+    /**
+     * @return Search bounds.
+     */
+    public List<SearchBounds> bounds() {
+        return bounds;
+    }
+
+    /** {@inheritDoc} */
+    @Override public Type type() {
+        return Type.MULTI;
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean equals(Object o) {
+        if (this == o)
+            return true;
+
+        if (o == null || getClass() != o.getClass())
+            return false;
+
+        return bounds.equals(((MultiBounds)o).bounds);
+    }
+
+    /** {@inheritDoc} */
+    @Override public int hashCode() {
+        return Objects.hash(bounds);
+    }
+
+    /** */
+    @Override public String toString() {
+        return S.toString(MultiBounds.class, this);
+    }
+}
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/RangeBounds.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/RangeBounds.java
new file mode 100644
index 00000000000..0d61987adf5
--- /dev/null
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/RangeBounds.java
@@ -0,0 +1,113 @@
+/*
+ * 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.
+ */
+
+package org.apache.ignite.internal.processors.query.calcite.prepare.bounds;
+
+import java.util.Objects;
+import org.apache.calcite.rex.RexNode;
+import org.apache.ignite.internal.util.typedef.internal.S;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Range bounds holder for search row.
+ */
+public class RangeBounds extends SearchBounds {
+    /** */
+    private final RexNode lowerBound;
+
+    /** */
+    private final RexNode upperBound;
+
+    /** */
+    private final boolean lowerInclude;
+
+    /** */
+    private final boolean upperInclude;
+
+    /**
+     */
+    public RangeBounds(
+        RexNode condition,
+        @Nullable RexNode lowerBound,
+        @Nullable RexNode upperBound,
+        boolean lowerInclude,
+        boolean upperInclude
+    ) {
+        super(condition);
+        this.lowerBound = lowerBound;
+        this.upperBound = upperBound;
+        this.lowerInclude = lowerInclude;
+        this.upperInclude = upperInclude;
+    }
+
+    /**
+     * @return Lower search bound.
+     */
+    public RexNode lowerBound() {
+        return lowerBound;
+    }
+
+    /**
+     * @return Upper search bound.
+     */
+    public RexNode upperBound() {
+        return upperBound;
+    }
+
+    /**
+     * @return {@code True} if the lower bound is inclusive.
+     */
+    public boolean lowerInclude() {
+        return lowerInclude;
+    }
+
+    /**
+     * @return {@code True} if the upper bound is inclusive.
+     */
+    public boolean upperInclude() {
+        return upperInclude;
+    }
+
+    /** {@inheritDoc} */
+    @Override public Type type() {
+        return Type.RANGE;
+    }
+
+    /** {@inheritDoc} */
+    @Override public boolean equals(Object o) {
+        if (this == o)
+            return true;
+
+        if (o == null || getClass() != o.getClass())
+            return false;
+
+        return lowerInclude == ((RangeBounds)o).lowerInclude &&
+            upperInclude == ((RangeBounds)o).upperInclude &&
+            Objects.equals(lowerBound, ((RangeBounds)o).lowerBound) &&
+            Objects.equals(upperBound, ((RangeBounds)o).upperBound);
+    }
+
+    /** {@inheritDoc} */
+    @Override public int hashCode() {
+        return Objects.hash(lowerBound, upperBound, lowerInclude, upperInclude);
+    }
+
+    /** */
+    @Override public String toString() {
+        return S.toString(RangeBounds.class, this);
+    }
+}
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/SearchBounds.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/SearchBounds.java
new file mode 100644
index 00000000000..42caf9e4488
--- /dev/null
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/prepare/bounds/SearchBounds.java
@@ -0,0 +1,56 @@
+/*
+ * 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.
+ */
+
+package org.apache.ignite.internal.processors.query.calcite.prepare.bounds;
+
+import org.apache.calcite.rex.RexNode;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Bounds holder for search row.
+ *
+ * This class is describing bounds on per-column basis, and such choice was made to optimize serialization of a plan.
+ */
+public abstract class SearchBounds {
+    /** Condition required only for cost calculation, no serialization needed. */
+    private final RexNode condition;
+
+    /** */
+    protected SearchBounds(@Nullable RexNode condition) {
+        this.condition = condition;
+    }
+
+    /** */
+    public RexNode condition() {
+        return condition;
+    }
+
+    /** */
+    public abstract Type type();
+
+    /** */
+    public enum Type {
+        /** Exact search value. */
+        EXACT,
+
+        /** Range of values. */
+        RANGE,
+
+        /** Multiple values or multiple ranges. */
+        MULTI
+    }
+}
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/AbstractIndexScan.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/AbstractIndexScan.java
index 41916dcef60..c63a59c28f7 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/AbstractIndexScan.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/AbstractIndexScan.java
@@ -18,7 +18,6 @@
 package org.apache.ignite.internal.processors.query.calcite.rel;
 
 import java.util.List;
-
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptCost;
 import org.apache.calcite.plan.RelOptPlanner;
@@ -32,8 +31,10 @@ import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.util.ImmutableBitSet;
+import org.apache.ignite.internal.processors.query.calcite.externalize.RelInputEx;
 import org.apache.ignite.internal.processors.query.calcite.metadata.cost.IgniteCost;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
+import org.apache.ignite.internal.processors.query.calcite.util.Commons;
 import org.jetbrains.annotations.Nullable;
 
 /**
@@ -44,7 +45,7 @@ public abstract class AbstractIndexScan extends ProjectableFilterableTableScan {
     protected final String idxName;
 
     /** */
-    protected final IndexConditions idxCond;
+    protected final List<SearchBounds> searchBounds;
 
     /**
      * Constructor used for deserialization.
@@ -54,7 +55,7 @@ public abstract class AbstractIndexScan extends ProjectableFilterableTableScan {
     protected AbstractIndexScan(RelInput input) {
         super(input);
         idxName = input.getString("index");
-        idxCond = new IndexConditions(input);
+        searchBounds = ((RelInputEx)input).getSearchBounds("searchBounds");
     }
 
     /** */
@@ -66,21 +67,22 @@ public abstract class AbstractIndexScan extends ProjectableFilterableTableScan {
         String idxName,
         @Nullable List<RexNode> proj,
         @Nullable RexNode cond,
-        @Nullable IndexConditions idxCond,
+        @Nullable List<SearchBounds> searchBounds,
         @Nullable ImmutableBitSet reqColumns
     ) {
         super(cluster, traitSet, hints, table, proj, cond, reqColumns);
 
         this.idxName = idxName;
-        this.idxCond = idxCond;
+        this.searchBounds = searchBounds;
     }
 
     /** {@inheritDoc} */
     @Override protected RelWriter explainTerms0(RelWriter pw) {
         pw = pw.item("index", idxName);
         pw = super.explainTerms0(pw);
+        pw = pw.itemIf("searchBounds", searchBounds, searchBounds != null);
 
-        return idxCond.explainTerms(pw);
+        return pw;
     }
 
     /**
@@ -90,34 +92,6 @@ public abstract class AbstractIndexScan extends ProjectableFilterableTableScan {
         return idxName;
     }
 
-    /**
-     * @return Lower index condition.
-     */
-    public List<RexNode> lowerCondition() {
-        return idxCond == null ? null : idxCond.lowerCondition();
-    }
-
-    /**
-     * @return Lower index condition.
-     */
-    public List<RexNode> lowerBound() {
-        return idxCond == null ? null : idxCond.lowerBound();
-    }
-
-    /**
-     * @return Upper index condition.
-     */
-    public List<RexNode> upperCondition() {
-        return idxCond == null ? null : idxCond.upperCondition();
-    }
-
-    /**
-     * @return Upper index condition.
-     */
-    public List<RexNode> upperBound() {
-        return idxCond == null ? null : idxCond.upperBound();
-    }
-
     /** {@inheritDoc} */
     @Override public RelOptCost computeSelfCost(RelOptPlanner planner, RelMetadataQuery mq) {
         double rows = table.getRowCount();
@@ -133,18 +107,11 @@ public abstract class AbstractIndexScan extends ProjectableFilterableTableScan {
 
             cost = 0;
 
-            if (lowerCondition() != null) {
-                double selectivity0 = mq.getSelectivity(this, RexUtil.composeConjunction(builder, lowerCondition()));
-
-                selectivity -= 1 - selectivity0;
-
-                cost += Math.log(rows) * IgniteCost.ROW_COMPARISON_COST;
-            }
-
-            if (upperCondition() != null && (lowerCondition() == null || !lowerCondition().equals(upperCondition()))) {
-                double selectivity0 = mq.getSelectivity(this, RexUtil.composeConjunction(builder, upperCondition()));
+            if (searchBounds != null) {
+                selectivity = mq.getSelectivity(this, RexUtil.composeConjunction(builder,
+                        Commons.transform(searchBounds, b -> b == null ? null : b.condition())));
 
-                selectivity -= 1 - selectivity0;
+                cost = Math.log(rows) * IgniteCost.ROW_COMPARISON_COST;
             }
 
             rows *= selectivity;
@@ -160,7 +127,7 @@ public abstract class AbstractIndexScan extends ProjectableFilterableTableScan {
     }
 
     /** */
-    public IndexConditions indexConditions() {
-        return idxCond;
+    public List<SearchBounds> searchBounds() {
+        return searchBounds;
     }
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteIndexScan.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteIndexScan.java
index 981744017ca..a5a3c82c9b3 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteIndexScan.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteIndexScan.java
@@ -27,7 +27,7 @@ import org.apache.calcite.rel.RelInput;
 import org.apache.calcite.rel.RelWriter;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.util.ImmutableBitSet;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.jetbrains.annotations.Nullable;
 
 import static org.apache.ignite.internal.processors.query.calcite.trait.TraitUtils.changeTraits;
@@ -77,11 +77,11 @@ public class IgniteIndexScan extends AbstractIndexScan implements SourceAwareIgn
         String idxName,
         @Nullable List<RexNode> proj,
         @Nullable RexNode cond,
-        @Nullable IndexConditions idxCond,
+        @Nullable List<SearchBounds> searchBounds,
         @Nullable ImmutableBitSet requiredCols,
         RelCollation collation
     ) {
-        this(-1L, cluster, traits, tbl, idxName, proj, cond, idxCond, requiredCols, collation);
+        this(-1L, cluster, traits, tbl, idxName, proj, cond, searchBounds, requiredCols, collation);
     }
 
     /**
@@ -103,11 +103,11 @@ public class IgniteIndexScan extends AbstractIndexScan implements SourceAwareIgn
         String idxName,
         @Nullable List<RexNode> proj,
         @Nullable RexNode cond,
-        @Nullable IndexConditions idxCond,
+        @Nullable List<SearchBounds> searchBounds,
         @Nullable ImmutableBitSet requiredCols,
         RelCollation collation
     ) {
-        super(cluster, traits, ImmutableList.of(), tbl, idxName, proj, cond, idxCond, requiredCols);
+        super(cluster, traits, ImmutableList.of(), tbl, idxName, proj, cond, searchBounds, requiredCols);
 
         this.sourceId = sourceId;
         this.collation = collation;
@@ -133,13 +133,13 @@ public class IgniteIndexScan extends AbstractIndexScan implements SourceAwareIgn
     /** {@inheritDoc} */
     @Override public IgniteRel clone(long sourceId) {
         return new IgniteIndexScan(sourceId, getCluster(), getTraitSet(), getTable(),
-            idxName, projects, condition, idxCond, requiredColumns, collation);
+            idxName, projects, condition, searchBounds, requiredColumns, collation);
     }
 
     /** {@inheritDoc} */
     @Override public IgniteRel clone(RelOptCluster cluster, List<IgniteRel> inputs) {
         return new IgniteIndexScan(sourceId, cluster, getTraitSet(), getTable(),
-            idxName, projects, condition, idxCond, requiredColumns, collation);
+            idxName, projects, condition, searchBounds, requiredColumns, collation);
     }
 
     /** {@inheritDoc} */
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteSortedIndexSpool.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteSortedIndexSpool.java
index 8dfea7495b1..33d45ea51a7 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteSortedIndexSpool.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/IgniteSortedIndexSpool.java
@@ -19,7 +19,6 @@ package org.apache.ignite.internal.processors.query.calcite.rel;
 
 import java.util.List;
 import java.util.Objects;
-
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptCost;
 import org.apache.calcite.plan.RelOptPlanner;
@@ -31,9 +30,10 @@ import org.apache.calcite.rel.RelWriter;
 import org.apache.calcite.rel.core.Spool;
 import org.apache.calcite.rel.metadata.RelMetadataQuery;
 import org.apache.calcite.rex.RexNode;
+import org.apache.ignite.internal.processors.query.calcite.externalize.RelInputEx;
 import org.apache.ignite.internal.processors.query.calcite.metadata.cost.IgniteCost;
 import org.apache.ignite.internal.processors.query.calcite.metadata.cost.IgniteCostFactory;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 
 /**
  * Relational operator that returns the sorted contents of a table
@@ -43,8 +43,8 @@ public class IgniteSortedIndexSpool extends Spool implements IgniteRel {
     /** */
     private final RelCollation collation;
 
-    /** Index condition. */
-    private final IndexConditions idxCond;
+    /** */
+    private final List<SearchBounds> searchBounds;
 
     /** Filters. */
     protected final RexNode condition;
@@ -56,14 +56,13 @@ public class IgniteSortedIndexSpool extends Spool implements IgniteRel {
         RelNode input,
         RelCollation collation,
         RexNode condition,
-        IndexConditions idxCond
+        List<SearchBounds> searchBounds
     ) {
         super(cluster, traits, input, Type.LAZY, Type.EAGER);
 
-        assert Objects.nonNull(idxCond);
         assert Objects.nonNull(condition);
 
-        this.idxCond = idxCond;
+        this.searchBounds = searchBounds;
         this.condition = condition;
         this.collation = collation;
     }
@@ -79,7 +78,7 @@ public class IgniteSortedIndexSpool extends Spool implements IgniteRel {
             input.getInputs().get(0),
             input.getCollation(),
             input.getExpression("condition"),
-            new IndexConditions(input)
+            ((RelInputEx)input).getSearchBounds("searchBounds")
         );
     }
 
@@ -90,12 +89,12 @@ public class IgniteSortedIndexSpool extends Spool implements IgniteRel {
 
     /** */
     @Override public IgniteRel clone(RelOptCluster cluster, List<IgniteRel> inputs) {
-        return new IgniteSortedIndexSpool(cluster, getTraitSet(), inputs.get(0), collation, condition, idxCond);
+        return new IgniteSortedIndexSpool(cluster, getTraitSet(), inputs.get(0), collation, condition, searchBounds);
     }
 
     /** {@inheritDoc} */
     @Override protected Spool copy(RelTraitSet traitSet, RelNode input, Type readType, Type writeType) {
-        return new IgniteSortedIndexSpool(getCluster(), traitSet, input, collation, condition, idxCond);
+        return new IgniteSortedIndexSpool(getCluster(), traitSet, input, collation, condition, searchBounds);
     }
 
     /** {@inheritDoc} */
@@ -109,8 +108,9 @@ public class IgniteSortedIndexSpool extends Spool implements IgniteRel {
 
         writer.item("condition", condition);
         writer.item("collation", collation);
+        writer.itemIf("searchBounds", searchBounds, searchBounds != null);
 
-        return idxCond.explainTerms(writer);
+        return writer;
     }
 
     /** {@inheritDoc} */
@@ -119,8 +119,8 @@ public class IgniteSortedIndexSpool extends Spool implements IgniteRel {
     }
 
     /** */
-    public IndexConditions indexCondition() {
-        return idxCond;
+    public List<SearchBounds> searchBounds() {
+        return searchBounds;
     }
 
     /** */
@@ -140,7 +140,7 @@ public class IgniteSortedIndexSpool extends Spool implements IgniteRel {
         double totalBytes = rowCnt * bytesPerRow;
         double cpuCost;
 
-        if (idxCond.lowerCondition() != null)
+        if (searchBounds != null)
             cpuCost = Math.log(rowCnt) * IgniteCost.ROW_COMPARISON_COST;
         else
             cpuCost = rowCnt * IgniteCost.ROW_PASS_THROUGH_COST;
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/ProjectableFilterableTableScan.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/ProjectableFilterableTableScan.java
index 09673940770..5aa5c67e4e7 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/ProjectableFilterableTableScan.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/ProjectableFilterableTableScan.java
@@ -117,8 +117,12 @@ public abstract class ProjectableFilterableTableScan extends TableScan {
 
     /** */
     protected RelWriter explainTerms0(RelWriter pw) {
+        if (condition != null) {
+            pw.item("filters", pw.nest() ? condition :
+                RexUtil.expandSearch(getCluster().getRexBuilder(), null, condition));
+        }
+
         return pw
-            .itemIf("filters", condition, condition != null)
             .itemIf("projects", projects, projects != null)
             .itemIf("requiredColumns", requiredColumns, requiredColumns != null);
     }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/logical/IgniteLogicalIndexScan.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/logical/IgniteLogicalIndexScan.java
index af11ef2d694..1cb7dd0cd80 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/logical/IgniteLogicalIndexScan.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rel/logical/IgniteLogicalIndexScan.java
@@ -24,10 +24,10 @@ import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.plan.RelTraitSet;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.util.ImmutableBitSet;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.AbstractIndexScan;
 import org.apache.ignite.internal.processors.query.calcite.schema.IgniteIndex;
 import org.apache.ignite.internal.processors.query.calcite.schema.IgniteTable;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
 import org.jetbrains.annotations.Nullable;
 
 /** */
@@ -45,7 +45,7 @@ public class IgniteLogicalIndexScan extends AbstractIndexScan {
         IgniteTable tbl = table.unwrap(IgniteTable.class);
         IgniteIndex idx = tbl.getIndex(idxName);
 
-        IndexConditions idxCond = idx.toIndexCondition(cluster, cond, requiredColumns);
+        List<SearchBounds> searchBounds = idx.toSearchBounds(cluster, cond, requiredColumns);
 
         return new IgniteLogicalIndexScan(
             cluster,
@@ -54,7 +54,7 @@ public class IgniteLogicalIndexScan extends AbstractIndexScan {
             idxName,
             proj,
             cond,
-            idxCond,
+            searchBounds,
             requiredColumns);
     }
 
@@ -66,7 +66,7 @@ public class IgniteLogicalIndexScan extends AbstractIndexScan {
      * @param idxName Index name.
      * @param proj Projects.
      * @param cond Filters.
-     * @param idxCond Index conditions.
+     * @param searchBounds Index search bounds.
      * @param requiredCols Participating columns.
      */
     private IgniteLogicalIndexScan(
@@ -76,9 +76,9 @@ public class IgniteLogicalIndexScan extends AbstractIndexScan {
         String idxName,
         @Nullable List<RexNode> proj,
         @Nullable RexNode cond,
-        @Nullable IndexConditions idxCond,
+        @Nullable List<SearchBounds> searchBounds,
         @Nullable ImmutableBitSet requiredCols
     ) {
-        super(cluster, traits, ImmutableList.of(), tbl, idxName, proj, cond, idxCond, requiredCols);
+        super(cluster, traits, ImmutableList.of(), tbl, idxName, proj, cond, searchBounds, requiredCols);
     }
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToHashIndexSpoolRule.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToHashIndexSpoolRule.java
index dae8f276840..f3bec5479e4 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToHashIndexSpoolRule.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToHashIndexSpoolRule.java
@@ -64,13 +64,7 @@ public class FilterSpoolMergeToHashIndexSpoolRule extends RelRule<FilterSpoolMer
 
         RelNode input = spool.getInput();
 
-        List<RexNode> searchRow = RexUtils.buildHashSearchRow(
-            cluster,
-            filter.getCondition(),
-            spool.getRowType(),
-            null,
-            false
-        );
+        List<RexNode> searchRow = RexUtils.buildHashSearchRow(cluster, filter.getCondition(), spool.getRowType());
 
         if (F.isEmpty(searchRow))
             return;
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToSortedIndexSpoolRule.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToSortedIndexSpoolRule.java
index 024be7e6457..aa55c2e2081 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToSortedIndexSpoolRule.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/FilterSpoolMergeToSortedIndexSpoolRule.java
@@ -20,6 +20,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
+import org.apache.calcite.linq4j.Ord;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptRule;
 import org.apache.calcite.plan.RelOptRuleCall;
@@ -31,13 +32,12 @@ import org.apache.calcite.rel.RelFieldCollation;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.Filter;
 import org.apache.calcite.rel.core.Spool;
-import org.apache.calcite.rex.RexNode;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteFilter;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteSortedIndexSpool;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteTableSpool;
 import org.apache.ignite.internal.processors.query.calcite.trait.CorrelationTrait;
 import org.apache.ignite.internal.processors.query.calcite.trait.TraitUtils;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
 import org.apache.ignite.internal.processors.query.calcite.util.RexUtils;
 import org.apache.ignite.internal.util.typedef.F;
 import org.immutables.value.Value;
@@ -72,7 +72,7 @@ public class FilterSpoolMergeToSortedIndexSpoolRule extends RelRule<FilterSpoolM
 
         RelCollation inCollation = TraitUtils.collation(input);
 
-        IndexConditions idxCond = RexUtils.buildSortedIndexConditions(
+        List<SearchBounds> bounds = RexUtils.buildSortedSearchBounds(
             cluster,
             inCollation,
             filter.getCondition(),
@@ -80,31 +80,21 @@ public class FilterSpoolMergeToSortedIndexSpoolRule extends RelRule<FilterSpoolM
             null
         );
 
-        if (F.isEmpty(idxCond.lowerCondition()) && F.isEmpty(idxCond.upperCondition()))
+        if (F.isEmpty(bounds))
             return;
 
         RelCollation traitCollation;
         RelCollation searchCollation;
 
         if (inCollation == null || inCollation.isDefault()) {
-            // Create collation by index condition.
-            List<RexNode> lowerBound = idxCond.lowerBound();
-            List<RexNode> upperBound = idxCond.upperBound();
-
-            assert lowerBound == null || upperBound == null || lowerBound.size() == upperBound.size();
-
-            int cardinality = lowerBound != null ? lowerBound.size() : upperBound.size();
-
-            List<Integer> equalsFields = new ArrayList<>(cardinality);
-            List<Integer> otherFields = new ArrayList<>(cardinality);
+            // Create collation by index bounds.
+            List<Integer> equalsFields = new ArrayList<>(bounds.size());
+            List<Integer> otherFields = new ArrayList<>(bounds.size());
 
             // First, add all equality filters to collation, then add other fields.
-            for (int i = 0; i < cardinality; i++) {
-                RexNode lowerNode = lowerBound != null ? lowerBound.get(i) : null;
-                RexNode upperNode = upperBound != null ? upperBound.get(i) : null;
-
-                if (RexUtils.isNotNull(lowerNode) || RexUtils.isNotNull(upperNode))
-                    (F.eq(lowerNode, upperNode) ? equalsFields : otherFields).add(i);
+            for (int i = 0; i < bounds.size(); i++) {
+                if (bounds.get(i) != null)
+                    (bounds.get(i).type() == SearchBounds.Type.EXACT ? equalsFields : otherFields).add(i);
             }
 
             searchCollation = traitCollation = TraitUtils.createCollation(F.concat(true, equalsFields, otherFields));
@@ -113,7 +103,10 @@ public class FilterSpoolMergeToSortedIndexSpoolRule extends RelRule<FilterSpoolM
             // Create search collation as a prefix of input collation.
             traitCollation = inCollation;
 
-            Set<Integer> searchKeys = idxCond.keys();
+            Set<Integer> searchKeys = Ord.zip(bounds).stream()
+                .filter(v -> v.e != null)
+                .map(v -> v.i)
+                .collect(Collectors.toSet());
 
             List<RelFieldCollation> collationFields = inCollation.getFieldCollations().subList(0, searchKeys.size());
 
@@ -130,7 +123,7 @@ public class FilterSpoolMergeToSortedIndexSpoolRule extends RelRule<FilterSpoolM
             convert(input, input.getTraitSet().replace(traitCollation)),
             searchCollation,
             filter.getCondition(),
-            idxCond
+            bounds
         );
 
         call.transformTo(res);
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/LogicalScanConverterRule.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/LogicalScanConverterRule.java
index 348f20fd18a..28053b9c701 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/LogicalScanConverterRule.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/LogicalScanConverterRule.java
@@ -106,7 +106,7 @@ public abstract class LogicalScanConverterRule<T extends ProjectableFilterableTa
                     rel.indexName(),
                     rel.projects(),
                     rel.condition(),
-                    rel.indexConditions(),
+                    rel.searchBounds(),
                     rel.requiredColumns(),
                     idx.collation()
                 );
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/logical/FilterScanMergeRule.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/logical/FilterScanMergeRule.java
index b8a8e62b7f5..76459398d86 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/logical/FilterScanMergeRule.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/rule/logical/FilterScanMergeRule.java
@@ -17,6 +17,7 @@
 package org.apache.ignite.internal.processors.query.calcite.rule.logical;
 
 import org.apache.calcite.plan.RelOptCluster;
+import org.apache.calcite.plan.RelOptPredicateList;
 import org.apache.calcite.plan.RelOptRule;
 import org.apache.calcite.plan.RelOptRuleCall;
 import org.apache.calcite.plan.RelRule;
@@ -27,6 +28,7 @@ import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexInputRef;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.rex.RexShuttle;
+import org.apache.calcite.rex.RexSimplify;
 import org.apache.calcite.rex.RexUtil;
 import org.apache.ignite.internal.processors.query.calcite.rel.ProjectableFilterableTableScan;
 import org.apache.ignite.internal.processors.query.calcite.rel.logical.IgniteLogicalIndexScan;
@@ -84,9 +86,13 @@ public abstract class FilterScanMergeRule<T extends ProjectableFilterableTableSc
         if (scan.condition() != null)
             condition = RexUtil.composeConjunction(builder, F.asList(scan.condition(), condition));
 
+        // We need to replace RexLocalRef with RexInputRef because "simplify" doesn't understand local refs.
+        condition = RexUtils.replaceLocalRefs(condition);
+        condition = new RexSimplify(builder, RelOptPredicateList.EMPTY, call.getPlanner().getExecutor())
+            .simplifyUnknownAsFalse(condition);
+
         // We need to replace RexInputRef with RexLocalRef because TableScan doesn't have inputs.
-        // TODO SEARCH support
-        condition = RexUtils.replaceInputRefs(RexUtil.expandSearch(builder, null, condition));
+        condition = RexUtils.replaceInputRefs(condition);
 
         // Set default traits, real traits will be calculated for physical node.
         RelTraitSet trait = cluster.traitSet();
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/CacheIndexImpl.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/CacheIndexImpl.java
index 5b969b03baa..705c5288afb 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/CacheIndexImpl.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/CacheIndexImpl.java
@@ -21,7 +21,6 @@ import java.util.List;
 import java.util.UUID;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.rel.RelCollation;
@@ -35,10 +34,11 @@ import org.apache.ignite.internal.cache.query.index.sorted.inline.IndexQueryCont
 import org.apache.ignite.internal.cache.query.index.sorted.inline.InlineIndex;
 import org.apache.ignite.internal.processors.query.calcite.exec.ExecutionContext;
 import org.apache.ignite.internal.processors.query.calcite.exec.IndexScan;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.metadata.ColocationGroup;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.logical.IgniteLogicalIndexScan;
 import org.apache.ignite.internal.processors.query.calcite.util.Commons;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
 import org.apache.ignite.internal.processors.query.calcite.util.RexUtils;
 import org.apache.ignite.spi.indexing.IndexingQueryFilter;
 import org.apache.ignite.spi.indexing.IndexingQueryFilterImpl;
@@ -104,16 +104,14 @@ public class CacheIndexImpl implements IgniteIndex {
         ExecutionContext<Row> execCtx,
         ColocationGroup group,
         Predicate<Row> filters,
-        Supplier<Row> lowerIdxConditions,
-        Supplier<Row> upperIdxConditions,
+        RangeIterable<Row> ranges,
         Function<Row, Row> rowTransformer,
         @Nullable ImmutableBitSet requiredColumns
     ) {
         UUID localNodeId = execCtx.localNodeId();
         if (group.nodeIds().contains(localNodeId) && idx != null) {
             return new IndexScan<>(execCtx, tbl.descriptor(), idx.unwrap(InlineIndex.class), collation.getKeys(),
-                group.partitions(localNodeId), filters, lowerIdxConditions, upperIdxConditions, rowTransformer,
-                requiredColumns);
+                group.partitions(localNodeId), filters, ranges, rowTransformer, requiredColumns);
         }
 
         return Collections.emptyList();
@@ -142,7 +140,7 @@ public class CacheIndexImpl implements IgniteIndex {
     }
 
     /** {@inheritDoc} */
-    @Override public IndexConditions toIndexCondition(
+    @Override public List<SearchBounds> toSearchBounds(
         RelOptCluster cluster,
         @Nullable RexNode cond,
         @Nullable ImmutableBitSet requiredColumns
@@ -154,7 +152,7 @@ public class CacheIndexImpl implements IgniteIndex {
             collation = collation.apply(Commons.mapping(requiredColumns, rowType.getFieldCount()));
 
         if (!collation.getFieldCollations().isEmpty()) {
-            return RexUtils.buildSortedIndexConditions(
+            return RexUtils.buildSortedSearchBounds(
                 cluster,
                 collation,
                 cond,
@@ -164,6 +162,6 @@ public class CacheIndexImpl implements IgniteIndex {
         }
 
         // Empty index find predicate.
-        return new IndexConditions();
+        return null;
     }
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/IgniteIndex.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/IgniteIndex.java
index 5753302dbf0..5df2b9d60d3 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/IgniteIndex.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/IgniteIndex.java
@@ -19,16 +19,16 @@ package org.apache.ignite.internal.processors.query.calcite.schema;
 import java.util.List;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.internal.processors.query.calcite.exec.ExecutionContext;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.metadata.ColocationGroup;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.logical.IgniteLogicalIndexScan;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
 import org.jetbrains.annotations.Nullable;
 
 /**
@@ -70,7 +70,7 @@ public interface IgniteIndex {
      * @param requiredColumns Set of columns to extract from original row.
      * @return Index condition.
      */
-    public IndexConditions toIndexCondition(
+    public List<SearchBounds> toSearchBounds(
         RelOptCluster cluster,
         @Nullable RexNode cond,
         @Nullable ImmutableBitSet requiredColumns
@@ -81,8 +81,7 @@ public interface IgniteIndex {
         ExecutionContext<Row> execCtx,
         ColocationGroup grp,
         Predicate<Row> filters,
-        Supplier<Row> lowerIdxConditions,
-        Supplier<Row> upperIdxConditions,
+        RangeIterable<Row> ranges,
         Function<Row, Row> rowTransformer,
         @Nullable ImmutableBitSet requiredColumns
     );
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/SystemViewIndexImpl.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/SystemViewIndexImpl.java
index 050678107cc..eb203b1c0cb 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/SystemViewIndexImpl.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/schema/SystemViewIndexImpl.java
@@ -19,7 +19,6 @@ package org.apache.ignite.internal.processors.query.calcite.schema;
 import java.util.List;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.rel.RelCollation;
@@ -29,9 +28,10 @@ import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.internal.processors.query.calcite.exec.ExecutionContext;
 import org.apache.ignite.internal.processors.query.calcite.exec.SystemViewScan;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.metadata.ColocationGroup;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.logical.IgniteLogicalIndexScan;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
 import org.apache.ignite.internal.processors.query.calcite.util.RexUtils;
 import org.jetbrains.annotations.Nullable;
 
@@ -82,15 +82,14 @@ public class SystemViewIndexImpl implements IgniteIndex {
         ExecutionContext<Row> execCtx,
         ColocationGroup grp,
         Predicate<Row> filters,
-        Supplier<Row> lowerIdxConditions,
-        Supplier<Row> upperIdxConditions,
+        RangeIterable<Row> ranges,
         Function<Row, Row> rowTransformer,
         @Nullable ImmutableBitSet requiredColumns
     ) {
         return new SystemViewScan<>(
             execCtx,
             tbl.descriptor(),
-            lowerIdxConditions, // Should have the same values as upperIdxConditions.
+            ranges,
             filters,
             rowTransformer,
             requiredColumns
@@ -103,18 +102,16 @@ public class SystemViewIndexImpl implements IgniteIndex {
     }
 
     /** {@inheritDoc} */
-    @Override public IndexConditions toIndexCondition(
+    @Override public List<SearchBounds> toSearchBounds(
         RelOptCluster cluster,
         @Nullable RexNode cond,
         @Nullable ImmutableBitSet requiredColumns
     ) {
         if (cond == null)
-            return new IndexConditions();
+            return null;
 
         RelDataType rowType = tbl.getRowType(cluster.getTypeFactory());
 
-        List<RexNode> searchRow = RexUtils.buildHashSearchRow(cluster, cond, rowType, requiredColumns, true);
-
-        return new IndexConditions(searchRow, searchRow, searchRow, searchRow);
+        return RexUtils.buildHashSearchBounds(cluster, cond, rowType, requiredColumns, true);
     }
 }
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/trait/TraitUtils.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/trait/TraitUtils.java
index 29744c06070..bc9a0a81005 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/trait/TraitUtils.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/trait/TraitUtils.java
@@ -52,6 +52,8 @@ import org.apache.calcite.util.ControlFlowException;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.calcite.util.Pair;
 import org.apache.calcite.util.mapping.Mappings;
+import org.apache.ignite.internal.processors.query.calcite.externalize.RelInputEx;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteConvention;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteExchange;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteRel;
@@ -266,7 +268,7 @@ public class TraitUtils {
 
         RelTraitSet traitSet0 = traitSet;
 
-        return new RelInput() {
+        return new RelInputEx() {
             @Override public RelOptCluster getCluster() {
                 return input.getCluster();
             }
@@ -359,6 +361,14 @@ public class TraitUtils {
             @Override public boolean getBoolean(String tag, boolean default_) {
                 return input.getBoolean(tag, default_);
             }
+
+            @Override public RelCollation getCollation(String tag) {
+                return ((RelInputEx)input).getCollation(tag);
+            }
+
+            @Override public List<SearchBounds> getSearchBounds(String tag) {
+                return ((RelInputEx)input).getSearchBounds(tag);
+            }
         };
     }
 
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/IndexConditions.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/IndexConditions.java
deleted file mode 100644
index b0a0212febb..00000000000
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/IndexConditions.java
+++ /dev/null
@@ -1,142 +0,0 @@
-/*
- * 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.
- */
-
-package org.apache.ignite.internal.processors.query.calcite.util;
-
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-
-import org.apache.calcite.rel.RelInput;
-import org.apache.calcite.rel.RelWriter;
-import org.apache.calcite.rex.RexNode;
-import org.apache.ignite.internal.util.typedef.F;
-import org.apache.ignite.internal.util.typedef.internal.S;
-import org.jetbrains.annotations.Nullable;
-
-/**
- * Index conditions and bounds holder.
- * Conditions are not printed to terms (serialized). They are used only to calculate selectivity.
- */
-public class IndexConditions {
-    /** */
-    private final List<RexNode> lowerCond;
-
-    /** */
-    private final List<RexNode> upperCond;
-
-    /** */
-    private final List<RexNode> lowerBound;
-
-    /** */
-    private final List<RexNode> upperBound;
-
-    /** */
-    public IndexConditions() {
-        this(null, null, null, null);
-    }
-
-    /**
-     */
-    public IndexConditions(
-        @Nullable List<RexNode> lowerCond,
-        @Nullable List<RexNode> upperCond,
-        @Nullable List<RexNode> lowerBound,
-        @Nullable List<RexNode> upperBound
-    ) {
-        this.lowerCond = lowerCond;
-        this.upperCond = upperCond;
-        this.lowerBound = lowerBound;
-        this.upperBound = upperBound;
-    }
-
-    /** */
-    public IndexConditions(RelInput input) {
-        lowerCond = null;
-        upperCond = null;
-        lowerBound = input.get("lower") == null ? null : input.getExpressionList("lower");
-        upperBound = input.get("upper") == null ? null : input.getExpressionList("upper");
-    }
-
-    /**
-     * @return Lower index condition.
-     */
-    public List<RexNode> lowerCondition() {
-        return lowerCond;
-    }
-
-    /**
-     * @return Upper index condition.
-     */
-    public List<RexNode> upperCondition() {
-        return upperCond;
-    }
-
-    /**
-     * @return Lower index bounds (a row with values at the index columns).
-     */
-    public List<RexNode> lowerBound() {
-        return lowerBound;
-    }
-
-    /**
-     * @return Upper index bounds (a row with values at the index columns).
-     */
-    public List<RexNode> upperBound() {
-        return upperBound;
-    }
-
-    /** */
-    public Set<Integer> keys() {
-        if (upperBound == null && lowerBound == null)
-            return Collections.emptySet();
-
-        Set<Integer> keys = new HashSet<>();
-
-        int cols = lowerBound != null ? lowerBound.size() : upperBound.size();
-
-        for (int i = 0; i < cols; ++i ) {
-            if (upperBound != null && RexUtils.isNotNull(upperBound.get(i))
-                || lowerBound != null && RexUtils.isNotNull(lowerBound.get(i)))
-                keys.add(i);
-        }
-
-        return Collections.unmodifiableSet(keys);
-    }
-
-    /**
-     * Describes index bounds.
-     *
-     * @param pw Plan writer
-     * @return Plan writer for fluent-explain pattern
-     */
-    public RelWriter explainTerms(RelWriter pw) {
-        return pw
-            .itemIf("lower", lowerBound, !F.isEmpty(lowerBound))
-            .itemIf("upper", upperBound, !F.isEmpty(upperBound));
-    }
-
-    /** */
-    @Override public String toString() {
-        return S.toString(IndexConditions.class, this,
-            "lower", lowerCond,
-            "upper", upperCond,
-            "lowerBound", lowerBound,
-            "upperBound", upperBound);
-    }
-}
diff --git a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/RexUtils.java b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/RexUtils.java
index b00b1dcbf5e..e8e120edd73 100644
--- a/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/RexUtils.java
+++ b/modules/calcite/src/main/java/org/apache/ignite/internal/processors/query/calcite/util/RexUtils.java
@@ -49,6 +49,7 @@ import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.rex.RexShuttle;
 import org.apache.calcite.rex.RexSimplify;
 import org.apache.calcite.rex.RexSlot;
+import org.apache.calcite.rex.RexUnknownAs;
 import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.rex.RexVisitor;
 import org.apache.calcite.rex.RexVisitorImpl;
@@ -59,22 +60,35 @@ import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.util.ControlFlowException;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.calcite.util.Litmus;
+import org.apache.calcite.util.Sarg;
 import org.apache.calcite.util.Util;
 import org.apache.calcite.util.mapping.MappingType;
 import org.apache.calcite.util.mapping.Mappings;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.MultiBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.RangeBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.trait.TraitUtils;
 import org.apache.ignite.internal.util.typedef.F;
 import org.apache.ignite.internal.util.typedef.internal.U;
 import org.jetbrains.annotations.Nullable;
 
+import static org.apache.calcite.rex.RexUtil.removeCast;
+import static org.apache.calcite.rex.RexUtil.sargRef;
 import static org.apache.calcite.sql.SqlKind.EQUALS;
 import static org.apache.calcite.sql.SqlKind.GREATER_THAN;
 import static org.apache.calcite.sql.SqlKind.GREATER_THAN_OR_EQUAL;
+import static org.apache.calcite.sql.SqlKind.IS_NOT_NULL;
+import static org.apache.calcite.sql.SqlKind.IS_NULL;
 import static org.apache.calcite.sql.SqlKind.LESS_THAN;
 import static org.apache.calcite.sql.SqlKind.LESS_THAN_OR_EQUAL;
+import static org.apache.calcite.sql.SqlKind.SEARCH;
 
 /** */
 public class RexUtils {
+    /** Maximum amount of search bounds tuples per scan. */
+    public static final int MAX_SEARCH_BOUNDS_COMPLEXITY = 100;
+
     /** */
     public static RexNode makeCast(RexBuilder builder, RexNode node, RelDataType type) {
         return TypeUtils.needCast(builder.getTypeFactory(), node.getType(), type) ? builder.makeCast(type, node) : node;
@@ -149,17 +163,24 @@ public class RexUtils {
         return true;
     }
 
+    /** Binary comparison operations. */
+    private static final Set<SqlKind> BINARY_COMPARISON =
+        EnumSet.of(EQUALS, LESS_THAN, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL);
+
     /** Supported index operations. */
     private static final Set<SqlKind> TREE_INDEX_COMPARISON =
         EnumSet.of(
+            SEARCH,
+            IS_NULL,
+            IS_NOT_NULL,
             EQUALS,
             LESS_THAN, GREATER_THAN,
             GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL);
 
     /**
-     * Builds index conditions.
+     * Builds sorted search bounds.
      */
-    public static IndexConditions buildSortedIndexConditions(
+    public static List<SearchBounds> buildSortedSearchBounds(
         RelOptCluster cluster,
         RelCollation collation,
         RexNode condition,
@@ -167,17 +188,14 @@ public class RexUtils {
         ImmutableBitSet requiredColumns
     ) {
         if (condition == null)
-            return new IndexConditions();
+            return null;
 
         condition = RexUtil.toCnf(builder(cluster), condition);
 
         Map<Integer, List<RexCall>> fieldsToPredicates = mapPredicatesToFields(condition, cluster);
 
         if (F.isEmpty(fieldsToPredicates))
-            return new IndexConditions();
-
-        List<RexNode> lower = new ArrayList<>();
-        List<RexNode> upper = new ArrayList<>();
+            return null;
 
         // Force collation for all fields of the condition.
         if (collation == null || collation.isDefault()) {
@@ -191,6 +209,17 @@ public class RexUtils {
             collation = TraitUtils.createCollation(F.concat(true, equalsFields, otherFields));
         }
 
+        List<RelDataType> types = RelOptUtil.getFieldTypeList(rowType);
+
+        Mappings.TargetMapping mapping = null;
+
+        if (requiredColumns != null)
+            mapping = Commons.inverseMapping(requiredColumns, types.size());
+
+        List<SearchBounds> bounds = Arrays.asList(new SearchBounds[types.size()]);
+        boolean boundsEmpty = true;
+        int prevComplexity = 1;
+
         for (int i = 0; i < collation.getFieldCollations().size(); i++) {
             RelFieldCollation fc = collation.getFieldCollations().get(i);
 
@@ -201,92 +230,37 @@ public class RexUtils {
             if (F.isEmpty(collFldPreds))
                 break;
 
-            RexNode bestUpper = null;
-            RexNode bestLower = null;
-
-            for (RexCall pred : collFldPreds) {
-                if (U.assertionsEnabled()) {
-                    RexNode cond = RexUtil.removeCast(pred.operands.get(1));
-
-                    assert idxOpSupports(cond) : cond;
-                }
+            if (mapping != null)
+                collFldIdx = mapping.getSourceOpt(collFldIdx);
 
-                boolean lowerBoundBelow = !fc.getDirection().isDescending();
-                SqlOperator op = pred.getOperator();
-                switch (op.kind) {
-                    case EQUALS:
-                        bestUpper = pred;
-                        bestLower = pred;
-                        break;
-
-                    case LESS_THAN:
-                    case LESS_THAN_OR_EQUAL:
-                        lowerBoundBelow = !lowerBoundBelow;
-                        // Fall through.
-
-                    case GREATER_THAN:
-                    case GREATER_THAN_OR_EQUAL:
-                        if (lowerBoundBelow)
-                            bestLower = pred;
-                        else
-                            bestUpper = pred;
-                        break;
+            SearchBounds fldBounds = createBounds(fc, collFldPreds, cluster, types.get(collFldIdx), prevComplexity);
 
-                    default:
-                        throw new AssertionError("Unknown condition: " + op.kind);
-                }
+            if (fldBounds == null)
+                break;
 
-                if (bestUpper != null && bestLower != null)
-                    break; // We've found either "=" condition or both lower and upper.
-            }
+            boundsEmpty = false;
 
-            if (bestLower == null && bestUpper == null)
-                break; // No bounds, so break the loop.
+            bounds.set(collFldIdx, fldBounds);
 
-            if (bestLower != null && bestUpper != null) { // "x>5 AND x<10"
-                upper.add(bestUpper);
-                lower.add(bestLower);
+            if (fldBounds instanceof MultiBounds) {
+                prevComplexity *= ((MultiBounds)fldBounds).bounds().size();
 
-                if (bestLower != bestUpper)
+                // Any bounds after multi range bounds are not allowed, since it can cause intervals intersection.
+                if (((MultiBounds)fldBounds).bounds().stream().anyMatch(b -> b.type() != SearchBounds.Type.EXACT))
                     break;
             }
-            else if (bestLower != null) { // "x>5"
-                lower.add(bestLower);
-
-                break; // TODO https://issues.apache.org/jira/browse/IGNITE-13568
-            }
-            else { // "x<10"
-                upper.add(bestUpper);
 
+            if (fldBounds.type() == SearchBounds.Type.RANGE)
                 break; // TODO https://issues.apache.org/jira/browse/IGNITE-13568
-            }
         }
 
-        Mappings.TargetMapping mapping = null;
-
-        if (requiredColumns != null)
-            mapping = Commons.inverseMapping(requiredColumns, rowType.getFieldCount());
-
-        List<RexNode> lowerBound = null;
-        List<RexNode> upperBound = null;
-
-        if (!F.isEmpty(lower))
-            lowerBound = asBound(cluster, lower, rowType, mapping);
-        else
-            lower = null;
-
-        if (!F.isEmpty(upper))
-            upperBound = asBound(cluster, upper, rowType, mapping);
-        else
-            upper = null;
-
-        return new IndexConditions(lower, upper, lowerBound, upperBound);
+        return boundsEmpty ? null : bounds;
     }
 
     /**
-     * Builds index conditions.
+     * Builds hash index search bounds.
      */
-    public static List<RexNode> buildHashSearchRow(
+    public static List<SearchBounds> buildHashSearchBounds(
         RelOptCluster cluster,
         RexNode condition,
         RelDataType rowType,
@@ -300,7 +274,14 @@ public class RexUtils {
         if (F.isEmpty(fieldsToPredicates))
             return null;
 
-        List<RexNode> searchPreds = null;
+        List<SearchBounds> bounds = null;
+
+        List<RelDataType> types = RelOptUtil.getFieldTypeList(rowType);
+
+        Mappings.TargetMapping mapping = null;
+
+        if (requiredColumns != null)
+            mapping = Commons.inverseMapping(requiredColumns, types.size());
 
         for (int fldIdx : fieldsToPredicates.keySet()) {
             List<RexCall> collFldPreds = fieldsToPredicates.get(fldIdx);
@@ -309,12 +290,6 @@ public class RexUtils {
                 break;
 
             for (RexCall pred : collFldPreds) {
-                if (U.assertionsEnabled()) {
-                    RexNode cond = RexUtil.removeCast(pred.operands.get(1));
-
-                    assert idxOpSupports(cond) : cond;
-                }
-
                 if (pred.getOperator().kind != SqlKind.EQUALS) {
                     if (ignoreNotEqualPreds)
                         continue;
@@ -322,22 +297,160 @@ public class RexUtils {
                         return null;
                 }
 
-                if (searchPreds == null)
-                    searchPreds = new ArrayList<>();
+                if (bounds == null)
+                    bounds = Arrays.asList(new SearchBounds[types.size()]);
+
+                if (mapping != null)
+                    fldIdx = mapping.getSourceOpt(fldIdx);
 
-                searchPreds.add(pred);
+                bounds.set(fldIdx, createBounds(null, Collections.singletonList(pred), cluster, types.get(fldIdx), 1));
             }
         }
 
-        if (searchPreds == null)
-            return null;
+        return bounds;
+    }
 
-        Mappings.TargetMapping mapping = null;
+    /** Create index search bound by conditions of the field. */
+    private static SearchBounds createBounds(
+        @Nullable RelFieldCollation fc, // Can be null for EQUALS condition.
+        List<RexCall> collFldPreds,
+        RelOptCluster cluster,
+        RelDataType fldType,
+        int prevComplexity
+    ) {
+        RexBuilder builder = builder(cluster);
 
-        if (requiredColumns != null)
-            mapping = Commons.inverseMapping(requiredColumns, rowType.getFieldCount());
+        RexNode nullVal = builder.makeNullLiteral(fldType);
+
+        RexNode upperCond = null;
+        RexNode lowerCond = null;
+        RexNode upperBound = null;
+        RexNode lowerBound = null;
+        boolean upperInclude = true;
+        boolean lowerInclude = true;
+
+        for (RexCall pred : collFldPreds) {
+            RexNode val = null;
+
+            if (isBinaryComparison(pred)) {
+                val = removeCast(pred.operands.get(1));
+
+                assert idxOpSupports(val) : val;
+
+                val = makeCast(builder, val, fldType);
+            }
+
+            SqlOperator op = pred.getOperator();
+
+            if (op.kind == EQUALS)
+                return new ExactBounds(pred, val);
+            else if (op.kind == IS_NULL)
+                return new ExactBounds(pred, nullVal);
+            else if (op.kind == SEARCH) {
+                Sarg<?> sarg = ((RexLiteral)pred.operands.get(1)).getValueAs(Sarg.class);
+
+                int complexity = prevComplexity * sarg.complexity();
 
-        return asBound(cluster, searchPreds, rowType, mapping);
+                // Limit amount of search bounds tuples.
+                if (complexity > MAX_SEARCH_BOUNDS_COMPLEXITY)
+                    return null;
+
+                RexNode sargCond = sargRef(builder, pred.operands.get(0), sarg, fldType, RexUnknownAs.UNKNOWN);
+                List<RexNode> disjunctions = RelOptUtil.disjunctions(RexUtil.toDnf(builder, sargCond));
+                List<SearchBounds> bounds = new ArrayList<>(disjunctions.size());
+
+                for (RexNode bound : disjunctions) {
+                    List<RexNode> conjunctions = RelOptUtil.conjunctions(bound);
+                    List<RexCall> calls = new ArrayList<>(conjunctions.size());
+
+                    for (RexNode rexNode : conjunctions) {
+                        if (isSupportedTreeComparison(rexNode))
+                            calls.add((RexCall)rexNode);
+                        else
+                            return null; // Cannot filter using this predicate (NOT_EQUALS for example).
+                    }
+
+                    bounds.add(createBounds(fc, calls, cluster, fldType, complexity));
+                }
+
+                if (bounds.size() == 1)
+                    return bounds.get(0);
+
+                return new MultiBounds(pred, bounds);
+            }
+
+            // Range bounds.
+            boolean lowerBoundBelow = !fc.getDirection().isDescending();
+            switch (op.kind) {
+                case LESS_THAN:
+                case LESS_THAN_OR_EQUAL:
+                    lowerBoundBelow = !lowerBoundBelow;
+                    // Fall through.
+
+                case GREATER_THAN:
+                case GREATER_THAN_OR_EQUAL:
+                    if (lowerBoundBelow) {
+                        lowerCond = pred;
+                        lowerBound = val;
+
+                        if (op.kind == GREATER_THAN || op.kind == LESS_THAN)
+                            lowerInclude = false;
+                    }
+                    else {
+                        upperCond = pred;
+                        upperBound = val;
+
+                        if (op.kind == GREATER_THAN || op.kind == LESS_THAN)
+                            upperInclude = false;
+                    }
+
+                case IS_NOT_NULL:
+                    if (fc.nullDirection == RelFieldCollation.NullDirection.FIRST && lowerBound == null) {
+                        lowerCond = pred;
+                        lowerBound = nullVal;
+                        lowerInclude = false;
+                    }
+                    else if (fc.nullDirection == RelFieldCollation.NullDirection.LAST && upperBound == null) {
+                        upperCond = pred;
+                        upperBound = nullVal;
+                        upperInclude = false;
+                    }
+                    break;
+
+                default:
+                    throw new AssertionError("Unknown condition: " + op.kind);
+            }
+        }
+
+        if (lowerBound == null && upperBound == null)
+            return null; // No bounds.
+
+        // Found upper bound, lower bound or both.
+        RexNode cond = lowerCond == null ? upperCond :
+            upperCond == null ? lowerCond :
+                upperCond == lowerCond ? lowerCond : builder.makeCall(SqlStdOperatorTable.AND, lowerCond, upperCond);
+
+        return new RangeBounds(cond, lowerBound, upperBound, lowerInclude, upperInclude);
+    }
+
+    /**
+     * Builds index conditions.
+     */
+    public static List<RexNode> buildHashSearchRow(
+        RelOptCluster cluster,
+        RexNode condition,
+        RelDataType rowType
+    ) {
+        List<SearchBounds> searchBounds = buildHashSearchBounds(cluster, condition, rowType, null, false);
+
+        if (searchBounds == null)
+            return null;
+
+        return Commons.transform(searchBounds, b -> {
+            assert b == null || b instanceof ExactBounds : b;
+
+            return b == null ? null : ((ExactBounds)b).bound();
+        });
     }
 
     /** */
@@ -347,18 +460,28 @@ public class RexUtils {
         Map<Integer, List<RexCall>> res = new HashMap<>(conjunctions.size());
 
         for (RexNode rexNode : conjunctions) {
-            if (!isBinaryComparison(rexNode))
+            if (!isSupportedTreeComparison(rexNode))
                 continue;
 
             RexCall predCall = (RexCall)rexNode;
-            RexSlot ref = (RexSlot)extractRef(predCall);
+            RexSlot ref;
 
-            if (ref == null)
-                continue;
+            if (isBinaryComparison(rexNode)) {
+                ref = (RexSlot)extractRefFromBinary(predCall, cluster);
+
+                if (ref == null)
+                    continue;
 
-            // Let RexLocalRef be on the left side.
-            if (refOnTheRight(predCall))
-                predCall = (RexCall)RexUtil.invert(builder(cluster), predCall);
+                // Let RexLocalRef be on the left side.
+                if (refOnTheRight(predCall))
+                    predCall = (RexCall)RexUtil.invert(builder(cluster), predCall);
+            }
+            else {
+                ref = (RexSlot)extractRefFromOperand(predCall, cluster, 0);
+
+                if (ref == null)
+                    continue;
+            }
 
             List<RexCall> fldPreds = res.computeIfAbsent(ref.getIndex(), k -> new ArrayList<>(conjunctions.size()));
 
@@ -368,19 +491,36 @@ public class RexUtils {
     }
 
     /** */
-    private static RexNode extractRef(RexCall call) {
+    private static RexNode extractRefFromBinary(RexCall call, RelOptCluster cluster) {
         assert isBinaryComparison(call);
 
-        RexNode leftOp = call.getOperands().get(0);
+        RexNode leftRef = extractRefFromOperand(call, cluster, 0);
         RexNode rightOp = call.getOperands().get(1);
 
-        leftOp = RexUtil.removeCast(leftOp);
-        rightOp = RexUtil.removeCast(rightOp);
+        if (leftRef != null)
+            return idxOpSupports(removeCast(rightOp)) ? leftRef : null;
 
-        if ((leftOp instanceof RexLocalRef || leftOp instanceof RexInputRef) && idxOpSupports(rightOp))
-            return leftOp;
-        else if ((rightOp instanceof RexLocalRef || rightOp instanceof RexInputRef) && idxOpSupports(leftOp))
-            return rightOp;
+        RexNode rightRef = extractRefFromOperand(call, cluster, 1);
+        RexNode leftOp = call.getOperands().get(0);
+
+        if (rightRef != null)
+            return idxOpSupports(removeCast(leftOp)) ? rightRef : null;
+
+        return null;
+    }
+
+    /** */
+    private static RexNode extractRefFromOperand(RexCall call, RelOptCluster cluster, int operandNum) {
+        assert isSupportedTreeComparison(call);
+
+        RexNode op = call.getOperands().get(operandNum);
+
+        op = removeCast(op);
+
+        // Can proceed without ref cast only if cast was redundant in terms of values comparison.
+        if ((op instanceof RexSlot) &&
+            !TypeUtils.needCast(cluster.getTypeFactory(), op.getType(), call.getOperands().get(operandNum).getType()))
+            return op;
 
         return null;
     }
@@ -389,18 +529,24 @@ public class RexUtils {
     private static boolean refOnTheRight(RexCall predCall) {
         RexNode rightOp = predCall.getOperands().get(1);
 
-        rightOp = RexUtil.removeCast(rightOp);
+        rightOp = removeCast(rightOp);
 
         return rightOp.isA(SqlKind.LOCAL_REF) || rightOp.isA(SqlKind.INPUT_REF);
     }
 
     /** */
-    public static boolean isBinaryComparison(RexNode exp) {
-        return TREE_INDEX_COMPARISON.contains(exp.getKind()) &&
+    private static boolean isBinaryComparison(RexNode exp) {
+        return BINARY_COMPARISON.contains(exp.getKind()) &&
             (exp instanceof RexCall) &&
             ((RexCall)exp).getOperands().size() == 2;
     }
 
+    /** */
+    private static boolean isSupportedTreeComparison(RexNode exp) {
+        return TREE_INDEX_COMPARISON.contains(exp.getKind()) &&
+            (exp instanceof RexCall);
+    }
+
     /** */
     private static boolean idxOpSupports(RexNode op) {
         return op instanceof RexLiteral
@@ -416,35 +562,6 @@ public class RexUtils {
         return !(op instanceof RexLiteral) || !((RexLiteral)op).isNull();
     }
 
-    /** */
-    public static List<RexNode> asBound(RelOptCluster cluster, Iterable<RexNode> idxCond, RelDataType rowType,
-        @Nullable Mappings.TargetMapping mapping) {
-        if (F.isEmpty(idxCond))
-            return null;
-
-        RexBuilder builder = builder(cluster);
-        List<RelDataType> types = RelOptUtil.getFieldTypeList(rowType);
-        List<RexNode> res = Arrays.asList(new RexNode[types.size()]);
-
-        for (RexNode pred : idxCond) {
-            assert pred instanceof RexCall;
-
-            RexCall call = (RexCall)pred;
-            RexSlot ref = (RexSlot)RexUtil.removeCast(call.operands.get(0));
-            RexNode cond = RexUtil.removeCast(call.operands.get(1));
-
-            assert idxOpSupports(cond) : cond;
-
-            int index = mapping == null ? ref.getIndex() : mapping.getSourceOpt(ref.getIndex());
-
-            assert index != -1;
-
-            res.set(index, makeCast(builder, cond, types.get(index)));
-        }
-
-        return res;
-    }
-
     /** */
     public static Mappings.TargetMapping inversePermutation(List<RexNode> nodes, RelDataType inputRowType, boolean local) {
         final Mappings.TargetMapping mapping =
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementorTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementorTest.java
index ee6f7fd5765..b3ec5455981 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementorTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/LogicalRelImplementorTest.java
@@ -248,7 +248,7 @@ public class LogicalRelImplementorTest extends GridCommonAbstractTest {
             "IDX",
             project,
             filter,
-            RexUtils.buildSortedIndexConditions(cluster, idxCollation, filter, rowType, requiredColumns),
+            RexUtils.buildSortedSearchBounds(cluster, idxCollation, filter, rowType, requiredColumns),
             requiredColumns,
             idxCollation
         );
@@ -333,7 +333,7 @@ public class LogicalRelImplementorTest extends GridCommonAbstractTest {
             templateScan.indexName(),
             projects,
             filters,
-            templateScan.indexConditions(),
+            templateScan.searchBounds(),
             requiredColumns,
             templateScan.collation()
         );
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndexTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndexTest.java
index 0ffaae6fed9..60d75349544 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndexTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/RuntimeSortedIndexTest.java
@@ -82,7 +82,7 @@ public class RuntimeSortedIndexTest extends GridCommonAbstractTest {
                     Object[] lower = generateFindRow(rowIdLow, testIdx.getKey(), notUnique, testIdx.getValue());
                     Object[] upper = generateFindRow(rowIdUp, testIdx.getKey(), notUnique, testIdx.getValue());
 
-                    GridCursor<Object[]> cur = idx0.find(lower, upper, null);
+                    GridCursor<Object[]> cur = idx0.find(lower, upper, true, true, null);
 
                     int rows = 0;
                     while (cur.next()) {
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/SortedIndexSpoolExecutionTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/SortedIndexSpoolExecutionTest.java
index 866af24c7c2..f28e08bf682 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/SortedIndexSpoolExecutionTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/exec/rel/SortedIndexSpoolExecutionTest.java
@@ -18,6 +18,7 @@
 package org.apache.ignite.internal.processors.query.calcite.exec.rel;
 
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.UUID;
@@ -27,6 +28,8 @@ import org.apache.calcite.rel.RelCollations;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.util.ImmutableIntList;
 import org.apache.ignite.internal.processors.query.calcite.exec.ExecutionContext;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeCondition;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.type.IgniteTypeFactory;
 import org.apache.ignite.internal.processors.query.calcite.util.TypeUtils;
 import org.apache.ignite.internal.util.lang.GridTuple4;
@@ -140,8 +143,7 @@ public class SortedIndexSpoolExecutionTest extends AbstractExecutionTest {
                 RelCollations.of(ImmutableIntList.of(0)),
                 (o1, o2) -> o1[0] != null ? ((Comparable)o1[0]).compareTo(o2[0]) : 0,
                 testFilter,
-                () -> lower,
-                () -> upper
+                new StaticRangeIterable(lower, upper)
             );
 
             spool.register(Arrays.asList(scan));
@@ -188,8 +190,7 @@ public class SortedIndexSpoolExecutionTest extends AbstractExecutionTest {
             collation,
             ctx.expressionFactory().comparator(collation),
             v -> true,
-            () -> lower,
-            () -> upper
+            new StaticRangeIterable(lower, upper)
         );
 
         spool.register(scan);
@@ -239,4 +240,47 @@ public class SortedIndexSpoolExecutionTest extends AbstractExecutionTest {
                 return delegate.test(objects);
         }
     }
+
+    /** */
+    private static class StaticRangeIterable implements RangeIterable<Object[]> {
+        /** */
+        private final Object[] lower;
+
+        /** */
+        private final Object[] upper;
+
+        /** */
+        private StaticRangeIterable(Object[] lower, Object[] upper) {
+            this.lower = lower;
+            this.upper = upper;
+        }
+
+        /** {@inheritDoc} */
+        @Override public int size() {
+            return 1;
+        }
+
+        /** {@inheritDoc} */
+        @Override public Iterator<RangeCondition<Object[]>> iterator() {
+            RangeCondition<Object[]> range = new RangeCondition<Object[]>() {
+                @Override public Object[] lower() {
+                    return lower;
+                }
+
+                @Override public Object[] upper() {
+                    return upper;
+                }
+
+                @Override public boolean lowerInclude() {
+                    return true;
+                }
+
+                @Override public boolean upperInclude() {
+                    return true;
+                }
+            };
+
+            return Collections.singleton(range).iterator();
+        }
+    }
 }
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/AbstractBasicIntegrationTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/AbstractBasicIntegrationTest.java
index 6e8dd456fb4..3add7c03e23 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/AbstractBasicIntegrationTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/AbstractBasicIntegrationTest.java
@@ -20,7 +20,6 @@ package org.apache.ignite.internal.processors.query.calcite.integration;
 import java.util.List;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.plan.RelOptCluster;
 import org.apache.calcite.plan.RelOptTable;
 import org.apache.calcite.rel.RelCollation;
@@ -39,12 +38,13 @@ import org.apache.ignite.internal.processors.query.QueryEngine;
 import org.apache.ignite.internal.processors.query.calcite.CalciteQueryProcessor;
 import org.apache.ignite.internal.processors.query.calcite.QueryChecker;
 import org.apache.ignite.internal.processors.query.calcite.exec.ExecutionContext;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.metadata.ColocationGroup;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.logical.IgniteLogicalIndexScan;
 import org.apache.ignite.internal.processors.query.calcite.schema.IgniteIndex;
 import org.apache.ignite.internal.processors.query.calcite.schema.IgniteTable;
 import org.apache.ignite.internal.processors.query.calcite.util.Commons;
-import org.apache.ignite.internal.processors.query.calcite.util.IndexConditions;
 import org.apache.ignite.internal.util.typedef.F;
 import org.apache.ignite.internal.util.typedef.G;
 import org.apache.ignite.testframework.junits.WithSystemProperty;
@@ -226,12 +226,12 @@ public class AbstractBasicIntegrationTest extends GridCommonAbstractTest {
         }
 
         /** {@inheritDoc} */
-        @Override public IndexConditions toIndexCondition(
+        @Override public List<SearchBounds> toSearchBounds(
             RelOptCluster cluster,
             @Nullable RexNode cond,
             @Nullable ImmutableBitSet requiredColumns
         ) {
-            return delegate.toIndexCondition(cluster, cond, requiredColumns);
+            return delegate.toSearchBounds(cluster, cond, requiredColumns);
         }
 
         /** {@inheritDoc} */
@@ -239,13 +239,11 @@ public class AbstractBasicIntegrationTest extends GridCommonAbstractTest {
             ExecutionContext<Row> execCtx,
             ColocationGroup grp,
             Predicate<Row> filters,
-            Supplier<Row> lowerIdxConditions,
-            Supplier<Row> upperIdxConditions,
+            RangeIterable<Row> ranges,
             Function<Row, Row> rowTransformer,
             @Nullable ImmutableBitSet requiredColumns
         ) {
-            return delegate.scan(execCtx, grp, filters, lowerIdxConditions, upperIdxConditions, rowTransformer,
-                requiredColumns);
+            return delegate.scan(execCtx, grp, filters, ranges, rowTransformer, requiredColumns);
         }
 
         /** {@inheritDoc} */
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/CalciteBasicSecondaryIndexIntegrationTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/CalciteBasicSecondaryIndexIntegrationTest.java
index 9987405a5c7..6415d3bf50a 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/CalciteBasicSecondaryIndexIntegrationTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/CalciteBasicSecondaryIndexIntegrationTest.java
@@ -881,10 +881,8 @@ public class CalciteBasicSecondaryIndexIntegrationTest extends AbstractBasicInte
 
     /** */
     @Test
-    @Ignore("https://issues.apache.org/jira/browse/IGNITE-13710")
     public void testOrCondition2() {
         assertQuery("SELECT * FROM Developer WHERE name='Mozart' AND (depId=1 OR depId=3)")
-            .matches(containsUnion(true))
             .matches(containsIndexScan("PUBLIC", "DEVELOPER", NAME_DEPID_CITY_IDX))
             .returns(1, "Mozart", 3, "Vienna", 33)
             .check();
@@ -892,10 +890,8 @@ public class CalciteBasicSecondaryIndexIntegrationTest extends AbstractBasicInte
 
     /** */
     @Test
-    @Ignore("https://issues.apache.org/jira/browse/IGNITE-13710")
     public void testOrCondition3() {
         assertQuery("SELECT * FROM Developer WHERE name='Mozart' AND (age > 22 AND (depId=1 OR depId=3))")
-            .matches(containsUnion(true))
             .matches(containsIndexScan("PUBLIC", "DEVELOPER", NAME_DEPID_CITY_IDX))
             .returns(1, "Mozart", 3, "Vienna", 33)
             .check();
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/IndexScanlIntegrationTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/IndexScanlIntegrationTest.java
index 29f6b5fc36d..53457074fd5 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/IndexScanlIntegrationTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/IndexScanlIntegrationTest.java
@@ -20,13 +20,13 @@ package org.apache.ignite.internal.processors.query.calcite.integration;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Function;
 import java.util.function.Predicate;
-import java.util.function.Supplier;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.IgniteCache;
 import org.apache.ignite.cache.QueryEntity;
 import org.apache.ignite.configuration.CacheConfiguration;
 import org.apache.ignite.internal.processors.query.calcite.QueryChecker;
 import org.apache.ignite.internal.processors.query.calcite.exec.ExecutionContext;
+import org.apache.ignite.internal.processors.query.calcite.exec.exp.RangeIterable;
 import org.apache.ignite.internal.processors.query.calcite.metadata.ColocationGroup;
 import org.apache.ignite.internal.processors.query.calcite.schema.IgniteTable;
 import org.apache.ignite.internal.util.typedef.F;
@@ -57,8 +57,7 @@ public class IndexScanlIntegrationTest extends AbstractBasicIntegrationTest {
                 ExecutionContext<Row> execCtx,
                 ColocationGroup grp,
                 Predicate<Row> filters,
-                Supplier<Row> lowerIdxConditions,
-                Supplier<Row> upperIdxConditions,
+                RangeIterable<Row> ranges,
                 Function<Row, Row> rowTransformer,
                 @Nullable ImmutableBitSet requiredColumns
             ) {
@@ -70,8 +69,7 @@ public class IndexScanlIntegrationTest extends AbstractBasicIntegrationTest {
 
                 filters = filter.and(filters);
 
-                return delegate.scan(execCtx, grp, filters, lowerIdxConditions, upperIdxConditions, rowTransformer,
-                    requiredColumns);
+                return delegate.scan(execCtx, grp, filters, ranges, rowTransformer, requiredColumns);
             }
         });
 
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/SearchSargOnIndexIntegrationTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/SearchSargOnIndexIntegrationTest.java
new file mode 100644
index 00000000000..396c42e7784
--- /dev/null
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/integration/SearchSargOnIndexIntegrationTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.
+ */
+package org.apache.ignite.internal.processors.query.calcite.integration;
+
+import org.apache.ignite.internal.processors.query.calcite.QueryChecker;
+import org.hamcrest.CoreMatchers;
+import org.junit.Test;
+
+/**
+ * Tests of SEARCH/SARG operator.
+ */
+public class SearchSargOnIndexIntegrationTest extends AbstractBasicIntegrationTest {
+    /** {@inheritDoc} */
+    @Override protected void beforeTestsStarted() throws Exception {
+        super.beforeTestsStarted();
+
+        sql("CREATE TABLE test (c1 INTEGER, c2 VARCHAR, c3 INTEGER)");
+        sql("CREATE INDEX c1c2c3 ON test(c1, c2, c3)");
+
+        sql("INSERT INTO test VALUES (0, null, 0)");
+
+        for (int i = 0; i <= 5; i++) {
+            for (int j = 1; j <= 5; j++)
+                sql("INSERT INTO test VALUES (?, ?, ?)", i == 0 ? null : i, Integer.toString(j), i * j);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() {
+        // Skip super method to keep caches after each test.
+    }
+
+    /** */
+    @Test
+    public void testIn() {
+        assertQuery("SELECT * FROM test WHERE c1 IN (2, 3) AND c2 IN ('2', '3') AND c3 IN (6, 9)")
+            .returns(2, "3", 6)
+            .returns(3, "2", 6)
+            .returns(3, "3", 9)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE (c1 = 2 OR c1 IS NULL) AND c2 IN ('2', '3') AND c3 IN (0, 6)")
+            .returns(null, "2", 0)
+            .returns(null, "3", 0)
+            .returns(2, "3", 6)
+            .check();
+    }
+
+    /** */
+    @Test
+    public void testRange() {
+        assertQuery("SELECT * FROM test WHERE ((c1 > 1 AND c1 < 3) OR (c1 > 3 AND c1 < 5)) AND c2 > '2' AND c2 < '5'")
+            .returns(2, "3", 6)
+            .returns(2, "4", 8)
+            .returns(4, "3", 12)
+            .returns(4, "4", 16)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE c1 IN (1, 2) AND ((c2 >= '2' AND c2 < '3') OR (c2 > '4' AND c1 <= '5'))")
+            .returns(1, "2", 2)
+            .returns(1, "5", 5)
+            .returns(2, "2", 4)
+            .returns(2, "5", 10)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE c1 IN (1, 2) AND " +
+            "(c2 < '1' OR (c2 >= '2' AND c2 < '3') OR (c2 > '2' AND c2 < '4') OR c2 > '5')")
+            .returns(1, "2", 2)
+            .returns(1, "3", 3)
+            .returns(2, "2", 4)
+            .returns(2, "3", 6)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE c1 = 4 AND c2 > '3' AND c3 in (16, 20)")
+            .returns(4, "4", 16)
+            .returns(4, "5", 20)
+            .check();
+    }
+
+    /** */
+    @Test
+    public void testNulls() {
+        assertQuery("SELECT * FROM test WHERE c1 IS NULL AND c2 <= '1'")
+            .returns(null, "1", 0)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE c1 IS NOT NULL AND c2 IS NULL")
+            .returns(0, null, 0)
+            .check();
+    }
+
+    /** Tests not supported SEARCH/SARG conditions. */
+    @Test
+    public void testNot() {
+        assertQuery("SELECT * FROM test WHERE c1 <> 1 AND c3 = 6")
+            .matches(QueryChecker.containsTableScan("PUBLIC", "TEST")) // Can't use index scan.
+            .returns(2, "3", 6)
+            .returns(3, "2", 6)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE c1 NOT IN (1, 2, 5) AND c2 NOT IN (1, 2, 5)")
+            .matches(QueryChecker.containsTableScan("PUBLIC", "TEST")) // Can't use index scan.
+            .returns(3, "3", 9)
+            .returns(3, "4", 12)
+            .returns(4, "3", 12)
+            .returns(4, "4", 16)
+            .check();
+    }
+
+    /** Test correct index ordering without additional sorting. */
+    @Test
+    public void testOrdering() {
+        assertQuery("SELECT * FROM test WHERE c1 IN (3, 2) AND c2 IN ('3', '2') ORDER BY c1, c2, c3")
+            .matches(CoreMatchers.not(QueryChecker.containsSubPlan("IgniteSort"))) // Don't require additional sorting.
+            .ordered()
+            .returns(2, "2", 4)
+            .returns(2, "3", 6)
+            .returns(3, "2", 6)
+            .returns(3, "3", 9)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE c1 IN (2, 3) AND c2 < '3' ORDER BY c1, c2, c3")
+            .matches(CoreMatchers.not(QueryChecker.containsSubPlan("IgniteSort"))) // Don't require additional sorting.
+            .ordered()
+            .returns(2, "1", 2)
+            .returns(2, "2", 4)
+            .returns(3, "1", 3)
+            .returns(3, "2", 6)
+            .check();
+
+        assertQuery("SELECT * FROM test WHERE c1 IN (2, 3) AND c2 IN ('2', '3') AND c3 BETWEEN 5 AND 7 " +
+            "ORDER BY c1, c2, c3")
+            .matches(CoreMatchers.not(QueryChecker.containsSubPlan("IgniteSort"))) // Don't require additional sorting.
+            .ordered()
+            .returns(2, "3", 6)
+            .returns(3, "2", 6)
+            .check();
+    }
+}
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/AbstractPlannerTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/AbstractPlannerTest.java
index 9a62151ed58..a1ecdecb5c5 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/AbstractPlannerTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/AbstractPlannerTest.java
@@ -125,7 +125,7 @@ public abstract class AbstractPlannerTest extends GridCommonAbstractTest {
     protected volatile Throwable lastE;
 
     /** Last error message. */
-    private String lastErrorMsg;
+    protected String lastErrorMsg;
 
     /** */
     @Before
@@ -399,7 +399,7 @@ public abstract class AbstractPlannerTest extends GridCommonAbstractTest {
     }
 
     /** */
-    protected static void createTable(IgniteSchema schema, String name, RelDataType type, IgniteDistribution distr,
+    protected static TestTable createTable(IgniteSchema schema, String name, RelDataType type, IgniteDistribution distr,
         List<List<UUID>> assignment) {
         TestTable table = new TestTable(type) {
             @Override public ColocationGroup colocationGroup(MappingQueryContext ctx) {
@@ -419,6 +419,8 @@ public abstract class AbstractPlannerTest extends GridCommonAbstractTest {
         };
 
         schema.addTable(name, table);
+
+        return table;
     }
 
     /** */
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/CorrelatedNestedLoopJoinPlannerTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/CorrelatedNestedLoopJoinPlannerTest.java
index 812ec10c297..06051ccb0ff 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/CorrelatedNestedLoopJoinPlannerTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/CorrelatedNestedLoopJoinPlannerTest.java
@@ -21,7 +21,8 @@ import java.util.List;
 import org.apache.calcite.plan.RelOptUtil;
 import org.apache.calcite.rel.type.RelDataTypeFactory;
 import org.apache.calcite.rex.RexFieldAccess;
-import org.apache.calcite.rex.RexNode;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteIndexScan;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteRel;
 import org.apache.ignite.internal.processors.query.calcite.schema.IgniteSchema;
@@ -93,23 +94,15 @@ public class CorrelatedNestedLoopJoinPlannerTest extends AbstractPlannerTest {
 
         IgniteIndexScan idxScan = findFirstNode(phys, byClass(IgniteIndexScan.class));
 
-        List<RexNode> lBound = idxScan.lowerBound();
+        List<SearchBounds> searchBounds = idxScan.searchBounds();
 
-        assertNotNull("Invalid plan\n" + RelOptUtil.toString(phys), lBound);
-        assertEquals(3, lBound.size());
+        assertNotNull("Invalid plan\n" + RelOptUtil.toString(phys), searchBounds);
+        assertEquals(3, searchBounds.size());
 
-        assertNull(lBound.get(0));
-        assertTrue(lBound.get(1) instanceof RexFieldAccess);
-        assertNull(lBound.get(2));
-
-        List<RexNode> uBound = idxScan.upperBound();
-
-        assertNotNull("Invalid plan\n" + RelOptUtil.toString(phys), uBound);
-        assertEquals(3, uBound.size());
-
-        assertNull(uBound.get(0));
-        assertTrue(uBound.get(1) instanceof RexFieldAccess);
-        assertNull(uBound.get(2));
+        assertNull(searchBounds.get(0));
+        assertTrue(searchBounds.get(1) instanceof ExactBounds);
+        assertTrue(((ExactBounds)searchBounds.get(1)).bound() instanceof RexFieldAccess);
+        assertNull(searchBounds.get(2));
     }
 
     /**
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/ProjectFilterScanMergePlannerTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/ProjectFilterScanMergePlannerTest.java
index 225bbfc0232..7eb17b7eaef 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/ProjectFilterScanMergePlannerTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/ProjectFilterScanMergePlannerTest.java
@@ -17,10 +17,15 @@
 
 package org.apache.ignite.internal.processors.query.calcite.planner;
 
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.util.ImmutableBitSet;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteAggregate;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteIndexScan;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteTableScan;
@@ -95,8 +100,7 @@ public class ProjectFilterScanMergePlannerTest extends AbstractPlannerTest {
             .and(scan -> scan.condition() != null)
             .and(scan -> "=($t2, 0)".equals(scan.condition().toString()))
             .and(scan -> ImmutableBitSet.of(0, 1, 2).equals(scan.requiredColumns()))
-            .and(scan -> "[=($t2, 0)]".equals(scan.indexConditions().lowerCondition().toString()))
-            .and(scan -> "[=($t2, 0)]".equals(scan.indexConditions().upperCondition().toString()))
+            .and(scan -> "[=($t2, 0)]".equals(searchBoundsCondition(scan.searchBounds()).toString()))
         );
 
         // Index condition shifted according to requiredColumns.
@@ -106,8 +110,7 @@ public class ProjectFilterScanMergePlannerTest extends AbstractPlannerTest {
             .and(scan -> scan.condition() != null)
             .and(scan -> "=($t1, 0)".equals(scan.condition().toString()))
             .and(scan -> ImmutableBitSet.of(1, 2).equals(scan.requiredColumns()))
-            .and(scan -> "[=($t1, 0)]".equals(scan.indexConditions().lowerCondition().toString()))
-            .and(scan -> "[=($t1, 0)]".equals(scan.indexConditions().upperCondition().toString()))
+            .and(scan -> "[=($t1, 0)]".equals(searchBoundsCondition(scan.searchBounds()).toString()))
         );
     }
 
@@ -124,8 +127,7 @@ public class ProjectFilterScanMergePlannerTest extends AbstractPlannerTest {
             .and(scan -> scan.condition() != null)
             .and(scan -> "=($t2, 0)".equals(scan.condition().toString()))
             .and(scan -> ImmutableBitSet.of(0, 1, 2).equals(scan.requiredColumns()))
-            .and(scan -> "[=($t2, 0)]".equals(scan.indexConditions().lowerCondition().toString()))
-            .and(scan -> "[=($t2, 0)]".equals(scan.indexConditions().upperCondition().toString()))
+            .and(scan -> "[=($t2, 0)]".equals(searchBoundsCondition(scan.searchBounds()).toString()))
         );
 
         // Index condition shift and identity.
@@ -134,8 +136,7 @@ public class ProjectFilterScanMergePlannerTest extends AbstractPlannerTest {
             .and(scan -> scan.condition() != null)
             .and(scan -> "=($t1, 0)".equals(scan.condition().toString()))
             .and(scan -> ImmutableBitSet.of(1, 2).equals(scan.requiredColumns()))
-            .and(scan -> "[=($t1, 0)]".equals(scan.indexConditions().lowerCondition().toString()))
-            .and(scan -> "[=($t1, 0)]".equals(scan.indexConditions().upperCondition().toString()))
+            .and(scan -> "[=($t1, 0)]".equals(searchBoundsCondition(scan.searchBounds()).toString()))
         );
     }
 
@@ -244,4 +245,9 @@ public class ProjectFilterScanMergePlannerTest extends AbstractPlannerTest {
                 .and(scan -> ImmutableBitSet.of(0, 2).equals(scan.requiredColumns())),
             "ProjectFilterTransposeRule", "FilterProjectTransposeRule");
     }
+
+    /** */
+    private static List<RexNode> searchBoundsCondition(List<SearchBounds> searchBounds) {
+        return searchBounds.stream().filter(Objects::nonNull).map(SearchBounds::condition).collect(Collectors.toList());
+    }
 }
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/SearchSargOnIndexPlannerTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/SearchSargOnIndexPlannerTest.java
new file mode 100644
index 00000000000..a77a0fcd582
--- /dev/null
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/SearchSargOnIndexPlannerTest.java
@@ -0,0 +1,436 @@
+/*
+ * 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.
+ */
+
+package org.apache.ignite.internal.processors.query.calcite.planner;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.function.Predicate;
+import java.util.stream.IntStream;
+import org.apache.calcite.rel.RelCollations;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeFactory;
+import org.apache.calcite.rex.RexLiteral;
+import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexUtil;
+import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.MultiBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.RangeBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
+import org.apache.ignite.internal.processors.query.calcite.rel.IgniteIndexScan;
+import org.apache.ignite.internal.processors.query.calcite.rel.IgniteUnionAll;
+import org.apache.ignite.internal.processors.query.calcite.schema.IgniteSchema;
+import org.apache.ignite.internal.processors.query.calcite.trait.IgniteDistributions;
+import org.apache.ignite.internal.processors.query.calcite.trait.TraitUtils;
+import org.apache.ignite.internal.processors.query.calcite.type.IgniteTypeFactory;
+import org.apache.ignite.internal.processors.query.calcite.type.IgniteTypeSystem;
+import org.apache.ignite.internal.processors.query.calcite.util.RexUtils;
+import org.junit.Test;
+
+/**
+ * Planner test for SEARCH/SARG condition on indexed fields.
+ */
+public class SearchSargOnIndexPlannerTest extends AbstractPlannerTest {
+    /** */
+    private IgniteSchema publicSchema;
+
+    /** */
+    private TestTable tbl;
+
+    /** {@inheritDoc} */
+    @Override protected void beforeTest() throws Exception {
+        IgniteTypeFactory f = new IgniteTypeFactory(IgniteTypeSystem.INSTANCE);
+
+        RelDataType type = new RelDataTypeFactory.Builder(f)
+            .add("C1", f.createTypeWithNullability(f.createSqlType(SqlTypeName.INTEGER), true))
+            .add("C2", f.createTypeWithNullability(f.createSqlType(SqlTypeName.VARCHAR), true))
+            .add("C3", f.createTypeWithNullability(f.createSqlType(SqlTypeName.INTEGER), true))
+            .add("C4", f.createTypeWithNullability(f.createSqlType(SqlTypeName.INTEGER), true))
+            .build();
+
+        publicSchema = new IgniteSchema("PUBLIC");
+
+        tbl = createTable(publicSchema, "TEST", type, IgniteDistributions.single(), null);
+
+        tbl.addIndex("C1C2C3", 0, 1, 2);
+    }
+
+    /** Simple case on one field, without multi tuple SEARCH/SARG. */
+    @Test
+    public void testBoundsOneFieldSingleTuple() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1", exact(1));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C3 = 1", exact(1), empty(), empty(), empty());
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C1 <= 3",
+            range(1, 3, false, true));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 < 3 AND C1 IS NOT NULL",
+            range("null", 3, false, false));
+
+        // Redundant "IS NOT NULL condition".
+        assertBounds("SELECT * FROM TEST WHERE C1 > 3 AND C1 IS NOT NULL",
+            range(3, null, false, true));
+
+        // C4 field not in collation.
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C1 <= 3 AND C4 = 1",
+            range(1, 3, false, true),
+            empty(),
+            empty(),
+            empty()
+        );
+
+        // Cannot proceed to C3 without C2.
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C3 = 1",
+            exact(1),
+            empty(),
+            empty(),
+            empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C1 <= 3 AND C3 = 1",
+            range(1, 3, false, true),
+            empty(),
+            empty(),
+            empty()
+        );
+    }
+
+    /** Simple SEARCH/SARG. */
+    @Test
+    public void testBoundsOneFieldSearch() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3)",
+            multi(exact(1), exact(2), exact(3)));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3) AND C1 IS NOT NULL",
+            multi(exact(1), exact(2), exact(3)));
+
+        assertBounds("SELECT * FROM TEST WHERE (C1 > 1 AND C1 < 3) OR C1 IN (4, 5) OR C1 = 6 OR C1 > 7",
+            multi(
+                range(1, 3, false, false),
+                exact(4),
+                exact(5),
+                exact(6),
+                range(7, null, false, true)));
+    }
+
+    /** Simple SEARCH/SARG, values deduplication. */
+    @Test
+    public void testBoundsOneFieldSearchDeduplication() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3, 2, 1)",
+            multi(exact(1), exact(2), exact(3)));
+    }
+
+    /** Simple SEARCH/SARG, range optimization. */
+    @Test
+    public void testBoundsOneFieldSearchRangeOptimization() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE (C1 > 1 AND C1 < 4) OR (C1 > 3 AND C1 < 5) OR (C1 > 7) OR (C1 > 6)",
+            multi(
+                range(1, 5, false, false),
+                range(6, null, false, true)));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C1 < 3 AND C1 <> 2",
+            multi(
+                range(1, 2, false, false),
+                range(2, 3, false, false)));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND " +
+                "((C2 > '1' AND C2 < '3') OR (C2 > '11' AND C2 < '33') OR C2 > '4')",
+            exact(1),
+            multi(
+                range("1", "33", false, false),
+                range("4", null, false, true)));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND (C2 > '1' OR C2 < '3')",
+            exact(1),
+            range("null", null, false, true));
+    }
+
+    /** Simple SEARCH/SARG with "IS NULL" condition. */
+    @Test
+    public void testBoundsOneFieldSearchWithNull() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3) OR C1 IS NULL",
+            multi(exact("null"), exact(1), exact(2), exact(3)),
+            empty(),
+            empty(),
+            empty()
+        );
+    }
+
+    /** Tests bounds with DESC ordering. */
+    @Test
+    public void testBoundsDescOrdering() throws Exception {
+        tbl.addIndex(RelCollations.of(TraitUtils.createFieldCollation(3, false),
+            TraitUtils.createFieldCollation(2, true)), "C4");
+
+        assertBounds("SELECT * FROM TEST WHERE C4 > 1",
+            empty(),
+            empty(),
+            empty(),
+            range(null, 1, true, false));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 < 1",
+            empty(),
+            empty(),
+            empty(),
+            range(1, "null", false, false));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 IS NULL", empty(), empty(), empty(), exact("null"));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 IS NOT NULL",
+            empty(),
+            empty(),
+            empty(),
+            range(null, "null", true, false));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 IN (1, 2, 3) AND C3 > 1",
+            empty(),
+            empty(),
+            range(1, null, false, true),
+            multi(exact(1), exact(2), exact(3))
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE ((C4 > 1 AND C4 < 5) OR (C4 > 7 AND C4 < 9)) AND C3 = 1",
+            empty(),
+            empty(),
+            empty(),
+            multi(
+                range(5, 1, false, false),
+                range(9, 7, false, false))
+        );
+    }
+
+    /** Tests bounds with conditions on several fields. */
+    @Test
+    public void testBoundsSeveralFieldsSearch() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 IN ('a', 'b')",
+            exact(1),
+            multi(exact("a"), exact("b"))
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 > 'a'",
+            exact(1),
+            range("a", null, false, true)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3) AND C2 = 'a'",
+            multi(exact(1), exact(2), exact(3)),
+            exact("a")
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3) AND C2 IN ('a', 'b')",
+            multi(exact(1), exact(2), exact(3)),
+            multi(exact("a"), exact("b"))
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3) AND C2 IN ('a', 'b') AND C3 IN (4, 5) AND C4 = 1",
+            multi(exact(1), exact(2), exact(3)),
+            multi(exact("a"), exact("b")),
+            multi(exact(4), exact(5)),
+            empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3) AND C2 IN ('a', 'b') AND C3 > 4",
+            multi(exact(1), exact(2), exact(3)),
+            multi(exact("a"), exact("b")),
+            range(4, null, false, true)
+        );
+
+        // Cannot proceed to the next field after the range condition.
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C2 = 'a'",
+            range(1, null, false, true),
+            empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C2 > 'a'",
+            range(1, null, false, true),
+            empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 >= 1 AND C2 = 'a'",
+            range(1, null, true, true),
+            empty() // TODO https://issues.apache.org/jira/browse/IGNITE-13568 Fix to exact("a")
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 >= 1 AND C2 > 'a'",
+            range(1, null, true, true),
+            empty() // TODO https://issues.apache.org/jira/browse/IGNITE-13568 Fix to range("a", null, false, true)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 >= 1 AND C2 < 'a'",
+            range(1, null, true, true),
+            empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 >= 1 AND C2 IN ('a', 'b')",
+            range(1, null, true, true),
+            empty()
+        );
+
+        // Cannot proceed to the next field after SEARCH/SARG with range condition.
+        assertBounds("SELECT * FROM TEST WHERE ((C1 > 1 AND C1 < 3) OR C1 > 5) AND C2 = 'a'",
+            multi(
+                range(1, 3, false, false),
+                range(5, null, false, true)),
+            empty()
+        );
+    }
+
+    /** Tests max complexity of SEARCH/SARG to include into index scan. */
+    @Test
+    public void testBoundsMaxComplexity() throws Exception {
+        int limit = RexUtils.MAX_SEARCH_BOUNDS_COMPLEXITY;
+
+        String inVals = String.join(", ", IntStream.range(0, limit + 1).mapToObj(Integer::toString)
+            .toArray(String[]::new));
+
+        assertPlan("SELECT * FROM TEST WHERE C1 IN (" + inVals + ")", publicSchema, isTableScan("TEST"));
+
+        inVals = String.join(", ", IntStream.range(0, limit / 10 + 1).mapToObj(Integer::toString)
+            .toArray(String[]::new));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 IN (1, 2, 3, 4, 5) AND C2 IN ('a', 'b') AND C3 IN (" + inVals + ")",
+            multi(exact(1), exact(2), exact(3), exact(4), exact(5)),
+            multi(exact("a"), exact("b")),
+            empty()
+        );
+    }
+
+    /** Tests bounds with wrong literal types. */
+    @Test
+    public void testBoundsTypeConversion() throws Exception {
+        // Implicit cast of all filter values to INTEGER.
+        assertBounds("SELECT * FROM TEST WHERE C1 IN ('1', '2', '3')",
+            multi(exact(1), exact(2), exact(3))
+        );
+
+        // Implicit cast of '1' to INTEGER.
+        assertBounds("SELECT * FROM TEST WHERE C1 IN ('1', 2, 3)",
+            multi(exact(1), exact(2), exact(3))
+        );
+
+        // Casted to INTEGER type C2 column cannot be used as index bound.
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 > 1",
+            exact(1),
+            empty()
+        );
+
+        // Casted to INTEGER type C2 column cannot be used as index bound.
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 IN (2, 3)",
+            exact(1),
+            empty()
+        );
+
+        // Implicit cast of 2 to VARCHAR.
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 IN (2, '3')",
+            exact(1),
+            multi(exact("2"), exact("3"))
+        );
+    }
+
+    /** Tests bounds with dynamic parameters. */
+    @Test
+    public void testBoundsDynamicParams() throws Exception {
+        // Cannot optimize dynamic parameters to SEARCH/SARG, query is splitted by or-to-union rule.
+        assertPlan("SELECT * FROM TEST WHERE C1 IN (?, ?)", publicSchema, isInstanceOf(IgniteUnionAll.class)
+            .and(input(0, isIndexScan("TEST", "C1C2C3")))
+            .and(input(1, isIndexScan("TEST", "C1C2C3")))
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = ? AND C2 IN ('a', 'b')",
+            exact("?0"),
+            multi(exact("a"), exact("b"))
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = ? AND C2 > ? AND C2 < ?",
+            exact("?0"),
+            range("?1", "?2", false, false)
+        );
+    }
+
+    /** Tests bounds with correlated value. */
+    @Test
+    public void testBoundsWithCorrelate() throws Exception {
+        assertBounds("SELECT (SELECT C1 FROM TEST t2 WHERE t2.C1 = t1.C1) FROM TEST t1",
+            exact("$cor0.C1")
+        );
+
+        assertBounds(
+            "SELECT (SELECT C1 FROM TEST t2 WHERE C1 = 1 AND C2 = 'a' AND C3 IN (t1.C3, 0, 1, 2)) FROM TEST t1",
+            exact(1),
+            exact("a"),
+            empty()
+        );
+    }
+
+    /** */
+    private void assertBounds(String sql, Predicate<SearchBounds>... predicates) throws Exception {
+        assertPlan(sql, publicSchema, nodeOrAnyChild(isInstanceOf(IgniteIndexScan.class)
+            .and(scan -> matchBounds(scan.searchBounds(), predicates))));
+    }
+
+    /** */
+    private boolean matchBounds(List<SearchBounds> searchBounds, Predicate<SearchBounds>... predicates) {
+        for (int i = 0; i < predicates.length; i++) {
+            if (!predicates[i].test(searchBounds.get(i))) {
+                lastErrorMsg = "Not expected bounds: " + searchBounds.get(i);
+
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /** */
+    private static Predicate<SearchBounds> empty() {
+        return Objects::isNull;
+    }
+
+    /** */
+    private static Predicate<SearchBounds> exact(Object val) {
+        return b -> b instanceof ExactBounds && matchValue(val, ((ExactBounds)b).bound());
+    }
+
+    /** */
+    private static Predicate<SearchBounds> range(Object lower, Object upper, boolean lowerInclude, boolean upperInclude) {
+        return b -> b instanceof RangeBounds &&
+            matchValue(lower, ((RangeBounds)b).lowerBound()) &&
+            matchValue(upper, ((RangeBounds)b).upperBound()) &&
+            lowerInclude == ((RangeBounds)b).lowerInclude() &&
+            upperInclude == ((RangeBounds)b).upperInclude();
+    }
+
+    /** */
+    private Predicate<SearchBounds> multi(Predicate<SearchBounds>... predicates) {
+        return b -> b instanceof MultiBounds &&
+            ((MultiBounds)b).bounds().size() == predicates.length &&
+            matchBounds(((MultiBounds)b).bounds(), predicates);
+    }
+
+    /** */
+    private static boolean matchValue(Object val, RexNode bound) {
+        if (val == null || bound == null)
+            return val == bound;
+
+        bound = RexUtil.removeCast(bound);
+
+        return Objects.toString(val).equals(Objects.toString(
+            bound instanceof RexLiteral ? ((RexLiteral)bound).getValueAs(val.getClass()) : bound));
+    }
+}
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/SortedIndexSpoolPlannerTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/SortedIndexSpoolPlannerTest.java
index 096adfedf96..54b8f4893ea 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/SortedIndexSpoolPlannerTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/SortedIndexSpoolPlannerTest.java
@@ -23,7 +23,10 @@ import org.apache.calcite.plan.RelOptUtil;
 import org.apache.calcite.rel.RelCollations;
 import org.apache.calcite.rel.type.RelDataTypeFactory;
 import org.apache.calcite.rex.RexFieldAccess;
-import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexLiteral;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.RangeBounds;
+import org.apache.ignite.internal.processors.query.calcite.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteCorrelatedNestedLoopJoin;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteRel;
 import org.apache.ignite.internal.processors.query.calcite.rel.IgniteSortedIndexSpool;
@@ -95,23 +98,15 @@ public class SortedIndexSpoolPlannerTest extends AbstractPlannerTest {
 
         IgniteSortedIndexSpool idxSpool = findFirstNode(phys, byClass(IgniteSortedIndexSpool.class));
 
-        List<RexNode> lBound = idxSpool.indexCondition().lowerBound();
+        List<SearchBounds> searchBounds = idxSpool.searchBounds();
 
-        assertNotNull(lBound);
-        assertEquals(3, lBound.size());
+        assertNotNull("Invalid plan\n" + RelOptUtil.toString(phys), searchBounds);
+        assertEquals(3, searchBounds.size());
 
-        assertNull(lBound.get(0));
-        assertTrue(lBound.get(1) instanceof RexFieldAccess);
-        assertNull(lBound.get(2));
-
-        List<RexNode> uBound = idxSpool.indexCondition().upperBound();
-
-        assertNotNull(uBound);
-        assertEquals(3, uBound.size());
-
-        assertNull(uBound.get(0));
-        assertTrue(uBound.get(1) instanceof RexFieldAccess);
-        assertNull(uBound.get(2));
+        assertNull(searchBounds.get(0));
+        assertTrue(searchBounds.get(1) instanceof ExactBounds);
+        assertTrue(((ExactBounds)searchBounds.get(1)).bound() instanceof RexFieldAccess);
+        assertNull(searchBounds.get(2));
     }
 
     /**
@@ -172,25 +167,16 @@ public class SortedIndexSpoolPlannerTest extends AbstractPlannerTest {
 
         IgniteSortedIndexSpool idxSpool = findFirstNode(phys, byClass(IgniteSortedIndexSpool.class));
 
-        List<RexNode> lBound = idxSpool.indexCondition().lowerBound();
-
-        assertNotNull(lBound);
-        assertEquals(4, lBound.size());
-
-        assertNull(lBound.get(0));
-        assertTrue(lBound.get(1) instanceof RexFieldAccess);
-        assertNull(lBound.get(2));
-        assertNull(lBound.get(3));
+        List<SearchBounds> searchBounds = idxSpool.searchBounds();
 
-        List<RexNode> uBound = idxSpool.indexCondition().upperBound();
+        assertNotNull("Invalid plan\n" + RelOptUtil.toString(phys), searchBounds);
+        assertEquals(4, searchBounds.size());
 
-        assertNotNull(uBound);
-        assertEquals(4, uBound.size());
-
-        assertNull(uBound.get(0));
-        assertTrue(uBound.get(1) instanceof RexFieldAccess);
-        assertNull(uBound.get(2));
-        assertNull(uBound.get(3));
+        assertNull(searchBounds.get(0));
+        assertTrue(searchBounds.get(1) instanceof ExactBounds);
+        assertTrue(((ExactBounds)searchBounds.get(1)).bound() instanceof RexFieldAccess);
+        assertNull(searchBounds.get(2));
+        assertNull(searchBounds.get(3));
     }
 
     /**
@@ -215,20 +201,22 @@ public class SortedIndexSpoolPlannerTest extends AbstractPlannerTest {
             isInstanceOf(IgniteCorrelatedNestedLoopJoin.class)
                 .and(input(1, isInstanceOf(IgniteSortedIndexSpool.class)
                     .and(spool -> {
-                        List<RexNode> lBound = spool.indexCondition().lowerBound();
+                        List<SearchBounds> searchBounds = spool.searchBounds();
 
                         // Condition is LESS_THEN, but we have DESC field and condition should be in lower bound
                         // instead of upper bound.
-                        assertNotNull(lBound);
-                        assertEquals(3, lBound.size());
-
-                        assertNull(lBound.get(0));
-                        assertTrue(lBound.get(1) instanceof RexFieldAccess);
-                        assertNull(lBound.get(2));
-
-                        List<RexNode> uBound = spool.indexCondition().upperBound();
-
-                        assertNull(uBound);
+                        assertNotNull(searchBounds);
+                        assertEquals(3, searchBounds.size());
+
+                        assertNull(searchBounds.get(0));
+                        assertTrue(searchBounds.get(1) instanceof RangeBounds);
+                        RangeBounds fld1Bounds = (RangeBounds)searchBounds.get(1);
+                        assertTrue(fld1Bounds.lowerBound() instanceof RexFieldAccess);
+                        assertFalse(fld1Bounds.lowerInclude());
+                        // NULLS LAST in collation, so nulls can be skipped by upper bound.
+                        assertTrue(((RexLiteral)fld1Bounds.upperBound()).isNull());
+                        assertFalse(fld1Bounds.upperInclude());
+                        assertNull(searchBounds.get(2));
 
                         return true;
                     })
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/StatisticsPlannerTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/StatisticsPlannerTest.java
index 5c5de98f8bf..59409e1be0e 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/StatisticsPlannerTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/planner/StatisticsPlannerTest.java
@@ -212,7 +212,7 @@ public class StatisticsPlannerTest extends AbstractPlannerTest {
     }
 
     /**
-     * Check index choosing with is null condition. Due to AbstractIndexScan logic - no index should be choosen.
+     * Check index choosing with is null condition.
      */
     @Test
     public void testIsNull() throws Exception {
@@ -231,14 +231,15 @@ public class StatisticsPlannerTest extends AbstractPlannerTest {
 
                 String idxName = getIdxName(1, field.getName().toUpperCase());
 
-                checkIdxNotUsed(isNullSql, idxName);
-                checkIdxNotUsed(isNullPKSql, idxName);
+                assertPlan(isNullSql, publicSchema, hasChildThat(isIndexScan("TBL1", idxName)));
+                assertPlan(isNullPKSql, publicSchema, hasChildThat(isIndexScan("TBL1", idxName)
+                    .or(isIndexScan("TBL1", "PK"))));
             }
         }
     }
 
     /**
-     * Check index choosing with not null condition. Due to AbstractIndexScan logic - no index should be choosen.
+     * Check index choosing with not null condition.
      */
     @Test
     public void testNotNull() throws Exception {
@@ -257,8 +258,9 @@ public class StatisticsPlannerTest extends AbstractPlannerTest {
 
                 String idxName = getIdxName(1, field.getName().toUpperCase());
 
-                checkIdxNotUsed(isNullSql, idxName);
-                checkIdxNotUsed(isNullPKSql, idxName);
+                assertPlan(isNullSql, publicSchema, hasChildThat(isIndexScan("TBL1", idxName)));
+                assertPlan(isNullPKSql, publicSchema, hasChildThat(isIndexScan("TBL1", idxName)
+                    .or(isIndexScan("TBL1", "PK"))));
             }
         }
     }
@@ -327,20 +329,6 @@ public class StatisticsPlannerTest extends AbstractPlannerTest {
         assertEquals(idxName, idxScan.indexName());
     }
 
-    /**
-     * Check index is not used.
-     *
-     * @param sql Query.
-     * @param idxName Not expected index name.
-     * @throws Exception In case of errors.
-     */
-    private void checkIdxNotUsed(String sql, String idxName) throws Exception {
-        IgniteRel phys = physicalPlan(sql, publicSchema);
-        IgniteIndexScan idxScan = findFirstNode(phys, byClass(IgniteIndexScan.class));
-
-        assertTrue(idxScan == null || !idxName.equals(idxScan.indexName()));
-    }
-
     /**
      * Get index name by column.
      *
diff --git a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/rules/OrToUnionRuleTest.java b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/rules/OrToUnionRuleTest.java
index 3ea94ac21e5..926d288a384 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/rules/OrToUnionRuleTest.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/internal/processors/query/calcite/rules/OrToUnionRuleTest.java
@@ -129,8 +129,6 @@ public class OrToUnionRuleTest extends GridCommonAbstractTest {
 
     /**
      * Check 'OR -> UNION' rule is applied for equality conditions on indexed columns.
-     *
-     * @throws Exception If failed.
      */
     @Test
     public void testEqualityOrToUnionAllRewrite() {
@@ -150,9 +148,7 @@ public class OrToUnionRuleTest extends GridCommonAbstractTest {
     }
 
     /**
-     * Check 'OR -> UNION' rule is applied for equality conditions on indexed columns.
-     *
-     * @throws Exception If failed.
+     * Check 'OR -> UNION' rule is NOT applied for conditions on the same indexed column.
      */
     @Test
     public void testNonDistinctOrToUnionAllRewrite() {
@@ -160,8 +156,7 @@ public class OrToUnionRuleTest extends GridCommonAbstractTest {
             "FROM products " +
             "WHERE subcategory = 'Camera Lens' " +
             "OR subcategory = 'Other'")
-            .matches(containsUnion(true))
-            .matches(containsIndexScan("PUBLIC", "PRODUCTS", "IDX_SUBCATEGORY"))
+            .matches(CoreMatchers.not(containsUnion(true)))
             .matches(containsIndexScan("PUBLIC", "PRODUCTS", "IDX_SUBCATEGORY"))
             .returns(3, "Photo", 1, "Camera Lens", 12, "Lens 1")
             .returns(4, "Photo", 1, "Other", 12, "Charger 1")
@@ -172,8 +167,6 @@ public class OrToUnionRuleTest extends GridCommonAbstractTest {
 
     /**
      * Check 'OR -> UNION' rule is applied for mixed conditions on indexed columns.
-     *
-     * @throws Exception If failed.
      */
     @Test
     public void testMixedOrToUnionAllRewrite() {
@@ -254,8 +247,6 @@ public class OrToUnionRuleTest extends GridCommonAbstractTest {
 
     /**
      * Check 'OR -> UNION' rule is not applied if all columns are not indexed.
-     *
-     * @throws Exception If failed.
      */
     @Test
     public void testAllNonIndexedOrToUnionAllRewrite() {
diff --git a/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java b/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
index 2c14e4a2d5f..712589eb52a 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/testsuites/IntegrationTestSuite.java
@@ -41,6 +41,7 @@ import org.apache.ignite.internal.processors.query.calcite.integration.KillQuery
 import org.apache.ignite.internal.processors.query.calcite.integration.MetadataIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.QueryEngineConfigurationIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.RunningQueriesIntegrationTest;
+import org.apache.ignite.internal.processors.query.calcite.integration.SearchSargOnIndexIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.ServerStatisticsIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.SetOpIntegrationTest;
 import org.apache.ignite.internal.processors.query.calcite.integration.SortAggregateIntegrationTest;
@@ -103,6 +104,7 @@ import org.junit.runners.Suite;
     SystemViewsIntegrationTest.class,
     IndexRebuildIntegrationTest.class,
     QueryEngineConfigurationIntegrationTest.class,
+    SearchSargOnIndexIntegrationTest.class,
 })
 public class IntegrationTestSuite {
 }
diff --git a/modules/calcite/src/test/java/org/apache/ignite/testsuites/PlannerTestSuite.java b/modules/calcite/src/test/java/org/apache/ignite/testsuites/PlannerTestSuite.java
index d4f75e09668..e967eb26b64 100644
--- a/modules/calcite/src/test/java/org/apache/ignite/testsuites/PlannerTestSuite.java
+++ b/modules/calcite/src/test/java/org/apache/ignite/testsuites/PlannerTestSuite.java
@@ -32,6 +32,7 @@ import org.apache.ignite.internal.processors.query.calcite.planner.MergeJoinPlan
 import org.apache.ignite.internal.processors.query.calcite.planner.PlannerTest;
 import org.apache.ignite.internal.processors.query.calcite.planner.PlannerTimeoutTest;
 import org.apache.ignite.internal.processors.query.calcite.planner.ProjectFilterScanMergePlannerTest;
+import org.apache.ignite.internal.processors.query.calcite.planner.SearchSargOnIndexPlannerTest;
 import org.apache.ignite.internal.processors.query.calcite.planner.SetOpPlannerTest;
 import org.apache.ignite.internal.processors.query.calcite.planner.SortAggregatePlannerTest;
 import org.apache.ignite.internal.processors.query.calcite.planner.SortedIndexSpoolPlannerTest;
@@ -71,6 +72,7 @@ import org.junit.runners.Suite;
     ProjectFilterScanMergePlannerTest.class,
     IndexRebuildPlannerTest.class,
     PlannerTimeoutTest.class,
+    SearchSargOnIndexPlannerTest.class,
 })
 public class PlannerTestSuite {
 }