IGNITE-12590: Fix (remove) check KEY at the MERGE command (#7321)


diff --git a/modules/clients/pom.xml b/modules/clients/pom.xml
index a029cba..bcb3316 100644
--- a/modules/clients/pom.xml
+++ b/modules/clients/pom.xml
@@ -66,6 +66,14 @@
             <groupId>org.apache.ignite</groupId>
             <artifactId>ignite-indexing</artifactId>
             <version>${project.version}</version>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.ignite</groupId>
+            <artifactId>ignite-indexing</artifactId>
+            <version>${project.version}</version>
             <scope>test</scope>
         </dependency>
 
diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/suite/IgniteJdbcDriverTestSuite.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/suite/IgniteJdbcDriverTestSuite.java
index 58af83d..662d971 100644
--- a/modules/clients/src/test/java/org/apache/ignite/jdbc/suite/IgniteJdbcDriverTestSuite.java
+++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/suite/IgniteJdbcDriverTestSuite.java
@@ -77,6 +77,7 @@
 import org.apache.ignite.jdbc.thin.JdbcThinResultSetSelfTest;
 import org.apache.ignite.jdbc.thin.JdbcThinSchemaCaseSelfTest;
 import org.apache.ignite.jdbc.thin.JdbcThinSelectAfterAlterTable;
+import org.apache.ignite.jdbc.thin.JdbcThinSqlMergeTest;
 import org.apache.ignite.jdbc.thin.JdbcThinStatementCancelSelfTest;
 import org.apache.ignite.jdbc.thin.JdbcThinStatementSelfTest;
 import org.apache.ignite.jdbc.thin.JdbcThinStatementTimeoutSelfTest;
@@ -228,6 +229,7 @@
 
     JdbcThinPreparedStatementLeakTest.class,
     JdbcThinTransactionsLeaksMvccTest.class,
+    JdbcThinSqlMergeTest.class,
 })
 public class IgniteJdbcDriverTestSuite {
     /**
diff --git a/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinSqlMergeTest.java b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinSqlMergeTest.java
new file mode 100644
index 0000000..83774e3
--- /dev/null
+++ b/modules/clients/src/test/java/org/apache/ignite/jdbc/thin/JdbcThinSqlMergeTest.java
@@ -0,0 +1,82 @@
+/*
+ * 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.jdbc.thin;
+
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.apache.ignite.internal.processors.query.SqlMergeTest;
+
+/**
+ * Tests SQL MERGE command via JDBC thin driver.
+ */
+public class JdbcThinSqlMergeTest extends SqlMergeTest {
+    /** JDBC thin URL. */
+    private static final String JDBC_THIN_URL = "jdbc:ignite:thin://127.0.0.1";
+
+    /** Connection. */
+    private static Connection conn;
+
+    /** {@inheritDoc} */
+    @Override protected void beforeTestsStarted() throws Exception {
+        super.beforeTestsStarted();
+
+        conn = DriverManager.getConnection(JDBC_THIN_URL);
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTestsStopped() throws Exception {
+        conn.close();
+
+        super.afterTestsStopped();
+    }
+
+    /** {@inheritDoc} */
+    @Override protected List<List<?>> sql(String sql) throws Exception {
+        try (Statement stmt = conn.createStatement()) {
+            stmt.execute(sql);
+
+            int updCnt = stmt.getUpdateCount();
+
+            if (updCnt == -1) {
+                ResultSet rs = stmt.getResultSet();
+
+                int cols = rs.getMetaData().getColumnCount();
+
+                List<List<?>> res = new ArrayList<>();
+
+                while (rs.next()) {
+                    List<Object> row = new ArrayList<>();
+
+                    for (int i = 0; i < cols; ++i)
+                        row.add(rs.getObject(i + 1));
+
+                    res.add(row);
+                }
+
+                return res;
+            }
+            else
+                return Collections.singletonList(Collections.singletonList((long)updCnt));
+        }
+    }
+}
diff --git a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/QueryParser.java b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/QueryParser.java
index 8e29864..22b4114 100644
--- a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/QueryParser.java
+++ b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/QueryParser.java
@@ -418,7 +418,7 @@
 
             // Do actual parsing.
             if (CommandProcessor.isCommand(prepared)) {
-                GridSqlStatement cmdH2 = new GridSqlQueryParser(false).parse(prepared);
+                GridSqlStatement cmdH2 = new GridSqlQueryParser(false, log).parse(prepared);
 
                 QueryParserResultCommand cmd = new QueryParserResultCommand(null, cmdH2, false);
 
@@ -464,7 +464,7 @@
             }
 
             // Parse SELECT.
-            GridSqlQueryParser parser = new GridSqlQueryParser(false);
+            GridSqlQueryParser parser = new GridSqlQueryParser(false, log);
 
             GridSqlQuery selectStmt = (GridSqlQuery)parser.parse(prepared);
 
@@ -543,7 +543,8 @@
                         newQry.isEnforceJoinOrder(),
                         locSplit,
                         idx,
-                        paramsCnt
+                        paramsCnt,
+                        log
                     );
                 }
             }
@@ -560,7 +561,8 @@
                     newQry.isEnforceJoinOrder(),
                     locSplit,
                     idx,
-                    paramsCnt
+                    paramsCnt,
+                    log
                 );
             }
 
@@ -665,7 +667,7 @@
                 IgniteQueryErrorCode.UNSUPPORTED_OPERATION);
 
         // Prepare AST.
-        GridSqlQueryParser parser = new GridSqlQueryParser(false);
+        GridSqlQueryParser parser = new GridSqlQueryParser(false, log);
 
         GridSqlStatement stmt = parser.parse(prepared);
 
@@ -710,7 +712,8 @@
                 planKey,
                 stmt,
                 mvccEnabled,
-                idx
+                idx,
+                log
             );
         }
         catch (Exception e) {
diff --git a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/dml/UpdatePlanBuilder.java b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/dml/UpdatePlanBuilder.java
index 1d07e89..707a58a 100644
--- a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/dml/UpdatePlanBuilder.java
+++ b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/dml/UpdatePlanBuilder.java
@@ -28,6 +28,7 @@
 import java.util.List;
 import java.util.Set;
 import org.apache.ignite.IgniteCheckedException;
+import org.apache.ignite.IgniteLogger;
 import org.apache.ignite.IgniteSystemProperties;
 import org.apache.ignite.binary.BinaryObject;
 import org.apache.ignite.binary.BinaryObjectBuilder;
@@ -103,12 +104,13 @@
         QueryDescriptor planKey,
         GridSqlStatement stmt,
         boolean mvccEnabled,
-        IgniteH2Indexing idx
+        IgniteH2Indexing idx,
+        IgniteLogger log
     ) throws IgniteCheckedException {
         if (stmt instanceof GridSqlMerge || stmt instanceof GridSqlInsert)
-            return planForInsert(planKey, stmt, idx, mvccEnabled);
+            return planForInsert(planKey, stmt, idx, mvccEnabled, log);
         else if (stmt instanceof GridSqlUpdate || stmt instanceof GridSqlDelete)
-            return planForUpdate(planKey, stmt, idx, mvccEnabled);
+            return planForUpdate(planKey, stmt, idx, mvccEnabled, log);
         else
             throw new IgniteSQLException("Unsupported operation: " + stmt.getSQL(),
                 IgniteQueryErrorCode.UNSUPPORTED_OPERATION);
@@ -129,7 +131,8 @@
         QueryDescriptor planKey,
         GridSqlStatement stmt,
         IgniteH2Indexing idx,
-        boolean mvccEnabled
+        boolean mvccEnabled,
+        IgniteLogger log
     ) throws IgniteCheckedException {
         GridSqlQuery sel = null;
 
@@ -147,8 +150,13 @@
 
         List<GridSqlElement[]> elRows = null;
 
+        UpdateMode mode;
+
         if (stmt instanceof GridSqlInsert) {
+            mode = UpdateMode.INSERT;
+
             GridSqlInsert ins = (GridSqlInsert) stmt;
+
             target = ins.into();
 
             tbl = DmlAstUtils.gridTableForElement(target);
@@ -170,6 +178,8 @@
             rowsNum = isTwoStepSubqry ? 0 : ins.rows().size();
         }
         else if (stmt instanceof GridSqlMerge) {
+            mode = UpdateMode.MERGE;
+
             GridSqlMerge merge = (GridSqlMerge) stmt;
 
             target = merge.into();
@@ -260,12 +270,11 @@
                 mvccEnabled,
                 planKey,
                 selectSql,
-                tbl.dataTable().cacheName()
+                tbl.dataTable().cacheName(),
+                log
             );
         }
 
-        UpdateMode mode = stmt instanceof GridSqlMerge ? UpdateMode.MERGE : UpdateMode.INSERT;
-
         List<List<DmlArgument>> rows = null;
 
         if (elRows != null) {
@@ -343,7 +352,8 @@
         QueryDescriptor planKey,
         GridSqlStatement stmt,
         IgniteH2Indexing idx,
-        boolean mvccEnabled
+        boolean mvccEnabled,
+        IgniteLogger log
     ) throws IgniteCheckedException {
         GridSqlElement target;
 
@@ -441,7 +451,8 @@
                         mvccEnabled,
                         planKey,
                         selectSql,
-                        tbl.dataTable().cacheName()
+                        tbl.dataTable().cacheName(),
+                        log
                     );
                 }
 
@@ -475,7 +486,8 @@
                         mvccEnabled,
                         planKey,
                         selectSql,
-                        tbl.dataTable().cacheName()
+                        tbl.dataTable().cacheName(),
+                        log
                     );
                 }
 
@@ -883,7 +895,8 @@
         boolean mvccEnabled,
         QueryDescriptor planKey,
         String selectQry,
-        String cacheName
+        String cacheName,
+        IgniteLogger log
     )
         throws IgniteCheckedException {
         if ((!mvccEnabled && !planKey.skipReducerOnUpdate()) || planKey.batched())
@@ -893,7 +906,7 @@
             // Get a new prepared statement for derived select query.
             try (PreparedStatement stmt = conn.prepareStatement(selectQry)) {
                 Prepared prep = GridSqlQueryParser.prepared(stmt);
-                GridSqlQuery selectStmt = (GridSqlQuery)new GridSqlQueryParser(false).parse(prep);
+                GridSqlQuery selectStmt = (GridSqlQuery)new GridSqlQueryParser(false, log).parse(prep);
 
                 GridCacheTwoStepQuery qry = GridSqlQuerySplitter.split(
                     conn,
@@ -904,7 +917,8 @@
                     planKey.enforceJoinOrder(),
                     false,
                     idx,
-                    prep.getParameters().size()
+                    prep.getParameters().size(),
+                    log
                 );
 
                 boolean distributed =
diff --git a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlMerge.java b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlMerge.java
index f9b084b..5b348bc 100644
--- a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlMerge.java
+++ b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlMerge.java
@@ -29,9 +29,6 @@
     private GridSqlColumn[] cols;
 
     /** */
-    private GridSqlColumn[] keys;
-
-    /** */
     private List<GridSqlElement[]> rows;
 
     /** Insert subquery. */
@@ -51,16 +48,6 @@
         }
         buff.append("\n)\n");
 
-        if (keys != null) {
-            buff.append("KEY(\n");
-            buff.resetCount();
-            for (GridSqlColumn c : keys) {
-                buff.appendExceptFirst(",\n");
-                buff.append(c.getSQL());
-            }
-            buff.append(")\n");
-        }
-
         if (!rows.isEmpty()) {
             buff.append("VALUES\n");
             StatementBuilder valuesBuff = new StatementBuilder();
@@ -128,15 +115,4 @@
         this.cols = cols;
         return this;
     }
-
-    /** */
-    public GridSqlColumn[] keys() {
-        return keys;
-    }
-
-    /** */
-    public GridSqlMerge keys(GridSqlColumn[] keys) {
-        this.keys = keys;
-        return this;
-    }
 }
diff --git a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQueryParser.java b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQueryParser.java
index 573df3c..c605df8 100644
--- a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQueryParser.java
+++ b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQueryParser.java
@@ -28,8 +28,10 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import javax.cache.CacheException;
 import org.apache.ignite.IgniteException;
+import org.apache.ignite.IgniteLogger;
 import org.apache.ignite.cache.CacheAtomicityMode;
 import org.apache.ignite.cache.CacheWriteSynchronizationMode;
 import org.apache.ignite.cache.QueryIndex;
@@ -38,8 +40,6 @@
 import org.apache.ignite.internal.processors.cache.query.IgniteQueryErrorCode;
 import org.apache.ignite.internal.processors.query.IgniteSQLException;
 import org.apache.ignite.internal.processors.query.QueryUtils;
-import org.apache.ignite.internal.processors.query.h2.dml.DmlAstUtils;
-import org.apache.ignite.internal.processors.query.h2.opt.GridH2RowDescriptor;
 import org.apache.ignite.internal.processors.query.h2.opt.GridH2Table;
 import org.apache.ignite.internal.util.typedef.F;
 import org.h2.command.Command;
@@ -527,6 +527,9 @@
     /** */
     private final Map<String, Integer> optimizedTableFilterOrder;
 
+    /** */
+    private final IgniteLogger log;
+
     /**
      * We have a counter instead of a simple flag, because
      * a flag can be reset earlier than needed in case of
@@ -540,9 +543,14 @@
     /**
      * @param useOptimizedSubqry If we have to find correct order for table filters in FROM clause.
      *                           Relies on uniqueness of table filter aliases.
+     * @param log Logger.
      */
-    public GridSqlQueryParser(boolean useOptimizedSubqry) {
-        optimizedTableFilterOrder = useOptimizedSubqry ? new HashMap<String, Integer>() : null;
+    public GridSqlQueryParser(boolean useOptimizedSubqry, IgniteLogger log) {
+        assert Objects.nonNull(log);
+
+        optimizedTableFilterOrder = useOptimizedSubqry ? new HashMap<>() : null;
+
+        this.log = log;
     }
 
     /**
@@ -856,29 +864,11 @@
 
         res.columns(cols);
 
-        Column[] srcKeys = MERGE_KEYS.get(merge);
-
-        GridH2Table intoTbl = DmlAstUtils.gridTableForElement(tbl).dataTable();
-
-        GridH2RowDescriptor rowDesc = intoTbl.rowDescriptor();
-
-        GridSqlColumn[] keys = new GridSqlColumn[srcKeys.length];
-
-        for (int i = 0; i < srcKeys.length; i++) {
-            String colName = srcKeys[i].getName();
-
-            int colId = intoTbl.getColumn(colName).getColumnId();
-
-            if (!rowDesc.isKeyColumn(colId) && !F.eq(colName, rowDesc.type().affinityKey())) {
-                throw new IgniteSQLException("Invalid column name in KEYS clause of MERGE - it may include only " +
-                    "key and/or affinity columns: " + colName, IgniteQueryErrorCode.PARSING);
-            }
-
-            keys[i] = new GridSqlColumn(srcKeys[i], tbl, null, null, colName);
+        if (!F.isEmpty(MERGE_KEYS.get(merge))) {
+            log.warning("The search row by explicit KEY isn't supported. The primary key is always used to search row " +
+                "[sql=" + merge.getSQL() + ']');
         }
 
-        res.keys(keys);
-
         List<Expression[]> srcRows = MERGE_ROWS.get(merge);
         if (!srcRows.isEmpty()) {
             List<GridSqlElement[]> rows = new ArrayList<>(srcRows.size());
@@ -889,7 +879,7 @@
                     row[i] = parseExpression(srcRow[i], false);
 
                     if (row[i] == null) {
-                        throw new IgniteSQLException("DEFAULT values are unsupported for MERGE.",
+                        throw new IgniteSQLException("Explicit DEFAULT values are unsupported for MERGE.",
                             IgniteQueryErrorCode.UNSUPPORTED_OPERATION);
                     }
                 }
@@ -899,7 +889,7 @@
             res.rows(rows);
         }
         else {
-            res.rows(Collections.<GridSqlElement[]>emptyList());
+            res.rows(Collections.emptyList());
             res.query(parseQuery(MERGE_QUERY.get(merge)));
         }
 
@@ -947,7 +937,7 @@
                     row[i] = parseExpression(srcRow[i], false);
 
                     if (row[i] == null) {
-                        throw new IgniteSQLException("DEFAULT values are unsupported for MERGE.",
+                        throw new IgniteSQLException("Explicit DEFAULT values are unsupported for INSERT.",
                             IgniteQueryErrorCode.UNSUPPORTED_OPERATION);
                     }
                 }
@@ -1929,8 +1919,8 @@
      * @param useOptimizedSubqry Whether to user optimized subquery.
      * @return Parsed query.
      */
-    public static GridSqlQuery parseQuery(Prepared prepared, boolean useOptimizedSubqry) {
-        return (GridSqlQuery)new GridSqlQueryParser(useOptimizedSubqry).parse(prepared);
+    public static GridSqlQuery parseQuery(Prepared prepared, boolean useOptimizedSubqry, IgniteLogger log) {
+        return (GridSqlQuery)new GridSqlQueryParser(useOptimizedSubqry, log).parse(prepared);
     }
 
     /**
diff --git a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQuerySplitter.java b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQuerySplitter.java
index 04968ea..dc5786e 100644
--- a/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQuerySplitter.java
+++ b/modules/indexing/src/main/java/org/apache/ignite/internal/processors/query/h2/sql/GridSqlQuerySplitter.java
@@ -33,6 +33,7 @@
 import javax.cache.CacheException;
 import org.apache.ignite.IgniteCheckedException;
 import org.apache.ignite.IgniteException;
+import org.apache.ignite.IgniteLogger;
 import org.apache.ignite.internal.processors.cache.query.GridCacheSqlQuery;
 import org.apache.ignite.internal.processors.cache.query.GridCacheTwoStepQuery;
 import org.apache.ignite.internal.processors.cache.query.IgniteQueryErrorCode;
@@ -201,7 +202,8 @@
         boolean enforceJoinOrder,
         boolean locSplit,
         IgniteH2Indexing idx,
-        int paramsCnt
+        int paramsCnt,
+        IgniteLogger log
     ) throws SQLException, IgniteCheckedException {
         SplitterContext.set(distributedJoins);
 
@@ -215,7 +217,8 @@
                 enforceJoinOrder,
                 locSplit,
                 idx,
-                paramsCnt
+                paramsCnt,
+                log
             );
         }
         finally {
@@ -246,7 +249,8 @@
         boolean enforceJoinOrder,
         boolean locSplit,
         IgniteH2Indexing idx,
-        int paramsCnt
+        int paramsCnt,
+        IgniteLogger log
     ) throws SQLException, IgniteCheckedException {
         final boolean explain = qry.explain();
 
@@ -267,7 +271,9 @@
         // Here we will have correct normalized AST with optimized join order.
         // The distributedJoins parameter is ignored because it is not relevant for
         // the REDUCE query optimization.
-        qry = GridSqlQueryParser.parseQuery(prepare(conn, qry.getSQL(), false, enforceJoinOrder), true);
+        qry = GridSqlQueryParser.parseQuery(
+            prepare(conn, qry.getSQL(), false, enforceJoinOrder),
+            true, log);
 
         // Do the actual query split. We will update the original query AST, need to be careful.
         splitter.splitQuery(qry);
@@ -286,7 +292,7 @@
 
                 allCollocated &= isCollocated((Query)prepared0);
 
-                mapSqlQry.query(GridSqlQueryParser.parseQuery(prepared0, true).getSQL());
+                mapSqlQry.query(GridSqlQueryParser.parseQuery(prepared0, true, log).getSQL());
             }
 
             // We do not need distributed joins if all MAP queries are collocated.
diff --git a/modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/IgniteCacheMergeSqlQueryFailingTest.java b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/IgniteCacheMergeSqlQueryFailingTest.java
new file mode 100644
index 0000000..f7e8a0e
--- /dev/null
+++ b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/cache/IgniteCacheMergeSqlQueryFailingTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.processors.cache;
+
+import java.util.List;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.junit.Test;
+
+/**
+ *
+ */
+public class IgniteCacheMergeSqlQueryFailingTest extends IgniteCacheAbstractInsertSqlQuerySelfTest {
+    /**
+     *
+     */
+    @Test
+    public void testSqlMergeIntoQuery() throws Exception {
+        ignite(0).cache("S2P").query(new SqlFieldsQuery("CREATE TABLE " +
+                "USERPUBSTATICDATA (BOOK VARCHAR, DESK VARCHAR, TRADERS VARCHAR, REGION VARCHAR, LOB VARCHAR, " +
+                "EXCLUDE VARCHAR, TRANSIT VARCHAR, MAPBOOKTOTHISBOOK VARCHAR, " +
+                "CONSTRAINT USERPUBSTATICDATA_PK PRIMARY KEY (BOOK,DESK)) " +
+                "WITH \"template=replicated\"").setSchema("PUBLIC"));
+
+        Ignite ignite = startClientGrid(3);
+
+        IgniteCache srvCache = ignite(0).cache("S2P").withKeepBinary();
+
+        IgniteCache clientCache = ignite.cache("S2P").withKeepBinary();
+
+        srvCache.query(new SqlFieldsQuery(
+            "MERGE INTO USERPUBSTATICDATA(BOOK, DESK, TRADERS, REGION, LOB, EXCLUDE, TRANSIT, MAPBOOKTOTHISBOOK) " +
+                "VALUES('CADOIS', 'FRT TOR', 'Robin Das/Dave Carlson', 'Toronto', 'FRT', null, null, 'CADOIS');").setSchema("PUBLIC"));
+
+        srvCache.query(new SqlFieldsQuery(
+            "MERGE INTO USERPUBSTATICDATA(BOOK, DESK, TRADERS, REGION, LOB, EXCLUDE, TRANSIT, MAPBOOKTOTHISBOOK) " +
+                "VALUES('CADOIS', 'FRT TOR 1', 'Robin Das/Dave Carlson 1', 'Toronto', 'FRT', null, null, 'CADOIS');").setSchema("PUBLIC"));
+
+        clientCache.query(new SqlFieldsQuery(
+            "MERGE INTO USERPUBSTATICDATA(BOOK, DESK, TRADERS, REGION, LOB, EXCLUDE, TRANSIT, MAPBOOKTOTHISBOOK) " +
+                "VALUES('CADOIS', 'FRT TOR', 'Robin Das/Dave Carlson 2', 'Toronto', 'FRT', null, null, 'CADOIS');").setSchema("PUBLIC"));
+
+        clientCache.query(new SqlFieldsQuery(
+            "MERGE INTO USERPUBSTATICDATA(BOOK, TRADERS, REGION, LOB, EXCLUDE, TRANSIT, MAPBOOKTOTHISBOOK) " +
+                "VALUES('CADOIS', 'Robin Das/Dave Carlson 2', 'Toronto', 'FRT', null, null, 'CADOIS');").setSchema("PUBLIC"));
+
+        List<List<?>> res = clientCache.query(
+            new SqlFieldsQuery("SELECT TRADERS FROM USERPUBSTATICDATA WHERE BOOK='CADOIS'").setSchema("PUBLIC")).getAll();
+
+        assertEquals("Robin Das/Dave Carlson 2", res.get(0).get(0));
+    }
+}
diff --git a/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/SqlMergeOnClientNodeTest.java b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/SqlMergeOnClientNodeTest.java
new file mode 100644
index 0000000..debb37d
--- /dev/null
+++ b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/SqlMergeOnClientNodeTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.processors.query;
+
+import org.apache.ignite.internal.IgniteEx;
+
+/**
+ * Tests for SQL MERGE on client node.
+ */
+public class SqlMergeOnClientNodeTest extends SqlMergeTest {
+    /** Node. */
+    protected static IgniteEx cli;
+
+    /** {@inheritDoc} */
+    @Override protected void beforeTestsStarted() throws Exception {
+        super.beforeTestsStarted();
+
+        cli = startClientGrid(2);
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void beforeTest() throws Exception {
+        super.beforeTest();
+
+        node = cli;
+    }
+}
diff --git a/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/SqlMergeTest.java b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/SqlMergeTest.java
new file mode 100644
index 0000000..fe1e00f
--- /dev/null
+++ b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/SqlMergeTest.java
@@ -0,0 +1,201 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.processors.query;
+
+import java.util.Arrays;
+import java.util.List;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.internal.processors.cache.index.AbstractIndexingCommonTest;
+import org.apache.ignite.internal.processors.query.h2.IgniteH2Indexing;
+import org.apache.ignite.testframework.GridTestUtils;
+import org.apache.ignite.testframework.ListeningTestLogger;
+import org.apache.ignite.testframework.LogListener;
+import org.junit.Test;
+
+/**
+ * Tests for SQL MERGE.
+ */
+public class SqlMergeTest extends AbstractIndexingCommonTest {
+    /** Node. */
+    private static IgniteEx srv;
+
+    /** Node. */
+    protected IgniteEx node;
+
+
+    /** {@inheritDoc} */
+    @Override protected void beforeTestsStarted() throws Exception {
+        super.beforeTestsStarted();
+
+        srv = startGrid(0);
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void afterTest() throws Exception {
+        for (String cache : srv.cacheNames())
+            srv.cache(cache).destroy();
+
+        super.afterTest();
+    }
+
+    /** {@inheritDoc} */
+    @Override protected void beforeTest() throws Exception {
+        super.beforeTest();
+
+        node = srv;
+    }
+
+    /**
+     *
+     */
+    @Test
+    public void test() throws Exception {
+        sql("CREATE TABLE test1 (id INT, id2 INT, name VARCHAR, PRIMARY KEY (id, id2))");
+
+        checkMergeQuery("MERGE INTO test1 (id, id2, name) VALUES (1, 2, 'Kyle')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test1 WHERE id = 1",
+            Arrays.asList(1, 2, "Kyle"));
+
+        checkMergeQuery("MERGE INTO test1 (id2, id, name) VALUES (3, 2, 'Santa Claus')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test1 WHERE id = 2",
+            Arrays.asList(2, 3, "Santa Claus"));
+
+        checkMergeQuery("MERGE INTO test1 (name, id, id2) VALUES ('Holy Jesus', 1, 2), ('Kartman', 3, 4)", 2L);
+        checkSqlResults("SELECT id, id2, name FROM test1 WHERE id = 1",
+            Arrays.asList(1, 2, "Holy Jesus"));
+        checkSqlResults("SELECT id, id2, name FROM test1 WHERE id = 3",
+            Arrays.asList(3, 4, "Kartman"));
+
+        checkMergeQuery("MERGE INTO test1 (id, id2, name) " +
+            "SELECT id, id2 * 1000, UPPER(name) FROM test1 WHERE id < 2", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test1 WHERE id = 1 AND id2 = 2000",
+            Arrays.asList(1, 2000, "HOLY JESUS"));
+    }
+
+    /**
+     *
+     */
+    @Test
+    public void testCheckKeysWarning() throws Exception {
+        LogListener logLsnr = LogListener
+            .matches("The search row by explicit KEY isn't supported. The primary key is always used to search row")
+            .build();
+
+        ListeningTestLogger listeningTestLogger = testLog();
+
+        listeningTestLogger.registerListener(logLsnr);
+
+        sql("CREATE TABLE test2 (id INT, id2 INT, name VARCHAR, PRIMARY KEY (id, id2))");
+
+        checkMergeQuery("MERGE INTO test2 (id, id2, name) KEY(_key) VALUES (100, 1, 'Bob')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 100",
+            Arrays.asList(100, 1, "Bob"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+        checkMergeQuery("MERGE INTO test2 (id2, id, name) KEY(_key) VALUES (2, 100, 'Alice')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 100 AND id2 = 2",
+            Arrays.asList(100, 2, "Alice"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+        checkMergeQuery("MERGE INTO test2 (id, id2, name) KEY(id, id2) VALUES (3, 5, 'Stan')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 3",
+            Arrays.asList(3, 5, "Stan"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+        checkMergeQuery("MERGE INTO test2 (id, id2, name) KEY(id2, id) VALUES (1, 100, 'Satan')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 1",
+            Arrays.asList(1, 100, "Satan"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+        checkMergeQuery("MERGE INTO test2 (id2, id, name) KEY(id) VALUES (15, 32, 'Kyle')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 32",
+            Arrays.asList(32, 15, "Kyle"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+        checkMergeQuery("MERGE INTO test2 (name, id, id2) KEY(id2) VALUES ('Morlock', 13, 12)", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 13",
+            Arrays.asList(13, 12, "Morlock"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+        checkMergeQuery("MERGE INTO test2 (id, name, id2) KEY(_key, id) VALUES (10, 'Warlock', 52)", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 10",
+            Arrays.asList(10, 52, "Warlock"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+        checkMergeQuery("MERGE INTO test2 (id, id2, name) KEY(name) VALUES (10, -11, 'Sherlock')", 1L);
+        checkSqlResults("SELECT id, id2, name FROM test2 WHERE id = 10 and id2=-11",
+            Arrays.asList(10, -11, "Sherlock"));
+        assertTrue(logLsnr.check());
+        logLsnr.reset();
+
+    }
+
+    /**
+     * @param sql MERGE query.
+     */
+    private void checkMergeQuery(String sql, long expectedUpdateCounts) throws Exception {
+        List<List<?>> resMrg = sql(sql);
+
+        assertEquals(1, resMrg.size());
+        assertEquals(1, resMrg.get(0).size());
+        assertEquals(expectedUpdateCounts, resMrg.get(0).get(0));
+    }
+
+    /**
+     * @param sql SELECT query to check merge result.
+     * @param expectedRow Expected results of the SELECT.
+     */
+    private void checkSqlResults(String sql, List<?> expectedRow) throws Exception {
+        List<List<?>> res = sql(sql);
+        assertEquals(1, res.size());
+        assertEquals(expectedRow, res.get(0));
+    }
+
+    /**
+     * @param sql SQL query.
+     * @return Results.
+     */
+    protected List<List<?>> sql(String sql) throws Exception {
+        GridQueryProcessor qryProc = node.context().query();
+
+        SqlFieldsQuery qry = new SqlFieldsQuery(sql).setSchema("PUBLIC");
+
+        return qryProc.querySqlFields(qry, true).getAll();
+    }
+
+    /**
+     * Setup and return test log.
+     *
+     * @return Test logger.
+     */
+    private ListeningTestLogger testLog() {
+        ListeningTestLogger testLog = new ListeningTestLogger(false, log);
+
+        GridTestUtils.setFieldValue(((IgniteH2Indexing)node.context().query().getIndexing()).parser(), "log", testLog);
+
+        return testLog;
+    }
+}
diff --git a/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/h2/sql/GridQueryParsingTest.java b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/h2/sql/GridQueryParsingTest.java
index e88d04a..0e73d7b 100644
--- a/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/h2/sql/GridQueryParsingTest.java
+++ b/modules/indexing/src/test/java/org/apache/ignite/internal/processors/query/h2/sql/GridQueryParsingTest.java
@@ -342,7 +342,7 @@
         Prepared prepared = parse("select Person.old, p1.old, p1.addrId from Person, Person p1 " +
             "where exists(select 1 from sch2.Address a where a.id = p1.addrId)");
 
-        GridSqlSelect select = (GridSqlSelect)new GridSqlQueryParser(false).parse(prepared);
+        GridSqlSelect select = (GridSqlSelect)new GridSqlQueryParser(false, log).parse(prepared);
 
         GridSqlJoin join = (GridSqlJoin)select.from();
 
@@ -382,53 +382,6 @@
 
     /** */
     @Test
-    public void testParseMerge() throws Exception {
-        /* Plain rows w/functions, operators, defaults, and placeholders. */
-        checkQuery("merge into Person(old, name) values(5, 'John')");
-        checkQuery("merge into Person(name) values(null)");
-        checkQuery("merge into Person(name) values(null), (null)");
-        checkQuery("merge into Person(name, parentName) values(null, null), (?, ?)");
-        checkQuery("merge into Person(old, name) values(5, 'John',), (6, 'Jack')");
-        checkQuery("merge into Person(old, name) values(5 * 3, null,)");
-        checkQuery("merge into Person(old, name) values(ABS(-8), 'Max')");
-        checkQuery("merge into Person(old, name) values(5, 'Jane'), (null, null), (6, 'Jill')");
-        checkQuery("merge into Person(old, name, parentName) values(8 * 7, null, 'Unknown')");
-        checkQuery("merge into Person(old, name, parentName) values" +
-            "(2016 - 1828, CONCAT('Leo', 'Tolstoy'), CONCAT(?, 'Tolstoy'))," +
-            "(?, 'AlexanderPushkin', null)," +
-            "(ABS(1821 - 2016), CONCAT('Fyodor', null, UPPER(CONCAT(SQRT(?), 'dostoevsky'))), null)");
-        checkQuery("merge into Person(date, old, name, parentName, addrId) values " +
-            "('20160112', 1233, 'Ivan Ivanov', 'Peter Ivanov', 123)");
-        checkQuery("merge into Person(date, old, name, parentName, addrId) values " +
-            "(CURRENT_DATE(), RAND(), ASCII('Hi'), INSERT('Leo Tolstoy', 4, 4, 'Max'), ASCII('HI'))");
-        checkQuery("merge into Person(date, old, name, parentName, addrId) values " +
-            "(TRUNCATE(TIMESTAMP '2015-12-31 23:59:59'), POWER(3,12), NULL, NULL, NULL)");
-        checkQuery("merge into Person(old, name) select ASCII(parentName), INSERT(parentName, 4, 4, 'Max') from " +
-            "Person where date='2011-03-12'");
-
-        /* KEY clause. */
-        checkQuery("merge into Person(_key, old, name) key(_key) values('a', 5, 'John')");
-        checkQuery("merge into SCH3.Person(id, old, name) key(id) values(1, 5, 'John')");
-        checkQuery("merge into SCH3.Person(_key, old, name) key(_key) values(?, 5, 'John')");
-        checkQuery("merge into SCH3.Person(_key, id, old, name) key(_key, id) values(?, ?, 5, 'John')");
-        assertParseThrows("merge into Person(old, name) key(name) values(5, 'John')", IgniteSQLException.class,
-            "Invalid column name in KEYS clause of MERGE - it may include only key and/or affinity columns: NAME");
-        assertParseThrows("merge into SCH3.Person(id, stuff, old, name) key(stuff) values(1, 'x', 5, 'John')",
-            IgniteSQLException.class, "Invalid column name in KEYS clause of MERGE - it may include only key and/or " +
-                "affinity columns: STUFF");
-
-        /* Subqueries. */
-        checkQuery("merge into Person(old, name) select old, parentName from Person");
-        checkQuery("merge into Person(old, name) select old, parentName from Person where old > 5");
-        checkQuery("merge into Person(old, name) select 5, 'John'");
-        checkQuery("merge into Person(old, name) select p1.old, 'Name' from person p1 join person p2 on " +
-            "p2.name = p1.parentName where p2.old > 30");
-        checkQuery("merge into Person(old) select 5 from Person UNION select street from sch2.Address limit ? " +
-            "offset ?");
-    }
-
-    /** */
-    @Test
     public void testParseInsert() throws Exception {
         /* Plain rows w/functions, operators, defaults, and placeholders. */
         checkQuery("insert into Person(old, name) values(5, 'John')");
@@ -727,7 +680,7 @@
             @Override public Object call() throws Exception {
                 Prepared p = parse(sql);
 
-                return new GridSqlQueryParser(false).parse(p);
+                return new GridSqlQueryParser(false, log).parse(p);
             }
         }, exCls, msg);
     }
@@ -738,7 +691,7 @@
     private void assertCreateIndexEquals(GridSqlCreateIndex exp, String sql) throws Exception {
         Prepared prepared = parse(sql);
 
-        GridSqlStatement stmt = new GridSqlQueryParser(false).parse(prepared);
+        GridSqlStatement stmt = new GridSqlQueryParser(false, log).parse(prepared);
 
         assertTrue(stmt instanceof GridSqlCreateIndex);
 
@@ -751,7 +704,7 @@
     private void assertDropIndexEquals(GridSqlDropIndex exp, String sql) throws Exception {
         Prepared prepared = parse(sql);
 
-        GridSqlStatement stmt = new GridSqlQueryParser(false).parse(prepared);
+        GridSqlStatement stmt = new GridSqlQueryParser(false, log).parse(prepared);
 
         assertTrue(stmt instanceof GridSqlDropIndex);
 
@@ -786,7 +739,7 @@
     private void assertCreateTableEquals(GridSqlCreateTable exp, String sql) throws Exception {
         Prepared prepared = parse(sql);
 
-        GridSqlStatement stmt = new GridSqlQueryParser(false).parse(prepared);
+        GridSqlStatement stmt = new GridSqlQueryParser(false, log).parse(prepared);
 
         assertTrue(stmt instanceof GridSqlCreateTable);
 
@@ -848,7 +801,7 @@
     private void assertAlterTableAddColumnEquals(GridSqlAlterTableAddColumn exp, String sql) throws Exception {
         Prepared prepared = parse(sql);
 
-        GridSqlStatement stmt = new GridSqlQueryParser(false).parse(prepared);
+        GridSqlStatement stmt = new GridSqlQueryParser(false, log).parse(prepared);
 
         assertTrue(stmt instanceof GridSqlAlterTableAddColumn);
 
@@ -924,7 +877,7 @@
     private void assertDropTableEquals(GridSqlDropTable exp, String sql) throws Exception {
         Prepared prepared = parse(sql);
 
-        GridSqlStatement stmt = new GridSqlQueryParser(false).parse(prepared);
+        GridSqlStatement stmt = new GridSqlQueryParser(false, log).parse(prepared);
 
         assertTrue(stmt instanceof GridSqlDropTable);
 
@@ -1115,7 +1068,7 @@
     private void checkQuery(String qry) throws Exception {
         Prepared prepared = parse(qry);
 
-        GridSqlStatement gQry = new GridSqlQueryParser(false).parse(prepared);
+        GridSqlStatement gQry = new GridSqlQueryParser(false, log).parse(prepared);
 
         String res = gQry.getSQL();
 
diff --git a/modules/indexing/src/test/java/org/apache/ignite/testsuites/IgniteBinaryCacheQueryTestSuite.java b/modules/indexing/src/test/java/org/apache/ignite/testsuites/IgniteBinaryCacheQueryTestSuite.java
index 5bda57e..b752574 100644
--- a/modules/indexing/src/test/java/org/apache/ignite/testsuites/IgniteBinaryCacheQueryTestSuite.java
+++ b/modules/indexing/src/test/java/org/apache/ignite/testsuites/IgniteBinaryCacheQueryTestSuite.java
@@ -216,6 +216,8 @@
 import org.apache.ignite.internal.processors.query.RunningQueriesTest;
 import org.apache.ignite.internal.processors.query.SqlIllegalSchemaSelfTest;
 import org.apache.ignite.internal.processors.query.SqlIncompatibleDataTypeExceptionTest;
+import org.apache.ignite.internal.processors.query.SqlMergeOnClientNodeTest;
+import org.apache.ignite.internal.processors.query.SqlMergeTest;
 import org.apache.ignite.internal.processors.query.SqlNestedQuerySelfTest;
 import org.apache.ignite.internal.processors.query.SqlPushDownFunctionTest;
 import org.apache.ignite.internal.processors.query.SqlQueryHistoryFromClientSelfTest;
@@ -600,7 +602,12 @@
     RowCountTableStatisticsSurvivesNodeRestartTest.class,
 
     SqlViewExporterSpiTest.class,
-    SystemViewSelfTest.class
+    SystemViewSelfTest.class,
+
+    IgniteCacheMergeSqlQuerySelfTest.class,
+    SqlMergeTest.class,
+    SqlMergeOnClientNodeTest.class
+
 })
 public class IgniteBinaryCacheQueryTestSuite {
 }