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; + } + } + } +}