You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by am...@apache.org on 2023/12/13 11:21:16 UTC

(ignite-3) branch main updated: IGNITE-20778: Provide internal API to get a list of parameters for non-executed query (#2857)

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

amashenkov 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 32c7178459 IGNITE-20778: Provide internal API to get a list of parameters for non-executed query (#2857)
32c7178459 is described below

commit 32c7178459d59f91e6476e29430b465ab46f7098
Author: Max Zhuravkov <sh...@gmail.com>
AuthorDate: Wed Dec 13 13:21:11 2023 +0200

    IGNITE-20778: Provide internal API to get a list of parameters for non-executed query (#2857)
---
 .../client/fakes/FakeIgniteQueryProcessor.java     |   7 +
 .../internal/sql/engine/ItDataTypesTest.java       |   2 +-
 .../sql/engine/ItDynamicParameterTest.java         | 167 ++++++
 .../ignite/internal/sql/engine/QueryProcessor.java |  18 +
 .../internal/sql/engine/SqlQueryProcessor.java     |  88 ++-
 .../internal/sql/engine/prepare/CacheKey.java      |   7 +-
 .../internal/sql/engine/prepare/DdlPlan.java       |  10 +
 .../internal/sql/engine/prepare/ExplainPlan.java   |   6 +
 .../internal/sql/engine/prepare/IgnitePlanner.java |  18 +-
 .../sql/engine/prepare/IgniteSqlValidator.java     | 426 ++++++++++++---
 .../sql/engine/prepare/IgniteTypeCoercion.java     | 156 +++++-
 .../internal/sql/engine/prepare/MultiStepPlan.java |  11 +-
 .../{QueryPlan.java => ParameterMetadata.java}     |  36 +-
 .../internal/sql/engine/prepare/ParameterType.java |  96 ++++
 .../sql/engine/prepare/PlanningContext.java        |  26 +-
 .../sql/engine/prepare/PrepareService.java         |   1 +
 .../sql/engine/prepare/PrepareServiceImpl.java     | 227 +++++++-
 .../internal/sql/engine/prepare/QueryPlan.java     |   5 +
 .../ignite/internal/sql/engine/util/Commons.java   |  48 +-
 .../internal/sql/engine/util/IgniteResource.java   |  75 +++
 .../ignite/internal/sql/engine/util/TypeUtils.java |   2 +-
 .../sql/engine/exec/ExecutionServiceImplTest.java  |   2 +
 .../sql/engine/exec/mapping/MappingTestRunner.java |   5 +-
 .../sql/engine/planner/AbstractPlannerTest.java    |  29 +-
 .../sql/engine/planner/DynamicParametersTest.java  | 587 ++++++++++++++++++++-
 .../sql/engine/prepare/PrepareServiceImplTest.java | 166 +++++-
 .../internal/sql/engine/util/CommonsTest.java      |  13 +
 .../internal/sql/engine/util/QueryCheckerTest.java |   7 +
 .../internal/sql/engine/util/StatementChecker.java |  66 ++-
 .../sql/engine/util/StatementCheckerTest.java      |   8 +-
 .../sql/metrics/PlanningCacheMetricsTest.java      |  22 +-
 .../internal/sql/engine/util/SqlTestUtils.java     |  33 ++
 32 files changed, 2148 insertions(+), 222 deletions(-)

diff --git a/modules/client/src/test/java/org/apache/ignite/client/fakes/FakeIgniteQueryProcessor.java b/modules/client/src/test/java/org/apache/ignite/client/fakes/FakeIgniteQueryProcessor.java
index 0e2aade3dd..e6bb5b0d77 100644
--- a/modules/client/src/test/java/org/apache/ignite/client/fakes/FakeIgniteQueryProcessor.java
+++ b/modules/client/src/test/java/org/apache/ignite/client/fakes/FakeIgniteQueryProcessor.java
@@ -21,6 +21,7 @@ import java.util.concurrent.CompletableFuture;
 import org.apache.ignite.internal.sql.engine.AsyncSqlCursor;
 import org.apache.ignite.internal.sql.engine.InternalSqlRow;
 import org.apache.ignite.internal.sql.engine.QueryProcessor;
+import org.apache.ignite.internal.sql.engine.prepare.ParameterMetadata;
 import org.apache.ignite.internal.sql.engine.property.SqlProperties;
 import org.apache.ignite.internal.tx.InternalTransaction;
 import org.apache.ignite.tx.IgniteTransactions;
@@ -30,6 +31,12 @@ import org.jetbrains.annotations.Nullable;
  * Fake {@link QueryProcessor}.
  */
 public class FakeIgniteQueryProcessor implements QueryProcessor {
+    @Override
+    public CompletableFuture<ParameterMetadata> prepareSingleAsync(SqlProperties properties,
+            @Nullable InternalTransaction transaction, String qry, Object... params) {
+        throw new UnsupportedOperationException();
+    }
+
     @Override
     public CompletableFuture<AsyncSqlCursor<InternalSqlRow>> querySingleAsync(
             SqlProperties properties,
diff --git a/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDataTypesTest.java b/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDataTypesTest.java
index 85a23500a7..3161184a8f 100644
--- a/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDataTypesTest.java
+++ b/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDataTypesTest.java
@@ -410,7 +410,7 @@ public class ItDataTypesTest extends BaseSqlIntegrationTest {
         expectResult(checker, result);
     }
 
-    @ParameterizedTest(name = "{1}")
+    @ParameterizedTest(name = "{1} {2}")
     @MethodSource("decimalOverflows")
     public void testCalcOpOverflow(SqlTypeName type, String expr, Object param) {
         if (param == EMPTY_PARAM) {
diff --git a/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDynamicParameterTest.java b/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDynamicParameterTest.java
index 517b100b52..22ebd676f5 100644
--- a/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDynamicParameterTest.java
+++ b/modules/sql-engine/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDynamicParameterTest.java
@@ -19,20 +19,32 @@ package org.apache.ignite.internal.sql.engine;
 
 import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
 import static org.apache.ignite.internal.sql.engine.util.SqlTestUtils.assertThrowsSqlException;
+import static org.apache.ignite.internal.testframework.IgniteTestUtils.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.params.provider.Arguments.arguments;
 
 import java.sql.Date;
 import java.time.LocalDate;
+import java.util.Arrays;
 import java.util.List;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.ignite.internal.sql.BaseSqlIntegrationTest;
+import org.apache.ignite.internal.sql.engine.prepare.ParameterMetadata;
+import org.apache.ignite.internal.sql.engine.prepare.ParameterType;
+import org.apache.ignite.internal.sql.engine.property.SqlProperties;
+import org.apache.ignite.internal.sql.engine.property.SqlPropertiesHelper;
 import org.apache.ignite.internal.sql.engine.type.IgniteTypeFactory;
 import org.apache.ignite.internal.sql.engine.util.Commons;
 import org.apache.ignite.internal.sql.engine.util.MetadataMatcher;
 import org.apache.ignite.internal.sql.engine.util.SqlTestUtils;
+import org.apache.ignite.internal.sql.engine.util.TypeUtils;
+import org.apache.ignite.internal.type.NativeType;
+import org.apache.ignite.internal.type.NativeTypes;
+import org.apache.ignite.internal.util.Pair;
 import org.apache.ignite.lang.ErrorGroups.Sql;
 import org.apache.ignite.sql.ColumnType;
 import org.junit.jupiter.api.AfterEach;
@@ -279,6 +291,155 @@ public class ItDynamicParameterTest extends BaseSqlIntegrationTest {
         );
     }
 
+    @ParameterizedTest
+    @MethodSource("statementsWithParameters")
+    public void testGetParameterTypesSimple(String stmt, List<ColumnType> expectedTypes, Object[] params) {
+        ParameterMetadata parameterTypes = getParameterMetadata(stmt, params);
+
+        List<ColumnType> columnTypes = parameterTypes.parameterTypes()
+                .stream()
+                .map(ParameterType::columnType)
+                .collect(Collectors.toList());
+
+        assertEquals(expectedTypes, columnTypes, stmt);
+    }
+
+    private static Stream<Arguments> statementsWithParameters() {
+        return Stream.of(
+                arguments("SELECT CAST(? AS BIGINT)", List.of(ColumnType.INT64), new Object[0]),
+                arguments("SELECT CAST(? AS BIGINT)", List.of(ColumnType.INT32), new Object[]{1}),
+                arguments("SELECT CAST(? AS BIGINT)", List.of(ColumnType.STRING), new Object[]{"1"}),
+
+                arguments("INSERT INTO t1 VALUES(1, ?, ?)", List.of(ColumnType.INT32, ColumnType.INT32), new Object[0]),
+                arguments("UPDATE t1 SET val1 = ? WHERE id = ?", List.of(ColumnType.INT32, ColumnType.INT32), new Object[0]),
+                arguments("SELECT val1 + ? FROM t1 WHERE id = ?", List.of(ColumnType.INT32, ColumnType.INT32), new Object[0])
+        );
+    }
+
+    @Test
+    public void testRejectPrepareWithMoreParameters() {
+        assertThrowsSqlException(
+                Sql.STMT_VALIDATION_ERR,
+                "Unexpected number of query parameters",
+                () -> getParameterMetadata("SELECT ? + ?", 1, 2, 3));
+    }
+
+    @Test
+    public void testGetUnspecifiedParameterTypesInPredicate() {
+        List<Pair<String, NativeType>> dataTypes = columnNameAndType();
+
+        sql(createTableFoColumns("params0", dataTypes));
+
+        StringBuilder stmt = new StringBuilder("SELECT * FROM params0 WHERE ");
+        for (int i = 0; i < dataTypes.size(); i++) {
+            if (i > 0) {
+                stmt.append(" AND ");
+            }
+            stmt.append(dataTypes.get(i).getFirst());
+            stmt.append(" = ?");
+        }
+
+        log.info("SELECT from column names: {}", stmt);
+
+        ParameterMetadata parameterTypes = getParameterMetadata(stmt.toString());
+
+        List<NativeType> actualTypes = parameterTypes.parameterTypes().stream()
+                .map(p -> TypeUtils.columnType2NativeType(p.columnType(), p.precision(), p.scale(), p.precision()))
+                .collect(Collectors.toList());
+
+        assertEquals(actualTypes, dataTypes.stream().map(Pair::getSecond).collect(Collectors.toList()), "parameter types");
+    }
+
+    @Test
+    public void testGetUnspecifiedParameterTypesInInsert() {
+        List<Pair<String, NativeType>> dataTypes = columnNameAndType();
+
+        sql(createTableFoColumns("params1", dataTypes));
+
+        StringBuilder stmt = new StringBuilder("INSERT INTO params1 VALUES(1");
+        stmt.append(", ?".repeat(dataTypes.size()));
+        stmt.append(")");
+
+        log.info("INSERT from column names: {}", stmt);
+
+        ParameterMetadata parameterTypes = getParameterMetadata(stmt.toString());
+
+        List<NativeType> actualTypes = parameterTypes.parameterTypes().stream()
+                .map(p -> TypeUtils.columnType2NativeType(p.columnType(), p.precision(), p.scale(), p.precision()))
+                .collect(Collectors.toList());
+
+        assertEquals(actualTypes, dataTypes.stream().map(Pair::getSecond).collect(Collectors.toList()), "parameter types");
+    }
+
+    @Test
+    public void testGetUnspecifiedParameterTypesInUpdate() {
+        List<Pair<String, NativeType>> dataTypes = columnNameAndType();
+
+        sql(createTableFoColumns("params2", dataTypes));
+
+        StringBuilder stmt = new StringBuilder("UPDATE params2 SET ");
+        for (int i = 0; i < dataTypes.size(); i++) {
+            if (i > 0) {
+                stmt.append(", ");
+            }
+            String colName = dataTypes.get(i).getFirst();
+            stmt.append(colName);
+            stmt.append("=?");
+        }
+
+        log.info("UPDATE from column names: {}", stmt);
+
+        ParameterMetadata parameterTypes = getParameterMetadata(stmt.toString());
+
+        List<NativeType> actualTypes = parameterTypes.parameterTypes().stream()
+                .map(p -> TypeUtils.columnType2NativeType(p.columnType(), p.precision(), p.scale(), p.precision()))
+                .collect(Collectors.toList());
+
+        assertEquals(actualTypes, dataTypes.stream().map(Pair::getSecond).collect(Collectors.toList()), "parameter types");
+    }
+
+    private static List<Pair<String, NativeType>> columnNameAndType() {
+        return Arrays.asList(
+                new Pair<>("bool_col", NativeTypes.BOOLEAN),
+                new Pair<>("int8_col", NativeTypes.INT8),
+                new Pair<>("int16_col", NativeTypes.INT16),
+                new Pair<>("int32_col", NativeTypes.INT32),
+                new Pair<>("int64_col", NativeTypes.INT64),
+                new Pair<>("float_col", NativeTypes.FLOAT),
+                new Pair<>("double_col", NativeTypes.DOUBLE),
+                new Pair<>("dec_col", NativeTypes.decimalOf(5, 2)),
+                new Pair<>("date_col", NativeTypes.DATE),
+                new Pair<>("datetime_col", NativeTypes.datetime(2)),
+                new Pair<>("time_col", NativeTypes.time(2)),
+                new Pair<>("string_col", NativeTypes.stringOf(10)),
+                new Pair<>("bytes_col", NativeTypes.blobOf(10)),
+                new Pair<>("uuid_col", NativeTypes.UUID)
+        );
+    }
+
+    private String createTableFoColumns(String name, List<Pair<String, NativeType>> dataTypes) {
+        IgniteTypeFactory tf = Commons.typeFactory();
+
+        StringBuilder ddl = new StringBuilder("CREATE TABLE ");
+        ddl.append(name);
+        ddl.append("(id INTEGER PRIMARY KEY");
+
+        for (Pair<String, NativeType> entry : dataTypes) {
+            RelDataType relDataType = TypeUtils.native2relationalType(tf, entry.getSecond(), true);
+
+            ddl.append(", ");
+            ddl.append(entry.getFirst());
+            ddl.append(" ");
+            ddl.append(relDataType);
+        }
+
+        ddl.append(")");
+
+        log.info("CREATE TABLE from column names: {}", ddl);
+
+        return ddl.toString();
+    }
+
     @Override
     protected int initialNodes() {
         return 1;
@@ -290,4 +451,10 @@ public class ItDynamicParameterTest extends BaseSqlIntegrationTest {
                 "Unexpected number of query parameters",
                 () -> assertQuery(query).withParams(params).check());
     }
+
+    private ParameterMetadata getParameterMetadata(String query, Object... params) {
+        QueryProcessor qryProc = queryProcessor();
+        SqlProperties properties = SqlPropertiesHelper.emptyProperties();
+        return await(qryProc.prepareSingleAsync(properties, null, query, params));
+    }
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/QueryProcessor.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/QueryProcessor.java
index b64acc5103..258651d37e 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/QueryProcessor.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/QueryProcessor.java
@@ -19,6 +19,7 @@ package org.apache.ignite.internal.sql.engine;
 
 import java.util.concurrent.CompletableFuture;
 import org.apache.ignite.internal.manager.IgniteComponent;
+import org.apache.ignite.internal.sql.engine.prepare.ParameterMetadata;
 import org.apache.ignite.internal.sql.engine.property.SqlProperties;
 import org.apache.ignite.internal.tx.InternalTransaction;
 import org.apache.ignite.lang.IgniteException;
@@ -29,6 +30,23 @@ import org.jetbrains.annotations.Nullable;
  * QueryProcessor interface.
  */
 public interface QueryProcessor extends IgniteComponent {
+
+    /**
+     * Returns parameter metadata for the given statement. This method uses optional array of parameters to assist with type inference.
+     *
+     * @param properties User query properties. See {@link QueryProperty} for available properties.
+     * @param transaction A transaction to use to resolve a schema.
+     * @param qry Single statement SQL query.
+     * @param params Query parameters.
+     * @return Parameter metadata.
+     *
+     * @throws IgniteException in case of an error.
+     * @see QueryProperty
+     */
+    CompletableFuture<ParameterMetadata> prepareSingleAsync(SqlProperties properties,
+            @Nullable InternalTransaction transaction,
+            String qry, Object... params);
+
     /**
      * Execute the single statement query with given schema name and parameters.
      *
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/SqlQueryProcessor.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/SqlQueryProcessor.java
index bae9c77f99..dcc00d9aa6 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/SqlQueryProcessor.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/SqlQueryProcessor.java
@@ -86,6 +86,7 @@ import org.apache.ignite.internal.sql.engine.exec.mapping.ExecutionTargetFactory
 import org.apache.ignite.internal.sql.engine.exec.mapping.ExecutionTargetProvider;
 import org.apache.ignite.internal.sql.engine.exec.mapping.MappingServiceImpl;
 import org.apache.ignite.internal.sql.engine.message.MessageServiceImpl;
+import org.apache.ignite.internal.sql.engine.prepare.ParameterMetadata;
 import org.apache.ignite.internal.sql.engine.prepare.PrepareService;
 import org.apache.ignite.internal.sql.engine.prepare.PrepareServiceImpl;
 import org.apache.ignite.internal.sql.engine.prepare.QueryPlan;
@@ -416,6 +417,23 @@ public class SqlQueryProcessor implements QueryProcessor {
         IgniteUtils.closeAll(services.stream().map(s -> s::stop));
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public CompletableFuture<ParameterMetadata> prepareSingleAsync(SqlProperties properties,
+            @Nullable InternalTransaction transaction,
+            String qry, Object... params) {
+
+        if (!busyLock.enterBusy()) {
+            throw new IgniteInternalException(NODE_STOPPING_ERR, new NodeStoppingException());
+        }
+
+        try {
+            return prepareSingleAsync0(properties, transaction, qry, params);
+        } finally {
+            busyLock.leaveBusy();
+        }
+    }
+
     /** {@inheritDoc} */
     @Override
     public CompletableFuture<AsyncSqlCursor<InternalSqlRow>> querySingleAsync(
@@ -462,6 +480,43 @@ public class SqlQueryProcessor implements QueryProcessor {
         return service;
     }
 
+    private CompletableFuture<ParameterMetadata> prepareSingleAsync0(
+            SqlProperties properties,
+            @Nullable InternalTransaction explicitTransaction,
+            String sql,
+            Object... params
+    ) {
+        SqlProperties properties0 = SqlPropertiesHelper.chain(properties, DEFAULT_PROPERTIES);
+        String schemaName = properties0.get(QueryProperty.DEFAULT_SCHEMA);
+
+        QueryCancel queryCancel = new QueryCancel();
+
+        CompletableFuture<ParameterMetadata> start = new CompletableFuture<>();
+
+        CompletableFuture<ParameterMetadata> stage = start.thenCompose(ignored -> {
+            ParsedResult result = parserService.parse(sql);
+
+            validateParsedStatement(properties0, result);
+            validateDynamicParameters(result.dynamicParamsCount(), params, false);
+
+            HybridTimestamp timestamp = explicitTransaction != null ? explicitTransaction.startTimestamp() : clock.now();
+
+            return prepareParsedStatement(schemaName, result, timestamp, queryCancel, params)
+                    .thenApply(QueryPlan::parameterMetadata);
+        });
+
+        // TODO IGNITE-20078 Improve (or remove) CancellationException handling.
+        stage.whenComplete((cur, ex) -> {
+            if (ex instanceof CancellationException) {
+                queryCancel.cancel();
+            }
+        });
+
+        start.completeAsync(() -> null, taskExecutor);
+
+        return stage;
+    }
+
     private CompletableFuture<AsyncSqlCursor<InternalSqlRow>> querySingle0(
             SqlProperties properties,
             QueryTransactionContext txCtx,
@@ -478,7 +533,8 @@ public class SqlQueryProcessor implements QueryProcessor {
         CompletableFuture<AsyncSqlCursor<InternalSqlRow>> stage = start.thenCompose(ignored -> {
             ParsedResult result = parserService.parse(sql);
 
-            validateParsedStatement(properties0, result, params);
+            validateParsedStatement(properties0, result);
+            validateDynamicParameters(result.dynamicParamsCount(), params, true);
 
             QueryTransactionWrapper txWrapper = txCtx.getOrStartImplicit(result.queryType());
 
@@ -522,6 +578,25 @@ public class SqlQueryProcessor implements QueryProcessor {
         return parseFut;
     }
 
+    private CompletableFuture<QueryPlan> prepareParsedStatement(String schemaName,
+            ParsedResult parsedResult,
+            HybridTimestamp timestamp,
+            QueryCancel queryCancel,
+            Object[] params) {
+
+        return waitForActualSchema(schemaName, timestamp)
+                .thenCompose(schema -> {
+                    BaseQueryContext ctx = BaseQueryContext.builder()
+                            .frameworkConfig(Frameworks.newConfigBuilder(FRAMEWORK_CONFIG).defaultSchema(schema).build())
+                            .queryId(UUID.randomUUID())
+                            .cancel(queryCancel)
+                            .parameters(params)
+                            .build();
+
+                    return prepareSvc.prepareAsync(parsedResult, ctx);
+                });
+    }
+
     private CompletableFuture<AsyncSqlCursor<InternalSqlRow>> executeParsedStatement(
             String schemaName,
             ParsedResult parsedResult,
@@ -624,8 +699,7 @@ public class SqlQueryProcessor implements QueryProcessor {
     /** Performs additional validation of a parsed statement. **/
     private static void validateParsedStatement(
             SqlProperties properties,
-            ParsedResult parsedResult,
-            Object[] params
+            ParsedResult parsedResult
     ) {
         Set<SqlQueryType> allowedTypes = properties.get(QueryProperty.ALLOWED_QUERY_TYPES);
         SqlQueryType queryType = parsedResult.queryType();
@@ -641,12 +715,10 @@ public class SqlQueryProcessor implements QueryProcessor {
 
             throw new SqlException(STMT_VALIDATION_ERR, message);
         }
-
-        validateDynamicParameters(parsedResult.dynamicParamsCount(), params);
     }
 
-    private static void validateDynamicParameters(int expectedParamsCount, Object[] params) throws SqlException {
-        if (expectedParamsCount != params.length) {
+    private static void validateDynamicParameters(int expectedParamsCount, Object[] params, boolean exactMatch) throws SqlException {
+        if (exactMatch && expectedParamsCount != params.length || params.length > expectedParamsCount) {
             String message = format(
                     "Unexpected number of query parameters. Provided {} but there is only {} dynamic parameter(s).",
                     params.length, expectedParamsCount
@@ -695,7 +767,7 @@ public class SqlQueryProcessor implements QueryProcessor {
 
             int paramsCount = parsedResults.stream().mapToInt(ParsedResult::dynamicParamsCount).sum();
 
-            validateDynamicParameters(paramsCount, params);
+            validateDynamicParameters(paramsCount, params, true);
 
             ScriptStatement[] results = new ScriptStatement[parsedResults.size()];
 
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/CacheKey.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/CacheKey.java
index eb82b0d456..6a58ce4321 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/CacheKey.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/CacheKey.java
@@ -19,6 +19,7 @@ package org.apache.ignite.internal.sql.engine.prepare;
 
 import java.util.Arrays;
 import java.util.Objects;
+import org.apache.ignite.sql.ColumnType;
 
 /**
  * CacheKey.
@@ -26,7 +27,7 @@ import java.util.Objects;
  * context could be schema name, dynamic parameters, and so on...
  */
 public class CacheKey {
-    static final Class<?>[] EMPTY_CLASS_ARRAY = {};
+    static final ColumnType[] EMPTY_CLASS_ARRAY = {};
 
     private final int catalogVersion;
 
@@ -36,7 +37,7 @@ public class CacheKey {
 
     private final Object contextKey;
 
-    private final Class<?>[] paramTypes;
+    private final Object[] paramTypes;
 
     /**
      * Constructor.
@@ -48,7 +49,7 @@ public class CacheKey {
      *                   LOCAL flag)
      * @param paramTypes Types of all dynamic parameters, no any type can be {@code null}.
      */
-    public CacheKey(int catalogVersion, String schemaName, String query, Object contextKey, Class<?>[] paramTypes) {
+    public CacheKey(int catalogVersion, String schemaName, String query, Object contextKey, ColumnType[] paramTypes) {
         this.catalogVersion = catalogVersion;
         this.schemaName = schemaName;
         this.query = query;
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/DdlPlan.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/DdlPlan.java
index 3b27d68b85..87d845f38c 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/DdlPlan.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/DdlPlan.java
@@ -17,6 +17,7 @@
 
 package org.apache.ignite.internal.sql.engine.prepare;
 
+import java.util.Collections;
 import java.util.List;
 import org.apache.ignite.internal.sql.api.ColumnMetadataImpl;
 import org.apache.ignite.internal.sql.api.ResultSetMetadataImpl;
@@ -35,6 +36,9 @@ public class DdlPlan implements QueryPlan {
     private static final ResultSetMetadata DDL_METADATA = new ResultSetMetadataImpl(List.of(
             new ColumnMetadataImpl("APPLIED", ColumnType.BOOLEAN, 1, ColumnMetadata.UNDEFINED_SCALE, false, null)));
 
+    /** DDL has no parameters. */
+    private static final ParameterMetadata EMPTY_PARAMETERS = new ParameterMetadata(Collections.emptyList());
+
     private final PlanId id;
     private final DdlCommand cmd;
 
@@ -65,6 +69,12 @@ public class DdlPlan implements QueryPlan {
         return DDL_METADATA;
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public ParameterMetadata parameterMetadata() {
+        return EMPTY_PARAMETERS;
+    }
+
     /** {@inheritDoc} */
     @Override
     public String toString() {
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ExplainPlan.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ExplainPlan.java
index 4cb3522629..f5b9403d6f 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ExplainPlan.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ExplainPlan.java
@@ -60,6 +60,12 @@ public class ExplainPlan implements QueryPlan {
         return EXPLAIN_METADATA;
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public ParameterMetadata parameterMetadata() {
+        return plan.parameterMetadata();
+    }
+
     public MultiStepPlan plan() {
         return plan;
     }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgnitePlanner.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgnitePlanner.java
index 2870c97cca..d7f9f652d0 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgnitePlanner.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgnitePlanner.java
@@ -18,10 +18,8 @@
 package org.apache.ignite.internal.sql.engine.prepare;
 
 import static java.util.Objects.requireNonNull;
-import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
 import static org.apache.ignite.internal.sql.engine.util.Commons.shortRuleName;
 import static org.apache.ignite.lang.ErrorGroups.Sql.STMT_PARSE_ERR;
-import static org.apache.ignite.lang.ErrorGroups.Sql.STMT_VALIDATION_ERR;
 
 import java.io.PrintWriter;
 import java.io.Reader;
@@ -182,18 +180,8 @@ public class IgnitePlanner implements Planner, RelOptTable.ViewExpander {
     /** {@inheritDoc} */
     @Override
     public SqlNode parse(Reader reader) throws SqlParseException {
+        // This method is only used in tests.
         StatementParseResult parseResult = IgniteSqlParser.parse(reader, StatementParseResult.MODE);
-        Object[] parameters = ctx.parameters();
-
-        // Parse method is only used in tests.
-        if (parameters.length != parseResult.dynamicParamsCount()) {
-            String message = format(
-                    "Unexpected number of query parameters. Provided {} but there is only {} dynamic parameter(s).",
-                    parameters.length, parseResult.dynamicParamsCount()
-            );
-
-            throw new SqlException(STMT_VALIDATION_ERR, message);
-        }
 
         return parseResult.statement();
     }
@@ -210,9 +198,11 @@ public class IgnitePlanner implements Planner, RelOptTable.ViewExpander {
     public Pair<SqlNode, RelDataType> validateAndGetType(SqlNode sqlNode) {
         SqlNode validatedNode = validator().validate(sqlNode);
         RelDataType type = validator().getValidatedNodeType(validatedNode);
+        this.validatedSqlNode = validatedNode;
         return Pair.of(validatedNode, type);
     }
 
+    /** {@inheritDoc} */
     @Override
     public RelDataType getParameterRowType() {
         return requireNonNull(validator, "validator")
@@ -316,6 +306,8 @@ public class IgnitePlanner implements Planner, RelOptTable.ViewExpander {
             }
         }
 
+        this.validatedSqlNode = validatedNode;
+
         return new ValidationResult(validatedNode, type, origins, derived == null ? List.of() : derived);
     }
 
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlValidator.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlValidator.java
index a3a4a40a1d..c6d2ce411b 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlValidator.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteSqlValidator.java
@@ -18,17 +18,24 @@
 package org.apache.ignite.internal.sql.engine.prepare;
 
 import static java.util.Objects.requireNonNull;
+import static org.apache.calcite.sql.type.SqlTypeName.INTEGER;
 import static org.apache.calcite.sql.type.SqlTypeName.INT_TYPES;
-import static org.apache.calcite.sql.type.SqlTypeUtil.equalSansNullability;
 import static org.apache.calcite.sql.type.SqlTypeUtil.isNull;
 import static org.apache.calcite.util.Static.RESOURCE;
 import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
 import static org.apache.ignite.lang.ErrorGroups.Sql.STMT_PARSE_ERR;
 
+import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.IntArraySet;
+import it.unimi.dsi.fastutil.ints.IntSet;
 import java.math.BigDecimal;
 import java.math.RoundingMode;
+import java.util.AbstractList;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
+import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -40,6 +47,7 @@ import org.apache.calcite.prepare.CalciteCatalogReader;
 import org.apache.calcite.prepare.Prepare;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rel.type.RelDataTypeField;
+import org.apache.calcite.schema.impl.ModifiableViewTable;
 import org.apache.calcite.sql.JoinConditionType;
 import org.apache.calcite.sql.SqlAggFunction;
 import org.apache.calcite.sql.SqlBasicCall;
@@ -67,6 +75,7 @@ import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.calcite.sql.type.SqlTypeName.Limit;
 import org.apache.calcite.sql.type.SqlTypeUtil;
 import org.apache.calcite.sql.util.SqlBasicVisitor;
+import org.apache.calcite.sql.util.SqlShuttle;
 import org.apache.calcite.sql.validate.SelectScope;
 import org.apache.calcite.sql.validate.SqlValidator;
 import org.apache.calcite.sql.validate.SqlValidatorImpl;
@@ -119,14 +128,18 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
         HUMAN_READABLE_ALIASES_FOR = Collections.unmodifiableSet(kinds);
     }
 
-    /** Dynamic parameter values. */
-    private final Object[] dynamicParamValues;
+    /** Dynamic parameters state. */
+    private final Int2ObjectMap<DynamicParamState> dynamicParameters;
 
-    /** Dynamic parameters SQL AST nodes for invariant checks - see {@link #validateInferredDynamicParameters()}. */
-    private final SqlDynamicParam[] dynamicParamNodes;
+    /**
+     * The same dynamic parameter can be used in the same SQL tree multiple types after a rewrite.
+     * (E.g. COALESCE(?0, ?1) is rewritten into CASE WHEN ?0 IS NOT NULL THEN ?0 ELSE ?1 END)
+     * We store them to check that every i-th parameter has the same type.
+     */
+    private final IdentityHashMap<SqlDynamicParam, SqlDynamicParam> dynamicParamNodes = new IdentityHashMap<>();
 
     /** Literal processing. */
-    private LiteralExtractor litExtractor;
+    private final LiteralExtractor litExtractor = new LiteralExtractor();
 
     /**
      * Creates a validator.
@@ -138,11 +151,14 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
      * @param parameters    Dynamic parameters
      */
     public IgniteSqlValidator(SqlOperatorTable opTab, CalciteCatalogReader catalogReader,
-            IgniteTypeFactory typeFactory, SqlValidator.Config config, Object[] parameters) {
+            IgniteTypeFactory typeFactory, SqlValidator.Config config, Int2ObjectMap<Object> parameters) {
         super(opTab, catalogReader, typeFactory, config);
 
-        this.dynamicParamValues = parameters;
-        this.dynamicParamNodes = new SqlDynamicParam[parameters.length];
+        this.dynamicParameters = new Int2ObjectArrayMap<>(parameters.size());
+        for (Map.Entry<Integer, Object> param : parameters.int2ObjectEntrySet()) {
+            Object value = param.getValue();
+            dynamicParameters.put(param.getKey().intValue(), new DynamicParamState(value));
+        }
     }
 
     /** {@inheritDoc} */
@@ -234,7 +250,7 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
         }
 
         if (!coerced) {
-            super.checkTypeAssignment(sourceScope, table, sourceRowType, targetRowType, query);
+            doCheckTypeAssignment(sourceScope, table, sourceRowType, targetRowType, query);
         }
     }
 
@@ -276,6 +292,80 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
         return (IgniteTable) dataSource;
     }
 
+    /**
+     * The copy of {@link SqlValidatorImpl#checkTypeAssignment(SqlValidatorScope, SqlValidatorTable, RelDataType, RelDataType, SqlNode)}
+     * with a fixed condition to skip dynamic parameters + this method does not try to find a location of a type error.
+     */
+    private void doCheckTypeAssignment(
+            @Nullable SqlValidatorScope sourceScope,
+            SqlValidatorTable table,
+            RelDataType sourceRowType,
+            RelDataType targetRowType,
+            final SqlNode query) {
+        // NOTE jvs 23-Feb-2006: subclasses may allow for extra targets
+        // representing system-maintained columns, so stop after all sources
+        // matched
+        boolean isUpdateModifiableViewTable = false;
+        if (query instanceof SqlUpdate) {
+            final SqlNodeList targetColumnList =
+                    requireNonNull(((SqlUpdate) query).getTargetColumnList());
+            final int targetColumnCount = targetColumnList.size();
+            targetRowType =
+                    SqlTypeUtil.extractLastNFields(typeFactory, targetRowType,
+                            targetColumnCount);
+            sourceRowType =
+                    SqlTypeUtil.extractLastNFields(typeFactory, sourceRowType,
+                            targetColumnCount);
+            isUpdateModifiableViewTable =
+                    table.unwrap(ModifiableViewTable.class) != null;
+        }
+        if (SqlTypeUtil.equalAsStructSansNullability(typeFactory,
+                sourceRowType, targetRowType, null)) {
+            // Returns early if source and target row type equals sans nullability.
+            return;
+        }
+        if (config().typeCoercionEnabled() && !isUpdateModifiableViewTable) {
+            // Try type coercion first if implicit type coercion is allowed.
+            boolean coerced =
+                    getTypeCoercion().querySourceCoercion(sourceScope, sourceRowType,
+                            targetRowType, query);
+            if (coerced) {
+                return;
+            }
+        }
+
+        // Fall back to default behavior: compare the type families.
+        List<RelDataTypeField> sourceFields = sourceRowType.getFieldList();
+        List<RelDataTypeField> targetFields = targetRowType.getFieldList();
+        final int sourceCount = sourceFields.size();
+        for (int i = 0; i < sourceCount; ++i) {
+            RelDataType sourceType = sourceFields.get(i).getType();
+            RelDataType targetType = targetFields.get(i).getType();
+            if (!SqlTypeUtil.canAssignFrom(targetType, sourceType)) {
+                // A correct condition for skipping dynamic parameters.
+                if (sourceType == unknownType) {
+                    continue;
+                }
+                String targetTypeString;
+                String sourceTypeString;
+                if (SqlTypeUtil.areCharacterSetsMismatched(
+                        sourceType,
+                        targetType)) {
+                    sourceTypeString = sourceType.getFullTypeString();
+                    targetTypeString = targetType.getFullTypeString();
+                } else {
+                    sourceTypeString = sourceType.toString();
+                    targetTypeString = targetType.toString();
+                }
+                // Always use a query as an error source.
+                throw newValidationError(query,
+                        RESOURCE.typeNotAssignable(
+                                targetFields.get(i).getName(), targetTypeString,
+                                sourceFields.get(i).getName(), sourceTypeString));
+            }
+        }
+    }
+
     private static void syncSelectList(SqlSelect select, SqlUpdate update) {
         //
         // If a table has N columns and update::SourceExpressionList has size = M
@@ -401,13 +491,28 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
                 throw newValidationError(n, IgniteResource.INSTANCE.correctIntegerLimit(nodeName));
             }
         } else if (n instanceof SqlDynamicParam) {
-            Object param = getDynamicParamValue((SqlDynamicParam) n);
+            SqlDynamicParam dynamicParam = (SqlDynamicParam) n;
+            RelDataType intType = typeFactory.createSqlType(INTEGER);
+
+            // Validate value, if present.
+            if (!isUnspecified(dynamicParam)) {
+                Object param = getDynamicParamValue(dynamicParam);
 
-            if (param instanceof Integer) {
-                if ((Integer) param < 0) {
-                    throw newValidationError(n, IgniteResource.INSTANCE.correctIntegerLimit(nodeName));
+                if (param instanceof Integer) {
+                    if ((Integer) param < 0) {
+                        throw newValidationError(n, IgniteResource.INSTANCE.correctIntegerLimit(nodeName));
+                    }
+                } else {
+                    String actualType = deriveDynamicParamType(dynamicParam).toString();
+                    String expectedType = intType.toString();
+
+                    var err = IgniteResource.INSTANCE.incorrectDynamicParameterType(expectedType, actualType);
+                    throw newValidationError(n, err);
                 }
             }
+
+            // Dynamic parameters are nullable.
+            setDynamicParamType(dynamicParam, typeFactory.createTypeWithNullability(intType, true));
         }
     }
 
@@ -440,22 +545,14 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
     /** {@inheritDoc} */
     @Override
     public RelDataType deriveType(SqlValidatorScope scope, SqlNode expr) {
+        if (expr instanceof SqlDynamicParam) {
+            return deriveDynamicParamType((SqlDynamicParam) expr);
+        }
+
         checkTypesInteroperability(scope, expr);
 
         RelDataType dataType = super.deriveType(scope, expr);
 
-        // If type of dynamic parameter has not been inferred, use a type of its value.
-        RelDataType paramType = expr instanceof SqlDynamicParam
-                ? getDynamicParamType((SqlDynamicParam) expr) : null;
-
-        if (dataType.equals(unknownType) && expr instanceof SqlDynamicParam) {
-            // If paramType is unknown setValidatedNodeType is a no-op.
-            setValidatedNodeType(expr, paramType);
-            return paramType;
-        } else if (!(expr instanceof SqlCall)) {
-            return dataType;
-        }
-
         SqlKind sqlKind = expr.getKind();
         // See the comments below.
         if (!SqlKind.BINARY_COMPARISON.contains(sqlKind)) {
@@ -485,6 +582,37 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
         return dataType;
     }
 
+    /** {@inheritDoc} */
+    @Override public RelDataType getParameterRowType(SqlNode sqlQuery) {
+        // We do not use calcite' version since it is contains a bug,
+        // alreadyVisited visited uses object identity, but rewrites of NULLIF, COALESCE
+        // into dynamic parameters may place the same parameter into multiple positions
+        // in SQL tree.
+        List<RelDataType> types = new ArrayList<>();
+        IntSet alreadyVisited = new IntArraySet(dynamicParameters.size());
+        sqlQuery.accept(
+                new SqlShuttle() {
+                    @Override public SqlNode visit(SqlDynamicParam param) {
+                        if (alreadyVisited.add(param.getIndex())) {
+                            RelDataType type = getValidatedNodeType(param);
+                            types.add(type);
+                        }
+                        return param;
+                    }
+                });
+        return typeFactory.createStructType(
+                types,
+                new AbstractList<String>() {
+                    @Override public String get(int index) {
+                        return "?" + index;
+                    }
+
+                    @Override public int size() {
+                        return types.size();
+                    }
+                });
+    }
+
     /** Check appropriate type cast availability. */
     private void checkTypesInteroperability(SqlValidatorScope scope, SqlNode expr) {
         boolean castOp = expr.getKind() == SqlKind.CAST;
@@ -498,7 +626,8 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
             RelDataType returnType = super.deriveType(scope, ret);
 
             if (first instanceof SqlDynamicParam) {
-                firstType = getDynamicParamType((SqlDynamicParam) first);
+                SqlDynamicParam dynamicParam = (SqlDynamicParam) first;
+                firstType = deriveDynamicParamType(dynamicParam);
             } else {
                 firstType = super.deriveType(scope, first);
             }
@@ -518,7 +647,7 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
 
             if (fromCustomType != null && returnCustomType != null) {
                 // it`s not allowed to convert between different custom types for now.
-                check = equalSansNullability(typeFactory, firstType, returnType);
+                check = SqlTypeUtil.equalSansNullability(typeFactory, firstType, returnType);
             } else if (fromCustomType != null) {
                 check = coercionRules.needToCast(returnType, (IgniteCustomType) fromCustomType);
             } else if (returnCustomType != null) {
@@ -549,7 +678,7 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
     /** Check literal can fit to declared exact numeric type, work only for single literal. */
     private void literalCanFitType(SqlNode expr, RelDataType toType) {
         if (INT_TYPES.contains(toType.getSqlTypeName())) {
-            SqlLiteral literal = Objects.requireNonNullElseGet(litExtractor, LiteralExtractor::new).getLiteral(expr);
+            SqlLiteral literal = litExtractor.getLiteral(expr);
 
             if (literal == null || literal.toValue() == null) {
                 return;
@@ -581,9 +710,10 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
     }
 
     private static class LiteralExtractor extends SqlBasicVisitor<SqlNode> {
-        private @Nullable SqlLiteral extracted = null;
+        private @Nullable SqlLiteral extracted;
 
         private @Nullable SqlLiteral getLiteral(SqlNode expr) {
+            extracted = null;
             try {
                 expr.accept(this);
             } catch (Util.FoundOne e) {
@@ -772,47 +902,93 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
     /** {@inheritDoc} */
     @Override
     protected void inferUnknownTypes(RelDataType inferredType, SqlValidatorScope scope, SqlNode node) {
-        if (node instanceof SqlDynamicParam) {
-            RelDataType result = inferDynamicParamType(inferredType, (SqlDynamicParam) node);
 
-            this.setValidatedNodeType(node, result);
+        // See SqlStdOperatorTable::IS_NULL and  SqlStdOperatorTable::IS_NOT_NULL
+        // Operators use VARCHAR_1024 for argument type inference, so we need to manually fix this.
+        if (node.getKind() == SqlKind.IS_NULL || node.getKind() == SqlKind.IS_NOT_NULL) {
+            SqlCall call = (SqlCall) node;
+
+            if (isUnspecifiedDynamicParam(call.operand(0))) {
+                SqlCallBinding binding = new SqlCallBinding(this, scope, call);
+                String signature = IgniteResource.makeSignature(binding, List.of(unknownType));
+
+                throw binding.newValidationError(IgniteResource.INSTANCE.ambiguousOperator1(signature));
+            }
+        } else if (node.getKind() == SqlKind.IN) {
+            // TypeInference for IN operator fails with
+            // java.lang.UnsupportedOperationException: class org.apache.calcite.sql.SqlNodeList: 1
+            // if the first operand has unknown type.
+            SqlCall call = (SqlCall) node;
+
+            if (isUnspecifiedDynamicParam(call.operand(0))) {
+                SqlDynamicParam dynamicParam = call.operand(0);
+                throw newValidationError(dynamicParam, IgniteResource.INSTANCE.unableToResolveDynamicParameterType());
+            }
+        }
+
+        if (node instanceof SqlDynamicParam) {
+            SqlDynamicParam dynamicParam = (SqlDynamicParam) node;
+            inferDynamicParamType(inferredType, dynamicParam);
         } else {
             super.inferUnknownTypes(inferredType, scope, node);
         }
     }
 
-    private RelDataType inferDynamicParamType(RelDataType inferredType, SqlDynamicParam dynamicParam) {
-        RelDataType type = getDynamicParamType(dynamicParam);
-        RelDataType paramTypeToUse;
+    private void inferDynamicParamType(RelDataType inferredType, SqlDynamicParam dynamicParam) {
+        RelDataType type = deriveDynamicParamType(dynamicParam);
 
         /*
-         * If inferredType is unknown - use a type of dynamic parameter since there is no other source of type information.
+         * If inferredType type is unknown and parameter is not specified, then use unknown type.
+         * Otherwise use parameter type if it set, or inferredType provided by operator's
+         * SqlOperandTypeInference and SqlOperandTypeCheckers.
          *
-         * If parameter's type and the inferredType do not match - use parameter's type.
-         * This makes CAST operations to work correctly. Otherwise cast's operand is going to have
-         * the same type as a target type which is not correct as it
-         * makes every CAST operation eligible to redundant type conversion elimination
-         * at later stages:
-         * E.g: CAST(? AS INTEGER) where ?='hello' operand is going to be inferred as INTEGER
-         * although it is a string.
-         *
-         * In other cases use the inferredType and we rely on type inference rules provided by
-         * operator's SqlOperandTypeInference and SqlOperandTypeCheckers.
+         * If dynamic parameter is an operand of a CAST expression, its type is set to
+         * the type equal to the target type. Although CAST(p:T AS T) is a no-op and later removed at the sql-to-rel conversion phase,
+         * the runtime _should_ perform type conversion of unspecified dynamic parameter values to complete type checking.
          */
 
-        if (inferredType.equals(unknownType) || (!equalSansNullability(type, inferredType))) {
-            paramTypeToUse = type;
+        if (inferredType == unknownType && type == unknownType) {
+            setDynamicParamType(dynamicParam, unknownType);
+        } else if (type != unknownType) {
+            setDynamicParamType(dynamicParam, type);
         } else {
-            paramTypeToUse = inferredType;
+            // Make sure to set nullability to true, so types are nullable in all cases.
+            RelDataType nullableType = typeFactory.createTypeWithNullability(inferredType, true);
+            setDynamicParamType(dynamicParam, nullableType);
         }
+    }
+
+    /** Derives the type of the given dynamic parameter. */
+    private RelDataType deriveDynamicParamType(SqlDynamicParam dynamicParam) {
+        dynamicParamNodes.put(dynamicParam, dynamicParam);
+
+        if (isUnspecified(dynamicParam)) {
+            RelDataType validatedNodeType = getValidatedNodeTypeIfKnown(dynamicParam);
+
+            if (validatedNodeType == null) {
+                setDynamicParamType(dynamicParam, unknownType);
+                return unknownType;
+            } else {
+                setDynamicParamType(dynamicParam, validatedNodeType);
+                return validatedNodeType;
+            }
+        } else {
+            Object value = getDynamicParamValue(dynamicParam);
+            RelDataType parameterType = deriveTypeFromDynamicParamValue(value);
 
-        return typeFactory.createTypeWithNullability(paramTypeToUse, true);
+            // Dynamic parameters are always nullable.
+            // Otherwise it seem to cause "Conversion to relational algebra failed to preserve datatypes" errors
+            // in some cases.
+            RelDataType nullableType = typeFactory.createTypeWithNullability(parameterType, true);
+
+            setDynamicParamType(dynamicParam, nullableType);
+
+            return nullableType;
+        }
     }
 
-    /** Returns type of the given dynamic parameter. */
-    RelDataType getDynamicParamType(SqlDynamicParam dynamicParam) {
-        IgniteTypeFactory typeFactory = (IgniteTypeFactory) this.getTypeFactory();
-        Object value = getDynamicParamValue(dynamicParam);
+    private RelDataType deriveTypeFromDynamicParamValue(@Nullable Object value) {
+        IgniteTypeFactory typeFactory = typeFactory();
 
         RelDataType parameterType;
         // IgniteCustomType: first we must check whether dynamic parameter is a custom data type.
@@ -825,38 +1001,142 @@ public class IgniteSqlValidator extends SqlValidatorImpl {
             parameterType = typeFactory.toSql(typeFactory.createType(value.getClass()));
         }
 
-        // Dynamic parameters are always nullable.
-        // Otherwise it seem to cause "Conversion to relational algebra failed to preserve datatypes" errors
-        // in some cases.
-        return typeFactory.createTypeWithNullability(parameterType, true);
+        return parameterType;
     }
 
+    /** if dynamic parameter is not specified, set its type to the provided type, otherwise return the type of its value. */
+    RelDataType resolveDynamicParameterType(SqlDynamicParam dynamicParam, RelDataType contextType) {
+        if (isUnspecified(dynamicParam)) {
+            RelDataType nullableContextType = typeFactory.createTypeWithNullability(contextType, true);
+
+            setDynamicParamType(dynamicParam, nullableContextType);
+
+            return nullableContextType;
+        } else {
+            return deriveDynamicParamType(dynamicParam);
+        }
+    }
+
+    private void setDynamicParamType(SqlDynamicParam dynamicParam, RelDataType dataType) {
+        setValidatedNodeType(dynamicParam, dataType);
+
+        setDynamicParamResolvedType(dynamicParam, dataType);
+    }
+
+    /**
+     * Returns the value of the given dynamic parameter. If the value is not specified,
+     * this method throws {@link IllegalArgumentException}.
+     */
+    @Nullable
     private Object getDynamicParamValue(SqlDynamicParam dynamicParam) {
-        Object value = dynamicParamValues[dynamicParam.getIndex()];
-        // save dynamic parameter for later validation.
-        dynamicParamNodes[dynamicParam.getIndex()] = dynamicParam;
-        return value;
+        int index = dynamicParam.getIndex();
+        DynamicParamState paramState = dynamicParameters.computeIfAbsent(index, (i) -> new DynamicParamState());
+        Object value = paramState.value;
+        if (!paramState.hasValue) {
+            throw new IllegalArgumentException(format("Value of dynamic parameter#{} is not specified", index));
+        } else {
+            return value;
+        }
     }
 
     private void validateInferredDynamicParameters() {
-        // Derived types of dynamic parameters should not change (current type inference behavior).
+        for (int i = 0; i < dynamicParameters.size(); i++) {
+            DynamicParamState paramState = dynamicParameters.get(i);
 
-        for (int i = 0; i < dynamicParamValues.length; i++) {
-            SqlDynamicParam param = dynamicParamNodes[i];
-            assert param != null : format("Dynamic parameter#{} has not been checked", i);
+            if (paramState == null || paramState.resolvedType == null || paramState.node == null) {
+                throw new AssertionError("Dynamic parameter has not been validated: " + i);
+            } else if (paramState.resolvedType == unknownType) {
+                throw newValidationError(paramState.node, IgniteResource.INSTANCE.unableToResolveDynamicParameterType());
+            }
+        }
+
+        // Ensure that all nodes for i-th parameter have the same type.
+        for (SqlDynamicParam node : dynamicParamNodes.keySet()) {
+            int i = node.getIndex();
+            DynamicParamState state = dynamicParameters.get(i);
+
+            if (!state.hasValue) {
+                continue;
+            }
 
-            RelDataType paramType = getDynamicParamType(param);
-            RelDataType derivedType = getValidatedNodeType(param);
+            Object value = state.value;
+            RelDataType valueType = deriveTypeFromDynamicParamValue(value);
+            RelDataType derivedType = getValidatedNodeTypeIfKnown(node);
+            RelDataType paramType = state.resolvedType;
 
-            // We can check for nullability, but it was set to true.
-            if (!equalSansNullability(derivedType, paramType)) {
+            // Ensure that derived type matches parameter's value.
+            if (!SqlTypeUtil.equalSansNullability(derivedType, valueType)) {
                 String message = format(
-                        "Type of dynamic parameter#{} does not match. Expected: {} derived: {}", i, paramType.getFullTypeString(),
-                        derivedType.getFullTypeString()
+                        "Type of dynamic parameter#{} value type does not match. Expected: {} derived: {}",
+                        i, valueType.getFullTypeString(), derivedType.getFullTypeString()
                 );
 
                 throw new AssertionError(message);
             }
+
+            if (!Objects.equals(paramType, derivedType)) {
+                String message = format(
+                        "Type of dynamic parameter node#{} does not match. Expected: {} derived: {}", i, paramType.getFullTypeString(),
+                        derivedType != null ? derivedType.getFullTypeString() : null
+                );
+
+                throw new AssertionError(message);
+            }
+        }
+    }
+
+    private void setDynamicParamResolvedType(SqlDynamicParam param, RelDataType type) {
+        int index = param.getIndex();
+        DynamicParamState state = dynamicParameters.computeIfAbsent(index, (i) -> new DynamicParamState());
+
+        state.node = param;
+        state.resolvedType = type;
+    }
+
+    /** Returns {@code true} if the given dynamic parameter has no value set. */
+    public boolean isUnspecified(SqlDynamicParam param) {
+        int index = param.getIndex();
+        DynamicParamState state = dynamicParameters.computeIfAbsent(index, (i) -> new DynamicParamState());
+
+        return !state.hasValue;
+    }
+
+    /** Returns {@code true} if the given node is dynamic parameter that has no value set. */
+    public boolean isUnspecifiedDynamicParam(SqlNode node) {
+        if (node.getKind() != SqlKind.DYNAMIC_PARAM) {
+            return false;
+        } else {
+            return isUnspecified((SqlDynamicParam) node);
+        }
+    }
+
+    private static final class DynamicParamState {
+
+        final Object value;
+
+        final boolean hasValue;
+
+        SqlDynamicParam node;
+
+        /**
+         * Resolved type of a dynamic parameter.
+         *
+         * <ul>
+         *    <li>{@code null} - parameter has not been checked - this is a bug.</li>
+         *    <li>{@code unknownType} means the type of this parameter has not been resolved due to ambiguity.</li>
+         *    <li>Otherwise contains a type of a parameter.</li>
+         * </ul>
+         */
+        RelDataType resolvedType;
+
+        private DynamicParamState(@Nullable Object value) {
+            this.value = value;
+            this.hasValue = true;
+        }
+
+        private DynamicParamState() {
+            this.value = null;
+            this.hasValue = false;
         }
     }
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteTypeCoercion.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteTypeCoercion.java
index 5ddb4efce7..c5931dab69 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteTypeCoercion.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/IgniteTypeCoercion.java
@@ -23,6 +23,7 @@ import static org.apache.calcite.util.Static.RESOURCE;
 
 import java.nio.charset.Charset;
 import java.util.ArrayDeque;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Objects;
@@ -38,10 +39,13 @@ import org.apache.calcite.sql.SqlDataTypeSpec;
 import org.apache.calcite.sql.SqlDynamicParam;
 import org.apache.calcite.sql.SqlFunction;
 import org.apache.calcite.sql.SqlIdentifier;
+import org.apache.calcite.sql.SqlInsert;
 import org.apache.calcite.sql.SqlKind;
 import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.sql.SqlOperator;
+import org.apache.calcite.sql.SqlSelect;
+import org.apache.calcite.sql.SqlUpdate;
 import org.apache.calcite.sql.SqlUtil;
 import org.apache.calcite.sql.fun.SqlStdOperatorTable;
 import org.apache.calcite.sql.parser.SqlParserPos;
@@ -93,8 +97,8 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
         }
 
         SqlValidatorScope scope = binding.getScope();
-        RelDataType leftType = validator.deriveType(scope, call.operand(0));
-        RelDataType rightType = validator.deriveType(scope, call.operand(1));
+        RelDataType leftType = binding.getOperandType(0);
+        RelDataType rightType = binding.getOperandType(1);
 
         //
         // binaryComparisonCoercion that makes '1' > 1 work, may introduce some inconsistent results
@@ -151,7 +155,16 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
     public boolean binaryArithmeticCoercion(SqlCallBinding binding) {
         ContextStack ctxStack = contextStack.get();
         Context ctx = ctxStack.push(ContextType.UNSPECIFIED);
+
         try {
+            // Called from CompositeOperandTypeChecker that does not care whether this is a binary arithmetic or not.
+            if (binding.getOperandCount() == 2 && SqlKind.BINARY_ARITHMETIC.contains(binding.getCall().getKind())) {
+                RelDataType leftType = binding.getOperandType(0);
+                RelDataType rightType = binding.getOperandType(1);
+
+                validateBinaryOperation(binding, leftType, rightType);
+            }
+
             return super.binaryArithmeticCoercion(binding);
         } finally {
             ctxStack.pop(ctx);
@@ -164,6 +177,8 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
         ContextStack ctxStack = contextStack.get();
         Context ctx = ctxStack.push(ContextType.UNSPECIFIED);
         try {
+            validateFunctionOperands(binding, operandTypes, expectedFamilies);
+
             return super.builtinFunctionCoercion(binding, operandTypes, expectedFamilies);
         } finally {
             ctxStack.pop(ctx);
@@ -238,6 +253,8 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
         ContextStack ctxStack = contextStack.get();
         Context ctx = ctxStack.push(ContextType.MODIFY);
         try {
+            validateDynamicParametersInModify(scope, targetRowType, query);
+
             return super.querySourceCoercion(scope, sourceRowType, targetRowType, query);
         } finally {
             ctxStack.pop(ctx);
@@ -529,6 +546,103 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
         return SqlStdOperatorTable.CAST.createCall(SqlParserPos.ZERO, node, targetDataType);
     }
 
+    /**
+     * Validate dynamic parameters in binary operator.
+     */
+    private void validateBinaryOperation(SqlCallBinding binding, RelDataType type1, RelDataType type2) {
+        SqlValidator validator = binding.getValidator();
+        boolean lhsUnknown = validator.getUnknownType() == type1;
+        boolean rhsUnknown = validator.getUnknownType() == type2;
+
+        if (lhsUnknown && rhsUnknown) {
+            String signature = IgniteResource.makeSignature(binding, type1, type2);
+            throw binding.newValidationError(IgniteResource.INSTANCE.ambiguousOperator1(signature));
+        } else if (lhsUnknown) {
+            RelDataType nullableType = typeFactory.createTypeWithNullability(type2, true);
+            validator.setValidatedNodeType(binding.operand(0), nullableType);
+        } else if (rhsUnknown) {
+            RelDataType nullableType = typeFactory.createTypeWithNullability(type1, true);
+            validator.setValidatedNodeType(binding.operand(1), nullableType);
+        }
+    }
+
+    /**
+     * Validates dynamic parameters passed as function arguments.
+     */
+    private void validateFunctionOperands(SqlCallBinding binding, List<RelDataType> operandTypes, List<SqlTypeFamily> expectedFamilies) {
+        // This method is also called from SqlBinaryOperator's CompositeOperandTypeChecker
+        // in case fo binary operators.
+        IgniteSqlValidator validator = (IgniteSqlValidator) binding.getValidator();
+
+        for (int i = 0; i < binding.getOperandCount(); i++) {
+            SqlNode operand = binding.getCall().operand(i);
+
+            if (validator.isUnspecifiedDynamicParam(operand)) {
+                SqlTypeFamily expectedTypeFamily = expectedFamilies.get(i);
+
+                if (expectedTypeFamily.getTypeNames().size() > 1) {
+                    String signature = IgniteResource.makeSignature(binding, operandTypes);
+                    String allowedSignatures = binding.getOperator().getAllowedSignatures();
+
+                    throw binding.newValidationError(IgniteResource.INSTANCE.ambiguousOperator2(signature, allowedSignatures));
+                }
+            }
+        }
+    }
+
+    /**
+     * Validates parameters in INSERT/UPDATE.
+     */
+    private void validateDynamicParametersInModify(@Nullable SqlValidatorScope scope, RelDataType targetRowType, SqlNode query) {
+        ContextType contextType;
+        List<List<SqlNode>> sourceLists;
+
+        if (query instanceof SqlInsert) {
+            SqlInsert insert = (SqlInsert) query;
+
+            if (insert.getSource() instanceof SqlSelect) {
+                // NOT MATCHED THEN arm of a MERGE statement.
+                SqlSelect select = (SqlSelect) insert.getSource();
+                sourceLists = List.of(select.getSelectList());
+            } else  {
+                // Basic INSERT INTO ... VALUES (...).
+                SqlCall values = (SqlCall) insert.getSource();
+                assert values.getKind() == SqlKind.VALUES : "Unexpected source node for INSERT " + values;
+                List<List<SqlNode>> rows = new ArrayList<>(values.getOperandList().size());
+
+                for (SqlNode rowNode : values.getOperandList()) {
+                    SqlCall row = (SqlCall) rowNode;
+                    rows.add(row.getOperandList());
+                }
+
+                sourceLists = rows;
+            }
+
+            contextType = ContextType.INSERT;
+        } else if (query instanceof SqlUpdate) {
+            SqlUpdate update = (SqlUpdate) query;
+            sourceLists = List.of(update.getSourceExpressionList());
+
+            contextType = ContextType.MODIFY;
+        } else {
+            throw new AssertionError("Encountered unexpected SQL node during dynamic parameter validation: " + query);
+        }
+
+        for (List<SqlNode> sourceList : sourceLists) {
+            for (int i = 0; i < sourceList.size(); i++) {
+                SqlNode node = sourceList.get(i);
+
+                if (node.getKind() == SqlKind.DYNAMIC_PARAM) {
+                    SqlDynamicParam dynamicParam = (SqlDynamicParam) node;
+                    RelDataType targetType = targetRowType.getFieldList().get(i).getType();
+                    IgniteSqlValidator validator1 = (IgniteSqlValidator) scope.getValidator();
+
+                    validateAssignment(dynamicParam, targetType, contextType, validator1);
+                }
+            }
+        }
+    }
+
     /**
      * Validates dynamic parameters in binary comparison operation.
      */
@@ -538,12 +652,30 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
         SqlNode lhs = binding.operand(0);
         SqlNode rhs = binding.operand(1);
 
+        boolean lhsUnknown = validator.isUnspecifiedDynamicParam(lhs);
+        boolean rhsUnknown = validator.isUnspecifiedDynamicParam(rhs);
+
+        if (lhsUnknown && rhsUnknown) {
+            String signature = IgniteResource.makeSignature(binding, leftType, rightType);
+            throw binding.newValidationError(IgniteResource.INSTANCE.ambiguousOperator1(signature));
+        }
+
         if (lhs instanceof SqlDynamicParam) {
-            validateOperand((SqlDynamicParam) lhs, rightType, binding.getOperator(), validator);
+            if (rhsUnknown) {
+                RelDataType nullableType = typeFactory.createTypeWithNullability(rightType, true);
+                validator.setValidatedNodeType(binding.operand(0), nullableType);
+            } else {
+                validateOperand((SqlDynamicParam) lhs, rightType, binding.getOperator(), validator);
+            }
         }
 
         if (rhs instanceof SqlDynamicParam) {
-            validateOperand((SqlDynamicParam) rhs, leftType, binding.getOperator(), validator);
+            if (lhsUnknown) {
+                RelDataType nullableType = typeFactory.createTypeWithNullability(leftType, true);
+                validator.setValidatedNodeType(binding.operand(1), nullableType);
+            } else {
+                validateOperand((SqlDynamicParam) rhs, leftType, binding.getOperator(), validator);
+            }
         }
     }
 
@@ -580,7 +712,7 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
         // (and we have no such information here).
 
         if (ctxType == ContextType.SET_OP) {
-            RelDataType paramType = validator.getDynamicParamType(dynamicParam);
+            RelDataType paramType = validator.resolveDynamicParameterType(dynamicParam, targetType);
             return TypeUtils.typeFamiliesAreCompatible(typeFactory, targetType, paramType);
         } else {
             validateAssignment(dynamicParam, targetType, ctxType, validator);
@@ -590,8 +722,7 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
 
     // TODO: https://issues.apache.org/jira/browse/IGNITE-19721 - move this check to SqlValidator (if possible).
     private void validateAssignment(SqlDynamicParam node, RelDataType targetType, ContextType ctxType, IgniteSqlValidator validator) {
-
-        RelDataType paramType = validator.getDynamicParamType(node);
+        RelDataType paramType = validator.resolveDynamicParameterType(node, targetType);
         boolean compatible = TypeUtils.typeFamiliesAreCompatible(typeFactory, targetType, paramType);
         if (compatible) {
             return;
@@ -619,14 +750,19 @@ public class IgniteTypeCoercion extends TypeCoercionImpl {
     // TODO: https://issues.apache.org/jira/browse/IGNITE-19721 - move this check to SqlValidator (if possible).
     private void validateOperand(SqlDynamicParam node, RelDataType targetType, SqlOperator operator, IgniteSqlValidator validator) {
 
-        RelDataType paramType = validator.getDynamicParamType(node);
+        RelDataType paramType = validator.resolveDynamicParameterType(node, targetType);
         boolean compatible = TypeUtils.typeFamiliesAreCompatible(typeFactory, targetType, paramType);
         if (compatible) {
             return;
         }
 
-        var ex = IgniteResource.INSTANCE.operationRequiresExplicitCast(operator.getName());
-        throw SqlUtil.newContextException(node.getParserPosition(), ex);
+        if (validator.isUnspecified(node)) {
+            var ex = IgniteResource.INSTANCE.ambiguousOperator1(operator.getName());
+            throw SqlUtil.newContextException(node.getParserPosition(), ex);
+        } else {
+            var ex = IgniteResource.INSTANCE.operationRequiresExplicitCast(operator.getName());
+            throw SqlUtil.newContextException(node.getParserPosition(), ex);
+        }
     }
 
     /**
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/MultiStepPlan.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/MultiStepPlan.java
index e1978e34e2..0ed45ad47a 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/MultiStepPlan.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/MultiStepPlan.java
@@ -35,12 +35,15 @@ public class MultiStepPlan implements QueryPlan {
 
     private final IgniteRel root;
 
+    private final ParameterMetadata parameterMetadata;
+
     /** Constructor. */
-    public MultiStepPlan(PlanId id, SqlQueryType type, IgniteRel root, ResultSetMetadata meta) {
+    public MultiStepPlan(PlanId id, SqlQueryType type, IgniteRel root, ResultSetMetadata meta, ParameterMetadata parameterMetadata) {
         this.id = id;
         this.type = type;
         this.root = root;
         this.meta = meta;
+        this.parameterMetadata = parameterMetadata;
     }
 
     /** {@inheritDoc} */
@@ -55,6 +58,12 @@ public class MultiStepPlan implements QueryPlan {
         return meta;
     }
 
+    /** {@inheritDoc} */
+    @Override
+    public ParameterMetadata parameterMetadata() {
+        return parameterMetadata;
+    }
+
     /** {@inheritDoc} */
     @Override
     @Nullable
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/QueryPlan.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ParameterMetadata.java
similarity index 58%
copy from modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/QueryPlan.java
copy to modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ParameterMetadata.java
index 95d752d1b2..2fe60c909d 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/QueryPlan.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ParameterMetadata.java
@@ -17,26 +17,28 @@
 
 package org.apache.ignite.internal.sql.engine.prepare;
 
-import org.apache.ignite.internal.sql.engine.SqlQueryType;
-import org.apache.ignite.sql.ResultSetMetadata;
+import java.util.List;
+import org.apache.ignite.internal.tostring.S;
 
 /**
- * QueryPlan interface.
- * TODO Documentation https://issues.apache.org/jira/browse/IGNITE-15859
+ * Metadata for dynamic parameters.
  */
-public interface QueryPlan {
-    /**
-     * Get a unique identifier of a plan.
-     */
-    PlanId id();
+public final class ParameterMetadata {
 
-    /**
-     * Get query type, or {@code null} if this is a fragment.
-     */
-    SqlQueryType type();
+    private final List<ParameterType> parameterTypes;
 
-    /**
-     * Get fields metadata.
-     */
-    ResultSetMetadata metadata();
+    /** Constructor. */
+    public ParameterMetadata(List<ParameterType> parameterTypes) {
+        this.parameterTypes = parameterTypes;
+    }
+
+    /** Return metadata for each parameter. */
+    public List<ParameterType> parameterTypes() {
+        return parameterTypes;
+    }
+
+    @Override
+    public String toString() {
+        return S.toString(ParameterMetadata.class, this, "parametersTypes", parameterTypes);
+    }
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ParameterType.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ParameterType.java
new file mode 100644
index 0000000000..8afff2cf53
--- /dev/null
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/ParameterType.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.sql.engine.prepare;
+
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.ignite.internal.sql.engine.util.TypeUtils;
+import org.apache.ignite.internal.tostring.S;
+import org.apache.ignite.sql.ColumnMetadata;
+import org.apache.ignite.sql.ColumnType;
+
+/**
+ * Dynamic parameter metadata.
+ */
+public final class ParameterType {
+
+    private final ColumnType columnType;
+
+    private final int precision;
+
+    private final int scale;
+
+    private final boolean nullable;
+
+    /** Constructor. */
+    public ParameterType(ColumnType columnType, int precision, int scale, boolean nullable) {
+        this.columnType = columnType;
+        this.precision = precision;
+        this.scale = scale;
+        this.nullable = nullable;
+    }
+
+    /**
+     * {@link ColumnType} of this parameter.
+     */
+    public ColumnType columnType() {
+        return columnType;
+    }
+
+    /**
+     * Returns precision of parameter type, if applicable.
+     *
+     * @see ColumnMetadata#precision()
+     */
+    public int precision() {
+        return precision;
+    }
+
+    /**
+     * Returns scale of this parameter type, if applicable.
+     *
+     * @see ColumnMetadata#scale()
+     */
+    public int scale() {
+        return scale;
+    }
+
+    /** Returns {@code true} if parameter is nullable and {@code false} otherwise. */
+    public boolean nullable() {
+        return nullable;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return S.toString(this);
+    }
+
+    /** Creates parameter metadata from the given logical type. */
+    static ParameterType fromRelDataType(RelDataType type) {
+        ColumnType columnType = TypeUtils.columnType(type);
+        assert columnType != null : "No column type for " + type;
+
+        int precision = columnType.lengthAllowed() || columnType.precisionAllowed()
+                ? type.getPrecision()
+                : ColumnMetadata.UNDEFINED_PRECISION;
+
+        int scale = columnType.scaleAllowed() ? type.getScale() : ColumnMetadata.UNDEFINED_SCALE;
+
+        return new ParameterType(columnType, precision, scale, type.isNullable());
+    }
+}
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PlanningContext.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PlanningContext.java
index c74d75191e..275bb580ec 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PlanningContext.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PlanningContext.java
@@ -17,6 +17,8 @@
 
 package org.apache.ignite.internal.sql.engine.prepare;
 
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMaps;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.Function;
 import org.apache.calcite.plan.Context;
@@ -58,17 +60,21 @@ public final class PlanningContext implements Context {
     /** Flag indicated if planning has been canceled due to timeout. */
     private boolean timeouted = false;
 
+    private final Int2ObjectMap<Object> parameters;
+
     /** Private constructor, used by a builder. */
     private PlanningContext(
             Context parentCtx,
             String qry,
-            long plannerTimeout
+            long plannerTimeout,
+            Int2ObjectMap<Object> parameters
     ) {
         this.qry = qry;
         this.parentCtx = parentCtx;
 
         startTs = FastTimestamps.coarseCurrentTimeMillis();
         this.plannerTimeout = plannerTimeout;
+        this.parameters = parameters;
     }
 
     /** Get framework config. */
@@ -82,8 +88,8 @@ public final class PlanningContext implements Context {
     }
 
     /** Get query parameters. */
-    public Object[] parameters() {
-        return unwrap(BaseQueryContext.class).parameters();
+    public Int2ObjectMap<Object> parameters() {
+        return parameters;
     }
 
     // Helper methods
@@ -185,34 +191,46 @@ public final class PlanningContext implements Context {
      * Planner context builder.
      */
     public static class Builder {
+
         private Context parentCtx = Contexts.empty();
 
         private String qry;
 
         private long plannerTimeout;
 
+        private Int2ObjectMap<Object> parameters = Int2ObjectMaps.emptyMap();
+
+        /** Parent context. */
         public Builder parentContext(Context parentCtx) {
             this.parentCtx = parentCtx;
             return this;
         }
 
+        /** SQL statement. */
         public Builder query(String qry) {
             this.qry = qry;
             return this;
         }
 
+        /** Planner timeout. */
         public Builder plannerTimeout(long plannerTimeout) {
             this.plannerTimeout = plannerTimeout;
             return this;
         }
 
+        /** Values of dynamic parameters to assist with type inference. */
+        public Builder parameters(Int2ObjectMap<Object> parameters) {
+            this.parameters = parameters;
+            return this;
+        }
+
         /**
          * Builds planner context.
          *
          * @return Planner context.
          */
         public PlanningContext build() {
-            return new PlanningContext(parentCtx, qry, plannerTimeout);
+            return new PlanningContext(parentCtx, qry, plannerTimeout, parameters);
         }
     }
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareService.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareService.java
index 8c0de1a4b7..769bedfc20 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareService.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareService.java
@@ -26,6 +26,7 @@ import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
  * Preparation service that accepts an AST of the query and returns a prepared query plan.
  */
 public interface PrepareService extends LifecycleAware {
+
     /**
      * Prepare query plan.
      *
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImpl.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImpl.java
index beff874de2..d828777231 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImpl.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImpl.java
@@ -23,7 +23,7 @@ import static org.apache.ignite.internal.sql.engine.trait.TraitUtils.distributio
 import static org.apache.ignite.lang.ErrorGroups.Sql.PLANNING_TIMEOUT_ERR;
 
 import java.util.ArrayList;
-import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
@@ -60,6 +60,7 @@ import org.apache.ignite.internal.sql.engine.util.cache.CacheFactory;
 import org.apache.ignite.internal.sql.metrics.SqlPlanCacheMetricSource;
 import org.apache.ignite.internal.storage.DataStorageManager;
 import org.apache.ignite.internal.thread.NamedThreadFactory;
+import org.apache.ignite.internal.type.NativeTypeSpec;
 import org.apache.ignite.internal.util.ExceptionUtils;
 import org.apache.ignite.lang.ErrorGroups.Sql;
 import org.apache.ignite.sql.ColumnMetadata;
@@ -79,6 +80,10 @@ public class PrepareServiceImpl implements PrepareService {
             new ColumnMetadataImpl("ROWCOUNT", ColumnType.INT64,
                     ColumnMetadata.UNDEFINED_PRECISION, ColumnMetadata.UNDEFINED_SCALE, false, null)));
 
+    /** Parameter metadata. */
+    private static final ParameterMetadata EMPTY_PARAMETER_METADATA =
+            new ParameterMetadata(Collections.emptyList());
+
     /** Default planner timeout, in ms. */
     public static final long DEFAULT_PLANNER_TIMEOUT = 15000L;
 
@@ -192,6 +197,7 @@ public class PrepareServiceImpl implements PrepareService {
                 .parentContext(ctx)
                 .query(parsedResult.originalQuery())
                 .plannerTimeout(plannerTimeout)
+                .parameters(Commons.arrayToMap(ctx.parameters()))
                 .build();
 
         result = prepareAsync0(parsedResult, planningContext);
@@ -280,9 +286,15 @@ public class PrepareServiceImpl implements PrepareService {
     }
 
     private CompletableFuture<QueryPlan> prepareQuery(ParsedResult parsedResult, PlanningContext ctx) {
-        CacheKey key = createCacheKey(parsedResult, ctx);
+        CompletableFuture<QueryPlan> f = getPlanIfParameterHaveValues(parsedResult, ctx);
+
+        if (f != null) {
+            return f;
+        }
+
+        // First validate statement
 
-        CompletableFuture<QueryPlan> planFut = cache.get(key, k -> CompletableFuture.supplyAsync(() -> {
+        CompletableFuture<ValidStatement<ValidationResult>> validFut = CompletableFuture.supplyAsync(() -> {
             IgnitePlanner planner = ctx.planner();
 
             SqlNode sqlNode = parsedResult.parsedTree();
@@ -292,20 +304,38 @@ public class PrepareServiceImpl implements PrepareService {
             // Validate
             ValidationResult validated = planner.validateAndGetTypeMetadata(sqlNode);
 
-            SqlNode validatedNode = validated.sqlNode();
+            // Get parameter metadata.
+            RelDataType parameterRowType = planner.getParameterRowType();
+            ParameterMetadata parameterMetadata = createParameterMetadata(parameterRowType);
+
+            return new ValidStatement<>(parsedResult, validated, parameterMetadata);
+        }, planningPool);
+
+        return validFut.thenCompose(stmt -> {
+            // Use parameter metadata to compute a cache key.
+            CacheKey key = createCacheKeyFromParameterMetadata(stmt.parsedResult, ctx, stmt.parameterMetadata);
+
+            CompletableFuture<QueryPlan> planFut = cache.get(key, k -> CompletableFuture.supplyAsync(() -> {
+                IgnitePlanner planner = ctx.planner();
+
+                ValidationResult validated = stmt.value;
+                ParameterMetadata parameterMetadata = stmt.parameterMetadata;
 
-            IgniteRel igniteRel = optimize(validatedNode, planner);
+                SqlNode validatedNode = validated.sqlNode();
 
-            // cluster keeps a lot of cached stuff that won't be used anymore.
-            // In order let GC collect that, let's reattach tree to an empty cluster
-            // before storing tree in plan cache
-            IgniteRel clonedTree = Cloner.clone(igniteRel, Commons.emptyCluster());
+                IgniteRel igniteRel = optimize(validatedNode, planner);
 
-            return new MultiStepPlan(nextPlanId(), SqlQueryType.QUERY, clonedTree,
-                    resultSetMetadata(validated.dataType(), validated.origins(), validated.aliases()));
-        }, planningPool));
+                // cluster keeps a lot of cached stuff that won't be used anymore.
+                // In order let GC collect that, let's reattach tree to an empty cluster
+                // before storing tree in plan cache
+                IgniteRel clonedTree = Cloner.clone(igniteRel, Commons.emptyCluster());
 
-        return planFut.thenApply(Function.identity());
+                ResultSetMetadata resultSetMetadata = resultSetMetadata(validated.dataType(), validated.origins(), validated.aliases());
+                return new MultiStepPlan(nextPlanId(), SqlQueryType.QUERY, clonedTree, resultSetMetadata, parameterMetadata);
+            }, planningPool));
+
+            return planFut.thenApply(Function.identity());
+        });
     }
 
     private PlanId nextPlanId() {
@@ -313,9 +343,13 @@ public class PrepareServiceImpl implements PrepareService {
     }
 
     private CompletableFuture<QueryPlan> prepareDml(ParsedResult parsedResult, PlanningContext ctx) {
-        var key = createCacheKey(parsedResult, ctx);
+        // If a caller passes all the parameters, then get parameter types and check to see whether a plan future already exists.
+        CompletableFuture<QueryPlan> f = getPlanIfParameterHaveValues(parsedResult, ctx);
+        if (f != null) {
+            return f;
+        }
 
-        CompletableFuture<QueryPlan> planFut = cache.get(key, k -> CompletableFuture.supplyAsync(() -> {
+        CompletableFuture<ValidStatement<SqlNode>> validFut = CompletableFuture.supplyAsync(() -> {
             IgnitePlanner planner = ctx.planner();
 
             SqlNode sqlNode = parsedResult.parsedTree();
@@ -325,27 +359,135 @@ public class PrepareServiceImpl implements PrepareService {
             // Validate
             SqlNode validatedNode = planner.validate(sqlNode);
 
-            // Convert to Relational operators graph
-            IgniteRel igniteRel = optimize(validatedNode, planner);
+            // Get parameter metadata.
+            RelDataType parameterRowType = planner.getParameterRowType();
+            ParameterMetadata parameterMetadata = createParameterMetadata(parameterRowType);
+
+            return new ValidStatement<>(parsedResult, validatedNode, parameterMetadata);
+        }, planningPool);
+
+        // Optimize
+
+        return validFut.thenCompose(stmt -> {
+            // Use parameter metadata to compute a cache key.
+            CacheKey key = createCacheKeyFromParameterMetadata(stmt.parsedResult, ctx, stmt.parameterMetadata);
+
+            CompletableFuture<QueryPlan> planFut = cache.get(key, k -> CompletableFuture.supplyAsync(() -> {
+                IgnitePlanner planner = ctx.planner();
+
+                SqlNode validatedNode = stmt.value;
+                ParameterMetadata parameterMetadata = stmt.parameterMetadata;
+
+                // Convert to Relational operators graph
+                IgniteRel igniteRel = optimize(validatedNode, planner);
+
+                // cluster keeps a lot of cached stuff that won't be used anymore.
+                // In order let GC collect that, let's reattach tree to an empty cluster
+                // before storing tree in plan cache
+                IgniteRel clonedTree = Cloner.clone(igniteRel, Commons.emptyCluster());
+
+                return new MultiStepPlan(nextPlanId(), SqlQueryType.DML, clonedTree, DML_METADATA, parameterMetadata);
+            }, planningPool));
+
+            return planFut.thenApply(Function.identity());
+        });
+    }
+
+    /**
+     * Tries to find a prepared plan if all parameters are set.
+     *
+     * <p>This method relies on the fact that if parameter is specified, it's type does not change during the validation.
+     * Given the following query: SELECT * FROM t WHERE int_key = ?0, the validator assigns type {@code INTEGER} to ?0,
+     * regardless whether prepare is called with parameter values (type hints) or not:
+     * <ul>
+     *     <li>If parameter value (type hint) is int, then the validator returns the same plan</li>
+     *     <li>if type hint is not an int, then the validator return different plan with different parameter metadata.</li>
+     * </ul>
+     *
+     * <p>Because of that we can optimistically create a cache key, if all parameters are set.
+     *
+     * <p>If some parameters are not, always returns {@code null}.
+     */
+    @Nullable
+    private CompletableFuture<QueryPlan> getPlanIfParameterHaveValues(ParsedResult parsedResult, PlanningContext ctx) {
+        // If a caller passes all the parameters, then get parameter types and check to see whether a plan future already exists.
+
+        CacheKey cacheKey = tryCreateCacheKeyFromParameterValues(parsedResult, ctx);
+        if (cacheKey != null) {
+            CompletableFuture<QueryPlan> f = cache.get(cacheKey);
+            if (f != null) {
+                return f;
+            }
+        }
+        return null;
+    }
+
+    @Nullable
+    private static CacheKey tryCreateCacheKeyFromParameterValues(ParsedResult parsedResult, PlanningContext ctx) {
+
+        Map<Integer, Object> parameters = ctx.parameters();
+
+        int maxParamNum = 0;
+        for (Integer key : parameters.keySet()) {
+            maxParamNum = Math.max(maxParamNum, key);
+        }
 
-            // cluster keeps a lot of cached stuff that won't be used anymore.
-            // In order let GC collect that, let's reattach tree to an empty cluster
-            // before storing tree in plan cache
-            IgniteRel clonedTree = Cloner.clone(igniteRel, Commons.emptyCluster());
+        for (int i = 0; i < maxParamNum; i++) {
+            if (!parameters.containsKey(i)) {
+                // Some parameters are not specified,
+                // we do not known the their type and we can not create a cache key.
+                return null;
+            }
+        }
 
-            return new MultiStepPlan(nextPlanId(), SqlQueryType.DML, clonedTree, DML_METADATA);
-        }, planningPool));
+        // If parameters type are known, they do not change and we can create a cache key.
+        // See IgniteSqlValidator::validateInferredDynamicParameters
 
-        return planFut.thenApply(Function.identity());
+        boolean distributed = distributionPresent(ctx.config().getTraitDefs());
+        int catalogVersion = ctx.unwrap(BaseQueryContext.class).schemaVersion();
+        ColumnType[] paramTypes;
+
+        if (parameters.isEmpty()) {
+            paramTypes = new ColumnType[0];
+        } else {
+            ColumnType[] result = new ColumnType[parameters.size()];
+
+            for (Map.Entry<Integer, Object> entry : parameters.entrySet()) {
+                Object value = entry.getValue();
+                ColumnType columnType;
+                if (value != null) {
+                    columnType = NativeTypeSpec.fromObject(value).asColumnType();
+                } else {
+                    columnType = ColumnType.NULL;
+                }
+                result[entry.getKey()] = columnType;
+            }
+
+            paramTypes = result;
+        }
+
+        return new CacheKey(catalogVersion, ctx.schemaName(), parsedResult.normalizedQuery(), distributed, paramTypes);
     }
 
-    private static CacheKey createCacheKey(ParsedResult parsedResult, PlanningContext ctx) {
+    private static CacheKey createCacheKeyFromParameterMetadata(ParsedResult parsedResult, PlanningContext ctx,
+            ParameterMetadata parameterMetadata) {
+
         boolean distributed = distributionPresent(ctx.config().getTraitDefs());
         int catalogVersion = ctx.unwrap(BaseQueryContext.class).schemaVersion();
+        ColumnType[] paramTypes;
+
+        List<ParameterType> parameterTypes = parameterMetadata.parameterTypes();
+        if (parameterTypes.isEmpty()) {
+            paramTypes = EMPTY_CLASS_ARRAY;
+        } else {
+            ColumnType[] result = new ColumnType[parameterTypes.size()];
 
-        Class[] paramTypes = ctx.parameters().length == 0
-                ? EMPTY_CLASS_ARRAY :
-                Arrays.stream(ctx.parameters()).map(p -> (p != null) ? p.getClass() : Void.class).toArray(Class[]::new);
+            for (int i = 0; i < parameterTypes.size(); i++) {
+                result[i] = parameterTypes.get(i).columnType();
+            }
+
+            paramTypes = result;
+        }
 
         return new CacheKey(catalogVersion, ctx.schemaName(), parsedResult.normalizedQuery(), distributed, paramTypes);
     }
@@ -380,6 +522,23 @@ public class PrepareServiceImpl implements PrepareService {
         );
     }
 
+    private static ParameterMetadata createParameterMetadata(RelDataType parameterRowType) {
+        if (parameterRowType.getFieldCount() == 0) {
+            return EMPTY_PARAMETER_METADATA;
+        }
+
+        List<ParameterType> parameterTypes = new ArrayList<>(parameterRowType.getFieldCount());
+
+        for (int i = 0; i < parameterRowType.getFieldCount(); i++) {
+            RelDataTypeField field = parameterRowType.getFieldList().get(i);
+            ParameterType parameterType = ParameterType.fromRelDataType(field.getType());
+
+            parameterTypes.add(parameterType);
+        }
+
+        return new ParameterMetadata(parameterTypes);
+    }
+
     private static class ParsedResultImpl implements ParsedResult {
         private final SqlQueryType queryType;
         private final String originalQuery;
@@ -431,4 +590,16 @@ public class PrepareServiceImpl implements PrepareService {
             return parsedTree;
         }
     }
+
+    private static class ValidStatement<T> {
+        final ParsedResult parsedResult;
+        final T value;
+        final ParameterMetadata parameterMetadata;
+
+        private ValidStatement(ParsedResult parsedResult, T value, ParameterMetadata parameterMetadata) {
+            this.parsedResult = parsedResult;
+            this.value = value;
+            this.parameterMetadata = parameterMetadata;
+        }
+    }
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/QueryPlan.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/QueryPlan.java
index 95d752d1b2..4fb88e9c5a 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/QueryPlan.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/QueryPlan.java
@@ -39,4 +39,9 @@ public interface QueryPlan {
      * Get fields metadata.
      */
     ResultSetMetadata metadata();
+
+    /**
+     * Returns parameters metadata.
+     */
+    ParameterMetadata parameterMetadata();
 }
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 85bab1af85..a3494146cf 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
@@ -23,6 +23,9 @@ import static org.apache.ignite.lang.ErrorGroups.Common.INTERNAL_ERR;
 import static org.apache.ignite.sql.ColumnMetadata.UNDEFINED_PRECISION;
 import static org.apache.ignite.sql.ColumnMetadata.UNDEFINED_SCALE;
 
+import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMap;
+import it.unimi.dsi.fastutil.ints.Int2ObjectMaps;
 import it.unimi.dsi.fastutil.ints.IntArrayList;
 import it.unimi.dsi.fastutil.ints.IntList;
 import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
@@ -307,16 +310,43 @@ public final class Commons {
     }
 
     /**
-     * ParametersMap.
-     * TODO Documentation https://issues.apache.org/jira/browse/IGNITE-15859
+     * Creates a map of parameters values from the given array of dynamic parameter values.
+     * All dynamic parameter should have their values specified.
      *
      * @param params Parameters.
      * @return Parameters map.
      */
     public static Map<String, Object> parametersMap(@Nullable Object[] params) {
-        HashMap<String, Object> res = new HashMap<>();
+        if (ArrayUtils.nullOrEmpty(params)) {
+            return Collections.emptyMap();
+        } else {
+            HashMap<String, Object> res = new HashMap<>();
+
+            populateParameters(res, params);
+
+            return res;
+        }
+    }
+
+    /**
+     * Creates an array from the given map in which array indices become map keys. e.g: [1, null, "3"] -> {0: 1, 1: null, 2: "3"}.
+     * If the given array is null, this method returns an empty map.
+     *
+     * @param params Array of values.
+     * @return Map of values.
+     */
+    public static Int2ObjectMap<Object> arrayToMap(@Nullable Object[] params) {
+        if (ArrayUtils.nullOrEmpty(params)) {
+            return Int2ObjectMaps.emptyMap();
+        } else {
+            Int2ObjectMap<Object> res = new Int2ObjectArrayMap<>(params.length);
+
+            for (int i = 0; i < params.length; i++) {
+                res.put(i, params[i]);
+            }
 
-        return params != null ? populateParameters(res, params) : res;
+            return res;
+        }
     }
 
     /**
@@ -324,15 +354,11 @@ public final class Commons {
      *
      * @param dst    Map to populate.
      * @param params Parameters.
-     * @return Parameters map.
      */
-    public static Map<String, Object> populateParameters(Map<String, Object> dst, @Nullable Object[] params) {
-        if (!ArrayUtils.nullOrEmpty(params)) {
-            for (int i = 0; i < params.length; i++) {
-                dst.put("?" + i, params[i]);
-            }
+    private static void populateParameters(Map<String, Object> dst, Object[] params) {
+        for (int i = 0; i < params.length; i++) {
+            dst.put("?" + i, params[i]);
         }
-        return dst;
     }
 
     /**
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteResource.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteResource.java
index 91421978da..c4b7974061 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteResource.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/IgniteResource.java
@@ -17,9 +17,20 @@
 
 package org.apache.ignite.internal.sql.engine.util;
 
+import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.runtime.Resources;
 import org.apache.calcite.runtime.Resources.ExInst;
+import org.apache.calcite.sql.SqlCallBinding;
+import org.apache.calcite.sql.SqlKind;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.calcite.sql.SqlOperator;
 import org.apache.calcite.sql.validate.SqlValidatorException;
+import org.apache.ignite.internal.sql.engine.prepare.IgniteSqlValidator;
 
 /**
  * IgniteResource interface.
@@ -51,4 +62,68 @@ public interface IgniteResource {
 
     @Resources.BaseMessage("System view {0} is not modifiable")
     ExInst<SqlValidatorException> systemViewIsNotModifiable(String systemViewName);
+
+    @Resources.BaseMessage("Ambiguous operator {0}. Dynamic parameter requires adding explicit type cast")
+    Resources.ExInst<SqlValidatorException> ambiguousOperator1(String signature);
+
+    @Resources.BaseMessage("Ambiguous operator {0}. Dynamic parameter requires adding explicit type cast. Supported form(s): \n{1}")
+    Resources.ExInst<SqlValidatorException> ambiguousOperator2(String signature, String allowedSignatures);
+
+    @Resources.BaseMessage("Unable to determine type of a dynamic parameter. Dynamic parameter requires adding explicit type cast")
+    Resources.ExInst<SqlValidatorException> unableToResolveDynamicParameterType();
+
+    @Resources.BaseMessage("Incorrect type of a dynamic parameter. Expected <{0}> but got <{1}>")
+    Resources.ExInst<SqlValidatorException> incorrectDynamicParameterType(String expected, String actual);
+
+    /** Constructs a signature string to use in error messages. */
+    static String makeSignature(SqlCallBinding binding, RelDataType... operandTypes) {
+        return makeSignature(binding, Arrays.asList(operandTypes));
+    }
+
+    /** Constructs a signature string to use in error messages. */
+    static String makeSignature(SqlCallBinding binding, List<RelDataType> operandTypes) {
+        IgniteSqlValidator validator = (IgniteSqlValidator) binding.getValidator();
+        List<SqlNode> operandList = binding.getCall().getOperandList();
+
+        return makeSignature(binding.getOperator(), validator, operandList, operandTypes);
+    }
+
+    /** Constructs a signature string to use in error messages. */
+    static String makeSignature(SqlOperator operator, IgniteSqlValidator validator,
+            List<SqlNode> operands, List<RelDataType> originalOperandTypes) {
+
+        List<String> operandTypeNames = new ArrayList<>(originalOperandTypes.size());
+
+        // Replace types for unspecified parameters with UNKNOWN type.
+
+        for (int i = 0; i < operands.size(); i++) {
+            SqlNode node = operands.get(i);
+            RelDataType dataType;
+
+            if (validator.isUnspecifiedDynamicParam(node)) {
+                dataType = validator.getUnknownType();
+            } else {
+                dataType = originalOperandTypes.get(i);
+            }
+
+            operandTypeNames.add(format("<{}>", dataType));
+        }
+
+        SqlKind kind = operator.getKind();
+        if (SqlKind.BINARY_ARITHMETIC.contains(kind) || SqlKind.BINARY_COMPARISON.contains(kind)) {
+            return format("{} {} {}", operandTypeNames.get(0), operator.getName(), operandTypeNames.get(1));
+        } else if (operator.getName().startsWith("IS ")) {
+            // IS NULL, IS NOT NULL, etc.
+            return format("{} {}", operandTypeNames.get(0), operator.getName());
+        } else if (kind == SqlKind.BETWEEN) {
+            return format("{} {} AND {}", operator.getName(), operandTypeNames.get(0), operandTypeNames.get(1));
+        } else if (kind == SqlKind.MINUS_PREFIX) {
+            // -
+            return format("{}{}", operator.getName(), operandTypeNames.get(0));
+        } else {
+            // Other operators
+            String operandStr = String.join(", ", operandTypeNames);
+            return format("{}({})", operator.getName(), operandStr);
+        }
+    }
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/TypeUtils.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/TypeUtils.java
index 20141b207c..c7c006d836 100644
--- a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/TypeUtils.java
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/TypeUtils.java
@@ -121,7 +121,7 @@ public class TypeUtils {
     }
 
     /** Return {@code true} if supplied object is suitable as dynamic parameter. */
-    public static boolean supportParamInstance(Object param) {
+    public static boolean supportParamInstance(@Nullable Object param) {
         return param == null || supportedParamClasses().contains(param.getClass());
     }
 
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/ExecutionServiceImplTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/ExecutionServiceImplTest.java
index 66bb31fa48..554883f4bf 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/ExecutionServiceImplTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/ExecutionServiceImplTest.java
@@ -139,6 +139,7 @@ import org.apache.ignite.sql.ColumnType;
 import org.jetbrains.annotations.Nullable;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 
 /**
@@ -663,6 +664,7 @@ public class ExecutionServiceImplTest extends BaseIgniteAbstractTest {
      * Test ensures that an exception during data prefetching is propagated to the callback.
      */
     @Test
+    @Disabled("https://issues.apache.org/jira/browse/IGNITE-21052")
     public void testErrorIsPropagatedToPrefetchCallback() {
         ExecutionService execService = executionServices.get(0);
         CompletableFuture<Void> prefetchFut = new CompletableFuture<>();
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/mapping/MappingTestRunner.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/mapping/MappingTestRunner.java
index 5c3bde8b95..bf5c148c1d 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/mapping/MappingTestRunner.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/exec/mapping/MappingTestRunner.java
@@ -41,6 +41,7 @@ import org.apache.ignite.internal.lang.IgniteSystemProperties;
 import org.apache.ignite.internal.sql.api.ResultSetMetadataImpl;
 import org.apache.ignite.internal.sql.engine.SqlQueryType;
 import org.apache.ignite.internal.sql.engine.prepare.MultiStepPlan;
+import org.apache.ignite.internal.sql.engine.prepare.ParameterMetadata;
 import org.apache.ignite.internal.sql.engine.prepare.PlanId;
 import org.apache.ignite.internal.sql.engine.rel.IgniteRel;
 import org.apache.ignite.internal.sql.engine.rel.IgniteTableModify;
@@ -164,7 +165,9 @@ final class MappingTestRunner {
                 sqlQueryType = SqlQueryType.QUERY;
             }
             ResultSetMetadataImpl resultSetMetadata = new ResultSetMetadataImpl(Collections.emptyList());
-            MultiStepPlan multiStepPlan = new MultiStepPlan(new PlanId(UUID.randomUUID(), 1), sqlQueryType, rel, resultSetMetadata);
+            ParameterMetadata parameterMetadata = new ParameterMetadata(Collections.emptyList());
+            MultiStepPlan multiStepPlan = new MultiStepPlan(new PlanId(UUID.randomUUID(), 1), sqlQueryType, rel,
+                    resultSetMetadata, parameterMetadata);
 
             String actualText = produceMapping(testDef.nodeName, targetProvider, snapshot, multiStepPlan);
 
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java
index 02f968fc96..b0cb5d75f2 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/AbstractPlannerTest.java
@@ -29,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.fail;
 
 import com.google.common.collect.ImmutableList;
+import it.unimi.dsi.fastutil.ints.Int2ObjectArrayMap;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -105,6 +106,7 @@ import org.apache.ignite.internal.sql.engine.util.StatementChecker;
 import org.apache.ignite.internal.testframework.IgniteAbstractTest;
 import org.apache.ignite.internal.testframework.IgniteTestUtils;
 import org.apache.ignite.internal.type.NativeType;
+import org.apache.ignite.internal.util.Pair;
 import org.jetbrains.annotations.Nullable;
 
 /**
@@ -232,9 +234,19 @@ public abstract class AbstractPlannerTest extends IgniteAbstractTest {
             List<Object> params,
             String... disabledRules
     ) {
+
+        Int2ObjectArrayMap<Object> paramsMap = new Int2ObjectArrayMap<>();
+        for (int i = 0; i < params.size(); i++) {
+            Object value = params.get(i);
+            if (value != Unspecified.UNKNOWN) {
+                paramsMap.put(i, value);
+            }
+        }
+
         PlanningContext ctx = PlanningContext.builder()
                 .parentContext(baseQueryContext(schemas, hintStrategies, params.toArray()))
                 .query(sql)
+                .parameters(paramsMap)
                 .build();
 
         IgnitePlanner planner = ctx.planner();
@@ -268,6 +280,7 @@ public abstract class AbstractPlannerTest extends IgniteAbstractTest {
             relConvCfg = relConvCfg.withHintStrategyTable(hintStrategies);
         }
 
+
         return BaseQueryContext.builder()
                 .queryId(UUID.randomUUID())
                 .frameworkConfig(
@@ -971,14 +984,17 @@ public abstract class AbstractPlannerTest extends IgniteAbstractTest {
 
         PlanChecker() {
             super((schema, sql, params) -> {
-                return physicalPlan(sql, List.of(schema), HintStrategyTable.EMPTY,
-                        params, new NoopRelOptListener());
+                PlanningContext planningContext = plannerCtx(sql, List.of(schema), HintStrategyTable.EMPTY, params);
+                IgnitePlanner planner = planningContext.planner();
+                IgniteRel igniteRel = physicalPlan(planner, sql);
+
+                return new Pair<>(igniteRel, planner);
             });
         }
 
         /** {@inheritDoc} */
         @Override
-        protected void checkRel(IgniteRel igniteRel, IgniteSchema schema) {
+        protected void checkRel(IgniteRel igniteRel, IgnitePlanner planner, IgniteSchema schema) {
             checkSplitAndSerialization(igniteRel, schema);
         }
     }
@@ -1022,4 +1038,11 @@ public abstract class AbstractPlannerTest extends IgniteAbstractTest {
     static Function<TableBuilder, TableBuilder> setSize(int size) {
         return t -> t.size(size);
     }
+
+
+    /** Unspecified dynamic parameter. */
+    public enum Unspecified {
+        /** Placeholder for unspecified dynamic parameter. */
+        UNKNOWN
+    }
 }
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DynamicParametersTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DynamicParametersTest.java
index 7ec2f77943..bda29a8abb 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DynamicParametersTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/planner/DynamicParametersTest.java
@@ -20,9 +20,17 @@ package org.apache.ignite.internal.sql.engine.planner;
 import java.util.UUID;
 import java.util.function.Consumer;
 import java.util.stream.Stream;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.ignite.internal.sql.engine.prepare.IgniteSqlValidator;
+import org.apache.ignite.internal.sql.engine.type.IgniteTypeFactory;
+import org.apache.ignite.internal.sql.engine.util.Commons;
 import org.apache.ignite.internal.sql.engine.util.StatementChecker;
+import org.apache.ignite.internal.sql.engine.util.TypeUtils;
+import org.apache.ignite.internal.type.NativeType;
 import org.apache.ignite.internal.type.NativeTypes;
+import org.apache.ignite.internal.type.VarlenNativeType;
+import org.jetbrains.annotations.Nullable;
 import org.junit.jupiter.api.DynamicTest;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestFactory;
@@ -48,15 +56,22 @@ public class DynamicParametersTest extends AbstractPlannerTest {
     @TestFactory
     public Stream<DynamicTest> testArithExprs() {
         return Stream.of(
-                sql("SELECT 1 + ?", 1).ok(),
-                sql("SELECT NULL + ?", 1).project("null:INTEGER"),
-                sql("SELECT ? + NULL", 1).project("null:INTEGER"),
+                sql("SELECT 1 + ?", 1).parameterTypes(nullable(NativeTypes.INT32)).ok(),
+                sql("SELECT NULL + ?", 1).parameterTypes(nullable(NativeTypes.INT32)).project("null:INTEGER"),
+                sql("SELECT ? + NULL", 1).parameterTypes(nullable(NativeTypes.INT32)).project("null:INTEGER"),
 
                 sql("SELECT 1 + ?", "1").fails("Values passed to + operator must have compatible types"),
                 sql("SELECT ? + 1", "1").fails("Values passed to + operator must have compatible types"),
 
-                // NULL is allowed in arithmetic expressions
-                sql("SELECT ? * 2", new Object[]{null}).ok()
+                // NULL is allowed in arithmetic expressions, if another operand is present.
+                sql("SELECT ? * 2", new Object[]{null})
+                        .parameterTypes(nullable(null))
+                        .ok(),
+
+                sql("SELECT 1 + ?", Unspecified.UNKNOWN).parameterTypes(nullable(NativeTypes.INT32)).ok(),
+                sql("SELECT ? + 1", Unspecified.UNKNOWN).parameterTypes(nullable(NativeTypes.INT32)).ok(),
+                sql("SELECT ? + ?", Unspecified.UNKNOWN, Unspecified.UNKNOWN).fails("Ambiguous operator <UNKNOWN> + <UNKNOWN>"),
+                sql("SELECT NULL + ?", Unspecified.UNKNOWN).fails("Ambiguous operator <NULL> + <UNKNOWN>")
         );
     }
 
@@ -65,14 +80,20 @@ public class DynamicParametersTest extends AbstractPlannerTest {
     public Stream<DynamicTest> testCmpExprs() {
         return Stream.of(
                 // comparison
-                sql("SELECT ? > 1", 1).ok(),
+                sql("SELECT ? > 1", 1).parameterTypes(nullable(NativeTypes.INT32)).ok(),
+                sql("SELECT ? > 1", Unspecified.UNKNOWN).parameterTypes(nullable(NativeTypes.INT32)).ok(),
+                sql("SELECT 1 > ?", Unspecified.UNKNOWN).parameterTypes(nullable(NativeTypes.INT32)).ok(),
+                sql("SELECT ? > ?", Unspecified.UNKNOWN, Unspecified.UNKNOWN).fails("Ambiguous operator <UNKNOWN> > <UNKNOWN>"),
+
+                sql("SELECT ? > 1", "1")
+                        .fails("Values passed to > operator must have compatible types"),
 
-                sql("SELECT ? > 1", "1").fails("Values passed to > operator must have compatible types"),
-                sql("SELECT 1 > ?", "1").fails("Values passed to > operator must have compatible types"),
+                sql("SELECT 1 > ?", "1")
+                        .fails("Values passed to > operator must have compatible types"),
 
-                sql("SELECT ? > NULL", 1).project("null:BOOLEAN"),
-                sql("SELECT NULL = ?", 1).project("null:BOOLEAN"),
-                sql("SELECT ? = NULL", 1).project("null:BOOLEAN"),
+                sql("SELECT ? > NULL", 1).parameterTypes(nullable(NativeTypes.INT32)).project("null:BOOLEAN"),
+                sql("SELECT NULL = ?", 1).parameterTypes(nullable(NativeTypes.INT32)).project("null:BOOLEAN"),
+                sql("SELECT ? = NULL", 1).parameterTypes(nullable(NativeTypes.INT32)).project("null:BOOLEAN"),
 
                 // NULL is allowed in comparison
                 sql("SELECT ? = ?", null, null).project("=(?0, ?1)")
@@ -84,28 +105,60 @@ public class DynamicParametersTest extends AbstractPlannerTest {
      */
     @TestFactory
     public Stream<DynamicTest> testInExpression() {
-        String error =
+        String requireExplicitCast =
                 "Values passed to IN operator must have compatible types. Dynamic parameter requires adding explicit type cast";
+
         return Stream.of(
-                sql("SELECT ? IN ('1', '2')", 1).project("OR(=(?0, 1), =(?0, 2))"),
-                sql("SELECT ? IN (1, 2)", "1").fails(error),
-                sql("SELECT ? IN ('1', 2)", 2).project("OR(=(?0, 1), =(?0, 2))"),
+                sql("SELECT ? IN ('1', '2')", 1).parameterTypes(nullable(NativeTypes.INT32)).project("OR(=(?0, 1), =(?0, 2))"),
+                sql("SELECT ? IN (1, 2)", "1").fails(requireExplicitCast),
+                sql("SELECT ? IN (1, 2)", 1).parameterTypes(nullable(NativeTypes.INT32)).project("OR(=(?0, 1), =(?0, 2))"),
+
+                sql("SELECT ? IN (1)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                sql("SELECT ? IN (?, 1)", Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                sql("SELECT ? IN (?, ?)", Unspecified.UNKNOWN, Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                sql("SELECT 1 IN (?, ?)", Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .project("OR(=(1, ?0), =(1, ?1))"),
+
+                sql("SELECT ? IN ('1')", 2)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("=(?0, 1)"),
 
-                sql("SELECT (?,?) IN ((1,2))", 1, 2).project("AND(=(?0, 1), =(?1, 2))"),
+                sql("SELECT ? IN ('1', 2)", 2)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("OR(=(?0, 1), =(?0, 2))"),
 
-                sql("SELECT (?,?) IN ((1,2))", "1", "2").fails(error),
-                sql("SELECT (?,?) IN (('1', 2))", 1, "2").fails(error),
-                sql("SELECT (?,?) IN ((1, '2'))", "1", "2").fails(error)
+                sql("SELECT (?,?) IN ((1,2))", 1, 2)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .project("AND(=(?0, 1), =(?1, 2))"),
+
+                sql("SELECT (?,?) IN ((1,2))", "1", "2").fails(requireExplicitCast),
+                sql("SELECT (?,?) IN (('1', 2))", 1, "2").fails(requireExplicitCast),
+                sql("SELECT (?,?) IN ((1, '2'))", "1", "2").fails(requireExplicitCast)
         );
     }
 
     /** CASE expression. */
     @TestFactory
     public Stream<DynamicTest> testCaseWhenExpression() {
+        IgniteTypeFactory tf = Commons.typeFactory();
+        // String parameter is inferred as VARCHAR with default attributes, but
+        // the createSqlType(VARCHAR, DEFAULT_PRECISION) != createSqlType(VARCHAR)
+        // have to create rel data type directly instead of building one from a native type.
+        RelDataType nullableVarchar = tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.VARCHAR), true);
+
         return Stream.of(
-                sql("SELECT CASE ? = ? WHEN true THEN 1 ELSE 2 END", 1, 1).ok(),
+                sql("SELECT CASE ? = ? WHEN true THEN 1 ELSE 2 END", 1, 1)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .ok(),
 
                 sql("SELECT CASE WHEN ? = '1' THEN ? ELSE ? END", "1", 2, 2.5)
+                        .parameterTypes(nullableVarchar, nullable(NativeTypes.INT32), nullable(NativeTypes.DOUBLE))
                         .project("CASE(=(?0, _UTF-8'1'), CAST(?1):DOUBLE, ?2)"),
 
                 sql("SELECT CASE ? = ? WHEN true THEN 1 ELSE 2 END", 1, "1")
@@ -114,7 +167,49 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                 sql("SELECT CASE WHEN ? = '1' THEN ? ELSE ? END", "1", "2", 2.5)
                         .fails("Illegal mixing of types in CASE or COALESCE statement"),
 
-                sql("SELECT CASE WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END", 1, 2).fails("Expected a boolean type")
+                sql("SELECT CASE WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END", 1, 2)
+                        .fails("Expected a boolean type")
+        );
+    }
+
+    @TestFactory
+    public Stream<DynamicTest> testCase() {
+        //CREATE TABLE TBL1(ID INT PRIMARY KEY, VAL VARCHAR, NUM INT)
+        //select case when (VAL = ?) then 0 else (case when (NUM IS NULL) then ? else ? end) end
+
+        IgniteTypeFactory tf = Commons.typeFactory();
+        RelDataType nullableStr = tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.VARCHAR), true);
+        RelDataType nullType = tf.createSqlType(SqlTypeName.NULL);
+        RelDataType nullableInt = tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.INTEGER), true);
+
+        return Stream.of(
+                checkStatement()
+                        .table("TBL1", "ID", NativeTypes.INT32, "VAL", NativeTypes.STRING, "NUM", NativeTypes.INT32)
+                        .sql("select case when (VAL = ?) then 0 else (case when (NUM IS NULL) then ? else ? end) end FROM TBL1",
+                                "diff", null, 1)
+                        .parameterTypes(nullableStr, nullType, nullableInt)
+                        .project("CASE(=(CAST($t0):VARCHAR CHARACTER SET \"UTF-8\", ?0), 0, CASE(IS NULL($t1), CAST(?1):INTEGER, ?2))"),
+
+                checkStatement()
+                        .table("TBL1", "ID", NativeTypes.INT32, "VAL", NativeTypes.STRING, "NUM", NativeTypes.INT32)
+                        .sql("select case when (VAL = ?) then 0 else (case when (NUM IS NULL) then ? else ? end) end FROM TBL1",
+                                Unspecified.UNKNOWN, Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .parameterTypes(nullableStr, nullType, nullableInt)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .table("TBL1", "ID", NativeTypes.INT32, "VAL", NativeTypes.STRING, "NUM", NativeTypes.INT32)
+                        .sql("select case when (VAL = ?) then 0 else (case when (NUM IS NULL) then ? else ? end) end FROM TBL1",
+                                "diff", 1, null)
+                        .parameterTypes(nullableStr, nullableInt, nullType)
+                        .project("CASE(=(CAST($t0):VARCHAR CHARACTER SET \"UTF-8\", ?0), 0, CASE(IS NULL($t1), ?1, CAST(?2):INTEGER))"),
+
+                checkStatement()
+                        .table("TBL1", "ID", NativeTypes.INT32, "VAL", NativeTypes.STRING, "NUM", NativeTypes.INT32)
+                        .sql("select case when (VAL = ?) then 0 else (case when (NUM IS NULL) then ? else ? end) end FROM TBL1",
+                                Unspecified.UNKNOWN, Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .parameterTypes(nullableStr, nullType, nullableInt)
+                        .fails("Unable to determine type of a dynamic parameter")
         );
     }
 
@@ -125,6 +220,12 @@ public class DynamicParametersTest extends AbstractPlannerTest {
     @TestFactory
     public Stream<DynamicTest> testWherePredicate() {
         return Stream.of(
+                checkStatement()
+                        .table("t1", "int_col", NativeTypes.INT32)
+                        .sql("SELECT * FROM t1 WHERE int_col = ?", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
                 checkStatement()
                         .table("t1", "int_col", NativeTypes.INT32)
                         .sql("SELECT * FROM t1 WHERE int_col = ?", "1").fails("Values passed to = operator must have compatible types"),
@@ -135,6 +236,247 @@ public class DynamicParametersTest extends AbstractPlannerTest {
         );
     }
 
+    /** Dynamic parameters in independent contexts. */
+    @TestFactory
+    public Stream<DynamicTest> testStandalone() {
+        return Stream.of(
+                checkStatement()
+                        .sql("SELECT ?", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement()
+                        .sql("SELECT ?", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .sql("SELECT CAST(? AS INTEGER)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        // We are going to cast at runtime.
+                        .project("?0"),
+
+                checkStatement()
+                        .sql("SELECT CAST(? AS INTEGER)", "1")
+                        .parameterTypes(nullable(NativeTypes.STRING))
+                        // We are going to cast at runtime.
+                        .project("CAST(?0):INTEGER"),
+
+                checkStatement()
+                        .sql("SELECT CAST(? AS INTEGER)", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        // We are going to cast at runtime.
+                        .project("?0"),
+
+                checkStatement()
+                        .sql("SELECT -?", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement()
+                        .sql("SELECT -?", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .fails("Ambiguous operator -<UNKNOWN>"),
+
+                checkStatement()
+                        .sql("SELECT NOT ?", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.BOOLEAN))
+                        .project("NOT(?0)"),
+
+                checkStatement()
+                        .sql("SELECT ? IS NULL", Unspecified.UNKNOWN)
+                        .fails("Ambiguous operator <UNKNOWN> IS NULL"),
+
+                checkStatement()
+                        .sql("SELECT ? IS NOT NULL", Unspecified.UNKNOWN)
+                        .fails("Ambiguous operator <UNKNOWN> IS NOT NULL")
+        );
+    }
+
+    /** Subqueries in various contexts. */
+    @TestFactory
+    public Stream<DynamicTest> testSubqueries() {
+        return Stream.of(
+                checkStatement()
+                        .sql("SELECT (SELECT ?)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement()
+                        .sql("SELECT (SELECT ?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                // Predicates
+
+                checkStatement()
+                        .table("t1", "int_col", NativeTypes.INT32)
+                        .sql("SELECT * FROM t1 WHERE int_col = SOME(SELECT ?)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+
+                checkStatement()
+                        .table("t1", "int_col", NativeTypes.INT32)
+                        .sql("SELECT * FROM t1 WHERE int_col = SOME(SELECT ?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .table("t1", "int_col", NativeTypes.INT32)
+                        .sql("SELECT * FROM t1 WHERE int_col = ANY(SELECT ?)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement()
+                        .table("t1", "int_col", NativeTypes.INT32)
+                        .sql("SELECT * FROM t1 WHERE int_col = ANY(SELECT ?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+
+                // DML
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32)
+                        .sql("INSERT INTO t1 VALUES (1), ((SELECT ?))", 1)
+                        .ok(),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32)
+                        .sql("INSERT INTO t1 VALUES (1), ((SELECT ?))", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32, "c2", NativeTypes.INT64)
+                        .sql("UPDATE t1 SET c1 = (SELECT ?)", 1)
+                        .ok()
+
+        //        TODO https://issues.apache.org/jira/browse/IGNITE-20835
+        //         Assertion is caused by incorrect subquery handling
+        //         checkStatement()
+        //                .table("t1", "c1", NativeTypes.INT32, "c2", NativeTypes.INT64)
+        //                .sql("UPDATE t1 SET c1 = (SELECT ?)", Unspecified.UNKNOWN)
+        //                .fails("Unable to determine type of a dynamic parameter")
+        );
+    }
+
+    /** BETWEEN operator. */
+    @TestFactory
+    public Stream<DynamicTest> testBetween() {
+        return Stream.of(
+                sql("SELECT 1 BETWEEN ? AND ?", 1, 10)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT 1 BETWEEN ? AND ?", 1, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT 1 BETWEEN ? AND ?", Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT ? BETWEEN ? AND ?", 1, Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT 1 BETWEEN ? AND ?", Unspecified.UNKNOWN, 10L)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT64))
+                        .ok(),
+
+                sql("SELECT ? BETWEEN ? AND ?", Unspecified.UNKNOWN, 1, 10)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT ? BETWEEN ? AND ?", Unspecified.UNKNOWN, 1, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT ? BETWEEN ? AND ?", Unspecified.UNKNOWN, Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter")
+        );
+    }
+
+    /** Aggregate functions. */
+    @TestFactory
+    public Stream<DynamicTest> testAggregateFunctions() {
+        return Stream.of(
+                checkStatement()
+                        .sql("SELECT MAX(?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .sql("SELECT MIN(?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .sql("SELECT AVG(?)", Unspecified.UNKNOWN)
+                        .fails("Ambiguous operator AVG(<UNKNOWN>)"),
+
+                checkStatement()
+                        .sql("SELECT COUNT(?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter")
+        );
+    }
+
+    /** NULLIF function. */
+    @TestFactory
+    public Stream<DynamicTest> testNullIf() {
+        // NULLIF($1, $2) is rewritten as case CASE WHEN $1 = $2 THEN NULL ELSE $1 END
+        return Stream.of(
+                checkStatement()
+                        .sql("SELECT NULLIF(?, ?)", Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .fails("Ambiguous operator <UNKNOWN> = <UNKNOWN>"),
+
+                checkStatement()
+                        .sql("SELECT NULLIF(?, ?)", 1, "1")
+                        .fails("Values passed to = operator must have compatible types"),
+
+                checkStatement()
+                        .sql("SELECT NULLIF(?, 1)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .sql("SELECT NULLIF(CAST(? AS INTEGER), 1)", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("CASE(=(?0, 1), null:INTEGER, ?0)")
+        );
+    }
+
+    /** COALESCE function. */
+    @TestFactory
+    public Stream<DynamicTest> testCoalesce() {
+        // COALESCE($1, $2) is rewritten into:  CASE WHEN $1 IS NOT NULL THEN $1 ELSE $2 END
+        return Stream.of(
+                checkStatement()
+                        .sql("SELECT COALESCE(?)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("?0"),
+
+                checkStatement()
+                        .sql("SELECT COALESCE(?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .sql("SELECT COALESCE(?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .sql("SELECT COALESCE(?, ?)", null, null)
+                        .parameterTypes(nullable(null), nullable(null))
+                        .project("CASE(IS NOT NULL(?0), ?0, ?1)"),
+
+                checkStatement()
+                        .sql("SELECT COALESCE(?, 1)", Unspecified.UNKNOWN)
+                        .fails("Ambiguous operator <UNKNOWN> IS NOT NULL"),
+
+                checkStatement()
+                        .sql("SELECT COALESCE(1, ?)", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                checkStatement()
+                        .sql("SELECT COALESCE(CAST(? AS INTEGER), 1)", 2)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("CASE(IS NOT NULL(?0), CAST(?0):INTEGER NOT NULL, 1)")
+        );
+    }
 
     /** Dynamic params in INSERT statement. */
     @TestFactory
@@ -143,6 +485,37 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                 checkStatement()
                         .table("t1", "c1", NativeTypes.INT32)
                         .sql("INSERT INTO t1 VALUES (?)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("?0"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT64)
+                        .sql("INSERT INTO t1 VALUES (?)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("CAST(?0):BIGINT"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT64)
+                        .sql("INSERT INTO t1 VALUES (?)", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT64))
+                        .project("?0"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT64)
+                        .sql("INSERT INTO t1 VALUES (?)", new Object[]{null})
+                        .parameterTypes(new NativeType[]{null})
+                        .project("CAST(?0):BIGINT"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32)
+                        .sql("INSERT INTO t1 VALUES (?), (2), (?)", 1, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
+                        .project("?0"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32)
+                        .sql("INSERT INTO t1 VALUES (?), (2), (?)", Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT32))
                         .project("?0"),
 
                 // compatible type
@@ -150,6 +523,7 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                 checkStatement()
                         .table("t1", "c1", NativeTypes.INT64)
                         .sql("INSERT INTO t1 VALUES (?)", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
                         .project("CAST(?0):BIGINT"),
 
 
@@ -177,18 +551,27 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                 checkStatement()
                         .table("t1", "c1", NativeTypes.INT32)
                         .sql("UPDATE t1 SET c1 = ?", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
                         .project("$t0", "?0"),
 
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32, "c2", NativeTypes.INT64)
+                        .sql("UPDATE t1 SET c1 = ?, c2 = ?", 1, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT64))
+                        .project("$t0", "$t1", "?0", "?1"),
+
                 // compatible type
                 checkStatement()
                         .table("t1", "c1", NativeTypes.INT64)
                         .sql("UPDATE t1 SET c1 = ?", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
                         .project("$t0", "CAST(?0):BIGINT"),
 
                 // null
                 checkStatement()
                         .table("t1", "c1", NativeTypes.INT32)
                         .sql("UPDATE t1 SET c1 = ?", new Object[]{null})
+                        .parameterTypes(new NativeType[]{null})
                         .project("$t0", "CAST(?0):INTEGER"),
 
                 // Incompatible types in dynamic params
@@ -214,6 +597,18 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                                     + "WHEN NOT MATCHED THEN INSERT (c1, c2, c3) VALUES (src.c1, src.c2, ?)";
                             return sql;
                         }, 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .project("$0", "$1", "?0"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32, "c2", NativeTypes.INT32, "c3", NativeTypes.INT32)
+                        .table("t2", "c1", NativeTypes.INT32, "c2", NativeTypes.INT32, "c3", NativeTypes.INT32)
+                        .sql(() -> {
+                            String sql = "MERGE INTO T2 dst USING t1 src ON dst.c1 = src.c1 "
+                                    + "WHEN NOT MATCHED THEN INSERT (c1, c2, c3) VALUES (src.c1, src.c2, ?)";
+                            return sql;
+                        }, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32))
                         .project("$0", "$1", "?0"),
 
                 // null
@@ -226,6 +621,7 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                                     + "WHEN NOT MATCHED THEN INSERT (c1, c2, c3) VALUES (src.c1, src.c2, ?)";
                             return sql;
                         }, new Object[]{null})
+                        .parameterTypes(new NativeType[]{null})
                         .project("$0", "$1", "CAST(?0):INTEGER"),
 
                 // incompatible types
@@ -248,13 +644,26 @@ public class DynamicParametersTest extends AbstractPlannerTest {
         return Stream.of(
                 checkStatement()
                         .table("t1", "c1", NativeTypes.INT32, "c2", NativeTypes.INT32, "c3", NativeTypes.INT32)
-                        .table("t2", "c1", NativeTypes.INT32, "c2", NativeTypes.INT32, "c3", NativeTypes.INT32)
+                        .table("t2", "c1", NativeTypes.INT32, "c2", NativeTypes.INT32, "c3", NativeTypes.INT64)
+                        .sql(() -> {
+                            String sql = "MERGE INTO T2 dst USING t1 src ON dst.c1 = src.c1 "
+                                    + "WHEN MATCHED THEN UPDATE SET c2 = ? "
+                                    + "WHEN NOT MATCHED THEN INSERT (c1, c2, c3) VALUES (src.c1, src.c2, ?)";
+                            return sql;
+                        }, 1, 2L)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT64))
+                        .project("$3", "$4", "?1", "$0", "$1", "$2", "?0"),
+
+                checkStatement()
+                        .table("t1", "c1", NativeTypes.INT32, "c2", NativeTypes.INT32, "c3", NativeTypes.INT32)
+                        .table("t2", "c1", NativeTypes.INT32, "c2", NativeTypes.INT32, "c3", NativeTypes.INT64)
                         .sql(() -> {
                             String sql = "MERGE INTO T2 dst USING t1 src ON dst.c1 = src.c1 "
                                     + "WHEN MATCHED THEN UPDATE SET c2 = ? "
                                     + "WHEN NOT MATCHED THEN INSERT (c1, c2, c3) VALUES (src.c1, src.c2, ?)";
                             return sql;
-                        }, 1, 2)
+                        }, 1, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32), nullable(NativeTypes.INT64))
                         .project("$3", "$4", "?1", "$0", "$1", "$2", "?0"),
 
                 // Incompatible types
@@ -283,12 +692,65 @@ public class DynamicParametersTest extends AbstractPlannerTest {
         );
     }
 
+    /**
+     * Dynamic parameters in LIMIT / OFFSET.
+     */
+    @TestFactory
+    public Stream<DynamicTest> testLimitOffset() {
+        Consumer<StatementChecker> setup = (checker) -> {
+            checker.table("t1", "c1", NativeTypes.INT32);
+        };
+
+        return Stream.of(
+                checkStatement(setup)
+                        .sql("SELECT * FROM t1 LIMIT ?", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement(setup)
+                        .sql("SELECT * FROM t1 LIMIT ?", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement(setup)
+                        .sql("SELECT * FROM t1 LIMIT ?", "a")
+                        .fails("Incorrect type of a dynamic parameter. Expected <INTEGER> but got <VARCHAR"),
+
+                checkStatement(setup)
+                        .sql("SELECT * FROM t1 OFFSET ?", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement(setup)
+                        .sql("SELECT * FROM t1 OFFSET ?", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                checkStatement(setup)
+                        .sql("SELECT * FROM t1 OFFSET ?", "a")
+                        .fails("Incorrect type of a dynamic parameter. Expected <INTEGER> but got <VARCHAR")
+        );
+    }
+
     /**
      * Function calls.
      */
     @TestFactory
     public Stream<DynamicTest> testFunction() {
         return Stream.of(
+                checkStatement()
+                        .sql("SELECT SUBSTRING(?, 1)", "abc")
+                        .parameterTypes(nullable(NativeTypes.STRING))
+                        .ok(),
+
+                checkStatement()
+                        .sql("SELECT SUBSTRING(?, 1)", Unspecified.UNKNOWN)
+                        .fails("Ambiguous operator SUBSTRING(<UNKNOWN>, <INTEGER>)"),
+
+                checkStatement()
+                        .sql("SELECT SUBSTRING('aaaa', ?)", Unspecified.UNKNOWN)
+                        .fails("Ambiguous operator SUBSTRING(<CHAR(4)>, <UNKNOWN>)"),
+
                 // nested function call - OK
                 checkStatement()
                         .sql("SELECT SUBSTRING(SUBSTRING(?, 1), 2)", "123456")
@@ -301,6 +763,33 @@ public class DynamicParametersTest extends AbstractPlannerTest {
         );
     }
 
+    /** Set operations. */
+    @TestFactory
+    public Stream<DynamicTest> testSetOps() {
+        return Stream.of(
+                sql("SELECT 1 UNION SELECT ?", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT 1 UNION SELECT ?", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                sql("SELECT ? UNION SELECT 1", 1)
+                        .parameterTypes(nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT ? UNION SELECT ?", 1L, 2)
+                        .parameterTypes(nullable(NativeTypes.INT64), nullable(NativeTypes.INT32))
+                        .ok(),
+
+                sql("SELECT ? UNION SELECT 1", Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter"),
+
+                sql("SELECT ? UNION SELECT ?", 1, Unspecified.UNKNOWN)
+                        .fails("Unable to determine type of a dynamic parameter")
+        );
+    }
+
     /**
      * Custom data types.
      */
@@ -332,6 +821,16 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                         .sql("SELECT str_col IN ('a'::UUID) FROM t1")
                         .project("=(CAST($t0):UUID, CAST(_UTF-8'a'):UUID NOT NULL)"),
 
+                checkStatement(setup)
+                        .sql("SELECT uuid_col IN (?) FROM t1", UUID.randomUUID())
+                        .parameterTypes(nullable(NativeTypes.UUID))
+                        .project("=($t0, ?0)"),
+
+                checkStatement(setup)
+                        .sql("SELECT uuid_col IN (?) FROM t1", Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.UUID))
+                        .project("=($t0, ?0)"),
+
                 // CASE
 
                 checkStatement(setup)
@@ -342,6 +841,31 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                         .sql("SELECT CASE RAND_UUID() WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END", new UUID(0, 1), 2)
                         .fails("Values passed to = operator must have compatible types"),
 
+                checkStatement(setup)
+                        .sql("SELECT CASE RAND_UUID() WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END", new UUID(0, 1), new UUID(0, 2))
+                        .parameterTypes(nullable(NativeTypes.UUID), nullable(NativeTypes.UUID))
+                        .ok(),
+
+                checkStatement(setup)
+                        .sql("SELECT CASE RAND_UUID() WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END", new UUID(0, 1), Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.UUID), nullable(NativeTypes.UUID))
+                        .ok(),
+
+                checkStatement(setup)
+                        .sql("SELECT CASE RAND_UUID() WHEN ? THEN 1 WHEN ? THEN 2 ELSE 3 END", Unspecified.UNKNOWN, Unspecified.UNKNOWN)
+                        .parameterTypes(nullable(NativeTypes.UUID), nullable(NativeTypes.UUID))
+                        .ok(),
+
+                checkStatement()
+                        .sql("SELECT COALESCE(?, 'UUID'::UUID)", new UUID(0, 0))
+                        .parameterTypes(nullable(NativeTypes.UUID))
+                        .project("CASE(IS NOT NULL(?0), ?0, CAST(_UTF-8'UUID'):UUID NOT NULL)"),
+
+                checkStatement()
+                        .sql("SELECT NULLIF(?, 'UUID'::UUID)", new UUID(0, 0))
+                        .parameterTypes(nullable(NativeTypes.UUID))
+                        .project("CASE(=(?0, CAST(_UTF-8'UUID'):UUID NOT NULL), null:NULL, ?0)"),
+
                 // Set operations
 
                 checkStatement(setup)
@@ -357,4 +881,21 @@ public class DynamicParametersTest extends AbstractPlannerTest {
                         .fails("Type mismatch in column 1 of UNION")
         );
     }
+
+    private static RelDataType nullable(@Nullable NativeType type) {
+        IgniteTypeFactory tf = Commons.typeFactory();
+        if (type == null) {
+            return tf.createTypeWithNullability(tf.createSqlType(SqlTypeName.NULL), true);
+        }
+
+        RelDataType relDataType = TypeUtils.native2relationalType(tf, type, true);
+
+        // For var length types inferred without precision.
+        if (type instanceof VarlenNativeType) {
+            SqlTypeName typeName = relDataType.getSqlTypeName();
+            return tf.createTypeWithNullability(tf.createSqlType(typeName), true);
+        }
+
+        return relDataType;
+    }
 }
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImplTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImplTest.java
index d0057bbec4..42c13dc6f4 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImplTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/prepare/PrepareServiceImplTest.java
@@ -18,15 +18,22 @@
 package org.apache.ignite.internal.sql.engine.prepare;
 
 import static org.apache.ignite.internal.sql.engine.util.Commons.FRAMEWORK_CONFIG;
+import static org.apache.ignite.internal.sql.engine.util.SqlTestUtils.assertThrowsSqlException;
 import static org.apache.ignite.internal.testframework.IgniteTestUtils.await;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.instanceOf;
 import static org.hamcrest.Matchers.sameInstance;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
 
 import java.util.ArrayList;
 import java.util.List;
 import java.util.UUID;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.apache.calcite.schema.SchemaPlus;
 import org.apache.calcite.tools.Frameworks;
 import org.apache.ignite.internal.metrics.MetricManager;
@@ -39,21 +46,29 @@ import org.apache.ignite.internal.sql.engine.sql.ParserServiceImpl;
 import org.apache.ignite.internal.sql.engine.trait.IgniteDistributions;
 import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
 import org.apache.ignite.internal.sql.engine.util.EmptyCacheFactory;
+import org.apache.ignite.internal.sql.engine.util.SqlTestUtils;
 import org.apache.ignite.internal.sql.engine.util.cache.CaffeineCacheFactory;
 import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.apache.ignite.internal.type.NativeType;
 import org.apache.ignite.internal.type.NativeTypes;
+import org.apache.ignite.lang.ErrorGroups.Sql;
+import org.apache.ignite.sql.ColumnMetadata;
+import org.apache.ignite.sql.ColumnType;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
 
 /**
  * Tests to verify {@link PrepareServiceImpl}.
  */
 @SuppressWarnings("DataFlowIssue")
-class PrepareServiceImplTest extends BaseIgniteAbstractTest {
+public class PrepareServiceImplTest extends BaseIgniteAbstractTest {
     private static final List<PrepareService> createdServices = new ArrayList<>();
 
     @AfterEach
-    void stopServices() throws Exception {
+    public void stopServices() throws Exception {
         for (PrepareService createdService : createdServices) {
             createdService.stop();
         }
@@ -62,7 +77,7 @@ class PrepareServiceImplTest extends BaseIgniteAbstractTest {
     }
 
     @Test
-    void prepareServiceReturnsExistingPlanForExplain() {
+    public void prepareServiceReturnsExistingPlanForExplain() {
         PrepareService service = createPlannerService();
 
         QueryPlan queryPlan = await(service.prepareAsync(
@@ -83,7 +98,7 @@ class PrepareServiceImplTest extends BaseIgniteAbstractTest {
     }
 
     @Test
-    void prepareServiceCachesPlanCreatedForExplain() {
+    public void prepareServiceCachesPlanCreatedForExplain() {
         PrepareService service = createPlannerService();
 
         QueryPlan explainPlan = await(service.prepareAsync(
@@ -103,11 +118,138 @@ class PrepareServiceImplTest extends BaseIgniteAbstractTest {
         assertThat(plan.plan(), sameInstance(queryPlan));
     }
 
+    @Test
+    public void prepareReturnsQueryPlanThatDependsOnParameterTypeMatchInferred() {
+        PrepareService service = createPlannerService();
+
+        QueryPlan queryPlan1 = await(service.prepareAsync(
+                parse("SELECT * FROM t WHERE a = ? and c = ?"),
+                createContext()
+        ));
+
+        List<ColumnType> parameterTypes = queryPlan1.parameterMetadata().parameterTypes()
+                .stream()
+                .map(ParameterType::columnType)
+                .collect(Collectors.toList());
+
+        assertEquals(List.of(ColumnType.INT64, ColumnType.INT32), parameterTypes);
+
+        // Parameter types match, we should return plan1.
+        QueryPlan queryPlan2 = await(service.prepareAsync(
+                parse("SELECT * FROM t WHERE a = ? and c = ?"),
+                createContext(1L, 1))
+        );
+        assertSame(queryPlan1, queryPlan2);
+
+        // Parameter types do not match
+        QueryPlan queryPlan3 = await(service.prepareAsync(
+                parse("SELECT * FROM t WHERE a = ? and c = ?"),
+                createContext(1, 1L)
+        ));
+        assertNotSame(queryPlan1, queryPlan3);
+    }
+
+    @Test
+    public void prepareReturnsDmlPlanThatDependsOnParameterTypeMatchInferred() {
+        PrepareService service = createPlannerService();
+
+        QueryPlan queryPlan1 = await(service.prepareAsync(
+                parse("UPDATE t SET a = ? WHERE c = ?"),
+                createContext()
+        ));
+
+        List<ColumnType> parameterTypes = queryPlan1.parameterMetadata().parameterTypes()
+                .stream()
+                .map(ParameterType::columnType)
+                .collect(Collectors.toList());
+
+        assertEquals(List.of(ColumnType.INT64, ColumnType.INT32), parameterTypes);
+
+        // Parameter types match, we should return plan1.
+        QueryPlan queryPlan2 = await(service.prepareAsync(
+                parse("UPDATE t SET a = ? WHERE c = ?"),
+                createContext(1L, 1)
+        ));
+        assertSame(queryPlan1, queryPlan2);
+
+        // Parameter types do not match
+        QueryPlan queryPlan3 = await(service.prepareAsync(
+                parse("UPDATE t SET a = ? WHERE c = ?"),
+                createContext(1, 1L)
+        ));
+        assertNotSame(queryPlan1, queryPlan3);
+    }
+
+    @Test
+    public void preparePropagatesValidationError() {
+        PrepareService service = createPlannerService();
+
+        assertThrowsSqlException(Sql.STMT_VALIDATION_ERR,
+                "Ambiguous operator <UNKNOWN> + <UNKNOWN>. Dynamic parameter requires adding explicit type cast",
+                () -> {
+                    ParsedResult parsedResult = parse("SELECT ? + ?");
+                    BaseQueryContext context = createContext();
+                    await(service.prepareAsync(parsedResult, context));
+                }
+        );
+    }
+
+    @ParameterizedTest
+    @MethodSource("parameterTypes")
+    public void prepareParamInPredicateAllTypes(NativeType nativeType, int precision, int scale) {
+        IgniteTable table = TestBuilders.table()
+                .name("T")
+                .addColumn("C", nativeType)
+                .distribution(IgniteDistributions.single())
+                .build();
+
+        PrepareService service = createPlannerService();
+
+        IgniteSchema schema = new IgniteSchema("PUBLIC", 0, List.of(table));
+        Object paramValue = SqlTestUtils.generateValueByType(nativeType.spec().asColumnType());
+
+        QueryPlan queryPlan = await(service.prepareAsync(
+                parse("SELECT * FROM t WHERE c = ?"),
+                createContext(schema, paramValue)
+        ));
+
+        ParameterType parameterType = queryPlan.parameterMetadata().parameterTypes().get(0);
+
+        ColumnType columnType = nativeType.spec().asColumnType();
+        assertEquals(columnType, parameterType.columnType(), "Column type does not match: " + parameterType);
+        assertEquals(precision, parameterType.precision(), "Precision does not match: " + parameterType);
+        assertEquals(scale, parameterType.scale(), "Scale does not match: " + parameterType);
+        assertTrue(parameterType.nullable(), "Nullabilty does not match: " + parameterType);
+    }
+
+    private static Stream<Arguments> parameterTypes() {
+        int noScale = ColumnMetadata.UNDEFINED_SCALE;
+        int noPrecision = ColumnMetadata.UNDEFINED_PRECISION;
+
+        return Stream.of(
+                Arguments.of(NativeTypes.BOOLEAN, noPrecision, noScale),
+                Arguments.of(NativeTypes.INT8, noPrecision, noScale),
+                Arguments.of(NativeTypes.INT16, noPrecision, noScale),
+                Arguments.of(NativeTypes.INT32, noPrecision, noScale),
+                Arguments.of(NativeTypes.INT64, noPrecision, noScale),
+                Arguments.of(NativeTypes.FLOAT, noPrecision, noScale),
+                Arguments.of(NativeTypes.DOUBLE, noPrecision, noScale),
+                Arguments.of(NativeTypes.decimalOf(10, 2), Short.MAX_VALUE, 0),
+                Arguments.of(NativeTypes.stringOf(42), -1, noScale),
+                Arguments.of(NativeTypes.blobOf(42), -1, noScale),
+                Arguments.of(NativeTypes.UUID, noPrecision, noScale),
+                Arguments.of(NativeTypes.DATE, noPrecision, noScale),
+                Arguments.of(NativeTypes.time(2), 0, noScale),
+                Arguments.of(NativeTypes.datetime(2), 6, noScale),
+                Arguments.of(NativeTypes.timestamp(2), 6, noScale)
+        );
+    }
+
     private static ParsedResult parse(String query) {
         return new ParserServiceImpl(0, EmptyCacheFactory.INSTANCE).parse(query);
     }
 
-    private static BaseQueryContext createContext() {
+    private static BaseQueryContext createContext(Object... params) {
         return BaseQueryContext.builder()
                 .queryId(UUID.randomUUID())
                 .frameworkConfig(
@@ -115,6 +257,19 @@ class PrepareServiceImplTest extends BaseIgniteAbstractTest {
                                 .defaultSchema(wrap(createSchema()))
                                 .build()
                 )
+                .parameters(params)
+                .build();
+    }
+
+    private static BaseQueryContext createContext(IgniteSchema schema, Object... params) {
+        return BaseQueryContext.builder()
+                .queryId(UUID.randomUUID())
+                .frameworkConfig(
+                        Frameworks.newConfigBuilder(FRAMEWORK_CONFIG)
+                                .defaultSchema(wrap(schema))
+                                .build()
+                )
+                .parameters(params)
                 .build();
     }
 
@@ -129,6 +284,7 @@ class PrepareServiceImplTest extends BaseIgniteAbstractTest {
     private static IgniteSchema createSchema() {
         IgniteTable table = TestBuilders.table()
                 .name("T")
+                .addColumn("A", NativeTypes.INT64)
                 .addColumn("C", NativeTypes.INT32)
                 .distribution(IgniteDistributions.single())
                 .build();
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/CommonsTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/CommonsTest.java
index 344e94e021..95acc299f5 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/CommonsTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/CommonsTest.java
@@ -19,6 +19,8 @@ package org.apache.ignite.internal.sql.engine.util;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 
+import java.util.HashMap;
+import java.util.Map;
 import org.apache.calcite.util.ImmutableBitSet;
 import org.apache.calcite.util.mapping.Mapping;
 import org.apache.calcite.util.mapping.Mappings;
@@ -39,6 +41,17 @@ public class CommonsTest {
         expectMapped(mapping, ImmutableBitSet.of(1, 4, 3, 7), ImmutableBitSet.of(0, 1, 2, 3));
     }
 
+    @Test
+    public void testArrayToMap() {
+        assertEquals(Map.of(), Commons.arrayToMap(null));
+
+        HashMap<Integer, Object> vals = new HashMap<>();
+        vals.put(0, 1);
+        vals.put(1, null);
+        vals.put(2, "3");
+        assertEquals(vals, Commons.arrayToMap(new Object[]{1, null, "3"}));
+    }
+
     private static void expectMapped(Mapping mapping, ImmutableBitSet bitSet, ImmutableBitSet expected) {
         assertEquals(expected, Mappings.apply(mapping, bitSet), "direct mapping");
 
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerTest.java
index e80e4e7b2b..9d1fe05381 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerTest.java
@@ -32,6 +32,7 @@ import org.apache.ignite.internal.sql.engine.framework.NoOpTransaction;
 import org.apache.ignite.internal.sql.engine.framework.TestBuilders;
 import org.apache.ignite.internal.sql.engine.framework.TestCluster;
 import org.apache.ignite.internal.sql.engine.framework.TestNode;
+import org.apache.ignite.internal.sql.engine.prepare.ParameterMetadata;
 import org.apache.ignite.internal.sql.engine.prepare.QueryPlan;
 import org.apache.ignite.internal.sql.engine.property.SqlProperties;
 import org.apache.ignite.internal.sql.engine.tx.QueryTransactionWrapperImpl;
@@ -266,6 +267,12 @@ public class QueryCheckerTest extends BaseIgniteAbstractTest {
             this.node = node;
         }
 
+        @Override
+        public CompletableFuture<ParameterMetadata> prepareSingleAsync(SqlProperties properties,
+                @Nullable InternalTransaction transaction, String qry, Object... params) {
+            throw new UnsupportedOperationException();
+        }
+
         @Override
         public CompletableFuture<AsyncSqlCursor<InternalSqlRow>> querySingleAsync(
                 SqlProperties properties,
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementChecker.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementChecker.java
index b49b003475..e2d2ef252d 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementChecker.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementChecker.java
@@ -31,14 +31,19 @@ import java.util.function.BiFunction;
 import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.function.Supplier;
+import java.util.stream.Collectors;
 import org.apache.calcite.plan.RelOptUtil;
 import org.apache.calcite.rel.RelNode;
+import org.apache.calcite.rel.type.RelDataType;
+import org.apache.calcite.rel.type.RelDataTypeField;
 import org.apache.calcite.rex.RexNode;
 import org.apache.calcite.sql.SqlExplainFormat;
 import org.apache.calcite.sql.SqlExplainLevel;
+import org.apache.calcite.sql.type.SqlTypeName;
 import org.apache.ignite.internal.sql.engine.framework.TestBuilders;
 import org.apache.ignite.internal.sql.engine.framework.TestBuilders.TableBuilder;
 import org.apache.ignite.internal.sql.engine.framework.TestTable;
+import org.apache.ignite.internal.sql.engine.prepare.IgnitePlanner;
 import org.apache.ignite.internal.sql.engine.rel.IgniteIndexScan;
 import org.apache.ignite.internal.sql.engine.rel.IgniteProject;
 import org.apache.ignite.internal.sql.engine.rel.IgniteRel;
@@ -49,6 +54,7 @@ import org.apache.ignite.internal.sql.engine.schema.IgniteSchema;
 import org.apache.ignite.internal.sql.engine.schema.IgniteTable;
 import org.apache.ignite.internal.sql.engine.trait.IgniteDistributions;
 import org.apache.ignite.internal.type.NativeType;
+import org.apache.ignite.internal.util.Pair;
 import org.hamcrest.CoreMatchers;
 import org.hamcrest.Description;
 import org.hamcrest.Matcher;
@@ -120,6 +126,8 @@ public class StatementChecker {
 
     private Consumer<StatementChecker> setup = (checker) -> {};
 
+    private List<RelDataType> expectedParameterTypes;
+
     private BiFunction<String, RelNode, String> planToString = (header, plan) -> {
         return RelOptUtil.dumpPlan(
                 header, plan,
@@ -141,7 +149,7 @@ public class StatementChecker {
          * @param sql An SQL statement.
          * @param params A list of dynamic parameters.
          */
-        RelNode prepare(IgniteSchema schema, String sql, List<Object> params) throws Exception;
+        Pair<IgniteRel, IgnitePlanner> prepare(IgniteSchema schema, String sql, List<Object> params) throws Exception;
     }
 
     /** Sets a function that is going to be called prior to test run. */
@@ -226,6 +234,24 @@ public class StatementChecker {
         return this;
     }
 
+    /** Specifies expected types of dynamic parameters. Use {@code null} to represent {@link SqlTypeName#NULL}. */
+    public StatementChecker parameterTypes(NativeType... types) {
+        this.expectedParameterTypes = Arrays.stream(types)
+                .map(t -> {
+                    if (t == null) {
+                        return Commons.typeFactory().createSqlType(SqlTypeName.NULL);
+                    } else {
+                        return TypeUtils.native2relationalType(Commons.typeFactory(), t);
+                    }
+                }).collect(Collectors.toList());
+        return this;
+    }
+
+    public StatementChecker parameterTypes(RelDataType... types) {
+        this.expectedParameterTypes = Arrays.asList(types);
+        return this;
+    }
+
     /** Expect that validation succeeds. */
     public DynamicTest ok() {
         return ok((node) -> {}, true);
@@ -288,6 +314,9 @@ public class StatementChecker {
         return shouldFail(name, exception, new TypeSafeMatcher<Throwable>() {
             @Override
             protected boolean matchesSafely(Throwable t) {
+                if (t.getMessage() == null) {
+                    return false;
+                }
                 return t.getMessage().contains(errorMessage);
             }
 
@@ -365,7 +394,7 @@ public class StatementChecker {
     }
 
     /** Executes default checks. Runs before a check provide by {@link StatementChecker#ok(Consumer)}. */
-    protected void checkRel(IgniteRel igniteRel, IgniteSchema schema) {
+    protected void checkRel(IgniteRel igniteRel, IgnitePlanner planner, IgniteSchema schema) {
 
     }
 
@@ -387,11 +416,15 @@ public class StatementChecker {
         return DynamicTest.dynamicTest(name, () -> {
             IgniteSchema schema = initSchema(exception);
             IgniteRel root;
+            IgnitePlanner planner;
 
             try {
-                root = (IgniteRel) sqlPrepare.prepare(schema, sqlStatement, dynamicParams);
+                Pair<IgniteRel, IgnitePlanner> result = sqlPrepare.prepare(schema, sqlStatement, dynamicParams);
+                root = result.getFirst();
+                planner = result.getSecond();
+
                 if (relCheck) {
-                    checkRel(root, schema);
+                    checkRel(root, planner, schema);
                 }
             } catch (Throwable e) {
                 String message = format("Failed to validate:\n{}\n", formatSqlStatementForErrorMessage());
@@ -405,6 +438,8 @@ public class StatementChecker {
 
             try {
                 check.accept(root);
+
+                checkParameterTypes(planner);
             } catch (Throwable e) {
                 String planDump = buildPlanInfo(root);
                 String mismatchDescription = format("Plan does not match:\n\n{}", planDump);
@@ -443,7 +478,8 @@ public class StatementChecker {
             Throwable err = null;
             IgniteRel unexpectedPlan = null;
             try {
-                unexpectedPlan = (IgniteRel) sqlPrepare.prepare(schema, sqlStatement, dynamicParams);
+                Pair<IgniteRel, ?> unexpected = sqlPrepare.prepare(schema, sqlStatement, dynamicParams);
+                unexpectedPlan = unexpected.getFirst();
             } catch (Throwable t) {
                 err = t;
             }
@@ -451,7 +487,7 @@ public class StatementChecker {
             if (err != null) {
                 if (!matcher.matches(err)) {
                     StringDescription desc = new StringDescription();
-                    matcher.describeTo(desc);
+                    matcher.describeMismatch(err, desc);
 
                     AssertionFailedError error = AssertionFailureBuilder.assertionFailure()
                             .reason("Error does not match")
@@ -520,5 +556,23 @@ public class StatementChecker {
 
         assertEquals(expected, projectionString, "Projection list does not match");
     }
+
+    private void checkParameterTypes(IgnitePlanner planner) {
+        if (expectedParameterTypes != null) {
+            RelDataType parameterRowType = planner.getParameterRowType();
+            // Compare string representation because that representation includes nullability attribute.
+
+            List<String> actualTypes = parameterRowType.getFieldList().stream()
+                    .map(RelDataTypeField::getType)
+                    .map(RelDataType::getFullTypeString)
+                    .collect(Collectors.toList());
+
+            List<String> expectedTypes = expectedParameterTypes.stream()
+                    .map(RelDataType::getFullTypeString)
+                    .collect(Collectors.toList());
+
+            assertEquals(expectedTypes, actualTypes, "Parameters do not match");
+        }
+    }
 }
 
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementCheckerTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementCheckerTest.java
index 6cc74a1ae7..ebddaef6c2 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementCheckerTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/StatementCheckerTest.java
@@ -31,17 +31,18 @@ import java.io.StringWriter;
 import java.util.List;
 import java.util.Map;
 import org.apache.calcite.plan.RelTraitSet;
-import org.apache.calcite.rel.RelNode;
 import org.apache.calcite.rel.type.RelDataType;
 import org.apache.calcite.rex.RexBuilder;
 import org.apache.calcite.rex.RexLiteral;
 import org.apache.calcite.sql.type.SqlTypeName;
+import org.apache.ignite.internal.sql.engine.prepare.IgnitePlanner;
 import org.apache.ignite.internal.sql.engine.rel.IgniteRel;
 import org.apache.ignite.internal.sql.engine.rel.IgniteValues;
 import org.apache.ignite.internal.sql.engine.schema.IgniteSchema;
 import org.apache.ignite.internal.sql.engine.type.IgniteTypeFactory;
 import org.apache.ignite.internal.sql.engine.util.StatementChecker.SqlPrepare;
 import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.apache.ignite.internal.util.Pair;
 import org.hamcrest.Matchers;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.DynamicTest;
@@ -54,7 +55,7 @@ import org.opentest4j.AssertionFailedError;
  */
 public class StatementCheckerTest extends BaseIgniteAbstractTest {
 
-    private final RelNode dummyNode = Mockito.mock(IgniteRel.class);
+    private final Pair<IgniteRel, IgnitePlanner> dummyNode = new Pair<>(Mockito.mock(IgniteRel.class), Mockito.mock(IgnitePlanner.class));
 
     private final SqlPrepare sqlPrepare = Mockito.mock(SqlPrepare.class);
 
@@ -144,8 +145,9 @@ public class StatementCheckerTest extends BaseIgniteAbstractTest {
                 .add(ImmutableList.of(lit))
                 .add(ImmutableList.of(lit)).build(), RelTraitSet.createEmpty());
 
+        Pair<IgniteValues, IgnitePlanner> result = new Pair<>(values, Mockito.mock(IgnitePlanner.class));
         when(sqlPrepare.prepare(any(IgniteSchema.class), any(String.class), any(List.class)))
-                .thenReturn(values);
+                .thenReturn(result);
 
         DynamicTest test = newChecker().sql("SELECT 1")
                 .planToString((header, node) -> {
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/metrics/PlanningCacheMetricsTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/metrics/PlanningCacheMetricsTest.java
index 163ed1a531..df43751fe4 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/metrics/PlanningCacheMetricsTest.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/metrics/PlanningCacheMetricsTest.java
@@ -56,20 +56,20 @@ public class PlanningCacheMetricsTest extends AbstractPlannerTest {
         MetricSet metricSet = metricManager.enable(SqlPlanCacheMetricSource.NAME);
 
         try {
-            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 0, 1);
-            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 1, 1);
+            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 0, 2);
+            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 1, 2);
 
-            checkCachePlanStatistics("SELECT * FROM T t1, T t2", prepareService, metricSet, 1, 2);
-            checkCachePlanStatistics("SELECT * FROM T t1, T t2", prepareService, metricSet, 2, 2);
-            checkCachePlanStatistics("SELECT * FROM T t1, T t2", prepareService, metricSet, 3, 2);
+            checkCachePlanStatistics("SELECT * FROM T t1, T t2", prepareService, metricSet, 1, 4);
+            checkCachePlanStatistics("SELECT * FROM T t1, T t2", prepareService, metricSet, 2, 4);
+            checkCachePlanStatistics("SELECT * FROM T t1, T t2", prepareService, metricSet, 3, 4);
 
-            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 4, 2);
+            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 4, 4);
 
-            checkCachePlanStatistics("SELECT * FROM T t1, T t2, T t3", prepareService, metricSet, 4, 3);
+            checkCachePlanStatistics("SELECT * FROM T t1, T t2, T t3", prepareService, metricSet, 4, 6);
 
             // Here, the very first plan has been evicted from cache.
-            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 4, 4);
-            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 5, 4);
+            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 4, 8);
+            checkCachePlanStatistics("SELECT * FROM T", prepareService, metricSet, 5, 8);
         } finally {
             prepareService.stop();
         }
@@ -92,7 +92,7 @@ public class PlanningCacheMetricsTest extends AbstractPlannerTest {
         await(prepareService.prepareAsync(parsedResult, ctx));
 
         assertEquals("sql.plan.cache", metricSet.name());
-        assertEquals(String.valueOf(hits), metricSet.get("Hits").getValueAsString());
-        assertEquals(String.valueOf(misses), metricSet.get("Misses").getValueAsString());
+        assertEquals(String.valueOf(hits), metricSet.get("Hits").getValueAsString(), "Hits");
+        assertEquals(String.valueOf(misses), metricSet.get("Misses").getValueAsString(), "Misses");
     }
 }
diff --git a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/SqlTestUtils.java b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/SqlTestUtils.java
index e3fbcb01fd..2ce4b1c59d 100644
--- a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/SqlTestUtils.java
+++ b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/SqlTestUtils.java
@@ -22,7 +22,10 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.core.StringContains.containsString;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.fail;
 
+import java.io.PrintWriter;
+import java.io.StringWriter;
 import java.math.BigDecimal;
 import java.math.BigInteger;
 import java.time.Duration;
@@ -49,6 +52,8 @@ import org.apache.ignite.lang.ErrorGroup;
 import org.apache.ignite.lang.ErrorGroups;
 import org.apache.ignite.sql.ColumnType;
 import org.apache.ignite.sql.SqlException;
+import org.hamcrest.CoreMatchers;
+import org.hamcrest.StringDescription;
 import org.junit.jupiter.api.function.Executable;
 
 /**
@@ -92,6 +97,34 @@ public class SqlTestUtils {
         String expectedError = format("{}-{}", expectedErrorGroup.name(), expectedErrorCode);
         String actualError = format("{}-{}", ex.groupName(), ex.errorCode());
 
+        boolean errorMatches = expectedError.equals(actualError);
+        boolean errorMessageMatches = CoreMatchers.containsString(expectedMessage).matches(ex.getMessage());
+
+        if (!errorMatches || !errorMessageMatches) {
+            StringWriter sw = new StringWriter();
+
+            try (PrintWriter pw = new PrintWriter(sw)) {
+                StringDescription description = new StringDescription();
+
+                if (errorMatches) {
+                    description.appendText("Error code does not match. Expected: ");
+                    description.appendValue(expectedError);
+                    description.appendText(" actual: ");
+                    description.appendValue(actualError);
+                } else {
+                    description.appendText("Error message does not match. Expected to include: ");
+                    description.appendValue(expectedMessage);
+                    description.appendText(" actual: ");
+                    description.appendValue(ex.getMessage());
+                }
+
+                pw.println(description);
+                ex.printStackTrace(pw);
+            }
+
+            fail(sw.toString());
+        }
+
         assertEquals(expectedError, actualError, "Error does not match. " + ex);
         assertThat("Error message", ex.getMessage(), containsString(expectedMessage));