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

zstan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new d53663e52c IGNITE-18746 Sql. Sargs implementation alignment - Fixes 
#1721.
d53663e52c is described below

commit d53663e52cc2b69af92abce6afad9ee3d81ffdf2
Author: zstan <[email protected]>
AuthorDate: Tue Mar 7 10:03:44 2023 +0300

    IGNITE-18746 Sql. Sargs implementation alignment - Fixes #1721.
    
    Signed-off-by: zstan <[email protected]>
---
 modules/rest-api/openapi/openapi.yaml              |   3 +
 .../sql/engine/ItHashSpoolIntegrationTest.java     |   4 +-
 .../ignite/internal/sql/engine/ItJoinTest.java     |  11 +-
 .../internal/sql/engine/ItSecondaryIndexTest.java  | 265 ++++++++++-
 .../sql/engine/exec/AbstractIndexScan.java         |  14 +-
 .../ignite/internal/sql/engine/exec/TreeIndex.java |   3 +-
 .../sql/engine/exec/exp/ExpressionFactoryImpl.java |  48 +-
 .../sql/engine/exec/exp/RangeIterable.java         |   8 +-
 .../sql/engine/exec/rel/IndexScanNode.java         |   1 -
 .../internal/sql/engine/rel/AbstractIndexScan.java |   2 +-
 .../exec/rel/IndexScanNodeExecutionTest.java       |   2 +-
 .../exec/rel/SortedIndexSpoolExecutionTest.java    |   5 +-
 .../sql/engine/planner/AbstractPlannerTest.java    |  46 +-
 .../planner/IndexSearchBoundsPlannerTest.java      | 510 +++++++++++++++++++++
 .../internal/sql/engine/planner/PlannerTest.java   | 118 -----
 15 files changed, 849 insertions(+), 191 deletions(-)

diff --git a/modules/rest-api/openapi/openapi.yaml 
b/modules/rest-api/openapi/openapi.yaml
index e7b2305b49..10d9f4e941 100644
--- a/modules/rest-api/openapi/openapi.yaml
+++ b/modules/rest-api/openapi/openapi.yaml
@@ -852,12 +852,15 @@ components:
       properties:
         id:
           type: string
+          description: Returns unit identifier.
         versionToConsistentIds:
           type: object
           additionalProperties:
             type: array
             items:
               type: string
+          description: Returns map from existing unit version to list of nodes 
consistent
+            ids where unit deployed.
       description: Unit status.
     Void:
       type: object
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItHashSpoolIntegrationTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItHashSpoolIntegrationTest.java
index 3101f099f2..cef1e09ad1 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItHashSpoolIntegrationTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItHashSpoolIntegrationTest.java
@@ -50,11 +50,11 @@ public class ItHashSpoolIntegrationTest extends 
ClusterPerClassIntegrationTest {
         sql("INSERT INTO t0(i1, i2) VALUES (null, 0), (1, null), (null, 2), 
(3, null), (1, 1)");
         sql("INSERT INTO t1(i1, i2) VALUES (null, 0), (null, 1), (2, null), 
(3, null), (1, 1)");
 
-        String sql = "SELECT /*+ DISABLE_RULE ('MergeJoinConverter', 
'NestedLoopJoinConverter', "
-                + "'FilterSpoolMergeToSortedIndexSpoolRule')*/ t0.i1, t0.i2, 
t1.i1, t1.i2 "
+        String sql = "SELECT t0.i1, t0.i2, t1.i1, t1.i2 "
                 + "FROM t0 JOIN t1 ON t0.i1=t1.i1 AND t0.i2=t1.i2";
 
         assertQuery(sql)
+                .disableRules("MergeJoinConverter", "NestedLoopJoinConverter", 
"FilterSpoolMergeToSortedIndexSpoolRule")
                 .returns(1, 1, 1, 1)
                 .check();
     }
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItJoinTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItJoinTest.java
index eb2f787dd0..817646bbea 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItJoinTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItJoinTest.java
@@ -794,17 +794,14 @@ public class ItJoinTest extends 
ClusterPerClassIntegrationTest {
     @ParameterizedTest(name = "join algo : {0}, index present: {1}")
     @MethodSource("joinTypes")
     @WithSystemProperty(key = "IMPLICIT_PK_ENABLED", value = "true")
-    public void testIsNotDistinctFrom(JoinType joinType, boolean indexScan) {
-        if (indexScan) {
-            // TODO: https://issues.apache.org/jira/browse/IGNITE-18468 Index 
scan eventually return partial data.
-            return;
-        }
-
+    public void testIsNotDistinctFrom(JoinType joinType, boolean indexScan) 
throws InterruptedException {
         try {
             sql("CREATE TABLE t11(i1 INTEGER, i2 INTEGER)");
 
             if (indexScan) {
                 sql("CREATE INDEX t11_idx ON t11(i1)");
+                // FIXME: https://issues.apache.org/jira/browse/IGNITE-18203
+                waitForIndex("t11_idx");
             }
 
             sql("INSERT INTO t11 VALUES (1, null), (2, 2), (null, 3), (3, 
null), (5, null)");
@@ -813,6 +810,8 @@ public class ItJoinTest extends 
ClusterPerClassIntegrationTest {
 
             if (indexScan) {
                 sql("CREATE INDEX t22_idx ON t22(i3)");
+                // FIXME: https://issues.apache.org/jira/browse/IGNITE-18203
+                waitForIndex("t22_idx");
             }
 
             sql("INSERT INTO t22 VALUES (1, 1), (2, 2), (null, 3), (4, null), 
(5, null)");
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSecondaryIndexTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSecondaryIndexTest.java
index 6154b74b57..b27eff0186 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSecondaryIndexTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSecondaryIndexTest.java
@@ -25,11 +25,35 @@ import static 
org.apache.ignite.internal.sql.engine.util.QueryChecker.containsTa
 import static 
org.apache.ignite.internal.sql.engine.util.QueryChecker.containsUnion;
 import static org.hamcrest.CoreMatchers.containsString;
 import static org.hamcrest.Matchers.not;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 
 import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.BitSet;
 import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.Flow.Publisher;
+import java.util.concurrent.Flow.Subscriber;
+import java.util.concurrent.Flow.Subscription;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.internal.app.IgniteImpl;
+import org.apache.ignite.internal.hlc.HybridTimestamp;
+import org.apache.ignite.internal.index.Index;
+import org.apache.ignite.internal.index.SortedIndex;
+import org.apache.ignite.internal.index.SortedIndexDescriptor;
+import org.apache.ignite.internal.schema.BinaryRow;
+import org.apache.ignite.internal.schema.BinaryTuple;
+import org.apache.ignite.internal.schema.BinaryTuplePrefix;
+import org.apache.ignite.internal.sql.engine.schema.IgniteIndex;
+import org.apache.ignite.internal.sql.engine.schema.IgniteTableImpl;
+import org.apache.ignite.internal.sql.engine.schema.SqlSchemaManagerImpl;
+import org.apache.ignite.internal.sql.engine.util.QueryChecker;
+import org.apache.ignite.internal.testframework.IgniteTestUtils;
+import org.apache.ignite.internal.utils.PrimaryReplica;
+import org.apache.ignite.network.ClusterNode;
+import org.jetbrains.annotations.Nullable;
 import org.junit.jupiter.api.BeforeAll;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -853,6 +877,11 @@ public class ItSecondaryIndexTest extends 
ClusterPerClassIntegrationTest {
                 .returns(5, 5)
                 .returns(6, 6)
                 .check();
+
+        // Not nullable column, filter is always - false.
+        assertQuery("SELECT * FROM T1 WHERE id IS NULL")
+                .matches(QueryChecker.matches(".*filters=\\[false\\].*"))
+                .check();
     }
 
     /**
@@ -881,7 +910,6 @@ public class ItSecondaryIndexTest extends 
ClusterPerClassIntegrationTest {
     }
 
     @Test
-    @Disabled("https://issues.apache.org/jira/browse/IGNITE-18468";)
     public void testNullCondition1() {
         assertQuery("SELECT * FROM T1 WHERE val is null")
                 .matches(containsIndexScan("PUBLIC", "T1", "T1_IDX"))
@@ -893,7 +921,6 @@ public class ItSecondaryIndexTest extends 
ClusterPerClassIntegrationTest {
     }
 
     @Test
-    @Disabled("https://issues.apache.org/jira/browse/IGNITE-18468";)
     public void testNullCondition2() {
         assertQuery("SELECT * FROM T1 WHERE (val <= 5) or (val is null)")
                 .matches(containsIndexScan("PUBLIC", "T1", "T1_IDX"))
@@ -908,7 +935,6 @@ public class ItSecondaryIndexTest extends 
ClusterPerClassIntegrationTest {
     }
 
     @Test
-    @Disabled("https://issues.apache.org/jira/browse/IGNITE-18468";)
     public void testNullCondition3() {
         assertQuery("SELECT * FROM T1 WHERE (val >= 5) or (val is null)")
                 .matches(containsIndexScan("PUBLIC", "T1", "T1_IDX"))
@@ -920,4 +946,235 @@ public class ItSecondaryIndexTest extends 
ClusterPerClassIntegrationTest {
                 .returns(7, null)
                 .check();
     }
+
+    @Test
+    public void testNullsInCorrNestedLoopJoinSearchRow() throws 
InterruptedException {
+        try {
+            sql("CREATE TABLE t(i0 INTEGER PRIMARY KEY, i1 INTEGER, i2 
INTEGER)");
+            sql("CREATE INDEX t_idx ON t(i1)");
+            // FIXME: https://issues.apache.org/jira/browse/IGNITE-18203
+            waitForIndex("t_idx");
+            sql("INSERT INTO t VALUES (1, 0, null), (2, 1, null), (3, 2, 2), 
(4, 3, null), (5, 4, null), (6, null, 5)");
+
+            List<RowCountingIndex> idxs = injectRowCountingIndex("T", "T_IDX");
+
+            String sql = "SELECT t1.i1, t2.i1 FROM t t1 LEFT JOIN t t2 ON 
t1.i2 = t2.i1";
+
+            assertQuery(sql)
+                    .disableRules("NestedLoopJoinConverter", 
"MergeJoinConverter")
+                    .matches(containsSubPlan("IgniteCorrelatedNestedLoopJoin"))
+                    .matches(containsIndexScan("PUBLIC", "T", "T_IDX"))
+                    .returns(0, null)
+                    .returns(1, null)
+                    .returns(2, 2)
+                    .returns(3, null)
+                    .returns(4, null)
+                    .returns(null, null)
+                    .check();
+
+            // There shouldn't be full index scan in case of null values in 
search row, only one value must be found by
+            // range scan and passed to predicate.
+            assertEquals(1, 
idxs.stream().mapToInt(RowCountingIndex::touchCount).sum());
+        } finally {
+            sql("DROP TABLE IF EXISTS t");
+        }
+    }
+
+    @Test
+    public void testNullsInSearchRow() throws InterruptedException {
+        try {
+            sql("CREATE TABLE t(i0 INTEGER PRIMARY KEY, i1 INTEGER, i2 
INTEGER)");
+            sql("CREATE INDEX t_idx ON t(i1, i2)");
+            // FIXME: https://issues.apache.org/jira/browse/IGNITE-18203
+            waitForIndex("t_idx");
+            sql("INSERT INTO t VALUES (1, null, 0), (2, 1, null), (3, 2, 2), 
(4, 3, null)");
+
+            List<RowCountingIndex> idxs = injectRowCountingIndex("T", "T_IDX");
+
+            assertQuery("SELECT * FROM t WHERE i1 = ?")
+                    .withParams(null)
+                    .matches(containsIndexScan("PUBLIC", "T", "T_IDX"))
+                    .check();
+
+            assertEquals(0, 
idxs.stream().mapToInt(RowCountingIndex::touchCount).sum());
+
+            assertQuery("SELECT * FROM t WHERE i1 = 1 AND i2 = ?")
+                    .withParams(new Object[] { null })
+                    .matches(containsIndexScan("PUBLIC", "T", "T_IDX"))
+                    .check();
+
+            // Multi ranges.
+            assertQuery("SELECT * FROM t WHERE i1 IN (1, 2, 3) AND i2 = ?")
+                    .withParams(new Object[] { null })
+                    .matches(containsIndexScan("PUBLIC", "T", "T_IDX"))
+                    .check();
+
+            assertEquals(0, 
idxs.stream().mapToInt(RowCountingIndex::touchCount).sum());
+
+            assertQuery("SELECT i1, i2 FROM t WHERE i1 IN (1, 2) AND i2 IS 
NULL")
+                    .matches(containsIndexScan("PUBLIC", "T", "T_IDX"))
+                    .returns(1, null)
+                    .check();
+
+            assertEquals(1, 
idxs.stream().mapToInt(RowCountingIndex::touchCount).sum());
+        } finally {
+            sql("DROP TABLE IF EXISTS t");
+        }
+    }
+
+    private List<RowCountingIndex> injectRowCountingIndex(String tableName, 
String idxName) {
+        List<RowCountingIndex> countingIdxs = new ArrayList<>();
+
+        for (Ignite ign : CLUSTER_NODES) {
+            IgniteImpl ignEx = (IgniteImpl) ign;
+
+            SqlQueryProcessor qp = (SqlQueryProcessor) ignEx.queryEngine();
+
+            SqlSchemaManagerImpl sqlSchemaManager = (SqlSchemaManagerImpl) 
IgniteTestUtils.getFieldValue(qp,
+                    SqlQueryProcessor.class, "sqlSchemaManager");
+
+            IgniteTableImpl tbl = (IgniteTableImpl) 
sqlSchemaManager.schema("PUBLIC").getTable(tableName);
+
+            IgniteIndex idx = tbl.getIndex(idxName);
+
+            Index<?> internalIdx = idx.index();
+
+            RowCountingIndex countingIdx = new RowCountingIndex((SortedIndex) 
internalIdx);
+
+            IgniteTestUtils.setFieldValue(idx, "index", countingIdx);
+
+            countingIdxs.add(countingIdx);
+        }
+
+        return countingIdxs;
+    }
+
+    private static class RowCountingIndex implements SortedIndex {
+        private SortedIndex delegate;
+
+        private WrappedPublisher wrpPublisher;
+
+        RowCountingIndex(SortedIndex delegate) {
+            this.delegate = delegate;
+        }
+
+        List<WrappedPublisher> wrpPublishers = new ArrayList<>();
+
+        int touchCount() {
+            return 
wrpPublishers.stream().mapToInt(WrappedPublisher::touchCount).sum();
+        }
+
+        @Override
+        public UUID id() {
+            return delegate.id();
+        }
+
+        @Override
+        public String name() {
+            return delegate.name();
+        }
+
+        @Override
+        public UUID tableId() {
+            return delegate.tableId();
+        }
+
+        @Override
+        public SortedIndexDescriptor descriptor() {
+            return delegate.descriptor();
+        }
+
+        @Override
+        public Publisher<BinaryRow> lookup(int partId, UUID txId, 
PrimaryReplica recipient, BinaryTuple key, @Nullable BitSet columns) {
+            return delegate.lookup(partId, txId, recipient, key, columns);
+        }
+
+        @Override
+        public Publisher<BinaryRow> lookup(int partId, HybridTimestamp 
readTimestamp, ClusterNode recipientNode, BinaryTuple key,
+                @Nullable BitSet columns) {
+            return delegate.lookup(partId, readTimestamp, recipientNode, key, 
columns);
+        }
+
+        @Override
+        public Publisher<BinaryRow> scan(int partId, UUID txId, PrimaryReplica 
recipient, @Nullable BinaryTuplePrefix leftBound,
+                @Nullable BinaryTuplePrefix rightBound, int flags, @Nullable 
BitSet columnsToInclude) {
+            Publisher<BinaryRow> pub =  delegate.scan(partId, txId, recipient, 
leftBound, rightBound, flags, columnsToInclude);
+
+            return wrap(pub);
+        }
+
+        @Override
+        public Publisher<BinaryRow> scan(int partId, HybridTimestamp 
readTimestamp, ClusterNode recipientNode,
+                @Nullable BinaryTuplePrefix leftBound, @Nullable 
BinaryTuplePrefix rightBound, int flags,
+                @Nullable BitSet columnsToInclude) {
+            Publisher<BinaryRow> pub = delegate.scan(partId, readTimestamp, 
recipientNode, leftBound, rightBound, flags, columnsToInclude);
+
+            return wrap(pub);
+        }
+
+        private WrappedPublisher wrap(Publisher<BinaryRow> pub) {
+            wrpPublisher = new WrappedPublisher(pub);
+
+            wrpPublishers.add(wrpPublisher);
+
+            return wrpPublisher;
+        }
+    }
+
+    private static class WrappedPublisher implements Publisher<BinaryRow> {
+        Publisher<BinaryRow> pub;
+
+        WrappedSubscriber subs;
+
+        WrappedPublisher(Publisher<BinaryRow> pub) {
+            this.pub = pub;
+        }
+
+        @Override
+        public void subscribe(Subscriber subscriber) {
+            subs = new WrappedSubscriber(subscriber);
+
+            pub.subscribe(subs);
+        }
+
+        int touchCount() {
+            return subs.touchCount();
+        }
+    }
+
+    private static class WrappedSubscriber implements Subscriber {
+        private Subscriber subs;
+
+        private AtomicInteger touched = new AtomicInteger();
+
+        WrappedSubscriber(Subscriber subs) {
+            this.subs = subs;
+        }
+
+        @Override
+        public void onSubscribe(Subscription subscription) {
+            subs.onSubscribe(subscription);
+        }
+
+        @Override
+        public void onNext(Object item) {
+            touched.incrementAndGet();
+
+            subs.onNext(item);
+        }
+
+        @Override
+        public void onError(Throwable throwable) {
+            subs.onError(throwable);
+        }
+
+        @Override
+        public void onComplete() {
+            subs.onComplete();
+        }
+
+        int touchCount() {
+            return touched.get();
+        }
+    }
 }
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/AbstractIndexScan.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/AbstractIndexScan.java
index f4baba4c21..a9f22d3677 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/AbstractIndexScan.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/AbstractIndexScan.java
@@ -18,10 +18,12 @@
 package org.apache.ignite.internal.sql.engine.exec;
 
 import com.google.common.collect.Streams;
+import java.util.Collections;
 import java.util.Iterator;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import org.apache.calcite.rel.type.RelDataType;
+import org.apache.ignite.internal.sql.engine.exec.exp.RangeCondition;
 import org.apache.ignite.internal.sql.engine.exec.exp.RangeIterable;
 import org.apache.ignite.internal.util.CollectionUtils;
 import org.apache.ignite.internal.util.Cursor;
@@ -57,7 +59,7 @@ public abstract class AbstractIndexScan<RowT, IdxRowT> 
implements Iterable<RowT>
      * @param ranges Index scan bounds.
      * @param rowTransformer Row transformer.
      */
-    protected AbstractIndexScan(
+    AbstractIndexScan(
             ExecutionContext<RowT> ectx,
             RelDataType rowType,
             TreeIndex<IdxRowT> idx,
@@ -79,13 +81,21 @@ public abstract class AbstractIndexScan<RowT, IdxRowT> 
implements Iterable<RowT>
         if (ranges == null) { // Full index scan.
             Cursor<IdxRowT> cursor = idx.find(null, null, true, true);
 
-            Iterator<RowT> it = new TransformingIterator<>(cursor, 
AbstractIndexScan.this::indexRow2Row);
+            Iterator<RowT> it = new TransformingIterator<>(cursor, 
this::indexRow2Row);
 
             it = (filters != null) ? new FilteringIterator<>(it, filters) : it;
 
             return (rowTransformer != null) ? new TransformingIterator<>(it, 
rowTransformer) : it;
         }
 
+        if (!ranges.multiBounds()) {
+            Iterator<RangeCondition<RowT>> it = ranges.iterator();
+
+            if (!it.hasNext()) {
+                return Collections.emptyIterator();
+            }
+        }
+
         Iterable<RowT>[] iterables = Streams.stream(ranges)
                 .map(range -> new Iterable<RowT>() {
                             @Override
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/TreeIndex.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/TreeIndex.java
index 5d40c5d5a7..aaf11c6da5 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/TreeIndex.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/TreeIndex.java
@@ -18,6 +18,7 @@
 package org.apache.ignite.internal.sql.engine.exec;
 
 import org.apache.ignite.internal.util.Cursor;
+import org.jetbrains.annotations.Nullable;
 
 /**
  * Tree index interface.
@@ -34,5 +35,5 @@ public interface TreeIndex<R> {
      * @param upperInclude Inclusive upper bound.
      * @return Cursor over the rows within bounds.
      */
-    Cursor<R> find(R lower, R upper, boolean lowerInclude, boolean 
upperInclude);
+    Cursor<R> find(@Nullable R lower, @Nullable R upper, boolean lowerInclude, 
boolean upperInclude);
 }
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactoryImpl.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactoryImpl.java
index 7e2e655321..e429ab00b4 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactoryImpl.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactoryImpl.java
@@ -722,6 +722,9 @@ public class ExpressionFactoryImpl<RowT> implements 
ExpressionFactory<RowT> {
         /** Upper row. */
         private @Nullable RowT upperRow;
 
+        /** Cached skip range flag. */
+        private Boolean skip;
+
         /** Row factory. */
         private final RowFactory<RowT> factory;
 
@@ -768,12 +771,44 @@ public class ExpressionFactoryImpl<RowT> implements 
ExpressionFactory<RowT> {
             RowT res = factory.create();
             scalar.execute(ctx, null, res);
 
+            RowHandler<RowT> hnd = ctx.rowHandler();
+
+            // Check bound for NULL values. If bound contains NULLs, the whole 
range should be skipped.
+            // There is special placeholder for searchable NULLs, make this 
replacement here too.
+            for (int i = 0; i < hnd.columnCount(res); i++) {
+                Object fldVal = hnd.get(i, res);
+
+                if (fldVal == null) {
+                    skip = Boolean.TRUE;
+                }
+
+                if (fldVal == ctx.nullBound()) {
+                    hnd.set(i, res, null);
+                }
+            }
+
             return res;
         }
 
         /** Clear cached rows. */
         void clearCache() {
             lowerRow = upperRow = null;
+            skip = null;
+        }
+
+        /** Skip this range. */
+        public boolean skip() {
+            if (skip == null) {
+                // Precalculate skip flag.
+                lower();
+                upper();
+
+                if (skip == null) {
+                    skip = Boolean.FALSE;
+                }
+            }
+
+            return skip;
         }
     }
 
@@ -793,9 +828,8 @@ public class ExpressionFactoryImpl<RowT> implements 
ExpressionFactory<RowT> {
         }
 
         /** {@inheritDoc} */
-        @Override
-        public int size() {
-            return ranges.size();
+        @Override public boolean multiBounds() {
+            return ranges.size() > 1;
         }
 
         /** {@inheritDoc} */
@@ -804,7 +838,11 @@ public class ExpressionFactoryImpl<RowT> implements 
ExpressionFactory<RowT> {
             ranges.forEach(b -> ((RangeConditionImpl) b).clearCache());
 
             if (ranges.size() == 1) {
-                return ranges.iterator();
+                if (((RangeConditionImpl) ranges.get(0)).skip()) {
+                    return Collections.emptyIterator();
+                } else {
+                    return ranges.iterator();
+                }
             }
 
             // Sort ranges using collation comparator to produce sorted 
output. There should be no ranges
@@ -816,7 +854,7 @@ public class ExpressionFactoryImpl<RowT> implements 
ExpressionFactory<RowT> {
                 sorted = true;
             }
 
-            return ranges.iterator();
+            return ranges.stream().filter(r -> !((RangeConditionImpl) 
r).skip()).iterator();
         }
     }
 
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeIterable.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeIterable.java
index 773b16a9a8..7684164cbb 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeIterable.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeIterable.java
@@ -21,6 +21,10 @@ package org.apache.ignite.internal.sql.engine.exec.exp;
  * Iterable over range conditions.
  */
 public interface RangeIterable<RowT> extends Iterable<RangeCondition<RowT>> {
-    /** Count of ranges in iterable. */
-    public int size();
+    /**
+     * Multi bounds condition.
+     *
+     * @return {@code True} if iterable can have more than one item.
+     */
+    public boolean multiBounds();
 }
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNode.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNode.java
index 0426df0fb6..fa54d78f71 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNode.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNode.java
@@ -96,7 +96,6 @@ public class IndexScanNode<RowT> extends 
StorageScanNode<RowT> {
         super(ctx, rowFactory, schemaTable, filters, rowTransformer, 
requiredColumns);
 
         assert partsWithTerms != null && !partsWithTerms.isEmpty();
-        assert rangeConditions == null || rangeConditions.size() > 0;
 
         this.schemaIndex = schemaIndex;
         this.partsWithTerms = partsWithTerms;
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/AbstractIndexScan.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/AbstractIndexScan.java
index cb7f84d74d..ba2c56b36d 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/AbstractIndexScan.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/AbstractIndexScan.java
@@ -57,7 +57,7 @@ public abstract class AbstractIndexScan extends 
ProjectableFilterableTableScan {
      *
      * @param input Serialized representation.
      */
-    protected AbstractIndexScan(RelInput input) {
+    AbstractIndexScan(RelInput input) {
         super(input);
         idxName = input.getString("index");
         type = input.getEnum("type", IgniteIndex.Type.class);
diff --git 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNodeExecutionTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNodeExecutionTest.java
index e38cbd9c89..78e53ff7dc 100644
--- 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNodeExecutionTest.java
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexScanNodeExecutionTest.java
@@ -431,7 +431,7 @@ public class IndexScanNodeExecutionTest extends 
AbstractExecutionTest {
             when(rangeIterable.iterator()).thenReturn(mockIterator);
             when(mockIterator.hasNext()).thenReturn(true, false);
             when(mockIterator.next()).thenReturn(range);
-            when(rangeIterable.size()).thenReturn(1);
+            when(rangeIterable.multiBounds()).thenReturn(false);
         }
 
         IndexScanNode<Object[]> scanNode = new IndexScanNode<>(
diff --git 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/SortedIndexSpoolExecutionTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/SortedIndexSpoolExecutionTest.java
index 13945d9373..9a7467b44d 100644
--- 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/SortedIndexSpoolExecutionTest.java
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/SortedIndexSpoolExecutionTest.java
@@ -253,9 +253,8 @@ public class SortedIndexSpoolExecutionTest extends 
AbstractExecutionTest {
         }
 
         /** {@inheritDoc} */
-        @Override
-        public int size() {
-            return 1;
+        @Override public boolean multiBounds() {
+            return false;
         }
 
         /** {@inheritDoc} */
diff --git 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java
index 783395754b..112c896b11 100644
--- 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java
@@ -67,9 +67,7 @@ import org.apache.calcite.rel.type.RelDataTypeField;
 import org.apache.calcite.rel.type.RelDataTypeImpl;
 import org.apache.calcite.rel.type.RelProtoDataType;
 import org.apache.calcite.rex.RexCall;
-import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
-import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.schema.ColumnStrategy;
 import org.apache.calcite.schema.Schema;
 import org.apache.calcite.schema.SchemaPlus;
@@ -105,8 +103,6 @@ import 
org.apache.ignite.internal.sql.engine.prepare.MappingQueryContext;
 import org.apache.ignite.internal.sql.engine.prepare.PlannerHelper;
 import org.apache.ignite.internal.sql.engine.prepare.PlanningContext;
 import org.apache.ignite.internal.sql.engine.prepare.Splitter;
-import org.apache.ignite.internal.sql.engine.prepare.bounds.ExactBounds;
-import org.apache.ignite.internal.sql.engine.prepare.bounds.RangeBounds;
 import org.apache.ignite.internal.sql.engine.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.sql.engine.rel.IgniteIndexScan;
 import org.apache.ignite.internal.sql.engine.rel.IgniteRel;
@@ -146,7 +142,7 @@ public abstract class AbstractPlannerTest extends 
IgniteAbstractTest {
     protected static final int DEFAULT_ZONE_ID = 0;
 
     /** Last error message. */
-    private String lastErrorMsg;
+    String lastErrorMsg;
 
     interface TestVisitor {
         void visit(RelNode node, int ordinal, RelNode parent);
@@ -1298,46 +1294,6 @@ public abstract class AbstractPlannerTest extends 
IgniteAbstractTest {
         }
     }
 
-    Predicate<SearchBounds> exact(Object val) {
-        return b -> b instanceof ExactBounds && matchValue(val, ((ExactBounds) 
b).bound());
-    }
-
-    void assertBounds(String sql, List<Object> params, IgniteSchema schema, 
Predicate<SearchBounds>... predicates) throws Exception {
-        assertPlan(sql, schema, 
nodeOrAnyChild(isInstanceOf(IgniteIndexScan.class)
-                .and(scan -> matchBounds(scan.searchBounds(), predicates))), 
params);
-    }
-
-    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;
-    }
-
-    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 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));
-    }
-
     Predicate<SearchBounds> empty() {
         return Objects::isNull;
     }
diff --git 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/IndexSearchBoundsPlannerTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/IndexSearchBoundsPlannerTest.java
new file mode 100644
index 0000000000..a19b1edc18
--- /dev/null
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/IndexSearchBoundsPlannerTest.java
@@ -0,0 +1,510 @@
+/*
+ * 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.sql.engine.planner;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.function.Predicate;
+import java.util.stream.IntStream;
+import org.apache.calcite.rel.RelCollations;
+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.index.ColumnCollation;
+import org.apache.ignite.internal.sql.engine.metadata.ColocationGroup;
+import org.apache.ignite.internal.sql.engine.prepare.MappingQueryContext;
+import org.apache.ignite.internal.sql.engine.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.sql.engine.prepare.bounds.MultiBounds;
+import org.apache.ignite.internal.sql.engine.prepare.bounds.RangeBounds;
+import org.apache.ignite.internal.sql.engine.prepare.bounds.SearchBounds;
+import org.apache.ignite.internal.sql.engine.rel.IgniteIndexScan;
+import org.apache.ignite.internal.sql.engine.rel.IgniteUnionAll;
+import org.apache.ignite.internal.sql.engine.schema.IgniteSchema;
+import org.apache.ignite.internal.sql.engine.trait.IgniteDistribution;
+import org.apache.ignite.internal.sql.engine.trait.IgniteDistributions;
+import org.apache.ignite.internal.sql.engine.trait.TraitUtils;
+import org.apache.ignite.internal.sql.engine.type.IgniteTypeFactory;
+import org.apache.ignite.internal.sql.engine.type.IgniteTypeSystem;
+import org.apache.ignite.internal.sql.engine.util.RexUtils;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Index bounds check tests.
+ */
+public class IndexSearchBoundsPlannerTest extends AbstractPlannerTest {
+    private static List<String> NODES = new ArrayList<>(4);
+
+    private IgniteSchema publicSchema;
+
+    private TestTable tbl;
+
+    @BeforeAll
+    public static void init() {
+        IntStream.rangeClosed(0, 3).forEach(i -> 
NODES.add(UUID.randomUUID().toString()));
+    }
+
+    @BeforeEach
+    public void beforeEach() {
+        IgniteTypeFactory typeFactory = new 
IgniteTypeFactory(IgniteTypeSystem.INSTANCE);
+
+        tbl = new TestTable(
+                new RelDataTypeFactory.Builder(typeFactory)
+                        .add("C1", 
typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.INTEGER),
 true))
+                        .add("C2", 
typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.VARCHAR),
 true))
+                        .add("C3", 
typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.INTEGER),
 true))
+                        .add("C4", 
typeFactory.createTypeWithNullability(typeFactory.createSqlType(SqlTypeName.INTEGER),
 true))
+                        .build(), "TEST") {
+            @Override
+            public ColocationGroup colocationGroup(MappingQueryContext ctx) {
+                return ColocationGroup.forNodes(select(NODES, 0));
+            }
+
+            @Override
+            public IgniteDistribution distribution() {
+                return IgniteDistributions.single();
+            }
+        };
+
+        tbl.addIndex("C1C2C3", 0, 1, 2);
+
+        publicSchema = new IgniteSchema("PUBLIC");
+
+        publicSchema.addTable(tbl);
+    }
+
+    /** 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());
+
+        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, true, false));
+
+        // Redundant "IS NOT NULL condition".
+        assertBounds("SELECT * FROM TEST WHERE C1 > 3 AND C1 IS NOT NULL",
+                range(3, "$NULL_BOUND()", false, false));
+
+        // 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()
+        );
+
+        // Cannot proceed to C3 without C2.
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C3 = 1",
+                exact(1),
+                empty(),
+                empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C1 <= 3 AND C3 = 1",
+                range(1, 3, false, true),
+                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_BOUND()", false, false)));
+    }
+
+    /** 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_BOUND()", false, false)));
+
+        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_BOUND()", false, false)));
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND (C2 > '1' OR C2 < 
'3')",
+                exact(1));
+    }
+
+    /** 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_BOUND()"), exact(1), exact(2), exact(3)),
+                empty(),
+                empty()
+        );
+    }
+
+    /** Tests bounds with DESC ordering. */
+    @Test
+    public void testBoundsDescOrdering() throws Exception {
+        tbl.addIndex(RelCollations.of(TraitUtils.createFieldCollation(3, 
ColumnCollation.DESC_NULLS_LAST),
+                TraitUtils.createFieldCollation(2, 
ColumnCollation.ASC_NULLS_FIRST)), "C4");
+
+        tbl.addIndex(RelCollations.of(TraitUtils.createFieldCollation(3, 
ColumnCollation.ASC_NULLS_FIRST),
+                TraitUtils.createFieldCollation(2, 
ColumnCollation.ASC_NULLS_FIRST)), "C4IDX");
+
+        assertBounds("SELECT * FROM TEST WHERE C4 > 1",
+                range(null, 1, true, false));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 < 1",
+                range(1, "$NULL_BOUND()", false, false));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 IS NULL", 
exact("$NULL_BOUND()"));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 IS NOT NULL",
+                range(null, "$NULL_BOUND()", true, false));
+
+        assertBounds("SELECT * FROM TEST WHERE C4 IN (1, 2, 3) AND C3 > 1",
+                multi(exact(1), exact(2), exact(3)),
+                range(1, null, false, true)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE ((C4 > 1 AND C4 < 5) OR (C4 > 7 
AND C4 < 9)) AND C3 = 1",
+                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_BOUND()", false, false)
+        );
+
+        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))
+        );
+
+        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_BOUND()", false, false)
+        );
+
+        // Cannot proceed to the next field after the range condition.
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C2 = 'a'",
+                range(1, "$NULL_BOUND()", false, false),
+                empty(),
+                empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > 1 AND C2 > 'a'",
+                range(1, "$NULL_BOUND()", false, false),
+                empty(),
+                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_BOUND()", true, false),
+                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_BOUND()", true, false),
+                empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 >= 1 AND C2 < 'a'",
+                range(1, "$NULL_BOUND()", true, false),
+                empty()
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 >= 1 AND C2 IN ('a', 'b')",
+                range(1, "$NULL_BOUND()", true, false),
+                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_BOUND()", false, false)),
+                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"))), List.of(1, 1)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = ? AND C2 IN ('a', 'b')", 
List.of(1), publicSchema,
+                exact("?0"),
+                multi(exact("a"), exact("b"))
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = ? AND C2 > ? AND C2 < ?", 
List.of(1, 'a', 'w'), publicSchema,
+                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()
+        );
+    }
+
+    /** Tests bounds merge. */
+    @Test
+    public void testBoundsMerge() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE C1 > ? AND C1 >= 1", 
List.of(10), publicSchema,
+                range("$GREATEST2(?0, 1)", "$NULL_BOUND()", true, false)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > ? AND C1 >= ? AND C1 > ?", 
List.of(10, 10, 10), publicSchema,
+                range("$GREATEST2($GREATEST2(?0, ?1), ?2)", "$NULL_BOUND()", 
true, false)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 > ? AND C1 >= 1 AND C1 < ? 
AND C1 < ?", List.of(10, 10, 10), publicSchema,
+                range("$GREATEST2(?0, 1)", "$LEAST2(?1, ?2)", true, false)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 < ? AND C1 BETWEEN 1 AND 10 
", List.of(10), publicSchema,
+                range(1, "$LEAST2(?0, 10)", true, true)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 NOT IN (1, 2) AND C1 >= ?", 
List.of(10), publicSchema,
+                range("?0", "$NULL_BOUND()", true, false)
+        );
+
+        tbl.addIndex(RelCollations.of(TraitUtils.createFieldCollation(3, 
ColumnCollation.DESC_NULLS_LAST),
+                TraitUtils.createFieldCollation(2, 
ColumnCollation.ASC_NULLS_FIRST)), "C4");
+
+        assertBounds("SELECT * FROM TEST WHERE C4 > ? AND C4 >= 1 AND C4 < ? 
AND C4 < ?", List.of(10, 10, 10), publicSchema,
+                range("$LEAST2(?1, ?2)", "$GREATEST2(?0, 1)", false, true)
+        );
+    }
+
+    /** Tests complex bounds expressions. */
+    @Test
+    public void testBoundsComplex() throws Exception {
+        assertBounds("SELECT * FROM TEST WHERE C1 = ? + 10", List.of(1), 
publicSchema,
+                exact("+(?0, 10)")
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 > 
SUBSTRING(?::VARCHAR, 1, 2) || '3'", List.of('1'), publicSchema,
+                exact(1),
+                range("||(SUBSTRING(?0, 1, 2), _UTF-8'3')", "$NULL_BOUND()", 
false, false)
+        );
+
+        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 > 
SUBSTRING(C3::VARCHAR, 1, 2) || '3'",
+                exact(1),
+                empty()
+        );
+
+        assertBounds("SELECT (SELECT C1 FROM TEST t2 WHERE t2.C1 = t1.C1 + 
t1.C3 * ?) FROM TEST t1", List.of(1), publicSchema,
+                exact("+($cor0.C1, *($cor0.C3, ?0))")
+        );
+
+        assertPlan("SELECT * FROM TEST WHERE C1 = ? + C3", publicSchema, 
isTableScan("TEST"), List.of(1));
+
+        assertPlan("SELECT (SELECT C1 FROM TEST t2 WHERE t2.C1 < t1.C1 + 
t2.C1) FROM TEST t1", publicSchema,
+                nodeOrAnyChild(isIndexScan("TEST", "C1C2C3")).negate());
+    }
+
+    private static Predicate<SearchBounds> exact(Object val) {
+        return b -> b instanceof ExactBounds && matchValue(val, ((ExactBounds) 
b).bound());
+    }
+
+    private Predicate<SearchBounds> multi(Predicate<SearchBounds>... 
predicates) {
+        return b -> b instanceof MultiBounds
+                && ((MultiBounds) b).bounds().size() == predicates.length
+                && matchBounds(((MultiBounds) b).bounds(), predicates);
+    }
+
+    private void assertBounds(String sql, Predicate<SearchBounds>... 
predicates) throws Exception {
+        assertPlan(sql, publicSchema, 
nodeOrAnyChild(isInstanceOf(IgniteIndexScan.class)
+                .and(scan -> matchBounds(scan.searchBounds(), predicates))), 
List.of());
+    }
+
+    private void assertBounds(
+            String sql,
+            List<Object> params,
+            IgniteSchema schema,
+            Predicate<SearchBounds>... predicates
+    ) throws Exception {
+        assertPlan(sql, schema, 
nodeOrAnyChild(isInstanceOf(IgniteIndexScan.class)
+                .and(scan -> matchBounds(scan.searchBounds(), predicates))), 
params);
+    }
+
+    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> range(
+            @Nullable Object lower,
+            @Nullable 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 static boolean matchValue(@Nullable 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/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/PlannerTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/PlannerTest.java
index 2a6ab9f3d1..dec8c44b32 100644
--- 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/PlannerTest.java
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/PlannerTest.java
@@ -43,7 +43,6 @@ import org.apache.calcite.schema.SchemaPlus;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.util.ImmutableIntList;
 import org.apache.calcite.util.Util;
-import org.apache.ignite.internal.index.ColumnCollation;
 import org.apache.ignite.internal.sql.engine.metadata.ColocationGroup;
 import org.apache.ignite.internal.sql.engine.metadata.MappingService;
 import org.apache.ignite.internal.sql.engine.metadata.NodeWithTerm;
@@ -63,7 +62,6 @@ import 
org.apache.ignite.internal.sql.engine.schema.IgniteIndex;
 import org.apache.ignite.internal.sql.engine.schema.IgniteSchema;
 import org.apache.ignite.internal.sql.engine.trait.IgniteDistribution;
 import org.apache.ignite.internal.sql.engine.trait.IgniteDistributions;
-import org.apache.ignite.internal.sql.engine.trait.TraitUtils;
 import org.apache.ignite.internal.sql.engine.type.IgniteTypeFactory;
 import org.apache.ignite.internal.sql.engine.type.IgniteTypeSystem;
 import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
@@ -436,122 +434,6 @@ public class PlannerTest extends AbstractPlannerTest {
         assertEquals(3, plan.fragments().size());
     }
 
-    /** Tests bounds merge. */
-    @Test
-    public void testBoundsMerge() throws Exception {
-        IgniteTypeFactory typeFactory = new 
IgniteTypeFactory(IgniteTypeSystem.INSTANCE);
-
-        TestTable tbl = new TestTable(
-                new RelDataTypeFactory.Builder(typeFactory)
-                        .add("C1", typeFactory.createJavaType(Integer.class))
-                        .add("C2", typeFactory.createJavaType(Integer.class))
-                        .add("C3", typeFactory.createJavaType(Integer.class))
-                        .add("C4", typeFactory.createJavaType(Integer.class))
-                        .build(), "TEST") {
-            @Override
-            public ColocationGroup colocationGroup(MappingQueryContext ctx) {
-                return ColocationGroup.forNodes(select(NODES, 0));
-            }
-
-            @Override
-            public IgniteDistribution distribution() {
-                return IgniteDistributions.hash(List.of(0));
-            }
-        };
-
-        tbl.addIndex("C1C2C3", 0, 1, 2);
-
-        IgniteSchema publicSchema = new IgniteSchema("PUBLIC");
-
-        publicSchema.addTable(tbl);
-
-        assertBounds("SELECT * FROM TEST WHERE C1 > ? AND C1 >= 1", 
List.of(10), publicSchema,
-                range("$GREATEST2(?0, 1)", "$NULL_BOUND()", true, false)
-        );
-
-        assertBounds("SELECT * FROM TEST WHERE C1 > ? AND C1 >= ? AND C1 > ?", 
List.of(10, 10, 10), publicSchema,
-                range("$GREATEST2($GREATEST2(?0, ?1), ?2)", "$NULL_BOUND()", 
true, false)
-        );
-
-        assertBounds("SELECT * FROM TEST WHERE C1 > ? AND C1 >= 1 AND C1 < ? 
AND C1 < ?", List.of(10, 10, 10), publicSchema,
-                range("$GREATEST2(?0, 1)", "$LEAST2(?1, ?2)", true, false)
-        );
-
-        assertBounds("SELECT * FROM TEST WHERE C1 < ? AND C1 BETWEEN 1 AND 10 
", List.of(10), publicSchema,
-                range(1, "$LEAST2(?0, 10)", true, true)
-        );
-
-        assertBounds("SELECT * FROM TEST WHERE C1 NOT IN (1, 2) AND C1 >= ?", 
List.of(10), publicSchema,
-                range("?0", "$NULL_BOUND()", true, false)
-        );
-
-        tbl.addIndex(RelCollations.of(TraitUtils.createFieldCollation(3, 
ColumnCollation.DESC_NULLS_LAST),
-                TraitUtils.createFieldCollation(2, 
ColumnCollation.ASC_NULLS_FIRST)), "C4");
-
-        assertBounds("SELECT * FROM TEST WHERE C4 > ? AND C4 >= 1 AND C4 < ? 
AND C4 < ?", List.of(10, 10, 10), publicSchema,
-                range("$LEAST2(?1, ?2)", "$GREATEST2(?0, 1)", false, true)
-        );
-    }
-
-    /** Tests complex bounds expressions. */
-    @Test
-    public void testBoundsComplex() throws Exception {
-        IgniteTypeFactory typeFactory = new 
IgniteTypeFactory(IgniteTypeSystem.INSTANCE);
-
-        TestTable tbl = new TestTable(
-                new RelDataTypeFactory.Builder(typeFactory)
-                        .add("C1", typeFactory.createJavaType(Integer.class))
-                        .add("C2", typeFactory.createJavaType(Integer.class))
-                        .add("C3", typeFactory.createJavaType(Integer.class))
-                        .add("C4", typeFactory.createJavaType(Integer.class))
-                        .build(), "TEST") {
-            @Override
-            public ColocationGroup colocationGroup(MappingQueryContext ctx) {
-                return ColocationGroup.forNodes(select(NODES, 0));
-            }
-
-            @Override
-            public IgniteDistribution distribution() {
-                return IgniteDistributions.hash(List.of(0));
-            }
-        };
-
-        tbl.addIndex("C1C2C3", 0, 1, 2);
-
-        IgniteSchema publicSchema = new IgniteSchema("PUBLIC");
-
-        publicSchema.addTable(tbl);
-
-        assertBounds("SELECT * FROM TEST WHERE C1 = ? + 10", List.of(10), 
publicSchema,
-                exact("+(?0, 10)")
-        );
-
-        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 > 
SUBSTRING(?::VARCHAR, 1, 2) || '3'", List.of("10"), publicSchema,
-                exact(1),
-                range("||(SUBSTRING(?0, 1, 2), _UTF-8'3')", "$NULL_BOUND()", 
false, false)
-        );
-
-        assertBounds("SELECT * FROM TEST WHERE C1 = 1 AND C2 > 
SUBSTRING(C3::VARCHAR, 1, 2) || '3'", List.of(), publicSchema,
-                exact(1),
-                empty()
-        );
-
-        assertBounds("SELECT (SELECT C1 FROM TEST t2 WHERE t2.C1 = t1.C1 + 
t1.C3 * ?) FROM TEST t1", List.of(10), publicSchema,
-                exact("+($cor0.C1, *($cor0.C3, ?0))")
-        );
-
-        assertPlan("SELECT * FROM TEST WHERE C1 = ? + C3", publicSchema, 
nodeOrAnyChild(isTableScan("TEST")), List.of(10));
-
-        assertPlan("SELECT (SELECT C1 FROM TEST t2 WHERE t2.C1 < t1.C1 + 
t2.C1) FROM TEST t1", publicSchema,
-                nodeOrAnyChild(isIndexScan("TEST", "C1C2C3")).negate());
-    }
-
-    /** String representation of LEAST or GREATEST operator converted to CASE. 
*/
-    private String leastOrGreatest(boolean least, String val0, String val1, 
String type) {
-        return "CASE(OR(IS NULL(" + val0 + "), IS NULL(" + val1 + ")), null:" 
+ type + ", " + (least ? '<' : '>')
-                + '(' + val0 + ", " + val1 + "), " + val0 + ", " + val1 + ')';
-    }
-
     @Test
     public void testSplitterPartiallyColocated2() throws Exception {
         IgniteTypeFactory f = new IgniteTypeFactory(IgniteTypeSystem.INSTANCE);

Reply via email to