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 d9cbd44f8b IGNITE-18442 Sql. JOIN with DISTINCT FROM fails - Fixes 
#1476.
d9cbd44f8b is described below

commit d9cbd44f8bfc05cadd994e0fd155bfb4e0f45383
Author: zstan <[email protected]>
AuthorDate: Thu Jan 5 16:57:07 2023 +0300

    IGNITE-18442 Sql. JOIN with DISTINCT FROM fails - Fixes #1476.
    
    Signed-off-by: zstan <[email protected]>
---
 .../sql/engine/AbstractBasicIntegrationTest.java   |  42 +++
 .../ignite/internal/sql/engine/ItDmlTest.java      |  13 +-
 .../ignite/internal/sql/engine/ItJoinTest.java     |  91 +++--
 .../internal/sql/engine/ItSecondaryIndexTest.java  |   4 +
 .../ignite/internal/sql/engine/ItSetOpTest.java    |   2 +-
 .../internal/sql/engine/ItSqlOperatorsTest.java    |   2 +
 .../ignite/internal/sqllogic/ItSqlLogicTest.java   |   6 -
 .../sql/join/test_not_distinct_from.test           |  30 ++
 .../sql/join/test_not_distinct_from.test_ignore    |   2 +-
 .../internal/sql/engine/exec/ExecutionContext.java |   7 -
 .../sql/engine/exec/LogicalRelImplementor.java     |  36 +-
 .../internal/sql/engine/exec/RuntimeHashIndex.java |   9 +-
 .../sql/engine/exec/exp/ExpressionFactory.java     |   4 +-
 .../sql/engine/exec/exp/ExpressionFactoryImpl.java |  97 +++---
 .../sql/engine/exec/exp/RangeCondition.java        |   8 +-
 .../internal/sql/engine/exec/exp/RexImpTable.java  |   5 +
 .../sql/engine/exec/rel/IndexSpoolNode.java        |   5 +-
 .../sql/engine/rel/IgniteHashIndexSpool.java       |  22 +-
 .../internal/sql/engine/rel/IgniteMergeJoin.java   |   5 +-
 .../engine/rel/logical/IgniteLogicalIndexScan.java |   4 +-
 .../rule/FilterSpoolMergeToHashIndexSpoolRule.java |  20 +-
 .../FilterSpoolMergeToSortedIndexSpoolRule.java    |   2 +-
 .../sql/engine/rule/MergeJoinConverterRule.java    |   3 +-
 .../ignite/internal/sql/engine/util/Commons.java   |   8 +-
 .../internal/sql/engine/util/IgniteMethod.java     |   6 +-
 .../ignite/internal/sql/engine/util/RexUtils.java  |  72 ++--
 .../exec/rel/HashIndexSpoolExecutionTest.java      |   6 +-
 .../engine/exec/rel/MergeJoinExecutionTest.java    | 388 +++++++++++++--------
 .../prepare/ddl/DdlSqlToCommandConverterTest.java  |  13 -
 .../internal/storage/impl/TestMvTableStorage.java  |   2 +-
 30 files changed, 606 insertions(+), 308 deletions(-)

diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/AbstractBasicIntegrationTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/AbstractBasicIntegrationTest.java
index 32dac50f4b..58201db721 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/AbstractBasicIntegrationTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/AbstractBasicIntegrationTest.java
@@ -31,11 +31,14 @@ import static org.junit.jupiter.api.Assertions.assertSame;
 
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
+import java.util.stream.Stream;
 import org.apache.ignite.Ignite;
 import org.apache.ignite.IgnitionManager;
 import org.apache.ignite.internal.app.IgniteImpl;
@@ -207,6 +210,45 @@ public class AbstractBasicIntegrationTest extends 
BaseIgniteAbstractTest {
         };
     }
 
+    /**
+     * Used for join checks, disables other join rules for executing exact 
join algo.
+     *
+     * @param qry Query for check.
+     * @param joinType Type of join algo.
+     * @param rules Additional rules need to be disabled.
+     */
+    static QueryChecker assertQuery(String qry, JoinType joinType, String... 
rules) {
+        return 
AbstractBasicIntegrationTest.assertQuery(qry.replaceAll("(?i)^select", "select "
+                + Stream.concat(Arrays.stream(joinType.disabledRules), 
Arrays.stream(rules))
+                .collect(Collectors.joining("','", "/*+ DISABLE_RULE('", "') 
*/"))));
+    }
+
+    enum JoinType {
+        NESTED_LOOP(
+                "CorrelatedNestedLoopJoin",
+                "JoinCommuteRule",
+                "MergeJoinConverter"
+        ),
+
+        MERGE(
+                "CorrelatedNestedLoopJoin",
+                "JoinCommuteRule",
+                "NestedLoopJoinConverter"
+        ),
+
+        CORRELATED(
+                "MergeJoinConverter",
+                "JoinCommuteRule",
+                "NestedLoopJoinConverter"
+        );
+
+        private final String[] disabledRules;
+
+        JoinType(String... disabledRules) {
+            this.disabledRules = disabledRules;
+        }
+    }
+
     protected static void createAndPopulateTable() {
         sql("CREATE TABLE person (id INT PRIMARY KEY, name VARCHAR, salary 
DOUBLE)");
 
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
index ab5665a40f..a77adc0454 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
@@ -29,15 +29,13 @@ import java.util.List;
 import java.util.stream.Collectors;
 import org.apache.calcite.runtime.CalciteContextException;
 import org.apache.ignite.internal.sql.engine.exec.rel.AbstractNode;
-import org.apache.ignite.internal.sql.engine.util.Commons;
-import org.apache.ignite.internal.testframework.IgniteTestUtils;
 import org.apache.ignite.internal.testframework.WithSystemProperty;
 import org.apache.ignite.lang.ErrorGroups.Sql;
 import org.apache.ignite.lang.IgniteException;
 import org.apache.ignite.sql.SqlException;
 import org.apache.ignite.tx.Transaction;
-import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInfo;
@@ -64,9 +62,10 @@ public class ItDmlTest extends AbstractBasicIntegrationTest {
         super.tearDownBase(testInfo);
     }
 
-    @AfterAll
-    public static void resetStaticState() {
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
+    @BeforeAll
+    public static void beforeTestsStarted() {
+        // IMPLICIT_PK_ENABLED hashed sys property needs
+        sql("CREATE TABLE fake_tbl (id INT PRIMARY KEY, c1 INT NOT NULL)");
     }
 
     @Test
@@ -408,8 +407,6 @@ public class ItDmlTest extends AbstractBasicIntegrationTest 
{
     @Test
     @WithSystemProperty(key = "IMPLICIT_PK_ENABLED", value = "true")
     public void implicitPk() {
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
-
         sql("CREATE TABLE T(VAL INT)");
 
         sql("INSERT INTO t VALUES (1), (2), (3)");
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 043daefe4e..369c46b09c 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
@@ -17,14 +17,19 @@
 
 package org.apache.ignite.internal.sql.engine;
 
+import static 
org.apache.ignite.internal.sql.engine.AbstractBasicIntegrationTest.JoinType.CORRELATED;
+
 import java.util.Arrays;
 import java.util.List;
-import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.apache.ignite.internal.sql.engine.util.QueryChecker;
+import org.apache.ignite.internal.testframework.WithSystemProperty;
 import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.MethodSource;
 
 /**
  * Check JOIN on basic cases.
@@ -784,34 +789,66 @@ public class ItJoinTest extends 
AbstractBasicIntegrationTest {
         //    .check();
     }
 
-    protected QueryChecker assertQuery(String qry, JoinType joinType) {
-        return AbstractBasicIntegrationTest.assertQuery(qry.replace("select", 
"select "
-            + 
Arrays.stream(joinType.disabledRules).collect(Collectors.joining("','", "/*+ 
DISABLE_RULE('", "') */"))));
-    }
+    /** Check IS NOT DISTINCT execution correctness and IndexSpool presence. */
+    @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;
+        }
 
-    enum JoinType {
-        NESTED_LOOP(
-            "CorrelatedNestedLoopJoin",
-            "JoinCommuteRule",
-            "MergeJoinConverter"
-        ),
-
-        MERGE(
-            "CorrelatedNestedLoopJoin",
-            "JoinCommuteRule",
-            "NestedLoopJoinConverter"
-        ),
-
-        CORRELATED(
-            "MergeJoinConverter",
-            "JoinCommuteRule",
-            "NestedLoopJoinConverter"
-        );
+        try {
+            sql("CREATE TABLE t11(i1 INTEGER, i2 INTEGER)");
+
+            if (indexScan) {
+                sql("CREATE INDEX t11_idx ON t11(i1)");
+            }
+
+            sql("INSERT INTO t11 VALUES (1, null), (2, 2), (null, 3), (3, 
null), (5, null)");
+
+            sql("CREATE TABLE t22(i3 INTEGER, i4 INTEGER)");
+
+            if (indexScan) {
+                sql("CREATE INDEX t22_idx ON t22(i3)");
+            }
+
+            sql("INSERT INTO t22 VALUES (1, 1), (2, 2), (null, 3), (4, null), 
(5, null)");
+
+            String sql = "SELECT i1, i4 FROM t11 JOIN t22 ON i1 IS NOT 
DISTINCT FROM i3";
+
+            assertQuery(sql, joinType, indexScan ? 
"LogicalTableScanConverterRule" : null)
+                    .matches(joinType == CORRELATED ? 
QueryChecker.containsSubPlan("IgniteHashIndexSpool")
+                            : QueryChecker.matches("(?i).*IS NOT DISTINCT.*"))
+                    .matches(indexScan ? 
QueryChecker.containsIndexScan("PUBLIC", "T11") :
+                            QueryChecker.containsTableScan("PUBLIC", "T11"))
+                    .returns(1, 1)
+                    .returns(2, 2)
+                    .returns(5, null)
+                    .returns(null, 3)
+                    .check();
+
+            sql = "SELECT i1, i4 FROM t11 JOIN t22 ON i1 IS NOT DISTINCT FROM 
i3 AND i2 = i4";
+
+            assertQuery(sql, joinType, indexScan ? 
"LogicalTableScanConverterRule" : null)
+                    .matches(joinType == CORRELATED ? 
QueryChecker.containsSubPlan("IgniteHashIndexSpool")
+                            : QueryChecker.matches("(?i).*IS NOT DISTINCT.*"))
+                    .matches(indexScan ? 
QueryChecker.containsIndexScan("PUBLIC", "T11") :
+                            QueryChecker.containsTableScan("PUBLIC", "T11"))
+                    .returns(2, 2)
+                    .returns(null, 3)
+                    .check();
+        } finally {
+            sql("DROP TABLE IF EXISTS t11");
+            sql("DROP TABLE IF EXISTS t22");
+        }
+    }
 
-        private final String[] disabledRules;
+    private static Stream<Arguments> joinTypes() {
+        Stream<Arguments> types = Arrays.stream(JoinType.values())
+                .flatMap(v -> Stream.of(Arguments.of(v, false), 
Arguments.of(v, true)));
 
-        JoinType(String... disabledRules) {
-            this.disabledRules = disabledRules;
-        }
+        return types;
     }
 }
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 12b8f071dd..8b2b3e777e 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
@@ -27,6 +27,7 @@ import static org.hamcrest.Matchers.not;
 
 import java.util.List;
 import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -770,6 +771,7 @@ public class ItSecondaryIndexTest extends 
AbstractBasicIntegrationTest {
     }
 
     @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"))
@@ -781,6 +783,7 @@ public class ItSecondaryIndexTest extends 
AbstractBasicIntegrationTest {
     }
 
     @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"))
@@ -795,6 +798,7 @@ public class ItSecondaryIndexTest extends 
AbstractBasicIntegrationTest {
     }
 
     @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"))
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSetOpTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSetOpTest.java
index 84e9d346be..8613fef5f7 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSetOpTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSetOpTest.java
@@ -105,7 +105,7 @@ public class ItSetOpTest extends 
AbstractBasicIntegrationTest {
     }
 
     @Test
-    @Disabled
+    @Disabled("https://issues.apache.org/jira/browse/IGNITE-18469";)
     public void testSetOpBigBatch() {
         sql("CREATE TABLE big_table1(key INT PRIMARY KEY, val INT)");
         sql("CREATE TABLE big_table2(key INT PRIMARY KEY, val INT)");
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSqlOperatorsTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSqlOperatorsTest.java
index f65441c6a7..73f2f51c4c 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSqlOperatorsTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItSqlOperatorsTest.java
@@ -108,6 +108,8 @@ public class ItSqlOperatorsTest extends 
AbstractBasicIntegrationTest {
         assertExpression("1=1 IS NOT TRUE").returns(false).check();
         assertExpression("1=1 IS FALSE").returns(false).check();
         assertExpression("1=1 IS NOT FALSE").returns(true).check();
+        assertExpression("NULL IS DISTINCT FROM NULL").returns(false).check();
+        assertExpression("NULL IS NOT DISTINCT FROM 
NULL").returns(true).check();
     }
 
     @Test
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sqllogic/ItSqlLogicTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sqllogic/ItSqlLogicTest.java
index 3fb955e8c0..9979ac72a0 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sqllogic/ItSqlLogicTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sqllogic/ItSqlLogicTest.java
@@ -41,9 +41,7 @@ import org.apache.ignite.Ignite;
 import org.apache.ignite.IgnitionManager;
 import org.apache.ignite.internal.logger.IgniteLogger;
 import org.apache.ignite.internal.logger.Loggers;
-import org.apache.ignite.internal.sql.engine.util.Commons;
 import org.apache.ignite.internal.sqllogic.SqlLogicTestEnvironment.RestartMode;
-import org.apache.ignite.internal.testframework.IgniteTestUtils;
 import org.apache.ignite.internal.testframework.SystemPropertiesExtension;
 import org.apache.ignite.internal.testframework.WithSystemProperty;
 import org.apache.ignite.internal.testframework.WorkDirectory;
@@ -161,16 +159,12 @@ public class ItSqlLogicTest {
     static void init() {
         config();
 
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
-
         startNodes();
     }
 
     @AfterAll
     static void shutdown() throws Exception {
         stopNodes();
-
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
     }
 
     @TestFactory
diff --git 
a/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test 
b/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test
index a5662eccef..37fbff5d2c 100644
--- a/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test
+++ b/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test
@@ -189,3 +189,33 @@ query T
 SELECT INTERVAL '30' DAY is not distinct from NULL
 ----
 false
+
+# Use on a bigger table (~10K elements)
+statement ok
+CREATE table big(a int);
+
+statement ok
+insert into big select x from table(system_range(1, 9999));
+
+statement ok
+insert into big values (NULL)
+
+query I
+select count(*) from big  inner join tbl_2 on (a IS NOT DISTINCT FROM tbl_2.b)
+----
+3
+
+# Use with filter (e.g. SELECT x IS NOT DISTINCT FROM y FROM tbl WHERE z%2=0, 
where the expression filters like 50% of the rows)
+query I
+select count(*) from big  inner join tbl_2 on (a IS NOT DISTINCT FROM tbl_2.b) 
where a >1  and a < 5000
+----
+1
+
+# Use in subqueries with correlated expressions
+query II
+SELECT a, (select count(*) from tbl_1 where tbl_1.a is distinct from t.b) from 
tbl_1 as t ORDER BY a NULLS LAST
+----
+1      2
+2      3
+NULL   2
+
diff --git 
a/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test_ignore
 
b/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test_ignore
index e972585f63..72d8ba926e 100644
--- 
a/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test_ignore
+++ 
b/modules/runner/src/integrationTest/sql/join/test_not_distinct_from.test_ignore
@@ -1,7 +1,7 @@
 # name: test/sql/join/test_not_distinct_from.test
 # description: Test join on is not distinct from query
 # group: [join]
-# Ignore https://issues.apache.org/jira/browse/IGNITE-15574
+# Ignore https://issues.apache.org/jira/browse/IGNITE-18444
 
 
 statement ok
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/ExecutionContext.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/ExecutionContext.java
index ec2d373c02..8d1bf48059 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/ExecutionContext.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/ExecutionContext.java
@@ -189,13 +189,6 @@ public class ExecutionContext<RowT> extends 
AbstractQueryContext implements Data
         return fragmentDesc.mapping().findGroup(sourceId);
     }
 
-    /**
-     * Get keep binary flag.
-     */
-    public boolean keepBinary() {
-        return true; // TODO
-    }
-
     /**
      * Get handler to access row fields.
      */
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/LogicalRelImplementor.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/LogicalRelImplementor.java
index 12298c3425..18cdd49fe7 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/LogicalRelImplementor.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/LogicalRelImplementor.java
@@ -30,6 +30,7 @@ import java.util.function.BiPredicate;
 import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
+import org.apache.calcite.plan.RelOptUtil;
 import org.apache.calcite.rel.RelCollation;
 import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.core.Intersect;
@@ -37,8 +38,11 @@ import org.apache.calcite.rel.core.JoinRelType;
 import org.apache.calcite.rel.core.Minus;
 import org.apache.calcite.rel.core.Spool;
 import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rex.RexInputRef;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.rex.RexShuttle;
+import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.internal.sql.engine.exec.RowHandler.RowFactory;
 import org.apache.ignite.internal.sql.engine.exec.exp.ExpressionFactory;
@@ -269,9 +273,36 @@ public class LogicalRelImplementor<RowT> implements 
IgniteRelVisitor<Node<RowT>>
 
         int pairsCnt = rel.analyzeCondition().pairs().size();
 
+        ImmutableBitSet leftKeys = rel.analyzeCondition().leftSet();
+
+        List<RexNode> conjunctions = 
RelOptUtil.conjunctions(rel.getCondition());
+
+        ImmutableBitSet.Builder nullCompAsEqualBuilder = 
ImmutableBitSet.builder();
+
+        ImmutableBitSet nullCompAsEqual;
+        RexShuttle shuttle = new RexShuttle() {
+            @Override
+            public RexNode visitInputRef(RexInputRef ref) {
+                int idx = ref.getIndex();
+                if (leftKeys.get(idx)) {
+                    nullCompAsEqualBuilder.set(idx);
+                }
+                return ref;
+            }
+        };
+
+        for (RexNode expr : conjunctions) {
+            if (expr.getKind() == SqlKind.IS_NOT_DISTINCT_FROM) {
+                shuttle.apply(expr);
+            }
+        }
+
+        nullCompAsEqual = nullCompAsEqualBuilder.build();
+
         Comparator<RowT> comp = expressionFactory.comparator(
                 rel.leftCollation().getFieldCollations().subList(0, pairsCnt),
-                rel.rightCollation().getFieldCollations().subList(0, pairsCnt)
+                rel.rightCollation().getFieldCollations().subList(0, pairsCnt),
+                nullCompAsEqual
         );
 
         Node<RowT> node = MergeJoinNode.create(ctx, leftType, rightType, 
joinType, comp);
@@ -481,7 +512,8 @@ public class LogicalRelImplementor<RowT> implements 
IgniteRelVisitor<Node<RowT>>
                 ctx,
                 ImmutableBitSet.of(rel.keys()),
                 filter,
-                searchRow
+                searchRow,
+                rel.allowNulls()
         );
 
         Node<RowT> input = visit(rel.getInput());
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/RuntimeHashIndex.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/RuntimeHashIndex.java
index 15c6147489..cc457120fe 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/RuntimeHashIndex.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/RuntimeHashIndex.java
@@ -50,19 +50,24 @@ public class RuntimeHashIndex<RowT> implements 
RuntimeIndex<RowT> {
     /** Rows. */
     private HashMap<GroupKey, List<RowT>> rows;
 
+    /** Allow NULL values. */
+    private final boolean allowNulls;
+
     /**
      * Constructor.
      * TODO Documentation https://issues.apache.org/jira/browse/IGNITE-15859
      */
     public RuntimeHashIndex(
             ExecutionContext<RowT> ectx,
-            ImmutableBitSet keys
+            ImmutableBitSet keys,
+            boolean allowNulls
     ) {
         this.ectx = ectx;
 
         assert !nullOrEmpty(keys);
 
         this.keys = keys;
+        this.allowNulls = allowNulls;
         rows = new HashMap<>();
     }
 
@@ -95,7 +100,7 @@ public class RuntimeHashIndex<RowT> implements 
RuntimeIndex<RowT> {
         for (Integer field : keys) {
             Object fieldVal = ectx.rowHandler().get(field, r);
 
-            if (fieldVal == null) {
+            if (fieldVal == null && !allowNulls) {
                 return NULL_KEY;
             }
 
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactory.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactory.java
index 0940ebe5ca..45454f35b4 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactory.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/ExpressionFactory.java
@@ -29,6 +29,7 @@ import org.apache.calcite.rel.core.AggregateCall;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.rex.RexNode;
+import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.internal.sql.engine.exec.exp.agg.AccumulatorWrapper;
 import org.apache.ignite.internal.sql.engine.exec.exp.agg.AggregateType;
 import org.apache.ignite.internal.sql.engine.prepare.bounds.SearchBounds;
@@ -59,9 +60,10 @@ public interface ExpressionFactory<RowT> {
      *
      * @param left  Collations of left row.
      * @param right Collations of right row.
+     * @param equalNulls Bitset with null comparison strategy, use in case of 
NOT DISTINCT FROM syntax.
      * @return Rows comparator.
      */
-    Comparator<RowT> comparator(List<RelFieldCollation> left, 
List<RelFieldCollation> right);
+    Comparator<RowT> comparator(List<RelFieldCollation> left, 
List<RelFieldCollation> right, ImmutableBitSet equalNulls);
 
     /**
      * Creates a Filter predicate.
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 2cc3a39d43..a9fa55dd62 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
@@ -63,6 +63,7 @@ import org.apache.calcite.rex.RexShuttle;
 import org.apache.calcite.rex.RexUtil;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.sql.validate.SqlConformance;
+import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.ignite.internal.sql.engine.exec.ExecutionContext;
 import org.apache.ignite.internal.sql.engine.exec.RowHandler;
 import org.apache.ignite.internal.sql.engine.exec.RowHandler.RowFactory;
@@ -139,42 +140,39 @@ public class ExpressionFactoryImpl<RowT> implements 
ExpressionFactory<RowT> {
             return null;
         }
 
-        return new Comparator<RowT>() {
-            @Override
-            public int compare(RowT o1, RowT o2) {
-                RowHandler<RowT> hnd = ctx.rowHandler();
-                Object unspecifiedVal = 
RexImpTable.UNSPECIFIED_VALUE_PLACEHOLDER;
+        return (o1, o2) -> {
+            RowHandler<RowT> hnd = ctx.rowHandler();
+            Object unspecifiedVal = RexImpTable.UNSPECIFIED_VALUE_PLACEHOLDER;
 
-                for (RelFieldCollation field : collation.getFieldCollations()) 
{
-                    int fieldIdx = field.getFieldIndex();
-                    int nullComparison = field.nullDirection.nullComparison;
+            for (RelFieldCollation field : collation.getFieldCollations()) {
+                int fieldIdx = field.getFieldIndex();
+                int nullComparison = field.nullDirection.nullComparison;
 
-                    Object c1 = hnd.get(fieldIdx, o1);
-                    Object c2 = hnd.get(fieldIdx, o2);
+                Object c1 = hnd.get(fieldIdx, o1);
+                Object c2 = hnd.get(fieldIdx, o2);
 
-                    // If filter for some field is unspecified, assume 
equality for this field and all subsequent fields.
-                    if (c1 == unspecifiedVal || c2 == unspecifiedVal) {
-                        return 0;
-                    }
+                // If filter for some field is unspecified, assume equality 
for this field and all subsequent fields.
+                if (c1 == unspecifiedVal || c2 == unspecifiedVal) {
+                    return 0;
+                }
 
-                    int res = (field.direction == 
RelFieldCollation.Direction.ASCENDING)
-                            ?
-                            ExpressionFactoryImpl.compare(c1, c2, 
nullComparison) :
-                            ExpressionFactoryImpl.compare(c2, c1, 
-nullComparison);
+                int res = (field.direction == 
RelFieldCollation.Direction.ASCENDING)
+                        ?
+                        compare(c1, c2, nullComparison) :
+                        compare(c2, c1, -nullComparison);
 
-                    if (res != 0) {
-                        return res;
-                    }
+                if (res != 0) {
+                    return res;
                 }
-
-                return 0;
             }
+
+            return 0;
         };
     }
 
     /** {@inheritDoc} */
     @Override
-    public Comparator<RowT> comparator(List<RelFieldCollation> left, 
List<RelFieldCollation> right) {
+    public Comparator<RowT> comparator(List<RelFieldCollation> left, 
List<RelFieldCollation> right, ImmutableBitSet equalNulls) {
         if (nullOrEmpty(left) || nullOrEmpty(right) || left.size() != 
right.size()) {
             throw new IllegalArgumentException("Both inputs should be 
non-empty and have the same size: left="
                     + (left != null ? left.size() : "null") + ", right=" + 
(right != null ? right.size() : "null"));
@@ -191,42 +189,39 @@ public class ExpressionFactoryImpl<RowT> implements 
ExpressionFactory<RowT> {
             }
         }
 
-        return new Comparator<RowT>() {
-            @Override
-            public int compare(RowT o1, RowT o2) {
-                boolean hasNulls = false;
-                RowHandler<RowT> hnd = ctx.rowHandler();
+        return (o1, o2) -> {
+            boolean hasNulls = false;
+            RowHandler<RowT> hnd = ctx.rowHandler();
 
-                for (int i = 0; i < left.size(); i++) {
-                    RelFieldCollation leftField = left.get(i);
-                    RelFieldCollation rightField = right.get(i);
+            for (int i = 0; i < left.size(); i++) {
+                RelFieldCollation leftField = left.get(i);
+                RelFieldCollation rightField = right.get(i);
 
-                    int leftIdx = leftField.getFieldIndex();
-                    int rightIdx = rightField.getFieldIndex();
+                int leftIdx = leftField.getFieldIndex();
+                int rightIdx = rightField.getFieldIndex();
 
-                    Object c1 = hnd.get(leftIdx, o1);
-                    Object c2 = hnd.get(rightIdx, o2);
+                Object c1 = hnd.get(leftIdx, o1);
+                Object c2 = hnd.get(rightIdx, o2);
 
-                    if (c1 == null && c2 == null) {
-                        hasNulls = true;
-                        continue;
-                    }
+                if (!equalNulls.get(leftIdx) && c1 == null && c2 == null) {
+                    hasNulls = true;
+                    continue;
+                }
 
-                    int nullComparison = 
leftField.nullDirection.nullComparison;
+                int nullComparison = leftField.nullDirection.nullComparison;
 
-                    int res = leftField.direction == 
RelFieldCollation.Direction.ASCENDING
-                            ?
-                            ExpressionFactoryImpl.compare(c1, c2, 
nullComparison) :
-                            ExpressionFactoryImpl.compare(c2, c1, 
-nullComparison);
+                int res = leftField.direction == 
RelFieldCollation.Direction.ASCENDING
+                        ? compare(c1, c2, nullComparison)
+                        : compare(c2, c1, -nullComparison);
 
-                    if (res != 0) {
-                        return res;
-                    }
+                if (res != 0) {
+                    return res;
                 }
-
-                // If compared rows contain NULLs, they shouldn't be treated 
as equals, since NULL <> NULL in SQL.
-                return hasNulls ? 1 : 0;
             }
+
+            // If compared rows contain NULLs, they shouldn't be treated as 
equals, since NULL <> NULL in SQL.
+            // Expect for cases with IS NOT DISTINCT
+            return hasNulls ? 1 : 0;
         };
     }
 
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeCondition.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeCondition.java
index 91c9cf5b1a..11c30292bf 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeCondition.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RangeCondition.java
@@ -24,14 +24,14 @@ package org.apache.ignite.internal.sql.engine.exec.exp;
  */
 public interface RangeCondition<RowT> {
     /** Lower search row. */
-    public RowT lower();
+    RowT lower();
 
     /** Upper search row. */
-    public RowT upper();
+    RowT upper();
 
     /** Inlusive search by lower row. */
-    public boolean lowerInclude();
+    boolean lowerInclude();
 
     /** Inlusive search by upper row. */
-    public boolean upperInclude();
+    boolean upperInclude();
 }
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RexImpTable.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RexImpTable.java
index bac45c0a5e..3a21b89194 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RexImpTable.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/exp/RexImpTable.java
@@ -110,6 +110,7 @@ import static 
org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_JSON_ARRAY;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_JSON_OBJECT;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_JSON_SCALAR;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_JSON_VALUE;
+import static 
org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_NOT_DISTINCT_FROM;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_NOT_EMPTY;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_NOT_FALSE;
 import static org.apache.calcite.sql.fun.SqlStdOperatorTable.IS_NOT_JSON_ARRAY;
@@ -498,6 +499,10 @@ public class RexImpTable {
         map.put(LOCALTIMESTAMP, systemFunctionImplementor);
 
         map.put(TYPEOF, systemFunctionImplementor);
+
+        // Operator IS_NOT_DISTINCT_FROM is removed by RexSimplify, but still 
possible in join conditions, so
+        // implementation required.
+        defineMethod(IS_NOT_DISTINCT_FROM, 
IgniteMethod.IS_NOT_DISTINCT_FROM.method(), NullPolicy.NONE);
     }
 
     private void defineMethod(SqlOperator operator, String functionName, 
NullPolicy nullPolicy) {
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexSpoolNode.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexSpoolNode.java
index 26957fcfea..e3450824f7 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexSpoolNode.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/exec/rel/IndexSpoolNode.java
@@ -197,9 +197,10 @@ public class IndexSpoolNode<RowT> extends 
AbstractNode<RowT> implements SingleNo
             ExecutionContext<RowT> ctx,
             ImmutableBitSet keys,
             @Nullable Predicate<RowT> filter,
-            Supplier<RowT> searchRow
+            Supplier<RowT> searchRow,
+            boolean allowNulls
     ) {
-        RuntimeHashIndex<RowT> idx = new RuntimeHashIndex<>(ctx, keys);
+        RuntimeHashIndex<RowT> idx = new RuntimeHashIndex<>(ctx, keys, 
allowNulls);
 
         ScanNode<RowT> scan = new ScanNode<>(
                 ctx,
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteHashIndexSpool.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteHashIndexSpool.java
index 926b3e8efa..945194154c 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteHashIndexSpool.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteHashIndexSpool.java
@@ -48,6 +48,9 @@ public class IgniteHashIndexSpool extends AbstractIgniteSpool 
implements Interna
     /** Condition (used to calculate selectivity). */
     private final RexNode cond;
 
+    /** Allow NULL values. */
+    private final boolean allowNulls;
+
     /**
      * Constructor.
      * TODO Documentation https://issues.apache.org/jira/browse/IGNITE-15859
@@ -57,7 +60,8 @@ public class IgniteHashIndexSpool extends AbstractIgniteSpool 
implements Interna
             RelTraitSet traits,
             RelNode input,
             List<RexNode> searchRow,
-            RexNode cond
+            RexNode cond,
+            boolean allowNulls
     ) {
         super(cluster, traits, Type.LAZY, input);
 
@@ -65,6 +69,7 @@ public class IgniteHashIndexSpool extends AbstractIgniteSpool 
implements Interna
 
         this.searchRow = searchRow;
         this.cond = cond;
+        this.allowNulls = allowNulls;
 
         keys = ImmutableBitSet.of(RexUtils.notNullKeys(searchRow));
     }
@@ -79,7 +84,8 @@ public class IgniteHashIndexSpool extends AbstractIgniteSpool 
implements Interna
                 input.getTraitSet().replace(IgniteConvention.INSTANCE),
                 input.getInputs().get(0),
                 input.getExpressionList("searchRow"),
-                input.getExpression("condition")
+                input.getExpression("condition"),
+                input.getBoolean("allowNulls", false)
         );
     }
 
@@ -91,13 +97,13 @@ public class IgniteHashIndexSpool extends 
AbstractIgniteSpool implements Interna
 
     @Override
     public IgniteRel clone(RelOptCluster cluster, List<IgniteRel> inputs) {
-        return new IgniteHashIndexSpool(cluster, getTraitSet(), inputs.get(0), 
searchRow, cond);
+        return new IgniteHashIndexSpool(cluster, getTraitSet(), inputs.get(0), 
searchRow, cond, allowNulls);
     }
 
     /** {@inheritDoc} */
     @Override
     protected Spool copy(RelTraitSet traitSet, RelNode input, Type readType, 
Type writeType) {
-        return new IgniteHashIndexSpool(getCluster(), traitSet, input, 
searchRow, cond);
+        return new IgniteHashIndexSpool(getCluster(), traitSet, input, 
searchRow, cond, allowNulls);
     }
 
     /** {@inheritDoc} */
@@ -112,7 +118,8 @@ public class IgniteHashIndexSpool extends 
AbstractIgniteSpool implements Interna
 
         return writer
                 .item("searchRow", searchRow)
-                .item("condition", cond);
+                .item("condition", cond)
+                .item("allowNulls", allowNulls);
     }
 
     /** {@inheritDoc} */
@@ -157,4 +164,9 @@ public class IgniteHashIndexSpool extends 
AbstractIgniteSpool implements Interna
     public RexNode condition() {
         return cond;
     }
+
+    /** Allow {@code null} comparison as equal, use in case of NOT DISTINCT 
FROM syntax. */
+    public boolean allowNulls() {
+        return allowNulls;
+    }
 }
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteMergeJoin.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteMergeJoin.java
index 0c1e68033c..7112ea67a9 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteMergeJoin.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/IgniteMergeJoin.java
@@ -82,8 +82,9 @@ public class IgniteMergeJoin extends AbstractIgniteJoin {
     }
 
     /**
-     * Constructor.
-     * TODO Documentation https://issues.apache.org/jira/browse/IGNITE-15859
+     * Constructor used for deserialization.
+     *
+     * @param input Serialized representation.
      */
     public IgniteMergeJoin(RelInput input) {
         this(
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/logical/IgniteLogicalIndexScan.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/logical/IgniteLogicalIndexScan.java
index 6f9a93b7d8..e9694c488f 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/logical/IgniteLogicalIndexScan.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rel/logical/IgniteLogicalIndexScan.java
@@ -131,7 +131,7 @@ public class IgniteLogicalIndexScan extends 
AbstractIndexScan {
             return null;
         }
 
-        return RexUtils.buildSortedIndexConditions(
+        return RexUtils.buildSortedSearchBounds(
                 cluster,
                 collation,
                 cond,
@@ -147,7 +147,7 @@ public class IgniteLogicalIndexScan extends 
AbstractIndexScan {
             RexNode cond,
             @Nullable ImmutableBitSet requiredColumns
     ) {
-        return RexUtils.buildHashIndexConditions(
+        return RexUtils.buildHashSearchBounds(
                 cluster,
                 collation,
                 cond,
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToHashIndexSpoolRule.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToHashIndexSpoolRule.java
index 0b6e051ae7..a39a27a96a 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToHashIndexSpoolRule.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToHashIndexSpoolRule.java
@@ -30,11 +30,15 @@ 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.calcite.sql.SqlKind;
+import org.apache.ignite.internal.sql.engine.prepare.bounds.ExactBounds;
+import org.apache.ignite.internal.sql.engine.prepare.bounds.SearchBounds;
 import org.apache.ignite.internal.sql.engine.rel.IgniteFilter;
 import org.apache.ignite.internal.sql.engine.rel.IgniteHashIndexSpool;
 import org.apache.ignite.internal.sql.engine.rel.IgniteTableSpool;
 import org.apache.ignite.internal.sql.engine.trait.CorrelationTrait;
 import org.apache.ignite.internal.sql.engine.trait.TraitUtils;
+import org.apache.ignite.internal.sql.engine.util.Commons;
 import org.apache.ignite.internal.sql.engine.util.RexUtils;
 import org.immutables.value.Value;
 
@@ -67,22 +71,30 @@ public class FilterSpoolMergeToHashIndexSpoolRule extends 
RelRule<FilterSpoolMer
 
         final RelNode input = spool.getInput();
 
-        List<RexNode> searchRow = RexUtils.buildHashSearchRow(
+        List<SearchBounds> searchBounds = RexUtils.buildHashSearchBounds(
                 cluster,
                 filter.getCondition(),
-                spool.getRowType()
+                spool.getRowType(),
+                null
         );
 
-        if (nullOrEmpty(searchRow)) {
+        if (nullOrEmpty(searchBounds)) {
             return;
         }
 
+        List<RexNode> searchRow = Commons.transform(searchBounds, b -> {
+            assert b == null || b instanceof ExactBounds : b;
+
+            return b == null ? null : ((ExactBounds) b).bound();
+        });
+
         RelNode res = new IgniteHashIndexSpool(
                 cluster,
                 trait.replace(RelCollations.EMPTY),
                 input,
                 searchRow,
-                filter.getCondition()
+                filter.getCondition(),
+                searchBounds.stream().anyMatch(b -> b != null && 
b.condition().getKind() == SqlKind.IS_NOT_DISTINCT_FROM)
         );
 
         call.transformTo(res);
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToSortedIndexSpoolRule.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToSortedIndexSpoolRule.java
index f0048d28a6..e117d97e5b 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToSortedIndexSpoolRule.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/FilterSpoolMergeToSortedIndexSpoolRule.java
@@ -76,7 +76,7 @@ public class FilterSpoolMergeToSortedIndexSpoolRule extends 
RelRule<FilterSpoolM
 
         RelCollation inCollation = TraitUtils.collation(input);
 
-        List<SearchBounds> searchBounds = RexUtils.buildSortedIndexConditions(
+        List<SearchBounds> searchBounds = RexUtils.buildSortedSearchBounds(
                 cluster,
                 inCollation,
                 filter.getCondition(),
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/MergeJoinConverterRule.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/MergeJoinConverterRule.java
index 58a2e7fd74..9453b716f8 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/MergeJoinConverterRule.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/rule/MergeJoinConverterRule.java
@@ -70,6 +70,7 @@ public class MergeJoinConverterRule extends 
AbstractIgniteConverterRule<LogicalJ
         RelNode left = convert(rel.getLeft(), leftInTraits);
         RelNode right = convert(rel.getRight(), rightInTraits);
 
-        return new IgniteMergeJoin(cluster, outTraits, left, right, 
rel.getCondition(), rel.getVariablesSet(), rel.getJoinType());
+        return new IgniteMergeJoin(cluster, outTraits, left, right, 
rel.getCondition(),
+                rel.getVariablesSet(), rel.getJoinType());
     }
 }
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/Commons.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/Commons.java
index 447c5a59bb..1d388ebe67 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/Commons.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/Commons.java
@@ -191,8 +191,6 @@ public final class Commons {
             .traitDefs(DISTRIBUTED_TRAITS_SET)
             .build();
 
-    private static Boolean implicitPkEnabled;
-
     private Commons() {
     }
 
@@ -893,10 +891,6 @@ public final class Commons {
      * @return A {@code true} if implicit pk mode is enabled, {@code false} 
otherwise.
      */
     public static boolean implicitPkEnabled() {
-        if (implicitPkEnabled == null) {
-            implicitPkEnabled = 
IgniteSystemProperties.getBoolean("IMPLICIT_PK_ENABLED", false);
-        }
-
-        return implicitPkEnabled;
+        return IgniteSystemProperties.getBoolean("IMPLICIT_PK_ENABLED", false);
     }
 }
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteMethod.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteMethod.java
index c63290af52..55c5bfc9c2 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteMethod.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteMethod.java
@@ -18,6 +18,7 @@
 package org.apache.ignite.internal.sql.engine.util;
 
 import java.lang.reflect.Method;
+import java.util.Objects;
 import org.apache.calcite.DataContext;
 import org.apache.calcite.avatica.util.ByteString;
 import org.apache.calcite.linq4j.tree.Types;
@@ -77,7 +78,10 @@ public enum IgniteMethod {
     STRING_TO_BYTESTRING(IgniteSqlFunctions.class, "toByteString", 
String.class),
 
     /** See {@link IgniteSqlFunctions#currentTime(DataContext)}. */
-    CURRENT_TIME(IgniteSqlFunctions.class, "currentTime", DataContext.class);
+    CURRENT_TIME(IgniteSqlFunctions.class, "currentTime", DataContext.class),
+
+    /** See {@link Objects#equals(Object, Object)}. */
+    IS_NOT_DISTINCT_FROM(Objects.class, "equals", Object.class, Object.class);
 
     private final Method method;
 
diff --git 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/RexUtils.java
 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/RexUtils.java
index 7db4e839e0..f2bb8c5c43 100644
--- 
a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/RexUtils.java
+++ 
b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/RexUtils.java
@@ -19,9 +19,11 @@ package org.apache.ignite.internal.sql.engine.util;
 
 import static org.apache.calcite.rex.RexUtil.removeCast;
 import static org.apache.calcite.rex.RexUtil.sargRef;
+import static org.apache.calcite.sql.SqlKind.BINARY_COMPARISON;
 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_DISTINCT_FROM;
 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;
@@ -30,6 +32,7 @@ import static org.apache.calcite.sql.SqlKind.SEARCH;
 import static org.apache.ignite.internal.util.CollectionUtils.nullOrEmpty;
 
 import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap.Entry;
 import it.unimi.dsi.fastutil.ints.Int2ObjectMaps;
 import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
 import it.unimi.dsi.fastutil.ints.IntArrayList;
@@ -97,6 +100,9 @@ public class RexUtils {
     /** Maximum amount of search bounds tuples per scan. */
     public static final int MAX_SEARCH_BOUNDS_COMPLEXITY = 100;
 
+    /** Hash index permitted search operations. */
+    private static final EnumSet<SqlKind> HASH_SEARCH_OPS = EnumSet.of(EQUALS, 
IS_NOT_DISTINCT_FROM);
+
     /**
      * MakeCast.
      * TODO Documentation https://issues.apache.org/jira/browse/IGNITE-15859
@@ -195,10 +201,6 @@ 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(
@@ -206,13 +208,14 @@ public class RexUtils {
                     IS_NULL,
                     IS_NOT_NULL,
                     EQUALS,
+                    IS_NOT_DISTINCT_FROM,
                     LESS_THAN, GREATER_THAN,
                     GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL);
 
     /**
      * Builds sorted index search bounds.
      */
-    public static @Nullable List<SearchBounds> buildSortedIndexConditions(
+    public static @Nullable List<SearchBounds> buildSortedSearchBounds(
             RelOptCluster cluster,
             RelCollation collation,
             @Nullable RexNode condition,
@@ -304,7 +307,7 @@ public class RexUtils {
     /**
      * Builds hash index search bounds.
      */
-    public static List<SearchBounds> buildHashIndexConditions(
+    public static List<SearchBounds> buildHashSearchBounds(
             RelOptCluster cluster,
             RelCollation collation,
             RexNode condition,
@@ -361,12 +364,13 @@ public class RexUtils {
     }
 
     /**
-     * Builds index conditions.
+     * Builds hash index search bounds.
      */
-    public static List<RexNode> buildHashSearchRow(
+    public static List<SearchBounds> buildHashSearchBounds(
             RelOptCluster cluster,
             RexNode condition,
-            RelDataType rowType
+            RelDataType rowType,
+            @Nullable ImmutableBitSet requiredColumns
     ) {
         condition = RexUtil.toCnf(builder(cluster), condition);
 
@@ -376,31 +380,46 @@ public class RexUtils {
             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.inverseTrimmingMapping(types.size(), 
requiredColumns);
+        }
+
+        for (Entry<List<RexCall>> fld : 
fieldsToPredicates.int2ObjectEntrySet()) {
+            List<RexCall> collFldPreds = fld.getValue();
 
-        for (List<RexCall> collFldPreds : fieldsToPredicates.values()) {
             if (nullOrEmpty(collFldPreds)) {
                 break;
             }
 
             for (RexCall pred : collFldPreds) {
-                if (pred.getOperator().kind != SqlKind.EQUALS) {
+                if (!pred.isA(HASH_SEARCH_OPS)) {
                     return null;
                 }
 
-                if (searchPreds == null) {
-                    searchPreds = new ArrayList<>();
+                if (bounds == null) {
+                    bounds = Arrays.asList(new SearchBounds[types.size()]);
                 }
 
-                searchPreds.add(pred);
-            }
-        }
+                int fldIdx;
 
-        if (searchPreds == null) {
-            return null;
+                if (mapping != null) {
+                    fldIdx = mapping.getSourceOpt(fld.getIntKey());
+                } else {
+                    fldIdx = fld.getIntKey();
+                }
+
+                bounds.set(fldIdx, new ExactBounds(pred,
+                        makeCast(builder(cluster), 
removeCast(pred.operands.get(1)), types.get(fldIdx))));
+            }
         }
 
-        return asBound(cluster, searchPreds, rowType, null);
+        return bounds;
     }
 
     /** Create index search bound by conditions of the field. */
@@ -437,6 +456,8 @@ public class RexUtils {
 
             if (op.kind == EQUALS) {
                 return new ExactBounds(pred, val);
+            } else if (op.kind == IS_NOT_DISTINCT_FROM) {
+                return new ExactBounds(pred, 
builder.makeCall(SqlStdOperatorTable.COALESCE, val, nullVal));
             } else if (op.kind == IS_NULL) {
                 return new ExactBounds(pred, nullVal);
             } else if (op.kind == SEARCH) {
@@ -557,7 +578,7 @@ public class RexUtils {
 
                 // Let RexLocalRef be on the left side.
                 if (refOnTheRight(predCall)) {
-                    predCall = (RexCall) RexUtil.invert(builder(cluster), 
predCall);
+                    predCall = (RexCall) invert(builder(cluster), predCall);
                 }
             } else {
                 ref = (RexSlot) extractRefFromOperand(predCall, cluster, 0);
@@ -575,6 +596,15 @@ public class RexUtils {
         return res;
     }
 
+    /** Extended version of {@link RexUtil#invert(RexBuilder, RexCall)} with 
additional operators support. */
+    private static RexNode invert(RexBuilder rexBuilder, RexCall call) {
+        if (call.getOperator() == SqlStdOperatorTable.IS_NOT_DISTINCT_FROM) {
+            return rexBuilder.makeCall(call.getOperator(), 
call.getOperands().get(1), call.getOperands().get(0));
+        } else {
+            return RexUtil.invert(rexBuilder, call);
+        }
+    }
+
     private static RexNode extractRefFromBinary(RexCall call, RelOptCluster 
cluster) {
         assert isBinaryComparison(call);
 
diff --git 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/HashIndexSpoolExecutionTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/HashIndexSpoolExecutionTest.java
index 56ad62f0c1..15b677b32e 100644
--- 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/HashIndexSpoolExecutionTest.java
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/HashIndexSpoolExecutionTest.java
@@ -106,7 +106,8 @@ public class HashIndexSpoolExecutionTest extends 
AbstractExecutionTest {
                         ctx,
                         ImmutableBitSet.of(0),
                         testFilter,
-                        () -> searchRow
+                        () -> searchRow,
+                        false
                 );
 
                 spool.register(singletonList(scan));
@@ -147,7 +148,8 @@ public class HashIndexSpoolExecutionTest extends 
AbstractExecutionTest {
                 ctx,
                 ImmutableBitSet.of(0, 1),
                 null,
-                () -> searchRow
+                () -> searchRow,
+                false
         );
 
         spool.register(scan);
diff --git 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/MergeJoinExecutionTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/MergeJoinExecutionTest.java
index 9628c92d73..ee4680ed19 100644
--- 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/MergeJoinExecutionTest.java
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/rel/MergeJoinExecutionTest.java
@@ -23,96 +23,117 @@ import static 
org.apache.calcite.rel.core.JoinRelType.INNER;
 import static org.apache.calcite.rel.core.JoinRelType.LEFT;
 import static org.apache.calcite.rel.core.JoinRelType.RIGHT;
 import static org.apache.calcite.rel.core.JoinRelType.SEMI;
+import static 
org.apache.ignite.internal.sql.engine.util.BaseQueryContext.CALCITE_CONNECTION_CONFIG;
 import static org.apache.ignite.internal.util.ArrayUtils.asList;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.core.IsEqual.equalTo;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Comparator;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Set;
+import org.apache.calcite.rel.RelFieldCollation;
+import org.apache.calcite.rel.RelFieldCollation.Direction;
+import org.apache.calcite.rel.RelFieldCollation.NullDirection;
 import org.apache.calcite.rel.core.JoinRelType;
 import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeSystem;
+import org.apache.calcite.sql.validate.SqlConformanceEnum;
+import org.apache.calcite.util.ImmutableBitSet;
+import org.apache.ignite.internal.sql.engine.exec.ArrayRowHandler;
 import org.apache.ignite.internal.sql.engine.exec.ExecutionContext;
+import org.apache.ignite.internal.sql.engine.exec.exp.ExpressionFactoryImpl;
+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;
 import org.apache.ignite.internal.sql.engine.util.TypeUtils;
-import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 /**
  * MergeJoinExecutionTest;
  * TODO Documentation https://issues.apache.org/jira/browse/IGNITE-15859
  */
 public class MergeJoinExecutionTest extends AbstractExecutionTest {
-    @Test
-    public void joinEmptyTables() {
-        verifyJoin(EMPTY, EMPTY, INNER, EMPTY);
-        verifyJoin(EMPTY, EMPTY, LEFT, EMPTY);
-        verifyJoin(EMPTY, EMPTY, RIGHT, EMPTY);
-        verifyJoin(EMPTY, EMPTY, FULL, EMPTY);
-        verifyJoin(EMPTY, EMPTY, SEMI, EMPTY);
-        verifyJoin(EMPTY, EMPTY, ANTI, EMPTY);
+    @ParameterizedTest(name = "treat nulls as equals: {0}")
+    @ValueSource(booleans = {true, false})
+    public void joinEmptyTables(boolean equalNulls) {
+        verifyJoin(EMPTY, EMPTY, INNER, EMPTY, equalNulls);
+        verifyJoin(EMPTY, EMPTY, LEFT, EMPTY, equalNulls);
+        verifyJoin(EMPTY, EMPTY, RIGHT, EMPTY, equalNulls);
+        verifyJoin(EMPTY, EMPTY, FULL, EMPTY, equalNulls);
+        verifyJoin(EMPTY, EMPTY, SEMI, EMPTY, equalNulls);
+        verifyJoin(EMPTY, EMPTY, ANTI, EMPTY, equalNulls);
     }
 
-    @Test
-    public void joinEmptyLeftTable() {
+    @ParameterizedTest(name = "treat nulls as equals: {0}")
+    @ValueSource(booleans = {true, false})
+    public void joinEmptyLeftTable(boolean equalNulls) {
         Object[][] right = {
                 {1, "Core"},
                 {1, "OLD_Core"},
                 {2, "SQL"}
         };
 
-        verifyJoin(EMPTY, right, INNER, EMPTY);
-        verifyJoin(EMPTY, right, LEFT, EMPTY);
+        verifyJoin(EMPTY, right, INNER, EMPTY, equalNulls);
+        verifyJoin(EMPTY, right, LEFT, EMPTY, equalNulls);
         verifyJoin(EMPTY, right, RIGHT, new Object[][]{
                 {null, null, "Core"},
                 {null, null, "OLD_Core"},
                 {null, null, "SQL"}
-        });
+        }, equalNulls);
         verifyJoin(EMPTY, right, FULL, new Object[][]{
                 {null, null, "Core"},
                 {null, null, "OLD_Core"},
                 {null, null, "SQL"}
-        });
-        verifyJoin(EMPTY, right, SEMI, EMPTY);
-        verifyJoin(EMPTY, right, ANTI, EMPTY);
+        }, equalNulls);
+        verifyJoin(EMPTY, right, SEMI, EMPTY, equalNulls);
+        verifyJoin(EMPTY, right, ANTI, EMPTY, equalNulls);
     }
 
-    @Test
-    public void joinEmptyRightTable() {
+    @ParameterizedTest(name = "treat nulls as equals: {0}")
+    @ValueSource(booleans = {true, false})
+    public void joinEmptyRightTable(boolean equalNulls) {
         Object[][] left = {
                 {1, "Roman", null},
                 {2, "Igor", 1},
                 {3, "Alexey", 2}
         };
 
-        verifyJoin(left, EMPTY, INNER, EMPTY);
+        verifyJoin(left, EMPTY, INNER, EMPTY, equalNulls);
         verifyJoin(left, EMPTY, LEFT, new Object[][]{
                 {1, "Roman", null},
                 {2, "Igor", null},
                 {3, "Alexey", null}
-        });
-        verifyJoin(left, EMPTY, RIGHT, EMPTY);
+        }, equalNulls);
+        verifyJoin(left, EMPTY, RIGHT, EMPTY, equalNulls);
         verifyJoin(left, EMPTY, FULL, new Object[][]{
                 {1, "Roman", null},
                 {2, "Igor", null},
                 {3, "Alexey", null}
-        });
-        verifyJoin(left, EMPTY, SEMI, EMPTY);
+        }, equalNulls);
+        verifyJoin(left, EMPTY, SEMI, EMPTY, equalNulls);
         verifyJoin(left, EMPTY, ANTI, new Object[][]{
                 {1, "Roman"},
                 {2, "Igor"},
                 {3, "Alexey"}
-        });
+        }, equalNulls);
     }
 
-    @Test
-    public void joinOneToMany() {
+    @ParameterizedTest(name = "treat nulls as equals: {0}")
+    @ValueSource(booleans = {true, false})
+    public void joinOneToMany(boolean equalNulls) {
         Object[][] left = {
                 {1, "Roman", null},
                 {2, "Igor", 1},
-                {3, "Alexey", 2}
+                {3, "Alexey", 2},
+                {4, "Taras", 5}
         };
 
         Object[][] right = {
+                {null, "None"},
                 {1, "Core"},
                 {1, "OLD_Core"},
                 {1, "NEW_Core"},
@@ -120,45 +141,91 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {3, "Arch"}
         };
 
-        verifyJoin(left, right, INNER, new Object[][] {
-                {2, "Igor", "Core"},
-                {2, "Igor", "OLD_Core"},
-                {2, "Igor", "NEW_Core"},
-                {3, "Alexey", "SQL"}
-        });
-        verifyJoin(left, right, LEFT, new Object[][] {
-                {1, "Roman", null},
-                {2, "Igor", "Core"},
-                {2, "Igor", "OLD_Core"},
-                {2, "Igor", "NEW_Core"},
-                {3, "Alexey", "SQL"}
-        });
-        verifyJoin(left, right, RIGHT, new Object[][] {
-                {2, "Igor", "Core"},
-                {2, "Igor", "OLD_Core"},
-                {2, "Igor", "NEW_Core"},
-                {3, "Alexey", "SQL"},
-                {null, null, "Arch"}
-        });
-        verifyJoin(left, right, FULL, new Object[][] {
-                {1, "Roman", null},
-                {2, "Igor", "Core"},
-                {2, "Igor", "OLD_Core"},
-                {2, "Igor", "NEW_Core"},
-                {3, "Alexey", "SQL"},
-                {null, null, "Arch"}
-        });
-        verifyJoin(left, right, SEMI, new Object[][] {
-                {2, "Igor"},
-                {3, "Alexey"}
-        });
-        verifyJoin(left, right, ANTI, new Object[][] {
-                {1, "Roman"}
-        });
+        verifyJoin(left, right, INNER, equalNulls
+                ? new Object[][] {
+                    {1, "Roman", "None"},
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"}}
+                : new Object[][]{
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"}},
+                equalNulls);
+        verifyJoin(left, right, LEFT, equalNulls
+                ? new Object[][] {
+                    {1, "Roman", "None"},
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"},
+                    {4, "Taras", null}}
+                : new Object[][] {
+                    {1, "Roman", null},
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"},
+                    {4, "Taras", null}},
+                equalNulls);
+        verifyJoin(left, right, RIGHT, equalNulls
+                ? new Object[][] {
+                    {1, "Roman", "None"},
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"},
+                    {null, null, "Arch"}}
+                : new Object[][] {
+                    {null, null, "None"},
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"},
+                    {null, null, "Arch"}},
+                equalNulls);
+        verifyJoin(left, right, FULL, equalNulls
+                ? new Object[][] {
+                    {1, "Roman", "None"},
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"},
+                    {null, null, "Arch"},
+                    {4, "Taras", null}}
+                : new Object[][] {
+                    {null, null, "None"},
+                    {1, "Roman", null},
+                    {2, "Igor", "Core"},
+                    {2, "Igor", "OLD_Core"},
+                    {2, "Igor", "NEW_Core"},
+                    {3, "Alexey", "SQL"},
+                    {null, null, "Arch"},
+                    {4, "Taras", null}},
+                equalNulls);
+        verifyJoin(left, right, SEMI, equalNulls
+                ? new Object[][] {
+                    {1, "Roman"},
+                    {2, "Igor"},
+                    {3, "Alexey"}}
+                : new Object[][]{
+                    {2, "Igor"},
+                    {3, "Alexey"}},
+                equalNulls);
+        verifyJoin(left, right, ANTI, equalNulls
+                ? new Object[][] {
+                    {4, "Taras"}}
+                : new Object[][] {
+                    {1, "Roman"},
+                    {4, "Taras"}},
+                equalNulls);
     }
 
-    @Test
-    public void joinOneToMany2() {
+    @ParameterizedTest(name = "treat nulls as equals: {0}")
+    @ValueSource(booleans = {true, false})
+    public void joinOneToMany2(boolean equalNulls) {
         Object[][] left = {
                 {1, "Roman", null},
                 {2, "Igor", 1},
@@ -181,7 +248,7 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {2, "Igor", "OLD_Core"},
                 {3, "Alexey", "SQL"},
                 {5, "Taras", "Arch"}
-        });
+        }, equalNulls);
         verifyJoin(left, right, LEFT, new Object[][]{
                 {1, "Roman", null},
                 {2, "Igor", "Core"},
@@ -190,14 +257,14 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {4, "Ivan", null},
                 {5, "Taras", "Arch"},
                 {6, "Lisa", null}
-        });
+        }, equalNulls);
         verifyJoin(left, right, RIGHT, new Object[][]{
                 {2, "Igor", "Core"},
                 {2, "Igor", "OLD_Core"},
                 {3, "Alexey", "SQL"},
                 {null, null, "QA"},
                 {5, "Taras", "Arch"}
-        });
+        }, equalNulls);
         verifyJoin(left, right, FULL, new Object[][]{
                 {1, "Roman", null},
                 {2, "Igor", "Core"},
@@ -207,21 +274,22 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {4, "Ivan", null},
                 {5, "Taras", "Arch"},
                 {6, "Lisa", null}
-        });
+        }, equalNulls);
         verifyJoin(left, right, SEMI, new Object[][]{
                 {2, "Igor"},
                 {3, "Alexey"},
                 {5, "Taras"}
-        });
+        }, equalNulls);
         verifyJoin(left, right, ANTI, new Object[][]{
                 {1, "Roman"},
                 {4, "Ivan"},
                 {6, "Lisa"}
-        });
+        }, equalNulls);
     }
 
-    @Test
-    public void joinManyToMany() {
+    @ParameterizedTest(name = "treat nulls as equals: {0}")
+    @ValueSource(booleans = {true, false})
+    public void joinManyToMany(boolean equalNulls) {
         Object[][] left = {
                 {1, "Roman", null},
                 {2, "Igor", 1},
@@ -250,7 +318,7 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {5, "Ivan", "OLD_QA"},
                 {6, "Andrey", "QA"},
                 {6, "Andrey", "OLD_QA"}
-        });
+        }, equalNulls);
         verifyJoin(left, right, LEFT, new Object[][]{
                 {1, "Roman", null},
                 {2, "Igor", "Core"},
@@ -262,7 +330,7 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {5, "Ivan", "OLD_QA"},
                 {6, "Andrey", "QA"},
                 {6, "Andrey", "OLD_QA"}
-        });
+        }, equalNulls);
         verifyJoin(left, right, RIGHT, new Object[][]{
                 {2, "Igor", "Core"},
                 {2, "Igor", "OLD_Core"},
@@ -274,7 +342,7 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {5, "Ivan", "OLD_QA"},
                 {6, "Andrey", "QA"},
                 {6, "Andrey", "OLD_QA"}
-        });
+        }, equalNulls);
         verifyJoin(left, right, FULL, new Object[][]{
                 {1, "Roman", null},
                 {2, "Igor", "Core"},
@@ -287,21 +355,22 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {5, "Ivan", "OLD_QA"},
                 {6, "Andrey", "QA"},
                 {6, "Andrey", "OLD_QA"}
-        });
+        }, equalNulls);
         verifyJoin(left, right, SEMI, new Object[][]{
                 {2, "Igor"},
                 {3, "Taras"},
                 {4, "Alexey"},
                 {5, "Ivan"},
                 {6, "Andrey"},
-        });
+        }, equalNulls);
         verifyJoin(left, right, ANTI, new Object[][]{
                 {1, "Roman"}
-        });
+        }, equalNulls);
     }
 
-    @Test
-    public void joinOnNullField() {
+    @ParameterizedTest(name = "treat nulls as equals: {0}")
+    @ValueSource(booleans = {true, false})
+    public void joinOnNullField(boolean equalNulls) {
         Object[][] left = {
                 {1, "Roman", null},
                 {2, "Igor", null},
@@ -316,43 +385,84 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
                 {2, "QA"}
         };
 
-        verifyJoin(left, right, INNER, new Object[][]{
-                {3, "Alexey", "SQL"},
-                {4, "Ivan", "QA"},
-        });
-
-        verifyJoin(left, right, LEFT, new Object[][]{
-                {1, "Roman", null},
-                {2, "Igor", null},
-                {3, "Alexey", "SQL"},
-                {4, "Ivan", "QA"},
-        });
-
-        verifyJoin(left, right, RIGHT, new Object[][]{
-                {null, null, "Core"},
-                {null, null, "OLD_Core"},
-                {3, "Alexey", "SQL"},
-                {4, "Ivan", "QA"},
-        });
-
-        verifyJoin(left, right, FULL, new Object[][]{
-                {null, null, "Core"},
-                {null, null, "OLD_Core"},
-                {1, "Roman", null},
-                {2, "Igor", null},
-                {3, "Alexey", "SQL"},
-                {4, "Ivan", "QA"},
-        });
-
-        verifyJoin(left, right, SEMI, new Object[][]{
-                {3, "Alexey"},
-                {4, "Ivan"},
-        });
-
-        verifyJoin(left, right, ANTI, new Object[][]{
-                {1, "Roman"},
-                {2, "Igor"},
-        });
+        verifyJoin(left, right, INNER, equalNulls
+                        ? new Object[][]{
+                            {1, "Roman", "Core"},
+                            {1, "Roman", "OLD_Core"},
+                            {2, "Igor", "Core"},
+                            {2, "Igor", "OLD_Core"},
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}}
+                        : new Object[][]{
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}},
+                equalNulls);
+
+        verifyJoin(left, right, LEFT, equalNulls
+                        ? new Object[][]{
+                            {1, "Roman", "Core"},
+                            {1, "Roman", "OLD_Core"},
+                            {2, "Igor", "Core"},
+                            {2, "Igor", "OLD_Core"},
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}}
+                        : new Object[][]{
+                            {1, "Roman", null},
+                            {2, "Igor", null},
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}},
+                equalNulls);
+
+        verifyJoin(left, right, RIGHT, equalNulls
+                        ? new Object[][]{
+                            {1, "Roman", "Core"},
+                            {1, "Roman", "OLD_Core"},
+                            {2, "Igor", "Core"},
+                            {2, "Igor", "OLD_Core"},
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}}
+                        : new Object[][]{
+                            {null, null, "Core"},
+                            {null, null, "OLD_Core"},
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}},
+                equalNulls);
+
+        verifyJoin(left, right, FULL, equalNulls
+                        ? new Object[][]{
+                            {1, "Roman", "Core"},
+                            {1, "Roman", "OLD_Core"},
+                            {2, "Igor", "Core"},
+                            {2, "Igor", "OLD_Core"},
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}}
+                        : new Object[][]{
+                            {null, null, "Core"},
+                            {null, null, "OLD_Core"},
+                            {1, "Roman", null},
+                            {2, "Igor", null},
+                            {3, "Alexey", "SQL"},
+                            {4, "Ivan", "QA"}},
+                equalNulls);
+
+        verifyJoin(left, right, SEMI,
+                equalNulls ? new Object[][]{
+                                {1, "Roman"},
+                                {2, "Igor"},
+                                {3, "Alexey"},
+                                {4, "Ivan"},
+                        } :
+                        new Object[][]{
+                                {3, "Alexey"},
+                                {4, "Ivan"}, },
+                equalNulls);
+
+        verifyJoin(left, right, ANTI,
+                equalNulls ? new Object[][]{}
+                        : new Object[][]{
+                                {1, "Roman"},
+                                {2, "Igor"}, },
+                equalNulls);
     }
 
     /**
@@ -363,31 +473,37 @@ public class MergeJoinExecutionTest extends 
AbstractExecutionTest {
      * @param joinType Join type.
      * @param expRes   Expected result.
      */
-    private void verifyJoin(Object[][] left, Object[][] right, JoinRelType 
joinType, Object[][] expRes) {
+    private void verifyJoin(Object[][] left, Object[][] right, JoinRelType 
joinType, Object[][] expRes, boolean equalNulls) {
         ExecutionContext<Object[]> ctx = executionContext(true);
 
         RelDataType leftType = TypeUtils.createRowType(ctx.getTypeFactory(), 
int.class, String.class, Integer.class);
         ScanNode<Object[]> leftNode = new ScanNode<>(ctx, Arrays.asList(left));
 
-        RelDataType rightType = TypeUtils.createRowType(ctx.getTypeFactory(), 
int.class, String.class);
+        RelDataType rightType = TypeUtils.createRowType(ctx.getTypeFactory(), 
Integer.class, String.class);
         ScanNode<Object[]> rightNode = new ScanNode<>(ctx, 
Arrays.asList(right));
 
-        MergeJoinNode<Object[]> join = MergeJoinNode.create(ctx, leftType, 
rightType, joinType, (r1, r2) -> {
-            Object o1 = r1[2];
-            Object o2 = r2[0];
-
-            if (o1 == null || o2 == null) {
-                if (o1 != null) {
-                    return 1;
-                } else if (o2 != null) {
-                    return -1;
-                } else {
-                    return 1;
-                }
-            }
-
-            return Integer.compare((Integer) o1, (Integer) o2);
-        });
+        RelDataTypeSystem typeSys = 
CALCITE_CONNECTION_CONFIG.typeSystem(RelDataTypeSystem.class, 
IgniteTypeSystem.INSTANCE);
+
+        IgniteTypeFactory typeFactory = new IgniteTypeFactory(typeSys);
+
+        ExecutionContext<Object[]> ectx =
+                new 
ExecutionContext<>(BaseQueryContext.builder().logger(log).build(), null, null, 
null,
+                        null, null, ArrayRowHandler.INSTANCE, null, null);
+
+        ExpressionFactoryImpl<Object[]> expFactory = new 
ExpressionFactoryImpl<>(ectx, typeFactory, SqlConformanceEnum.DEFAULT);
+
+        RelFieldCollation colLeft = new RelFieldCollation(2, 
Direction.ASCENDING, NullDirection.FIRST);
+        RelFieldCollation colRight = new RelFieldCollation(0, 
Direction.ASCENDING, NullDirection.FIRST);
+
+        ImmutableBitSet.Builder nullComparison = ImmutableBitSet.builder();
+
+        if (equalNulls && left.length > 0) {
+            nullComparison.set(0, left[0].length);
+        }
+
+        Comparator<Object[]> comp = expFactory.comparator(List.of(colLeft), 
List.of(colRight), nullComparison.build());
+
+        MergeJoinNode<Object[]> join = MergeJoinNode.create(ctx, leftType, 
rightType, joinType, comp);
 
         join.register(asList(leftNode, rightNode));
 
diff --git 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/ddl/DdlSqlToCommandConverterTest.java
 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/ddl/DdlSqlToCommandConverterTest.java
index 4978465b89..cd2d8da7b5 100644
--- 
a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/ddl/DdlSqlToCommandConverterTest.java
+++ 
b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/ddl/DdlSqlToCommandConverterTest.java
@@ -39,24 +39,17 @@ import org.apache.calcite.sql.type.SqlTypeName;
 import 
org.apache.ignite.internal.sql.engine.prepare.ddl.DefaultValueDefinition.FunctionCall;
 import 
org.apache.ignite.internal.sql.engine.prepare.ddl.DefaultValueDefinition.Type;
 import org.apache.ignite.internal.sql.engine.util.Commons;
-import org.apache.ignite.internal.testframework.IgniteTestUtils;
 import org.apache.ignite.internal.testframework.WithSystemProperty;
 import org.apache.ignite.lang.IgniteException;
 import org.hamcrest.CustomMatcher;
 import org.hamcrest.Matcher;
 import org.hamcrest.Matchers;
-import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Test;
 
 /**
  * For {@link DdlSqlToCommandConverter} testing.
  */
 public class DdlSqlToCommandConverterTest extends 
AbstractDdlSqlToCommandConverterTest {
-    @AfterAll
-    public static void resetStaticState() {
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
-    }
-
     @Test
     void testCollectDataStorageNames() {
         assertThat(collectDataStorageNames(Set.of()), equalTo(Map.of()));
@@ -105,8 +98,6 @@ public class DdlSqlToCommandConverterTest extends 
AbstractDdlSqlToCommandConvert
 
     @Test
     public void tableWithoutPkShouldThrowErrorWhenSysPropDefault() throws 
SqlParseException {
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
-
         var node = parse("CREATE TABLE t (val int)");
 
         assertThat(node, instanceOf(SqlDdl.class));
@@ -122,8 +113,6 @@ public class DdlSqlToCommandConverterTest extends 
AbstractDdlSqlToCommandConvert
     @Test
     @WithSystemProperty(key = "IMPLICIT_PK_ENABLED", value = "false")
     public void tableWithoutPkShouldThrowErrorWhenSysPropDisabled() throws 
SqlParseException {
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
-
         var node = parse("CREATE TABLE t (val int)");
 
         assertThat(node, instanceOf(SqlDdl.class));
@@ -139,8 +128,6 @@ public class DdlSqlToCommandConverterTest extends 
AbstractDdlSqlToCommandConvert
     @Test
     @WithSystemProperty(key = "IMPLICIT_PK_ENABLED", value = "true")
     public void tableWithoutPkShouldInjectImplicitPkWhenSysPropEnabled() 
throws SqlParseException {
-        IgniteTestUtils.setFieldValue(Commons.class, "implicitPkEnabled", 
null);
-
         var node = parse("CREATE TABLE t (val int)");
 
         assertThat(node, instanceOf(SqlDdl.class));
diff --git 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/impl/TestMvTableStorage.java
 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/impl/TestMvTableStorage.java
index 21c84b1051..d543fa74c9 100644
--- 
a/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/impl/TestMvTableStorage.java
+++ 
b/modules/storage-api/src/testFixtures/java/org/apache/ignite/internal/storage/impl/TestMvTableStorage.java
@@ -92,7 +92,7 @@ public class TestMvTableStorage implements MvTableStorage {
         }
     }
 
-    /** Costructor. */
+    /** Constructor. */
     public TestMvTableStorage(TableConfiguration tableCfg, TablesConfiguration 
tablesCfg) {
         this.tableCfg = tableCfg;
         this.tablesCfg = tablesCfg;

Reply via email to