You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ignite.apache.org by ko...@apache.org on 2023/07/05 10:25:46 UTC

[ignite-3] branch main updated: IGNITE-17765 Sql. Introduce cache for parsed statements (#2280)

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

korlov 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 5646997ae4 IGNITE-17765 Sql. Introduce cache for parsed statements (#2280)
5646997ae4 is described below

commit 5646997ae40d6fa1a936d928291d7bc2a09f5bf5
Author: korlov42 <ko...@gridgain.com>
AuthorDate: Wed Jul 5 13:25:40 2023 +0300

    IGNITE-17765 Sql. Introduce cache for parsed statements (#2280)
---
 .../internal/sql/engine/SqlQueryProcessor.java     |  52 +++----
 .../sql/engine/prepare/PrepareService.java         |   4 +-
 .../sql/engine/prepare/PrepareServiceImpl.java     |  59 +++----
 .../internal/sql/engine/sql/ParsedResult.java      |  48 ++++++
 .../PrepareService.java => sql/ParserService.java} |  21 +--
 .../internal/sql/engine/sql/ParserServiceImpl.java | 130 ++++++++++++++++
 .../ignite/internal/sql/engine/util/Cache.java     |  52 +++++++
 .../PrepareService.java => util/CacheFactory.java} |  20 +--
 .../sql/engine/util/CaffeineCacheFactory.java      |  66 ++++++++
 .../engine/benchmarks/TpchPrepareBenchmark.java    |  13 +-
 .../sql/engine/exec/ExecutionServiceImplTest.java  |  14 +-
 .../internal/sql/engine/framework/TestNode.java    |  29 ++--
 .../sql/engine/sql/ParserServiceImplTest.java      | 171 +++++++++++++++++++++
 .../sql/engine/util/EmptyCacheFactory.java         |  57 +++++++
 .../internal/sql/engine/util/StatementChecker.java |   4 +-
 15 files changed, 632 insertions(+), 108 deletions(-)

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 88e7b590d9..9b56c73757 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
@@ -23,7 +23,6 @@ import static org.apache.ignite.lang.ErrorGroups.Sql.QUERY_INVALID_ERR;
 import static org.apache.ignite.lang.ErrorGroups.Sql.SESSION_EXPIRED_ERR;
 import static org.apache.ignite.lang.ErrorGroups.Sql.SESSION_NOT_FOUND_ERR;
 import static org.apache.ignite.lang.ErrorGroups.Sql.UNSUPPORTED_DDL_OPERATION_ERR;
-import static org.apache.ignite.lang.ErrorGroups.Sql.UNSUPPORTED_SQL_OPERATION_KIND_ERR;
 import static org.apache.ignite.lang.IgniteStringFormatter.format;
 
 import java.util.ArrayList;
@@ -41,8 +40,6 @@ import java.util.function.Supplier;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.apache.calcite.schema.SchemaPlus;
-import org.apache.calcite.sql.SqlKind;
-import org.apache.calcite.sql.SqlNode;
 import org.apache.calcite.tools.Frameworks;
 import org.apache.calcite.util.Pair;
 import org.apache.ignite.internal.catalog.CatalogManager;
@@ -81,11 +78,11 @@ import org.apache.ignite.internal.sql.engine.session.SessionId;
 import org.apache.ignite.internal.sql.engine.session.SessionInfo;
 import org.apache.ignite.internal.sql.engine.session.SessionManager;
 import org.apache.ignite.internal.sql.engine.session.SessionProperty;
-import org.apache.ignite.internal.sql.engine.sql.IgniteSqlParser;
-import org.apache.ignite.internal.sql.engine.sql.ParseResult;
-import org.apache.ignite.internal.sql.engine.sql.StatementParseResult;
+import org.apache.ignite.internal.sql.engine.sql.ParsedResult;
+import org.apache.ignite.internal.sql.engine.sql.ParserService;
+import org.apache.ignite.internal.sql.engine.sql.ParserServiceImpl;
 import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
-import org.apache.ignite.internal.sql.engine.util.Commons;
+import org.apache.ignite.internal.sql.engine.util.CaffeineCacheFactory;
 import org.apache.ignite.internal.sql.engine.util.TypeUtils;
 import org.apache.ignite.internal.storage.DataStorageManager;
 import org.apache.ignite.internal.table.distributed.TableManager;
@@ -116,6 +113,8 @@ public class SqlQueryProcessor implements QueryProcessor {
     /** Size of the cache for query plans. */
     private static final int PLAN_CACHE_SIZE = 1024;
 
+    private static final int PARSED_RESULT_CACHE_SIZE = 10_000;
+
     /** Size of the table access cache. */
     private static final int TABLE_CACHE_SIZE = 1024;
 
@@ -136,6 +135,10 @@ public class SqlQueryProcessor implements QueryProcessor {
             .set(SessionProperty.IDLE_TIMEOUT, DEFAULT_SESSION_IDLE_TIMEOUT)
             .build();
 
+    private final ParserService parserService = new ParserServiceImpl(
+            PARSED_RESULT_CACHE_SIZE, CaffeineCacheFactory.INSTANCE
+    );
+
     private final List<LifecycleAware> services = new ArrayList<>();
 
     private final ClusterService clusterSrvc;
@@ -420,16 +423,12 @@ public class SqlQueryProcessor implements QueryProcessor {
         AtomicReference<InternalTransaction> tx = new AtomicReference<>();
 
         CompletableFuture<AsyncSqlCursor<List<Object>>> stage = start
-                .thenApply(v -> {
-                    StatementParseResult parseResult = IgniteSqlParser.parse(sql, StatementParseResult.MODE);
-                    SqlNode sqlNode = parseResult.statement();
+                .thenCompose(ignored -> {
+                    ParsedResult result = parserService.parse(sql);
 
-                    validateParsedStatement(context, outerTx, parseResult, sqlNode, params);
+                    validateParsedStatement(context, outerTx, result, params);
 
-                    return sqlNode;
-                })
-                .thenCompose(sqlNode -> {
-                    boolean rwOp = dataModificationOp(sqlNode);
+                    boolean rwOp = dataModificationOp(result);
 
                     boolean implicitTxRequired = outerTx == null;
 
@@ -453,7 +452,7 @@ public class SqlQueryProcessor implements QueryProcessor {
                             .plannerTimeout(PLANNER_TIMEOUT)
                             .build();
 
-                    return prepareSvc.prepareAsync(sqlNode, ctx)
+                    return prepareSvc.prepareAsync(result, ctx)
                             .thenApply(plan -> {
                                 var dataCursor = executionSrvc.executePlan(tx.get(), plan, ctx);
 
@@ -609,26 +608,19 @@ public class SqlQueryProcessor implements QueryProcessor {
     }
 
     /** Returns {@code true} if this is data modification operation. */
-    private static boolean dataModificationOp(SqlNode sqlNode) {
-        return SqlKind.DML.contains(sqlNode.getKind());
+    private static boolean dataModificationOp(ParsedResult parsedResult) {
+        return parsedResult.queryType() == SqlQueryType.DML;
     }
 
     /** Performs additional validation of a parsed statement. **/
     private static void validateParsedStatement(
             QueryContext context,
-            InternalTransaction outerTx,
-            ParseResult parseResult,
-            SqlNode node,
+            @Nullable InternalTransaction outerTx,
+            ParsedResult parsedResult,
             Object[] params
     ) {
         Set<SqlQueryType> allowedTypes = context.allowedQueryTypes();
-        SqlQueryType queryType = Commons.getQueryType(node);
-
-        if (queryType == null) {
-            throw new IgniteInternalException(UNSUPPORTED_SQL_OPERATION_KIND_ERR, "Unsupported operation ["
-                    + "sqlNodeKind=" + node.getKind() + "; "
-                    + "querySql=\"" + node + "\"]");
-        }
+        SqlQueryType queryType = parsedResult.queryType();
 
         if (!allowedTypes.contains(queryType)) {
             String message = format("Invalid SQL statement type in the batch. Expected {} but got {}.", allowedTypes, queryType);
@@ -640,10 +632,10 @@ public class SqlQueryProcessor implements QueryProcessor {
             throw new SqlException(UNSUPPORTED_DDL_OPERATION_ERR, "DDL doesn't support transactions.");
         }
 
-        if (parseResult.dynamicParamsCount() != params.length) {
+        if (parsedResult.dynamicParamsCount() != params.length) {
             String message = format(
                     "Unexpected number of query parameters. Provided {} but there is only {} dynamic parameter(s).",
-                    params.length, parseResult.dynamicParamsCount()
+                    params.length, parsedResult.dynamicParamsCount()
             );
 
             throw new SqlException(QUERY_INVALID_ERR, message);
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 7a42727412..907ef4c9a2 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
@@ -18,8 +18,8 @@
 package org.apache.ignite.internal.sql.engine.prepare;
 
 import java.util.concurrent.CompletableFuture;
-import org.apache.calcite.sql.SqlNode;
 import org.apache.ignite.internal.sql.engine.exec.LifecycleAware;
+import org.apache.ignite.internal.sql.engine.sql.ParsedResult;
 import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
 
 /**
@@ -29,5 +29,5 @@ public interface PrepareService extends LifecycleAware {
     /**
      * Prepare query plan.
      */
-    CompletableFuture<QueryPlan> prepareAsync(SqlNode sqlNode, BaseQueryContext ctx);
+    CompletableFuture<QueryPlan> prepareAsync(ParsedResult parsedResult, BaseQueryContext ctx);
 }
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 23a42b3a46..b3992f0076 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
@@ -21,7 +21,6 @@ import static org.apache.ignite.internal.sql.engine.prepare.CacheKey.EMPTY_CLASS
 import static org.apache.ignite.internal.sql.engine.prepare.PlannerHelper.optimize;
 import static org.apache.ignite.internal.sql.engine.trait.TraitUtils.distributionPresent;
 import static org.apache.ignite.lang.ErrorGroups.Sql.QUERY_VALIDATION_ERR;
-import static org.apache.ignite.lang.ErrorGroups.Sql.UNSUPPORTED_SQL_OPERATION_KIND_ERR;
 
 import com.github.benmanes.caffeine.cache.Caffeine;
 import java.util.ArrayList;
@@ -46,12 +45,11 @@ import org.apache.ignite.internal.logger.IgniteLogger;
 import org.apache.ignite.internal.logger.Loggers;
 import org.apache.ignite.internal.sql.api.ColumnMetadataImpl;
 import org.apache.ignite.internal.sql.api.ResultSetMetadataImpl;
-import org.apache.ignite.internal.sql.engine.SqlQueryType;
 import org.apache.ignite.internal.sql.engine.prepare.ddl.DdlSqlToCommandConverter;
 import org.apache.ignite.internal.sql.engine.rel.IgniteRel;
 import org.apache.ignite.internal.sql.engine.schema.SchemaUpdateListener;
+import org.apache.ignite.internal.sql.engine.sql.ParsedResult;
 import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
-import org.apache.ignite.internal.sql.engine.util.Commons;
 import org.apache.ignite.internal.sql.engine.util.TypeUtils;
 import org.apache.ignite.internal.storage.DataStorageManager;
 import org.apache.ignite.internal.thread.NamedThreadFactory;
@@ -143,34 +141,27 @@ public class PrepareServiceImpl implements PrepareService, SchemaUpdateListener
 
     /** {@inheritDoc} */
     @Override
-    public CompletableFuture<QueryPlan> prepareAsync(SqlNode sqlNode, BaseQueryContext ctx) {
+    public CompletableFuture<QueryPlan> prepareAsync(ParsedResult parsedResult, BaseQueryContext ctx) {
         try {
-            assert single(sqlNode);
-
-            SqlQueryType queryType = Commons.getQueryType(sqlNode);
-            assert queryType != null : "No query type for query: " + sqlNode;
-
             PlanningContext planningContext = PlanningContext.builder()
                     .parentContext(ctx)
                     .build();
 
-            switch (queryType) {
+            switch (parsedResult.queryType()) {
                 case QUERY:
-                    return prepareQuery(sqlNode, planningContext);
+                    return prepareQuery(parsedResult, planningContext);
 
                 case DDL:
-                    return prepareDdl(sqlNode, planningContext);
+                    return prepareDdl(parsedResult, planningContext);
 
                 case DML:
-                    return prepareDml(sqlNode, planningContext);
+                    return prepareDml(parsedResult, planningContext);
 
                 case EXPLAIN:
-                    return prepareExplain(sqlNode, planningContext);
+                    return prepareExplain(parsedResult, planningContext);
 
                 default:
-                    throw new IgniteInternalException(UNSUPPORTED_SQL_OPERATION_KIND_ERR, "Unsupported operation ["
-                            + "sqlNodeKind=" + sqlNode.getKind() + "; "
-                            + "querySql=\"" + planningContext.query() + "\"]");
+                    throw new AssertionError("Unexpected queryType=" + parsedResult.queryType());
             }
         } catch (CalciteContextException e) {
             throw new IgniteInternalException(QUERY_VALIDATION_ERR, "Failed to validate query. " + e.getMessage(), e);
@@ -183,19 +174,25 @@ public class PrepareServiceImpl implements PrepareService, SchemaUpdateListener
         cache.clear();
     }
 
-    private CompletableFuture<QueryPlan> prepareDdl(SqlNode sqlNode, PlanningContext ctx) {
+    private CompletableFuture<QueryPlan> prepareDdl(ParsedResult parsedResult, PlanningContext ctx) {
+        SqlNode sqlNode = parsedResult.parsedTree();
+
         assert sqlNode instanceof SqlDdl : sqlNode == null ? "null" : sqlNode.getClass().getName();
 
         return CompletableFuture.completedFuture(new DdlPlan(ddlConverter.convert((SqlDdl) sqlNode, ctx)));
     }
 
-    private CompletableFuture<QueryPlan> prepareExplain(SqlNode explain, PlanningContext ctx) {
+    private CompletableFuture<QueryPlan> prepareExplain(ParsedResult parsedResult, PlanningContext ctx) {
         return CompletableFuture.supplyAsync(() -> {
             IgnitePlanner planner = ctx.planner();
 
+            SqlNode sqlNode = parsedResult.parsedTree();
+
+            assert single(sqlNode);
+
             // Validate
             // We extract query subtree inside the validator.
-            SqlNode explainNode = planner.validate(explain);
+            SqlNode explainNode = planner.validate(sqlNode);
             // Extract validated query.
             SqlNode validNode = ((SqlExplain) explainNode).getExplicandum();
 
@@ -208,16 +205,20 @@ public class PrepareServiceImpl implements PrepareService, SchemaUpdateListener
         }, planningPool);
     }
 
-    private boolean single(SqlNode sqlNode) {
+    private static boolean single(SqlNode sqlNode) {
         return !(sqlNode instanceof SqlNodeList);
     }
 
-    private CompletableFuture<QueryPlan> prepareQuery(SqlNode sqlNode, PlanningContext ctx) {
-        CacheKey key = createCacheKey(sqlNode, ctx);
+    private CompletableFuture<QueryPlan> prepareQuery(ParsedResult parsedResult, PlanningContext ctx) {
+        CacheKey key = createCacheKey(parsedResult, ctx);
 
         CompletableFuture<QueryPlan> planFut = cache.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> {
             IgnitePlanner planner = ctx.planner();
 
+            SqlNode sqlNode = parsedResult.parsedTree();
+
+            assert single(sqlNode);
+
             // Validate
             ValidationResult validated = planner.validateAndGetTypeMetadata(sqlNode);
 
@@ -236,12 +237,16 @@ public class PrepareServiceImpl implements PrepareService, SchemaUpdateListener
         return planFut.thenApply(QueryPlan::copy);
     }
 
-    private CompletableFuture<QueryPlan> prepareDml(SqlNode sqlNode, PlanningContext ctx) {
-        var key = createCacheKey(sqlNode, ctx);
+    private CompletableFuture<QueryPlan> prepareDml(ParsedResult parsedResult, PlanningContext ctx) {
+        var key = createCacheKey(parsedResult, ctx);
 
         CompletableFuture<QueryPlan> planFut = cache.computeIfAbsent(key, k -> CompletableFuture.supplyAsync(() -> {
             IgnitePlanner planner = ctx.planner();
 
+            SqlNode sqlNode = parsedResult.parsedTree();
+
+            assert single(sqlNode);
+
             // Validate
             SqlNode validatedNode = planner.validate(sqlNode);
 
@@ -259,14 +264,14 @@ public class PrepareServiceImpl implements PrepareService, SchemaUpdateListener
         return planFut.thenApply(QueryPlan::copy);
     }
 
-    private static CacheKey createCacheKey(SqlNode sqlNode, PlanningContext ctx) {
+    private static CacheKey createCacheKey(ParsedResult parsedResult, PlanningContext ctx) {
         boolean distributed = distributionPresent(ctx.config().getTraitDefs());
 
         Class[] paramTypes = ctx.parameters().length == 0
                 ? EMPTY_CLASS_ARRAY :
                 Arrays.stream(ctx.parameters()).map(p -> (p != null) ? p.getClass() : Void.class).toArray(Class[]::new);
 
-        return new CacheKey(ctx.schemaName(), sqlNode.toString(), distributed, paramTypes);
+        return new CacheKey(ctx.schemaName(), parsedResult.normalizedQuery(), distributed, paramTypes);
     }
 
     private ResultSetMetadata resultSetMetadata(
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/sql/ParsedResult.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/sql/ParsedResult.java
new file mode 100644
index 0000000000..27b3a79192
--- /dev/null
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/sql/ParsedResult.java
@@ -0,0 +1,48 @@
+/*
+ * 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.sql;
+
+import org.apache.calcite.sql.SqlNode;
+import org.apache.ignite.internal.sql.engine.SqlQueryType;
+
+/**
+ * Result of the parse.
+ *
+ * @see ParserService
+ */
+public interface ParsedResult {
+    /** Returns the type of the query. */
+    SqlQueryType queryType();
+
+    /** Returns the original query string sent to the parser service, based on which this result was created. */
+    String originalQuery();
+
+    /**
+     * Returns the query string in a normal form.
+     *
+     * <p>That is, with keywords converted to upper case, and all non-quoted identifiers converted to upper case as well
+     * and wrapped with double quotes.
+     */
+    String normalizedQuery();
+
+    /** Returns the count of the dynamic params (specified by question marks in the query text) used in the query. */
+    int dynamicParamsCount();
+
+    /** Returns the syntax tree of the query according to the grammar rules. */
+    SqlNode parsedTree();
+}
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/sql/ParserService.java
similarity index 60%
copy from modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareService.java
copy to modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/sql/ParserService.java
index 7a42727412..d358ebf161 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/sql/ParserService.java
@@ -15,19 +15,20 @@
  * limitations under the License.
  */
 
-package org.apache.ignite.internal.sql.engine.prepare;
-
-import java.util.concurrent.CompletableFuture;
-import org.apache.calcite.sql.SqlNode;
-import org.apache.ignite.internal.sql.engine.exec.LifecycleAware;
-import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
+package org.apache.ignite.internal.sql.engine.sql;
 
 /**
- * Preparation service that accepts an AST of the query and returns a prepared query plan.
+ * A service whose sole purpose is to take a query string and convert it into a syntax tree according to the rules of grammar.
+ *
+ * @see ParsedResult
  */
-public interface PrepareService extends LifecycleAware {
+@SuppressWarnings("InterfaceMayBeAnnotatedFunctional")
+public interface ParserService {
     /**
-     * Prepare query plan.
+     * Takes a query string and convert it into a syntax tree according to the rules of grammar.
+     *
+     * @param query A query to convert.
+     * @return Result of the parsing.
      */
-    CompletableFuture<QueryPlan> prepareAsync(SqlNode sqlNode, BaseQueryContext ctx);
+    ParsedResult parse(String query);
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/sql/ParserServiceImpl.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/sql/ParserServiceImpl.java
new file mode 100644
index 0000000000..bf9a895430
--- /dev/null
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/sql/ParserServiceImpl.java
@@ -0,0 +1,130 @@
+/*
+ * 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.sql;
+
+import java.util.function.Supplier;
+import org.apache.calcite.sql.SqlNode;
+import org.apache.ignite.internal.sql.engine.SqlQueryType;
+import org.apache.ignite.internal.sql.engine.util.Cache;
+import org.apache.ignite.internal.sql.engine.util.CacheFactory;
+import org.apache.ignite.internal.sql.engine.util.Commons;
+
+/**
+ * An implementation of {@link ParserService} that, apart of parsing, introduces cache of parsed results.
+ */
+public class ParserServiceImpl implements ParserService {
+
+    private final Cache<String, ParsedResult> queryToParsedResultCache;
+
+    /**
+     * Constructs the object.
+     *
+     * @param cacheFactory A factory to create cache for parsed results.
+     */
+    public ParserServiceImpl(int cacheSize, CacheFactory cacheFactory) {
+        this.queryToParsedResultCache = cacheFactory.create(cacheSize);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public ParsedResult parse(String query) {
+        ParsedResult cachedResult = queryToParsedResultCache.get(query);
+
+        if (cachedResult != null) {
+            return cachedResult;
+        }
+
+        StatementParseResult parsedStatement = IgniteSqlParser.parse(query, StatementParseResult.MODE);
+
+        SqlNode parsedTree = parsedStatement.statement();
+
+        SqlQueryType queryType = Commons.getQueryType(parsedTree);
+
+        assert queryType != null : parsedTree.toString();
+
+        ParsedResult result = new ParsedResultImpl(
+                queryType,
+                query,
+                parsedTree.toString(),
+                parsedStatement.dynamicParamsCount(),
+                () -> IgniteSqlParser.parse(query, StatementParseResult.MODE).statement()
+        );
+
+        if (shouldBeCached(queryType)) {
+            queryToParsedResultCache.put(query, result);
+        }
+
+        return result;
+    }
+
+    static class ParsedResultImpl implements ParsedResult {
+        private final SqlQueryType queryType;
+        private final String originalQuery;
+        private final String normalizedQuery;
+        private final int dynamicParamCount;
+        private final Supplier<SqlNode> parsedTreeSupplier;
+
+        private ParsedResultImpl(
+                SqlQueryType queryType,
+                String originalQuery,
+                String normalizedQuery,
+                int dynamicParamCount,
+                Supplier<SqlNode> parsedTreeSupplier
+        ) {
+            this.queryType = queryType;
+            this.originalQuery = originalQuery;
+            this.normalizedQuery = normalizedQuery;
+            this.dynamicParamCount = dynamicParamCount;
+            this.parsedTreeSupplier = parsedTreeSupplier;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SqlQueryType queryType() {
+            return queryType;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String originalQuery() {
+            return originalQuery;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public String normalizedQuery() {
+            return normalizedQuery;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public int dynamicParamsCount() {
+            return dynamicParamCount;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        public SqlNode parsedTree() {
+            return parsedTreeSupplier.get();
+        }
+    }
+
+    private static boolean shouldBeCached(SqlQueryType queryType) {
+        return queryType == SqlQueryType.QUERY || queryType == SqlQueryType.DML;
+    }
+}
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/Cache.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/Cache.java
new file mode 100644
index 0000000000..0d31647797
--- /dev/null
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/Cache.java
@@ -0,0 +1,52 @@
+/*
+ * 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.util;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ *  A mapping from keys to values.
+ *
+ * <p>Implementations of this interface are expected to be thread-safe, and can be safely accessed by
+ * multiple concurrent threads.
+ *
+ * @param <K> Type of the key object.
+ * @param <V> Type of the value object.
+ */
+public interface Cache<K, V> {
+    /**
+     * Returns value associated with given key, or null if there no mapping exists.
+     *
+     * @param key A key to look up value for.
+     * @return A value.
+     */
+    @Nullable V get(K key);
+
+    /**
+     * Associates the {@code value} with the {@code key} in this cache. If the cache previously
+     * contained a value associated with the {@code key}, the old value is replaced by the new
+     * {@code value}.
+     *
+     * @param key A key with which the specified value is to be associated.
+     * @param value A value to be associated with the specified key.
+     */
+    void put(K key, V value);
+
+    /** Clears the given cache. That is, remove all keys and associated values. */
+    void clear();
+}
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/util/CacheFactory.java
similarity index 60%
copy from modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/prepare/PrepareService.java
copy to modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/CacheFactory.java
index 7a42727412..710c877f11 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/util/CacheFactory.java
@@ -15,19 +15,19 @@
  * limitations under the License.
  */
 
-package org.apache.ignite.internal.sql.engine.prepare;
-
-import java.util.concurrent.CompletableFuture;
-import org.apache.calcite.sql.SqlNode;
-import org.apache.ignite.internal.sql.engine.exec.LifecycleAware;
-import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
+package org.apache.ignite.internal.sql.engine.util;
 
 /**
- * Preparation service that accepts an AST of the query and returns a prepared query plan.
+ * Factory that creates a cache.
  */
-public interface PrepareService extends LifecycleAware {
+public interface CacheFactory {
     /**
-     * Prepare query plan.
+     * Creates a cache with a desired size.
+     *
+     * @param size Desired size of the cache.
+     * @return An instance of the cache.
+     * @param <K> Type of the key object.
+     * @param <V> Type of the value object.
      */
-    CompletableFuture<QueryPlan> prepareAsync(SqlNode sqlNode, BaseQueryContext ctx);
+    <K, V> Cache<K, V> create(int size);
 }
diff --git a/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/CaffeineCacheFactory.java b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/CaffeineCacheFactory.java
new file mode 100644
index 0000000000..093dca8a9d
--- /dev/null
+++ b/modules/sql-engine/src/main/java/org/apache/ignite/internal/sql/engine/util/CaffeineCacheFactory.java
@@ -0,0 +1,66 @@
+/*
+ * 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.util;
+
+import com.github.benmanes.caffeine.cache.Caffeine;
+import java.util.concurrent.ConcurrentMap;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Factory that creates caches backed by {@link Caffeine} cache.
+ */
+public class CaffeineCacheFactory implements CacheFactory {
+    public static CacheFactory INSTANCE = new CaffeineCacheFactory();
+
+    private CaffeineCacheFactory() {
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public <K, V> Cache<K, V> create(int size) {
+        return new ConcurrentMapToCacheAdapter<>(
+                Caffeine.newBuilder()
+                        .maximumSize(size)
+                        .<K, V>build()
+                        .asMap()
+        );
+    }
+
+    private static class ConcurrentMapToCacheAdapter<K, V> implements Cache<K, V> {
+        private final ConcurrentMap<K, V> map;
+
+        private ConcurrentMapToCacheAdapter(ConcurrentMap<K, V> map) {
+            this.map = map;
+        }
+
+        @Override
+        public @Nullable V get(K key) {
+            return map.get(key);
+        }
+
+        @Override
+        public void put(K key, V value) {
+            map.put(key, value);
+        }
+
+        @Override
+        public void clear() {
+            map.clear();
+        }
+    }
+}
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/benchmarks/TpchPrepareBenchmark.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/benchmarks/TpchPrepareBenchmark.java
index f988ad024a..424f21ba7a 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/benchmarks/TpchPrepareBenchmark.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/benchmarks/TpchPrepareBenchmark.java
@@ -18,12 +18,12 @@
 package org.apache.ignite.internal.sql.engine.benchmarks;
 
 import java.util.concurrent.TimeUnit;
-import org.apache.calcite.sql.SqlNode;
 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.sql.IgniteSqlParser;
-import org.apache.ignite.internal.sql.engine.sql.StatementParseResult;
+import org.apache.ignite.internal.sql.engine.sql.ParsedResult;
+import org.apache.ignite.internal.sql.engine.sql.ParserServiceImpl;
+import org.apache.ignite.internal.sql.engine.util.EmptyCacheFactory;
 import org.openjdk.jmh.annotations.Benchmark;
 import org.openjdk.jmh.annotations.BenchmarkMode;
 import org.openjdk.jmh.annotations.Fork;
@@ -65,7 +65,7 @@ public class TpchPrepareBenchmark {
 
     private TestNode gatewayNode;
 
-    private SqlNode queryAst;
+    private ParsedResult parsedResult;
 
     /** Starts the cluster and prepares the plan of the query. */
     @Setup
@@ -79,8 +79,7 @@ public class TpchPrepareBenchmark {
         gatewayNode = testCluster.node("N1");
 
         String query = TpchQueries.getQuery(queryId);
-        StatementParseResult parseResult = IgniteSqlParser.parse(query, StatementParseResult.MODE);
-        queryAst = parseResult.statement();
+        parsedResult = new ParserServiceImpl(0, EmptyCacheFactory.INSTANCE).parse(query);
     }
 
     /** Stops the cluster. */
@@ -96,7 +95,7 @@ public class TpchPrepareBenchmark {
      */
     @Benchmark
     public void prepareQuery(Blackhole bh) {
-        bh.consume(gatewayNode.prepare(queryAst));
+        bh.consume(gatewayNode.prepare(parsedResult));
     }
 
     /**
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 e95472779c..353008c8cc 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
@@ -92,11 +92,13 @@ import org.apache.ignite.internal.sql.engine.schema.DefaultValueStrategy;
 import org.apache.ignite.internal.sql.engine.schema.IgniteSchema;
 import org.apache.ignite.internal.sql.engine.schema.SqlSchemaManager;
 import org.apache.ignite.internal.sql.engine.schema.TableDescriptorImpl;
-import org.apache.ignite.internal.sql.engine.sql.IgniteSqlParser;
-import org.apache.ignite.internal.sql.engine.sql.StatementParseResult;
+import org.apache.ignite.internal.sql.engine.sql.ParsedResult;
+import org.apache.ignite.internal.sql.engine.sql.ParserService;
+import org.apache.ignite.internal.sql.engine.sql.ParserServiceImpl;
 import org.apache.ignite.internal.sql.engine.trait.IgniteDistribution;
 import org.apache.ignite.internal.sql.engine.trait.IgniteDistributions;
 import org.apache.ignite.internal.sql.engine.util.BaseQueryContext;
+import org.apache.ignite.internal.sql.engine.util.EmptyCacheFactory;
 import org.apache.ignite.internal.sql.engine.util.HashFunctionFactory;
 import org.apache.ignite.internal.sql.engine.util.HashFunctionFactoryImpl;
 import org.apache.ignite.internal.testframework.IgniteTestUtils.RunnableX;
@@ -142,6 +144,7 @@ public class ExecutionServiceImplTest {
     private TestCluster testCluster;
     private List<ExecutionServiceImpl<?>> executionServices;
     private PrepareService prepareService;
+    private ParserService parserService;
     private RuntimeException mappingException;
 
     @BeforeEach
@@ -149,6 +152,7 @@ public class ExecutionServiceImplTest {
         testCluster = new TestCluster();
         executionServices = nodeNames.stream().map(this::create).collect(Collectors.toList());
         prepareService = new PrepareServiceImpl("test", 0, null);
+        parserService = new ParserServiceImpl(0, EmptyCacheFactory.INSTANCE);
 
         prepareService.start();
     }
@@ -537,11 +541,11 @@ public class ExecutionServiceImplTest {
     }
 
     private QueryPlan prepare(String query, BaseQueryContext ctx) {
-        StatementParseResult parseResult = IgniteSqlParser.parse(query, StatementParseResult.MODE);
+        ParsedResult parsedResult = parserService.parse(query);
 
-        assertEquals(ctx.parameters().length, parseResult.dynamicParamsCount(), "Invalid number of dynamic parameters");
+        assertEquals(ctx.parameters().length, parsedResult.dynamicParamsCount(), "Invalid number of dynamic parameters");
 
-        return await(prepareService.prepareAsync(parseResult.statement(), ctx));
+        return await(prepareService.prepareAsync(parsedResult, ctx));
     }
 
     static class TestCluster {
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/framework/TestNode.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/framework/TestNode.java
index 8840ff3699..0ab72ec015 100644
--- a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/framework/TestNode.java
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/framework/TestNode.java
@@ -19,9 +19,6 @@ package org.apache.ignite.internal.sql.engine.framework;
 
 import static org.apache.ignite.internal.sql.engine.util.Commons.FRAMEWORK_CONFIG;
 import static org.apache.ignite.internal.testframework.IgniteTestUtils.await;
-import static org.hamcrest.CoreMatchers.not;
-import static org.hamcrest.MatcherAssert.assertThat;
-import static org.hamcrest.Matchers.instanceOf;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.mockito.Mockito.mock;
 
@@ -30,8 +27,6 @@ import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
 import org.apache.calcite.schema.SchemaPlus;
-import org.apache.calcite.sql.SqlNode;
-import org.apache.calcite.sql.SqlNodeList;
 import org.apache.calcite.tools.Frameworks;
 import org.apache.ignite.internal.sql.engine.AsyncCursor;
 import org.apache.ignite.internal.sql.engine.QueryCancel;
@@ -64,9 +59,11 @@ import org.apache.ignite.internal.sql.engine.prepare.ddl.DdlSqlToCommandConverte
 import org.apache.ignite.internal.sql.engine.rel.IgniteIndexScan;
 import org.apache.ignite.internal.sql.engine.rel.IgniteTableScan;
 import org.apache.ignite.internal.sql.engine.schema.SqlSchemaManager;
-import org.apache.ignite.internal.sql.engine.sql.IgniteSqlParser;
-import org.apache.ignite.internal.sql.engine.sql.StatementParseResult;
+import org.apache.ignite.internal.sql.engine.sql.ParsedResult;
+import org.apache.ignite.internal.sql.engine.sql.ParserService;
+import org.apache.ignite.internal.sql.engine.sql.ParserServiceImpl;
 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.HashFunctionFactoryImpl;
 import org.apache.ignite.internal.util.IgniteSpinBusyLock;
 import org.apache.ignite.internal.util.IgniteUtils;
@@ -84,6 +81,7 @@ public class TestNode implements LifecycleAware {
     private final SchemaPlus schema;
     private final PrepareService prepareService;
     private final ExecutionService executionService;
+    private final ParserService parserService;
 
     private final List<LifecycleAware> services = new ArrayList<>();
 
@@ -153,6 +151,8 @@ public class TestNode implements LifecycleAware {
                     }
                 }
         ));
+
+        parserService = new ParserServiceImpl(0, EmptyCacheFactory.INSTANCE);
     }
 
     /** {@inheritDoc} */
@@ -196,25 +196,23 @@ public class TestNode implements LifecycleAware {
      * @return A plan to execute.
      */
     public QueryPlan prepare(String query) {
-        StatementParseResult parseResult = IgniteSqlParser.parse(query, StatementParseResult.MODE);
+        ParsedResult parsedResult = parserService.parse(query);
         BaseQueryContext ctx = createContext();
 
-        assertEquals(ctx.parameters().length, parseResult.dynamicParamsCount(), "Invalid number of dynamic parameters");
+        assertEquals(ctx.parameters().length, parsedResult.dynamicParamsCount(), "Invalid number of dynamic parameters");
 
-        return await(prepareService.prepareAsync(parseResult.statement(), ctx));
+        return await(prepareService.prepareAsync(parsedResult, ctx));
     }
 
     /**
      * Prepares (validates, and optimizes) the given query AST
      * and returns the plan to execute.
      *
-     * @param queryAst Parsed ASD of a query to prepare.
+     * @param parsedResult Parsed AST of a query to prepare.
      * @return A plan to execute.
      */
-    public QueryPlan prepare(SqlNode queryAst) {
-        assertThat(queryAst, not(instanceOf(SqlNodeList.class)));
-
-        return await(prepareService.prepareAsync(queryAst, createContext()));
+    public QueryPlan prepare(ParsedResult parsedResult) {
+        return await(prepareService.prepareAsync(parsedResult, createContext()));
     }
 
     private BaseQueryContext createContext() {
@@ -233,5 +231,4 @@ public class TestNode implements LifecycleAware {
 
         return service;
     }
-
 }
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/sql/ParserServiceImplTest.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/sql/ParserServiceImplTest.java
new file mode 100644
index 0000000000..083a383ede
--- /dev/null
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/sql/ParserServiceImplTest.java
@@ -0,0 +1,171 @@
+/*
+ * 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.sql;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import org.apache.calcite.sql.SqlNode;
+import org.apache.ignite.internal.sql.engine.SqlQueryType;
+import org.apache.ignite.internal.sql.engine.util.Cache;
+import org.apache.ignite.internal.sql.engine.util.CacheFactory;
+import org.apache.ignite.internal.sql.engine.util.CaffeineCacheFactory;
+import org.apache.ignite.internal.sql.engine.util.EmptyCacheFactory;
+import org.jetbrains.annotations.Nullable;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+
+/**
+ * Tests to verify {@link ParserServiceImpl}.
+ */
+public class ParserServiceImplTest {
+    enum Statement {
+        QUERY("SELECT * FROM my_table", SqlQueryType.QUERY, true),
+        DML("INSERT INTO my_table VALUES (1, 1)", SqlQueryType.DML, true),
+        DDL("CREATE TABLE my_table (id INT PRIMARY KEY, avl INT)", SqlQueryType.DDL, false),
+        EXPLAIN_QUERY("EXPLAIN PLAN FOR SELECT * FROM my_table", SqlQueryType.EXPLAIN, false),
+        EXPLAIN_DML("EXPLAIN PLAN FOR INSERT INTO my_table VALUES (1, 1)", SqlQueryType.EXPLAIN, false);
+
+        private final String text;
+        private final SqlQueryType type;
+        private final boolean cacheable;
+
+        Statement(String text, SqlQueryType type, boolean cacheable) {
+            this.text = text;
+            this.type = type;
+            this.cacheable = cacheable;
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(Statement.class)
+    void serviceAlwaysReturnsResultFromCacheIfPresent(Statement statement) {
+        ParsedResult expected = new DummyParsedResults();
+
+        ParsedResult actual = new ParserServiceImpl(0, new SameObjectCacheFactory(expected)).parse(statement.text);
+
+        assertSame(actual, expected);
+    }
+
+    @ParameterizedTest
+    @EnumSource(Statement.class)
+    void serviceCachesOnlyCertainStatements(Statement statement) {
+        ParserServiceImpl service = new ParserServiceImpl(
+                Statement.values().length, CaffeineCacheFactory.INSTANCE
+        );
+
+        ParsedResult firstResult = service.parse(statement.text);
+        ParsedResult secondResult = service.parse(statement.text);
+
+        if (statement.cacheable) {
+            assertSame(firstResult, secondResult);
+        } else {
+            assertNotSame(firstResult, secondResult);
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(Statement.class)
+    void serviceReturnsResultOfExpectedType(Statement statement) {
+        ParserServiceImpl service = new ParserServiceImpl(0, EmptyCacheFactory.INSTANCE);
+
+        ParsedResult result = service.parse(statement.text);
+
+        assertThat(result.queryType(), is(statement.type));
+    }
+
+    @ParameterizedTest
+    @EnumSource(Statement.class)
+    void resultReturnedByServiceCreateNewInstanceOfTree(Statement statement) {
+        ParserServiceImpl service = new ParserServiceImpl(0, EmptyCacheFactory.INSTANCE);
+
+        ParsedResult result = service.parse(statement.text);
+
+        SqlNode firstCall = result.parsedTree();
+        SqlNode secondCall = result.parsedTree();
+
+        assertNotSame(firstCall, secondCall);
+        assertThat(firstCall.toString(), is(secondCall.toString()));
+    }
+
+    /**
+     * Parsed result that throws {@link AssertionError} on every method call.
+     *
+     * <p>Used in cases where you need to verify referential equality.
+     */
+    private static class DummyParsedResults implements ParsedResult {
+
+        @Override
+        public SqlQueryType queryType() {
+            throw new AssertionError();
+        }
+
+        @Override
+        public String originalQuery() {
+            throw new AssertionError();
+        }
+
+        @Override
+        public String normalizedQuery() {
+            throw new AssertionError();
+        }
+
+        @Override
+        public int dynamicParamsCount() {
+            throw new AssertionError();
+        }
+
+        @Override
+        public SqlNode parsedTree() {
+            throw new AssertionError();
+        }
+    }
+
+    /**
+     * A factory that creates a cache that always return value passed to the constructor of factory.
+     */
+    private static class SameObjectCacheFactory implements CacheFactory {
+        private final Object object;
+
+        private SameObjectCacheFactory(Object object) {
+            this.object = object;
+        }
+
+        @Override
+        public <K, V> Cache<K, V> create(int size) {
+            return new Cache<>() {
+                @Override
+                public @Nullable V get(K key) {
+                    return (V) object;
+                }
+
+                @Override
+                public void put(K key, V value) {
+                    // NO-OP
+                }
+
+                @Override
+                public void clear() {
+                    // NO-OP
+                }
+            };
+        }
+    }
+}
diff --git a/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/EmptyCacheFactory.java b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/EmptyCacheFactory.java
new file mode 100644
index 0000000000..39cc61352e
--- /dev/null
+++ b/modules/sql-engine/src/test/java/org/apache/ignite/internal/sql/engine/util/EmptyCacheFactory.java
@@ -0,0 +1,57 @@
+/*
+ * 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.util;
+
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A factory creating cache that keeps no objects.
+ *
+ * <p>That is, any instance of cache returned by this factory always returns {@code null}
+ * from {@link Cache#get(Object)} method, and do nothing when {@link Cache#put(Object, Object)}
+ * is invoked.
+ */
+public class EmptyCacheFactory implements CacheFactory {
+    public static CacheFactory INSTANCE = new EmptyCacheFactory();
+
+    private EmptyCacheFactory() {
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public <K, V> Cache<K, V> create(int size) {
+        return new EmptyCache<>();
+    }
+
+    private static class EmptyCache<K, V> implements Cache<K, V> {
+        @Override
+        public @Nullable V get(K key) {
+            return null;
+        }
+
+        @Override
+        public void put(K key, V value) {
+            // NO-OP
+        }
+
+        @Override
+        public void clear() {
+            // NO-OP
+        }
+    }
+}
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 47cf0eccc8..fb066e329f 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
@@ -272,7 +272,9 @@ public class StatementChecker {
         // Capture current stacktrace to show error location.
         AssertionError exception = new AssertionError("Statement check failed");
 
-        return shouldFail(name, exception, new TypeSafeMatcher<>() {
+        // please do not replace generic in TypeSafeMatcher with diamond.
+        // Sometimes IDEA goes crazy and start throwing error on compilation                                  V
+        return shouldFail(name, exception, new TypeSafeMatcher<Throwable>() {
             @Override
             protected boolean matchesSafely(Throwable t) {
                 return t.getMessage().contains(errorMessage);