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

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


The following commit(s) were added to refs/heads/main by this push:
     new edf625a5b4 IGNITE-20337 Ensure QueryChecker is properly used in tests 
(#2539)
edf625a5b4 is described below

commit edf625a5b47fba667287c4b941e13c50407f6b08
Author: Andrew V. Mashenkov <amashen...@users.noreply.github.com>
AuthorDate: Mon Sep 11 13:04:03 2023 +0300

    IGNITE-20337 Ensure QueryChecker is properly used in tests (#2539)
---
 .../sql/engine/ClusterPerClassIntegrationTest.java |  20 +-
 .../ignite/internal/sql/engine/ItDmlTest.java      |   2 +-
 .../ignite/internal/sql/engine/ItMetadataTest.java |  15 +-
 .../engine/datatypes/tests/BaseDataTypeTest.java   |  34 +-
 .../internal/sql/engine/util/ColumnMatcher.java    |  29 ++
 .../sql/engine/util/InjectQueryCheckerFactory.java |  35 ++
 .../internal/sql/engine/util/MetadataMatcher.java  |   5 +-
 .../internal/sql/engine/util/QueryChecker.java     | 561 +++------------------
 .../sql/engine/util/QueryCheckerExtension.java     | 122 +++++
 .../sql/engine/util/QueryCheckerFactory.java       |  41 ++
 .../sql/engine/util/QueryCheckerFactoryImpl.java   | 113 +++++
 .../internal/sql/engine/util/QueryCheckerImpl.java | 418 +++++++++++++++
 12 files changed, 873 insertions(+), 522 deletions(-)

diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ClusterPerClassIntegrationTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ClusterPerClassIntegrationTest.java
index 8234e04f6d..aadaa1b6d1 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ClusterPerClassIntegrationTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ClusterPerClassIntegrationTest.java
@@ -61,7 +61,10 @@ import 
org.apache.ignite.internal.schema.configuration.index.TableIndexConfigura
 import org.apache.ignite.internal.schema.configuration.index.TableIndexView;
 import org.apache.ignite.internal.sql.engine.property.PropertiesHelper;
 import org.apache.ignite.internal.sql.engine.session.SessionId;
+import org.apache.ignite.internal.sql.engine.util.InjectQueryCheckerFactory;
 import org.apache.ignite.internal.sql.engine.util.QueryChecker;
+import org.apache.ignite.internal.sql.engine.util.QueryCheckerExtension;
+import org.apache.ignite.internal.sql.engine.util.QueryCheckerFactory;
 import org.apache.ignite.internal.sql.engine.util.TestQueryProcessor;
 import org.apache.ignite.internal.storage.index.IndexStorage;
 import org.apache.ignite.internal.storage.index.StorageIndexDescriptor;
@@ -85,10 +88,12 @@ import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.ExtendWith;
 
 /**
  * Abstract basic integration test that starts a cluster once for all the 
tests it runs.
  */
+@ExtendWith(QueryCheckerExtension.class)
 @TestInstance(TestInstance.Lifecycle.PER_CLASS)
 public abstract class ClusterPerClassIntegrationTest extends 
IgniteIntegrationTest {
     private static final IgniteLogger LOG = 
Loggers.forClass(ClusterPerClassIntegrationTest.class);
@@ -139,6 +144,9 @@ public abstract class ClusterPerClassIntegrationTest 
extends IgniteIntegrationTe
         LOG.info("End beforeAll()");
     }
 
+    @InjectQueryCheckerFactory
+    protected static QueryCheckerFactory queryCheckerFactory;
+
     /**
      * Starts and initializes a test cluster.
      */
@@ -258,17 +266,9 @@ public abstract class ClusterPerClassIntegrationTest 
extends IgniteIntegrationTe
      * @return Instance of QueryChecker.
      */
     protected static QueryChecker assertQuery(Transaction tx, String qry) {
-        return new QueryChecker(tx, qry) {
-            @Override
-            protected QueryProcessor getEngine() {
-                return ((IgniteImpl) CLUSTER_NODES.get(0)).queryEngine();
-            }
+        IgniteImpl node = (IgniteImpl) CLUSTER_NODES.get(0);
 
-            @Override
-            protected IgniteTransactions transactions() {
-                return CLUSTER_NODES.get(0).transactions();
-            }
-        };
+        return queryCheckerFactory.create(node.queryEngine(), 
node.transactions(), tx, qry);
     }
 
     /**
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
index df7c2dc76b..0a464df2b7 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItDmlTest.java
@@ -669,7 +669,7 @@ public class ItDmlTest extends 
ClusterPerClassIntegrationTest {
         assertQuery("SELECT b FROM test").returns("4").check();
 
         sql("DELETE FROM test WHERE a = 0");
-        assertQuery("SELECT d FROM test").returnNothing();
+        assertQuery("SELECT d FROM test").returnNothing().check();
     }
 
     private static void checkDuplicatePk(IgniteException ex) {
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItMetadataTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItMetadataTest.java
index ef6bfcf166..c036dfa244 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItMetadataTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/ItMetadataTest.java
@@ -21,8 +21,6 @@ import static java.util.stream.Collectors.joining;
 import static java.util.stream.Stream.generate;
 import static org.apache.ignite.sql.ColumnMetadata.UNDEFINED_SCALE;
 
-import java.time.Duration;
-import java.time.Period;
 import org.apache.ignite.internal.sql.engine.util.MetadataMatcher;
 import org.apache.ignite.sql.ColumnType;
 import org.junit.jupiter.api.BeforeAll;
@@ -92,9 +90,14 @@ public class ItMetadataTest extends 
ClusterPerClassIntegrationTest {
     public void infixTypeCast() {
         assertQuery("select id, id::tinyint as tid, id::smallint as sid, 
id::varchar as vid, id::interval hour, "
                 + "id::interval year from person")
-                .columnNames("ID", "TID", "SID", "VID", "ID :: INTERVAL 
INTERVAL_HOUR", "ID :: INTERVAL INTERVAL_YEAR")
-                .columnTypes(Integer.class, Byte.class, Short.class, 
String.class, Duration.class, Period.class)
-                .check();
+                .columnMetadata(
+                        new 
MetadataMatcher().name("ID").type(ColumnType.INT32),
+                        new 
MetadataMatcher().name("TID").type(ColumnType.INT8),
+                        new 
MetadataMatcher().name("SID").type(ColumnType.INT16),
+                        new 
MetadataMatcher().name("VID").type(ColumnType.STRING),
+                        new MetadataMatcher().name("ID :: INTERVAL 
INTERVAL_HOUR").type(ColumnType.DURATION),
+                        new MetadataMatcher().name("ID :: INTERVAL 
INTERVAL_YEAR").type(ColumnType.PERIOD)
+                ).check();
     }
 
     @Test
@@ -109,7 +112,7 @@ public class ItMetadataTest extends 
ClusterPerClassIntegrationTest {
     @Test
     public void metadata() {
         sql("CREATE TABLE METADATA_TABLE (" + "ID INT PRIMARY KEY, "
-                 + "BOOLEAN_C BOOLEAN, "
+                + "BOOLEAN_C BOOLEAN, "
 
                 // Exact numeric types
                 + "TINY_C TINYINT, " // TINYINT is not a part of any SQL 
standard.
diff --git 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/datatypes/tests/BaseDataTypeTest.java
 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/datatypes/tests/BaseDataTypeTest.java
index bceb796de4..585bdbbc00 100644
--- 
a/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/datatypes/tests/BaseDataTypeTest.java
+++ 
b/modules/runner/src/integrationTest/java/org/apache/ignite/internal/sql/engine/datatypes/tests/BaseDataTypeTest.java
@@ -23,22 +23,18 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import java.util.List;
 import java.util.NavigableSet;
 import java.util.Objects;
-import java.util.Optional;
 import java.util.stream.Stream;
 import org.apache.calcite.sql.SqlKind;
 import org.apache.ignite.internal.app.IgniteImpl;
-import org.apache.ignite.internal.sql.engine.AsyncSqlCursor;
 import org.apache.ignite.internal.sql.engine.ClusterPerClassIntegrationTest;
-import org.apache.ignite.internal.sql.engine.QueryProcessor;
 import org.apache.ignite.internal.sql.engine.type.IgniteCustomTypeSpec;
 import org.apache.ignite.internal.sql.engine.util.Commons;
 import org.apache.ignite.internal.sql.engine.util.NativeTypeWrapper;
 import org.apache.ignite.internal.sql.engine.util.QueryChecker;
 import org.apache.ignite.internal.sql.engine.util.QueryChecker.QueryTemplate;
 import org.apache.ignite.internal.sql.engine.util.TestQueryProcessor;
-import org.apache.ignite.sql.ColumnMetadata;
 import org.apache.ignite.sql.ColumnType;
-import org.apache.ignite.tx.IgniteTransactions;
+import org.apache.ignite.sql.ResultSetMetadata;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.params.provider.Arguments;
@@ -120,33 +116,23 @@ public abstract class BaseDataTypeTest<T extends 
Comparable<T>> extends ClusterP
     protected final QueryChecker checkQuery(String query) {
         QueryTemplate queryTemplate = createQueryTemplate(query);
 
-        return new QueryChecker(null, queryTemplate) {
-            @Override
-            protected QueryProcessor getEngine() {
-                return ((IgniteImpl) CLUSTER_NODES.get(0)).queryEngine();
-            }
-
-            @Override
-            protected IgniteTransactions transactions() {
-                return igniteTx();
-            }
+        IgniteImpl node = (IgniteImpl) CLUSTER_NODES.get(0);
 
-            @Override
-            protected void checkMetadata(AsyncSqlCursor<?> cursor) {
-                Optional<ColumnMetadata> testKey = cursor.metadata().columns()
-                        .stream()
-                        .filter(c -> "test_key".equalsIgnoreCase(c.name()))
-                        .findAny();
+        return queryCheckerFactory.create(node.queryEngine(), 
node.transactions(), this::validateMetadata, queryTemplate);
+    }
 
-                testKey.ifPresent((c) -> {
+    private void validateMetadata(ResultSetMetadata metadata) {
+        metadata.columns()
+                .stream()
+                .filter(c -> "test_key".equalsIgnoreCase(c.name()))
+                .findAny()
+                .ifPresent((c) -> {
                     ColumnType columnType = testTypeSpec.columnType();
                     String error = format(
                             "test_key should have type {}. This can happen if 
a query returned a column ", columnType
                     );
                     assertEquals(c.type(), columnType, error);
                 });
-            }
-        };
     }
 
     /**
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/ColumnMatcher.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/ColumnMatcher.java
new file mode 100644
index 0000000000..02eb537870
--- /dev/null
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/ColumnMatcher.java
@@ -0,0 +1,29 @@
+/*
+ * 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.apache.ignite.sql.ColumnMetadata;
+
+/**
+ * Column metadata matcher interface.
+ */
+@FunctionalInterface
+public interface ColumnMatcher {
+    /** Validates column metadata. */
+    void check(ColumnMetadata columnMetadata);
+}
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/InjectQueryCheckerFactory.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/InjectQueryCheckerFactory.java
new file mode 100644
index 0000000000..4c0073ef60
--- /dev/null
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/InjectQueryCheckerFactory.java
@@ -0,0 +1,35 @@
+/*
+ * 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 java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation for injecting query checker factory instances into tests.
+ *
+ * <p>This annotation should be used on either fields or method parameters of 
the {@link QueryCheckerFactory} type.
+ *
+ * @see QueryCheckerExtension
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD, ElementType.PARAMETER})
+public @interface InjectQueryCheckerFactory {
+}
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/MetadataMatcher.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/MetadataMatcher.java
index f65d2d0fea..bc78b1ff0f 100644
--- 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/MetadataMatcher.java
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/MetadataMatcher.java
@@ -34,7 +34,7 @@ import org.junit.jupiter.api.function.Executable;
 /**
  * Column metadata checker.
  */
-public class MetadataMatcher {
+public class MetadataMatcher implements ColumnMatcher {
     /** Marker object. */
     private static final Object NO_CHECK = new Object() {
         @Override
@@ -132,7 +132,8 @@ public class MetadataMatcher {
      *
      * @param actualMeta Metadata to check.
      */
-    void check(ColumnMetadata actualMeta) {
+    @Override
+    public void check(ColumnMetadata actualMeta) {
         List<Executable> matchers = new ArrayList<>();
 
         if (name != NO_CHECK) {
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryChecker.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryChecker.java
index a676e3cdc2..44adbece25 100644
--- 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryChecker.java
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryChecker.java
@@ -17,52 +17,37 @@
 
 package org.apache.ignite.internal.sql.engine.util;
 
-import static 
org.apache.ignite.internal.sql.engine.util.CursorUtils.getAllFromCursor;
-import static org.apache.ignite.internal.testframework.IgniteTestUtils.await;
-import static org.apache.ignite.internal.util.ArrayUtils.OBJECT_EMPTY_ARRAY;
 import static org.apache.ignite.internal.util.ArrayUtils.nullOrEmpty;
-import static org.hamcrest.CoreMatchers.equalTo;
-import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.fail;
 
 import java.lang.reflect.Array;
 import java.lang.reflect.Type;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Objects;
-import java.util.concurrent.CompletableFuture;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
-import org.apache.ignite.internal.sql.engine.AsyncSqlCursor;
-import org.apache.ignite.internal.sql.engine.QueryContext;
-import org.apache.ignite.internal.sql.engine.QueryProcessor;
-import org.apache.ignite.internal.sql.engine.SqlQueryType;
-import org.apache.ignite.internal.sql.engine.hint.IgniteHint;
-import org.apache.ignite.internal.sql.engine.property.PropertiesHelper;
-import org.apache.ignite.internal.sql.engine.session.SessionId;
-import org.apache.ignite.internal.util.ArrayUtils;
-import org.apache.ignite.internal.util.CollectionUtils;
-import org.apache.ignite.sql.ColumnMetadata;
-import org.apache.ignite.sql.ColumnType;
-import org.apache.ignite.tx.IgniteTransactions;
-import org.apache.ignite.tx.Transaction;
 import org.hamcrest.CoreMatchers;
 import org.hamcrest.Matcher;
 import org.hamcrest.core.SubstringMatcher;
 
-/**
- * Query checker.
- */
-public abstract class QueryChecker {
-    private static final Object[] NULL_AS_VARARG = {null};
+/** Query checker interface. */
+public interface QueryChecker {
+    Object[] NULL_AS_VARARG = {null};
+    List<List<?>> EMPTY_RES = List.of(List.of());
 
-    private static final List<List<?>> EMPTY_RES = List.of(List.of());
+    /** Creates a matcher that matches if the examined string contains the 
specified string anywhere. */
+    static Matcher<String> containsUnion(boolean all) {
+        return CoreMatchers.containsString("IgniteUnionAll(all=[" + all + 
"])");
+    }
+
+    /** Creates a matcher that matches if the examined string contains the 
specified string anywhere. */
+    static Matcher<String> containsUnion() {
+        return CoreMatchers.containsString("IgniteUnionAll(all=");
+    }
 
     /**
      * Ignite table scan matcher.
@@ -71,7 +56,7 @@ public abstract class QueryChecker {
      * @param tblName Table name.
      * @return Matcher.
      */
-    public static Matcher<String> containsTableScan(String schema, String 
tblName) {
+    static Matcher<String> containsTableScan(String schema, String tblName) {
         return containsSubPlan("IgniteTableScan(table=[[" + schema + ", " + 
tblName + "]]");
     }
 
@@ -82,7 +67,7 @@ public abstract class QueryChecker {
      * @param tblName Table name.
      * @return Matcher.
      */
-    public static Matcher<String> containsIndexScan(String schema, String 
tblName) {
+    static Matcher<String> containsIndexScan(String schema, String tblName) {
         return matchesOnce(".*IgniteIndexScan\\(table=\\[\\[" + schema + ", " 
+ tblName + "\\]\\],"
                 + " tableId=\\[.*\\].*\\)");
     }
@@ -95,7 +80,7 @@ public abstract class QueryChecker {
      * @param idxName Index name.
      * @return Matcher.
      */
-    public static Matcher<String> containsIndexScan(String schema, String 
tblName, String idxName) {
+    static Matcher<String> containsIndexScan(String schema, String tblName, 
String idxName) {
         return matchesOnce(".*IgniteIndexScan\\(table=\\[\\[" + schema + ", " 
+ tblName + "\\]\\],"
                 + " tableId=\\[.*\\], index=\\[" + idxName + "\\].*\\)");
     }
@@ -107,7 +92,7 @@ public abstract class QueryChecker {
      * @param tblName Table name.
      * @return Matcher.
      */
-    public static Matcher<String> notContainsProject(String schema, String 
tblName) {
+    static Matcher<String> notContainsProject(String schema, String tblName) {
         return CoreMatchers.not(containsSubPlan("Scan(table=[[" + schema + ", "
                 + tblName + "]], " + "requiredColumns="));
     }
@@ -115,7 +100,7 @@ public abstract class QueryChecker {
     /**
      * {@link #containsProject(String, String, int...)} reverter.
      */
-    public static Matcher<String> notContainsProject(String schema, String 
tblName, int... requiredColumns) {
+    static Matcher<String> notContainsProject(String schema, String tblName, 
int... requiredColumns) {
         return CoreMatchers.not(containsProject(schema, tblName, 
requiredColumns));
     }
 
@@ -127,7 +112,7 @@ public abstract class QueryChecker {
      * @param requiredColumns columns in projection.
      * @return Matcher.
      */
-    public static Matcher<String> containsProject(String schema, String 
tblName, int... requiredColumns) {
+    static Matcher<String> containsProject(String schema, String tblName, 
int... requiredColumns) {
         return matches(".*Ignite(Table|Index)Scan\\(table=\\[\\[" + schema + 
", "
                 + tblName + "\\]\\], " + ".*requiredColumns=\\[\\{"
                 + Arrays.toString(requiredColumns)
@@ -143,7 +128,7 @@ public abstract class QueryChecker {
      * @param requiredColumns columns in projection.
      * @return Matcher.
      */
-    public static Matcher<String> containsOneProject(String schema, String 
tblName, int... requiredColumns) {
+    static Matcher<String> containsOneProject(String schema, String tblName, 
int... requiredColumns) {
         return matchesOnce(".*Ignite(Table|Index)Scan\\(table=\\[\\[" + schema 
+ ", "
                 + tblName + "\\]\\], " + ".*requiredColumns=\\[\\{"
                 + Arrays.toString(requiredColumns)
@@ -158,7 +143,7 @@ public abstract class QueryChecker {
      * @param tblName Table name.
      * @return Matcher.
      */
-    public static Matcher<String> containsAnyProject(String schema, String 
tblName) {
+    static Matcher<String> containsAnyProject(String schema, String tblName) {
         return matchesOnce(".*Ignite(Table|Index)Scan\\(table=\\[\\[" + schema 
+ ", "
                 + tblName + 
"\\]\\],.*requiredColumns=\\[\\{(\\d|\\W|,)+\\}\\].*");
     }
@@ -169,7 +154,7 @@ public abstract class QueryChecker {
      * @param subPlan Subplan.
      * @return Matcher.
      */
-    public static Matcher<String> containsSubPlan(String subPlan) {
+    static Matcher<String> containsSubPlan(String subPlan) {
         return CoreMatchers.containsString(subPlan);
     }
 
@@ -179,388 +164,20 @@ public abstract class QueryChecker {
      * @param substring Substring.
      * @return Matcher.
      */
-    public static Matcher<String> matches(final String substring) {
+    static Matcher<String> matches(String substring) {
         return new SubstringMatcher("contains", false, substring) {
             /** {@inheritDoc} */
             @Override
             protected boolean evalSubstringOf(String strIn) {
                 strIn = strIn.replaceAll(System.lineSeparator(), "");
 
-                return strIn.matches(substring);
+                return strIn.matches(this.substring);
             }
         };
     }
 
-    /**
-     * Adds plan matchers.
-     */
-    @SafeVarargs
-    public final QueryChecker matches(Matcher<String>... planMatcher) {
-        Collections.addAll(planMatchers, planMatcher);
-
-        return this;
-    }
-
-    /** Matches only one occurrence. */
-    public static Matcher<String> matchesOnce(final String substring) {
-        return new SubstringMatcher("contains once", false, substring) {
-            /** {@inheritDoc} */
-            @Override
-            protected boolean evalSubstringOf(String strIn) {
-                strIn = strIn.replaceAll(System.lineSeparator(), "");
-
-                return containsOnce(strIn, substring);
-            }
-        };
-    }
-
-    /** Check only single matching. */
-    public static boolean containsOnce(final String s, final CharSequence 
substring) {
-        Pattern pattern = Pattern.compile(substring.toString());
-        java.util.regex.Matcher matcher = pattern.matcher(s);
-
-        if (matcher.find()) {
-            return !matcher.find();
-        }
-
-        return false;
-    }
-
-    /**
-     * Ignite any index can matcher.
-     *
-     * @param schema Schema name.
-     * @param tblName Table name.
-     * @param idxNames Index names.
-     * @return Matcher.
-     */
-    public static Matcher<String> containsAnyScan(final String schema, final 
String tblName, String... idxNames) {
-        if (nullOrEmpty(idxNames)) {
-            return matchesOnce(".*Ignite(Table|Index)Scan\\(table=\\[\\[" + 
schema + ", " + tblName + "\\]\\].*");
-        }
-
-        return CoreMatchers.anyOf(
-                Arrays.stream(idxNames).map(idx -> containsIndexScan(schema, 
tblName, idx)).collect(Collectors.toList())
-        );
-    }
-
-    /**
-     * Allows to parameterize an SQL query string.
-     */
-    public interface QueryTemplate {
-
-        /** Template that always returns original query. **/
-        static QueryTemplate returnOriginalQuery(String query) {
-            return new QueryTemplate() {
-                @Override
-                public String originalQueryString() {
-                    return query;
-                }
-
-                @Override
-                public String createQuery() {
-                    return query;
-                }
-            };
-        }
-
-        /** Returns the original query string. **/
-        String originalQueryString();
-
-        /**
-         * Produces an SQL query from the original query string.
-         */
-        String createQuery();
-    }
-
-    private final QueryTemplate queryTemplate;
-
-    private final ArrayList<Matcher<String>> planMatchers = new ArrayList<>();
-
-    private final ArrayList<String> disabledRules = new ArrayList<>();
-
-    private List<List<?>> expectedResult;
-
-    private List<String> expectedColumnNames;
-
-    private List<Type> expectedColumnTypes;
-
-    private List<MetadataMatcher> metadataMatchers;
-
-    private boolean ordered;
-
-    private Object[] params = OBJECT_EMPTY_ARRAY;
-
-    private String exactPlan;
-
-    private Transaction tx;
-
-    /**
-     * Constructor.
-     *
-     * @param tx Transaction.
-     * @param qry Query.
-     */
-    public QueryChecker(Transaction tx, String qry) {
-        this(tx, QueryTemplate.returnOriginalQuery(qry));
-    }
-
-    /**
-     * Constructor.
-     *
-     * @param tx Transaction.
-     * @param queryTemplate A query template.
-     */
-    public QueryChecker(Transaction tx, QueryTemplate queryTemplate) {
-        this.tx = tx;
-        this.queryTemplate = new AddDisabledRulesTemplate(queryTemplate, 
disabledRules);
-    }
-
-    /**
-     * Sets ordered.
-     *
-     * @return This.
-     */
-    public QueryChecker ordered() {
-        ordered = true;
-
-        return this;
-    }
-
-    /**
-     * Sets params.
-     *
-     * @return This.
-     */
-    public QueryChecker withParams(Object... params) {
-        // let's interpret null array as simple single null.
-        if (params == null) {
-            params = NULL_AS_VARARG;
-        }
-
-        this.params = 
Arrays.stream(params).map(NativeTypeWrapper::unwrap).toArray();
-
-        return this;
-    }
-
-    /**
-     * Set a single param.
-     * Useful for specifying array parameters w/o triggering IDE-inspection 
warnings about confusing varargs/array params.
-     *
-     * @return This.
-     */
-    public QueryChecker withParam(Object param) {
-        return this.withParams(param);
-    }
-
-    /**
-     * Disables rules.
-     *
-     * @param rules Rules to disable.
-     * @return This.
-     */
-    public QueryChecker disableRules(String... rules) {
-        if (rules != null) {
-            
Arrays.stream(rules).filter(Objects::nonNull).forEach(disabledRules::add);
-        }
-
-        return this;
-    }
-
-    /**
-     * This method add the given row to the list of expected, the order of 
enumeration does not matter unless {@link #ordered()} is set.
-     *
-     * @param res Array with values one returning tuple. {@code null} array 
will be interpreted as single-column-null row.
-     * @return This.
-     */
-    public QueryChecker returns(Object... res) {
-        assert expectedResult != EMPTY_RES : "Erroneous awaiting results 
mixing, impossible to simultaneously wait something and nothing";
-
-        if (expectedResult == null) {
-            expectedResult = new ArrayList<>();
-        }
-
-        // let's interpret null array as simple single null.
-        if (res == null) {
-            res = NULL_AS_VARARG;
-        }
-
-        expectedResult.add(Arrays.asList(res));
-
-        return this;
-    }
-
-    /**
-     * Check that return empty result.
-     *
-     * @return This.
-     */
-    public QueryChecker returnNothing() {
-        assert expectedResult == null : "Erroneous awaiting results mixing, 
impossible to simultaneously wait nothing and something";
-
-        expectedResult = EMPTY_RES;
-
-        return this;
-    }
-
-    /** Creates a matcher that matches if the examined string contains the 
specified string anywhere. */
-    public static Matcher<String> containsUnion(boolean all) {
-        return CoreMatchers.containsString("IgniteUnionAll(all=[" + all + 
"])");
-    }
-
-    /** Creates a matcher that matches if the examined string contains the 
specified string anywhere. */
-    public static Matcher<String> containsUnion() {
-        return CoreMatchers.containsString("IgniteUnionAll(all=");
-    }
-
-    /**
-     * Sets columns names.
-     *
-     * @return This.
-     */
-    public QueryChecker columnNames(String... columns) {
-        expectedColumnNames = Arrays.asList(columns);
-
-        return this;
-    }
-
-    /**
-     * Sets columns types.
-     *
-     * @return This.
-     */
-    public QueryChecker columnTypes(Type... columns) {
-        expectedColumnTypes = Arrays.asList(columns);
-
-        return this;
-    }
-
-    /**
-     * Sets columns metadata.
-     *
-     * @return This.
-     */
-    public QueryChecker columnMetadata(MetadataMatcher... matchers) {
-        metadataMatchers = Arrays.asList(matchers);
-
-        return this;
-    }
-
-    /**
-     * Sets plan.
-     *
-     * @return This.
-     */
-    public QueryChecker planEquals(String plan) {
-        exactPlan = plan;
-
-        return this;
-    }
-
-    /**
-     * Run checks.
-     */
-    public void check() {
-        // Check plan.
-        QueryProcessor qryProc = getEngine();
-
-        SessionId sessionId = 
qryProc.createSession(PropertiesHelper.emptyHolder());
-
-        QueryContext context = QueryContext.create(SqlQueryType.ALL, tx);
-
-        String qry = queryTemplate.createQuery();
-
-        try {
-
-            if (!CollectionUtils.nullOrEmpty(planMatchers) || exactPlan != 
null) {
-
-                CompletableFuture<AsyncSqlCursor<List<Object>>> explainCursors 
= qryProc.querySingleAsync(sessionId,
-                        context, transactions(), "EXPLAIN PLAN FOR " + qry, 
params);
-                AsyncSqlCursor<List<Object>> explainCursor = 
await(explainCursors);
-                List<List<Object>> explainRes = 
getAllFromCursor(explainCursor);
-
-                String actualPlan = (String) explainRes.get(0).get(0);
-
-                if (!CollectionUtils.nullOrEmpty(planMatchers)) {
-                    for (Matcher<String> matcher : planMatchers) {
-                        assertThat("Invalid plan:\n" + actualPlan, actualPlan, 
matcher);
-                    }
-                }
-
-                if (exactPlan != null) {
-                    assertEquals(exactPlan, actualPlan);
-                }
-            }
-            // Check result.
-            CompletableFuture<AsyncSqlCursor<List<Object>>> cursors =
-                    qryProc.querySingleAsync(sessionId, context, 
transactions(), qry, params);
-
-            AsyncSqlCursor<List<Object>> cur = await(cursors);
-
-            checkMetadata(cur);
-
-            if (expectedColumnNames != null) {
-                List<String> colNames = cur.metadata().columns().stream()
-                        .map(ColumnMetadata::name)
-                        .collect(Collectors.toList());
-
-                assertThat("Column names don't match", colNames, 
equalTo(expectedColumnNames));
-            }
-
-            if (expectedColumnTypes != null) {
-                List<Type> colTypes = cur.metadata().columns().stream()
-                        .map(ColumnMetadata::type)
-                        .map(ColumnType::columnTypeToClass)
-                        .collect(Collectors.toList());
-
-                assertThat("Column types don't match", colTypes, 
equalTo(expectedColumnTypes));
-            }
-
-            if (metadataMatchers != null) {
-                List<ColumnMetadata> columnMetadata = cur.metadata().columns();
-
-                Iterator<ColumnMetadata> valueIterator = 
columnMetadata.iterator();
-                Iterator<MetadataMatcher> matcherIterator = 
metadataMatchers.iterator();
-
-                while (matcherIterator.hasNext() && valueIterator.hasNext()) {
-                    MetadataMatcher matcher = matcherIterator.next();
-                    ColumnMetadata actualElement = valueIterator.next();
-
-                    matcher.check(actualElement);
-                }
-
-                assertEquals(metadataMatchers.size(), columnMetadata.size(), 
"Column metadata doesn't match");
-            }
-
-            var res = getAllFromCursor(cur);
-
-            if (expectedResult != null) {
-                if (Objects.equals(expectedResult, EMPTY_RES)) {
-                    assertEquals(0, res.size(), "Empty result expected");
-
-                    return;
-                }
-
-                if (!ordered) {
-                    // Avoid arbitrary order.
-                    res.sort(new ListComparator());
-                    expectedResult.sort(new ListComparator());
-                }
-
-                assertEqualsCollections(expectedResult, res);
-            }
-        } finally {
-            await(qryProc.closeSession(sessionId));
-        }
-    }
-
-    protected abstract QueryProcessor getEngine();
-
-    protected abstract IgniteTransactions transactions();
-
-    protected void checkMetadata(AsyncSqlCursor<?> cursor) {
-
-    }
+    @SuppressWarnings("unchecked")
+    QueryChecker matches(Matcher<String>... planMatcher);
 
     /**
      * Check collections equals (ordered).
@@ -568,7 +185,7 @@ public abstract class QueryChecker {
      * @param exp Expected collection.
      * @param act Actual collection.
      */
-    private void assertEqualsCollections(Collection<?> exp, Collection<?> act) 
{
+    static void assertEqualsCollections(Collection<?> exp, Collection<?> act) {
         assertEquals(exp.size(), act.size(), "Collections sizes are not 
equal:\nExpected: " + exp + "\nActual:   " + act);
 
         Iterator<?> it1 = exp.iterator();
@@ -596,7 +213,7 @@ public abstract class QueryChecker {
     /**
      * Converts the given value to a test-output friendly representation that 
includes type information.
      */
-    private static String displayValue(Object value, boolean includeType) {
+    static String displayValue(Object value, boolean includeType) {
         if (value == null) {
             return "<null>";
         } else if (value.getClass().isArray()) {
@@ -630,90 +247,76 @@ public abstract class QueryChecker {
         }
     }
 
-    /**
-     * List comparator.
-     */
-    private static class ListComparator implements Comparator<List<?>> {
-        /** {@inheritDoc} */
-        @SuppressWarnings({"rawtypes", "unchecked"})
-        @Override
-        public int compare(List<?> o1, List<?> o2) {
-            if (o1.size() != o2.size()) {
-                fail("Collections are not equal:\nExpected:\t" + o1 + 
"\nActual:\t" + o2);
-            }
-
-            Iterator<?> it1 = o1.iterator();
-            Iterator<?> it2 = o2.iterator();
-
-            while (it1.hasNext()) {
-                Object item1 = it1.next();
-                Object item2 = it2.next();
+    /** Matches only one occurrence. */
+    static Matcher<String> matchesOnce(String substring) {
+        return new SubstringMatcher("contains once", false, substring) {
+            /** {@inheritDoc} */
+            @Override
+            protected boolean evalSubstringOf(String strIn) {
+                strIn = strIn.replaceAll(System.lineSeparator(), "");
 
-                if (Objects.deepEquals(item1, item2)) {
-                    continue;
-                }
+                return containsOnce(strIn, this.substring);
+            }
+        };
+    }
 
-                if (item1 == null) {
-                    return 1;
-                }
+    /** Check only single matching. */
+    static boolean containsOnce(String s, CharSequence substring) {
+        Pattern pattern = Pattern.compile(substring.toString());
+        java.util.regex.Matcher matcher = pattern.matcher(s);
 
-                if (item2 == null) {
-                    return -1;
-                }
+        // Find first, but no more.
+        return matcher.find() && !matcher.find();
+    }
 
-                if (!(item1 instanceof Comparable) && !(item2 instanceof 
Comparable)) {
-                    continue;
-                }
+    /**
+     * Ignite any index can matcher.
+     *
+     * @param schema Schema name.
+     * @param tblName Table name.
+     * @param idxNames Index names.
+     * @return Matcher.
+     */
+    static Matcher<String> containsAnyScan(String schema, String tblName, 
String... idxNames) {
+        if (nullOrEmpty(idxNames)) {
+            return matchesOnce(".*Ignite(Table|Index)Scan\\(table=\\[\\[" + 
schema + ", " + tblName + "\\]\\].*");
+        }
 
-                Comparable c1 = (Comparable) item1;
-                Comparable c2 = (Comparable) item2;
+        return CoreMatchers.anyOf(
+                Arrays.stream(idxNames).map(idx -> containsIndexScan(schema, 
tblName, idx)).collect(Collectors.toList())
+        );
+    }
 
-                int c = c1.compareTo(c2);
+    QueryChecker ordered();
 
-                if (c != 0) {
-                    return c;
-                }
-            }
+    QueryChecker withParams(Object... params);
 
-            return 0;
-        }
-    }
+    QueryChecker withParam(Object param);
 
-    /**
-     * Updates an SQL query string to include hints for the optimizer to 
disable certain rules.
-     */
-    private static final class AddDisabledRulesTemplate implements 
QueryTemplate {
-        private static final Pattern SELECT_REGEXP = 
Pattern.compile("(?i)^select");
-        private static final Pattern SELECT_QRY_CHECK = 
Pattern.compile("(?i)^select .*");
+    QueryChecker disableRules(String... rules);
 
-        private final QueryTemplate input;
+    QueryChecker returns(Object... res);
 
-        private final List<String> disabledRules;
+    QueryChecker returnNothing();
 
-        private AddDisabledRulesTemplate(QueryTemplate input, List<String> 
disabledRules) {
-            this.input = input;
-            this.disabledRules = disabledRules;
-        }
+    QueryChecker columnNames(String... columns);
 
-        @Override
-        public String originalQueryString() {
-            return input.originalQueryString();
-        }
+    QueryChecker columnTypes(Type... columns);
 
-        @Override
-        public String createQuery() {
-            String qry = input.createQuery();
+    QueryChecker columnMetadata(MetadataMatcher... matchers);
 
-            if (!disabledRules.isEmpty()) {
-                String originalQuery = input.originalQueryString();
+    void check();
 
-                assert SELECT_QRY_CHECK.matcher(qry).matches() : "SELECT query 
was expected: " + originalQuery + ". Updated: " + qry;
+    /**
+     * Allows to parameterize an SQL query string.
+     */
+    interface QueryTemplate {
+        /** Returns the original query string. **/
+        String originalQueryString();
 
-                return SELECT_REGEXP.matcher(qry).replaceAll("select "
-                        + HintUtils.toHint(IgniteHint.DISABLE_RULE, 
disabledRules.toArray(ArrayUtils.STRING_EMPTY_ARRAY)));
-            } else {
-                return qry;
-            }
-        }
+        /**
+         * Produces an SQL query from the original query string.
+         */
+        String createQuery();
     }
 }
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerExtension.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerExtension.java
new file mode 100644
index 0000000000..7c07e88093
--- /dev/null
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerExtension.java
@@ -0,0 +1,122 @@
+/*
+ * 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 static java.lang.reflect.Modifier.isStatic;
+import static org.apache.ignite.lang.IgniteStringFormatter.format;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.lang.reflect.Field;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeAllCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.platform.commons.support.AnnotationSupport;
+import org.junit.platform.commons.support.HierarchyTraversalMode;
+
+/**
+ * JUnit extension to inject {@link QueryCheckerFactory} instance into test 
classes, and ensure the {@link QueryChecker#check()} method is
+ * called for each {@link QueryChecker} instance, which was created via the 
factory.
+ *
+ * @see InjectQueryCheckerFactory
+ */
+public class QueryCheckerExtension implements BeforeEachCallback, 
BeforeAllCallback, AfterEachCallback {
+    /** QueryCheckers instances that are managed by this extension. */
+    private final Set<QueryChecker> queryCheckers = new HashSet<>();
+
+    private final QueryCheckerFactory factory = new QueryCheckerFactoryImpl(
+            this::register,
+            this::unregister
+    );
+
+    /** {@inheritDoc} */
+    @Override
+    public void beforeAll(ExtensionContext context) throws Exception {
+        injectFields(context, true);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void beforeEach(ExtensionContext context) throws Exception {
+        injectFields(context, false);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void afterEach(ExtensionContext context) throws Exception {
+        ensureNoUnusedChecker();
+    }
+
+    private void injectFields(ExtensionContext context, boolean forStatic) 
throws Exception {
+        Class<?> testClass = context.getRequiredTestClass();
+        Object testInstance = context.getTestInstance().orElse(null);
+
+        assert forStatic || testInstance != null;
+
+        List<Field> annotatedFields = AnnotationSupport.findAnnotatedFields(
+                testClass,
+                InjectQueryCheckerFactory.class,
+                field -> 
field.getType().isAssignableFrom(QueryCheckerFactory.class) && 
(isStatic(field.getModifiers()) == forStatic),
+                HierarchyTraversalMode.TOP_DOWN
+        );
+
+        for (Field field : annotatedFields) {
+            field.setAccessible(true);
+
+            field.set(forStatic ? null : testInstance, factory);
+        }
+    }
+
+    private void register(QueryChecker newChecker) {
+        queryCheckers.add(newChecker);
+    }
+
+    private void unregister(QueryChecker queryChecker) {
+        boolean remove = queryCheckers.remove(queryChecker);
+
+        if (!remove) {
+            throw new IllegalStateException(format("Unknown QueryChecker 
instance for SQL query: {}", queryChecker));
+        }
+    }
+
+    /**
+     * Validates that {@link QueryChecker#check()} was called for each 
QueryChecker instance, which was created via the factory.
+     *
+     * @throws AssertionError If found any registered QueryChecker.
+     * @see QueryCheckerExtension
+     */
+    private void ensureNoUnusedChecker() {
+        if (queryCheckers.isEmpty()) {
+            return;
+        }
+
+        String failureDetails = queryCheckers.stream()
+                .map(Object::toString)
+                .collect(Collectors.joining("\n", "Found unused QueryCheckers 
for queries: ", ""));
+
+        // Clear collection to allow passing next tests in suite.
+        queryCheckers.clear();
+
+        fail(failureDetails);
+    }
+
+}
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerFactory.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerFactory.java
new file mode 100644
index 0000000000..a0d56fbe8b
--- /dev/null
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerFactory.java
@@ -0,0 +1,41 @@
+/*
+ * 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 java.util.function.Consumer;
+import org.apache.ignite.internal.sql.engine.QueryProcessor;
+import org.apache.ignite.internal.sql.engine.util.QueryChecker.QueryTemplate;
+import org.apache.ignite.sql.ResultSetMetadata;
+import org.apache.ignite.tx.IgniteTransactions;
+import org.apache.ignite.tx.Transaction;
+
+/**
+ * Interface for {@link QueryChecker} factory.
+ */
+public interface QueryCheckerFactory {
+    /** Creates query checker instance. */
+    QueryChecker create(QueryProcessor queryProcessor, IgniteTransactions 
transactions, Transaction tx, String query);
+
+    /** Creates query checker with custom metadata validator. */
+    QueryChecker create(
+            QueryProcessor queryProcessor,
+            IgniteTransactions transactions,
+            Consumer<ResultSetMetadata> metadataValidator,
+            QueryTemplate queryTemplate
+    );
+}
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerFactoryImpl.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerFactoryImpl.java
new file mode 100644
index 0000000000..460d3b6b58
--- /dev/null
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerFactoryImpl.java
@@ -0,0 +1,113 @@
+/*
+ * 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 java.util.function.Consumer;
+import org.apache.ignite.internal.sql.engine.QueryProcessor;
+import org.apache.ignite.internal.sql.engine.util.QueryChecker.QueryTemplate;
+import org.apache.ignite.sql.ResultSetMetadata;
+import org.apache.ignite.tx.IgniteTransactions;
+import org.apache.ignite.tx.Transaction;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * Factory class for {@link QueryCheckerImpl}.
+ *
+ * @see QueryCheckerExtension
+ */
+class QueryCheckerFactoryImpl implements QueryCheckerFactory {
+    private final Consumer<QueryChecker> onCreatedCallback;
+    private final Consumer<QueryChecker> onUsedCallback;
+
+    /**
+     * Creates factory instance.
+     *
+     * @param onCreatedCallback Callback function that is called when a new 
QueryChecker is created.
+     * @param onUsedCallback Callback function that is called when previously 
created QueryChecker is used.
+     */
+    QueryCheckerFactoryImpl(Consumer<QueryChecker> onCreatedCallback, 
Consumer<QueryChecker> onUsedCallback) {
+        this.onCreatedCallback = onCreatedCallback;
+        this.onUsedCallback = onUsedCallback;
+    }
+
+    @Override
+    public QueryChecker create(QueryProcessor queryProcessor, 
IgniteTransactions transactions, Transaction tx, String query) {
+        return create(queryProcessor, transactions, (ignore) -> {}, tx, 
returnOriginalQuery(query));
+    }
+
+    @Override
+    public QueryChecker create(
+            QueryProcessor queryProcessor,
+            IgniteTransactions transactions,
+            Consumer<ResultSetMetadata> metadataValidator,
+            QueryTemplate queryTemplate
+    ) {
+        return create(queryProcessor, transactions, (ignore) -> {}, null, 
queryTemplate);
+    }
+
+    private QueryChecker create(
+            QueryProcessor queryProcessor,
+            IgniteTransactions transactions,
+            Consumer<ResultSetMetadata> metadataValidator,
+            @Nullable Transaction tx,
+            QueryTemplate queryTemplate
+    ) {
+        QueryCheckerImpl queryChecker = new QueryCheckerImpl(tx, 
queryTemplate) {
+            @Override
+            protected QueryProcessor getEngine() {
+                return queryProcessor;
+            }
+
+            @Override
+            protected IgniteTransactions transactions() {
+                return transactions;
+            }
+
+            @Override
+            protected void checkMetadata(ResultSetMetadata metadata) {
+                metadataValidator.accept(metadata);
+            }
+
+            @Override
+            public void check() {
+                onUsedCallback.accept(this);
+
+                super.check();
+            }
+        };
+
+        onCreatedCallback.accept(queryChecker);
+
+        return queryChecker;
+    }
+
+    /** Template that always returns original query. **/
+    private static QueryTemplate returnOriginalQuery(String query) {
+        return new QueryTemplate() {
+            @Override
+            public String originalQueryString() {
+                return query;
+            }
+
+            @Override
+            public String createQuery() {
+                return query;
+            }
+        };
+    }
+}
diff --git 
a/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerImpl.java
 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerImpl.java
new file mode 100644
index 0000000000..57f3211555
--- /dev/null
+++ 
b/modules/sql-engine/src/testFixtures/java/org/apache/ignite/internal/sql/engine/util/QueryCheckerImpl.java
@@ -0,0 +1,418 @@
+/*
+ * 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 static 
org.apache.ignite.internal.sql.engine.util.CursorUtils.getAllFromCursor;
+import static org.apache.ignite.internal.testframework.IgniteTestUtils.await;
+import static org.apache.ignite.internal.util.ArrayUtils.OBJECT_EMPTY_ARRAY;
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.lang.reflect.Type;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import org.apache.ignite.internal.sql.engine.AsyncSqlCursor;
+import org.apache.ignite.internal.sql.engine.QueryContext;
+import org.apache.ignite.internal.sql.engine.QueryProcessor;
+import org.apache.ignite.internal.sql.engine.SqlQueryType;
+import org.apache.ignite.internal.sql.engine.hint.IgniteHint;
+import org.apache.ignite.internal.sql.engine.property.PropertiesHelper;
+import org.apache.ignite.internal.sql.engine.session.SessionId;
+import org.apache.ignite.internal.util.ArrayUtils;
+import org.apache.ignite.internal.util.CollectionUtils;
+import org.apache.ignite.sql.ColumnMetadata;
+import org.apache.ignite.sql.ColumnType;
+import org.apache.ignite.sql.ResultSetMetadata;
+import org.apache.ignite.tx.IgniteTransactions;
+import org.apache.ignite.tx.Transaction;
+import org.hamcrest.Matcher;
+
+/**
+ * Query checker base class.
+ */
+abstract class QueryCheckerImpl implements QueryChecker {
+
+    final QueryTemplate queryTemplate;
+
+    private final ArrayList<Matcher<String>> planMatchers = new ArrayList<>();
+
+    private final ArrayList<String> disabledRules = new ArrayList<>();
+
+    private List<List<?>> expectedResult;
+
+    private List<ColumnMatcher> metadataMatchers;
+
+    private boolean ordered;
+
+    private Object[] params = OBJECT_EMPTY_ARRAY;
+
+    private final Transaction tx;
+
+    /**
+     * Constructor.
+     *
+     * @param tx Transaction.
+     * @param queryTemplate A query template.
+     */
+    QueryCheckerImpl(Transaction tx, QueryTemplate queryTemplate) {
+        this.tx = tx;
+        this.queryTemplate = new 
AddDisabledRulesTemplate(Objects.requireNonNull(queryTemplate), disabledRules);
+    }
+
+    /**
+     * Sets ordered.
+     *
+     * @return This.
+     */
+    @Override
+    public QueryChecker ordered() {
+        ordered = true;
+
+        return this;
+    }
+
+    /**
+     * Sets params.
+     *
+     * @return This.
+     */
+    @Override
+    public QueryChecker withParams(Object... params) {
+        // let's interpret null array as simple single null.
+        if (params == null) {
+            params = QueryChecker.NULL_AS_VARARG;
+        }
+
+        this.params = 
Arrays.stream(params).map(NativeTypeWrapper::unwrap).toArray();
+
+        return this;
+    }
+
+    /**
+     * Set a single param. Useful for specifying array parameters w/o 
triggering IDE-inspection warnings about confusing varargs/array
+     * params.
+     *
+     * @return This.
+     */
+    @Override
+    public QueryChecker withParam(Object param) {
+        return this.withParams(param);
+    }
+
+    /**
+     * Disables rules.
+     *
+     * @param rules Rules to disable.
+     * @return This.
+     */
+    @Override
+    public QueryChecker disableRules(String... rules) {
+        if (rules != null) {
+            
Arrays.stream(rules).filter(Objects::nonNull).forEach(disabledRules::add);
+        }
+
+        return this;
+    }
+
+    /**
+     * This method add the given row to the list of expected, the order of 
enumeration does not matter unless {@link #ordered()} is set.
+     *
+     * @param res Array with values one returning tuple. {@code null} array 
will be interpreted as single-column-null row.
+     * @return This.
+     */
+    @Override
+    public QueryChecker returns(Object... res) {
+        assert expectedResult != QueryChecker.EMPTY_RES
+                : "Erroneous awaiting results mixing, impossible to 
simultaneously wait something and nothing";
+
+        if (expectedResult == null) {
+            expectedResult = new ArrayList<>();
+        }
+
+        // let's interpret null array as simple single null.
+        if (res == null) {
+            res = QueryChecker.NULL_AS_VARARG;
+        }
+
+        expectedResult.add(Arrays.asList(res));
+
+        return this;
+    }
+
+    /**
+     * Check that return empty result.
+     *
+     * @return This.
+     */
+    @Override
+    public QueryChecker returnNothing() {
+        assert expectedResult == null : "Erroneous awaiting results mixing, 
impossible to simultaneously wait nothing and something";
+
+        expectedResult = QueryChecker.EMPTY_RES;
+
+        return this;
+    }
+
+    /**
+     * Sets columns names.
+     *
+     * @return This.
+     */
+    @Override
+    public QueryChecker columnNames(String... columns) {
+        assert metadataMatchers == null;
+
+        metadataMatchers = Arrays.stream(columns)
+                .map(name -> new MetadataMatcher().name(name))
+                .collect(Collectors.toList());
+
+        return this;
+    }
+
+    /**
+     * Sets columns types.
+     *
+     * @return This.
+     */
+    @Override
+    public QueryChecker columnTypes(Type... columns) {
+        assert metadataMatchers == null;
+
+        metadataMatchers = Arrays.stream(columns)
+                .map(t -> (ColumnMatcher) columnMetadata -> {
+                    Class<?> type = 
ColumnType.columnTypeToClass(columnMetadata.type());
+
+                    assertThat("Column type don't match", type, equalTo(t));
+                })
+                .collect(Collectors.toList());
+
+        return this;
+    }
+
+    /**
+     * Sets columns metadata.
+     *
+     * @return This.
+     */
+    @Override
+    public QueryChecker columnMetadata(MetadataMatcher... matchers) {
+        assert metadataMatchers == null;
+
+        metadataMatchers = Arrays.asList(matchers);
+
+        return this;
+    }
+
+    /**
+     * Adds plan matchers.
+     */
+    @Override
+    @SafeVarargs
+    public final QueryChecker matches(Matcher<String>... planMatcher) {
+        Collections.addAll(planMatchers, planMatcher);
+
+        return this;
+    }
+
+    /**
+     * Run checks.
+     */
+    @Override
+    public void check() {
+        // Check plan.
+        QueryProcessor qryProc = getEngine();
+
+        SessionId sessionId = 
qryProc.createSession(PropertiesHelper.emptyHolder());
+
+        QueryContext context = QueryContext.create(SqlQueryType.ALL, tx);
+
+        String qry = queryTemplate.createQuery();
+
+        try {
+
+            if (!CollectionUtils.nullOrEmpty(planMatchers)) {
+
+                CompletableFuture<AsyncSqlCursor<List<Object>>> explainCursors 
= qryProc.querySingleAsync(sessionId,
+                        context, transactions(), "EXPLAIN PLAN FOR " + qry, 
params);
+                AsyncSqlCursor<List<Object>> explainCursor = 
await(explainCursors);
+                List<List<Object>> explainRes = 
getAllFromCursor(explainCursor);
+
+                String actualPlan = (String) explainRes.get(0).get(0);
+
+                if (!CollectionUtils.nullOrEmpty(planMatchers)) {
+                    for (Matcher<String> matcher : planMatchers) {
+                        assertThat("Invalid plan:\n" + actualPlan, actualPlan, 
matcher);
+                    }
+                }
+            }
+            // Check result.
+            CompletableFuture<AsyncSqlCursor<List<Object>>> cursors =
+                    qryProc.querySingleAsync(sessionId, context, 
transactions(), qry, params);
+
+            AsyncSqlCursor<List<Object>> cur = await(cursors);
+
+            checkMetadata(cur.metadata());
+
+            if (metadataMatchers != null) {
+                List<ColumnMetadata> columnMetadata = cur.metadata().columns();
+
+                Iterator<ColumnMetadata> valueIterator = 
columnMetadata.iterator();
+                Iterator<ColumnMatcher> matcherIterator = 
metadataMatchers.iterator();
+
+                while (matcherIterator.hasNext() && valueIterator.hasNext()) {
+                    ColumnMatcher matcher = matcherIterator.next();
+                    ColumnMetadata actualElement = valueIterator.next();
+
+                    matcher.check(actualElement);
+                }
+
+                assertEquals(metadataMatchers.size(), columnMetadata.size(), 
"Column metadata doesn't match");
+            }
+
+            var res = getAllFromCursor(cur);
+
+            if (expectedResult != null) {
+                if (Objects.equals(expectedResult, QueryChecker.EMPTY_RES)) {
+                    assertEquals(0, res.size(), "Empty result expected");
+
+                    return;
+                }
+
+                if (!ordered) {
+                    // Avoid arbitrary order.
+                    res.sort(new ListComparator());
+                    expectedResult.sort(new ListComparator());
+                }
+
+                QueryChecker.assertEqualsCollections(expectedResult, res);
+            }
+        } finally {
+            await(qryProc.closeSession(sessionId));
+        }
+    }
+
+    @Override
+    public String toString() {
+        return QueryCheckerImpl.class.getSimpleName() + "[sql=" + 
queryTemplate.originalQueryString() + "]";
+    }
+
+    protected abstract QueryProcessor getEngine();
+
+    protected abstract IgniteTransactions transactions();
+
+    protected void checkMetadata(ResultSetMetadata metadata) {
+        // No-op.
+    }
+
+    /**
+     * List comparator.
+     */
+    private static class ListComparator implements Comparator<List<?>> {
+        /** {@inheritDoc} */
+        @SuppressWarnings({"rawtypes", "unchecked"})
+        @Override
+        public int compare(List<?> o1, List<?> o2) {
+            if (o1.size() != o2.size()) {
+                fail("Collections are not equal:\nExpected:\t" + o1 + 
"\nActual:\t" + o2);
+            }
+
+            Iterator<?> it1 = o1.iterator();
+            Iterator<?> it2 = o2.iterator();
+
+            while (it1.hasNext()) {
+                Object item1 = it1.next();
+                Object item2 = it2.next();
+
+                if (Objects.deepEquals(item1, item2)) {
+                    continue;
+                }
+
+                if (item1 == null) {
+                    return 1;
+                }
+
+                if (item2 == null) {
+                    return -1;
+                }
+
+                if (!(item1 instanceof Comparable) && !(item2 instanceof 
Comparable)) {
+                    continue;
+                }
+
+                Comparable c1 = (Comparable) item1;
+                Comparable c2 = (Comparable) item2;
+
+                int c = c1.compareTo(c2);
+
+                if (c != 0) {
+                    return c;
+                }
+            }
+
+            return 0;
+        }
+    }
+
+    /**
+     * Updates an SQL query string to include hints for the optimizer to 
disable certain rules.
+     */
+    private static final class AddDisabledRulesTemplate implements 
QueryTemplate {
+        private static final Pattern SELECT_REGEXP = 
Pattern.compile("(?i)^select");
+        private static final Pattern SELECT_QRY_CHECK = 
Pattern.compile("(?i)^select .*");
+
+        private final QueryTemplate input;
+
+        private final List<String> disabledRules;
+
+        private AddDisabledRulesTemplate(QueryTemplate input, List<String> 
disabledRules) {
+            this.input = input;
+            this.disabledRules = disabledRules;
+        }
+
+        @Override
+        public String originalQueryString() {
+            return input.originalQueryString();
+        }
+
+        @Override
+        public String createQuery() {
+            String qry = input.createQuery();
+
+            if (!disabledRules.isEmpty()) {
+                String originalQuery = input.originalQueryString();
+
+                assert SELECT_QRY_CHECK.matcher(qry).matches() : "SELECT query 
was expected: " + originalQuery + ". Updated: " + qry;
+
+                return SELECT_REGEXP.matcher(qry).replaceAll("select "
+                        + HintUtils.toHint(IgniteHint.DISABLE_RULE, 
disabledRules.toArray(ArrayUtils.STRING_EMPTY_ARRAY)));
+            } else {
+                return qry;
+            }
+        }
+    }
+}

Reply via email to