This is an automated email from the ASF dual-hosted git repository. anovikov 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 1263f6b92e IGNITE-20879 Additional criterions for queries (#2933) 1263f6b92e is described below commit 1263f6b92ed18b05d885961edd615e00a39929cc Author: Andrey Novikov <anovi...@apache.org> AuthorDate: Wed Jan 10 22:47:26 2024 +0700 IGNITE-20879 Additional criterions for queries (#2933) --- .../table/criteria/{Criteria.java => Column.java} | 34 +- .../criteria/{Criteria.java => Condition.java} | 39 ++- .../org/apache/ignite/table/criteria/Criteria.java | 367 ++++++++++++++++++++- .../ignite/table/criteria/CriteriaVisitor.java | 59 ++++ .../apache/ignite/table/criteria/Expression.java | 66 ++++ .../criteria/{Criteria.java => Operator.java} | 26 +- .../criteria/{Criteria.java => Parameter.java} | 34 +- .../internal/client/table/AbstractClientView.java | 78 +++++ .../client/table/ClientRecordBinaryView.java | 32 +- .../internal/client/table/ClientRecordView.java | 32 +- .../internal/table/criteria/ColumnValidator.java | 64 ++++ .../internal/table/criteria/SqlSerializer.java | 240 ++++++++++++++ .../internal/ClusterPerClassIntegrationTest.java | 27 +- .../ignite/internal/table/ItCriteriaQueryTest.java | 230 ++++++++++--- .../ignite/internal/table/AbstractTableView.java | 19 ++ .../internal/table/RecordBinaryViewImpl.java | 17 +- .../ignite/internal/table/RecordViewImpl.java | 16 +- .../internal/table/criteria/SqlSerializerTest.java | 208 ++++++++++++ 18 files changed, 1460 insertions(+), 128 deletions(-) diff --git a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java b/modules/api/src/main/java/org/apache/ignite/table/criteria/Column.java similarity index 55% copy from modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java copy to modules/api/src/main/java/org/apache/ignite/table/criteria/Column.java index 1734c30157..21c60daee3 100644 --- a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java +++ b/modules/api/src/main/java/org/apache/ignite/table/criteria/Column.java @@ -17,9 +17,37 @@ package org.apache.ignite.table.criteria; +import static org.apache.ignite.lang.util.IgniteNameUtils.parseSimpleName; + +import org.jetbrains.annotations.Nullable; + /** - * Represents a criteria query predicate. + * Represents a column reference for criteria query. */ -public interface Criteria { - // No-op. +public final class Column implements Criteria { + private final String name; + + /** + * Constructor. + * + * @param name A column name. + */ + Column(String name) { + this.name = parseSimpleName(name); + } + + /** + * Gets column name. + * + * @return A column name. + */ + public String getName() { + return name; + } + + /** {@inheritDoc} */ + @Override + public <C> void accept(CriteriaVisitor<C> v, @Nullable C context) { + v.visit(this, context); + } } diff --git a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java b/modules/api/src/main/java/org/apache/ignite/table/criteria/Condition.java similarity index 52% copy from modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java copy to modules/api/src/main/java/org/apache/ignite/table/criteria/Condition.java index 1734c30157..2bae02c9a8 100644 --- a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java +++ b/modules/api/src/main/java/org/apache/ignite/table/criteria/Condition.java @@ -18,8 +18,41 @@ package org.apache.ignite.table.criteria; /** - * Represents a criteria query predicate. + * Represents an condition with operator and elements for criteria query. + * + * @see Criteria */ -public interface Criteria { - // No-op. +public final class Condition { + private final Operator operator; + + private final Criteria[] elements; + + /** + * Constructor. + * + * @param operator Condition operator. + * @param elements Condition elements. + */ + Condition(Operator operator, Criteria... elements) { + this.operator = operator; + this.elements = elements; + } + + /** + * Get a condition operator. + * + * @return A condition operator. + */ + public Operator getOperator() { + return operator; + } + + /** + * Get a condition elements. + * + * @return A condition elements. + */ + public Criteria[] getElements() { + return elements; + } } diff --git a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java b/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java index 1734c30157..1533fa908c 100644 --- a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java +++ b/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java @@ -17,9 +17,374 @@ package org.apache.ignite.table.criteria; +import static java.util.Objects.requireNonNull; + +import java.util.Arrays; +import java.util.Objects; +import org.jetbrains.annotations.Nullable; + /** * Represents a criteria query predicate. + * + * <pre>{@code + * public ClosableCursor<Product> uncategorizedProducts() { + * return products.recordView(Product.class).queryCriteria(null, columnValue("category", nullValue())); + * } + * }</pre> + * + * @see CriteriaQuerySource */ public interface Criteria { - // No-op. + /** + * Accept the visitor with the given context. + * + * @param <C> Context type. + * @param v Visitor. + * @param context Context of visit. + */ + <C> void accept(CriteriaVisitor<C> v, @Nullable C context); + + /** + * Creates a predicate that tests whether the column value is equal to the given condition. + * + * <p>For example: + * <pre>{@code + * columnValue("category", equalTo("toys")) + * columnValue(IgniteNameUtils.quote("subCategory"), equalTo("puzzle")) + * }</pre> + * + * @param columnName Column name must use SQL-parser style notation; e.g., <br> + * "myColumn", creates a predicate for the column ignores case sensitivity, <br> + * "\"MyColumn\"", creates a predicate for the column with respect to case sensitivity. + * @param condition Target condition. + * @return The created expression instance. + */ + static Expression columnValue(String columnName, Condition condition) { + Criteria[] oldElements = condition.getElements(); + var newElements = new Criteria[oldElements.length + 1]; + + newElements[0] = new Column(columnName); + System.arraycopy(oldElements, 0, newElements, 1, oldElements.length); + + return new Expression(condition.getOperator(), newElements); + } + + /** + * Creates the negation of the predicate. + * + * <p>For example: + * <pre>{@code + * not(columnValue("category", equalTo("toys"))) + * }</pre> + * + * @param expression Expression. + * @return The created negation of the expression. + */ + static Expression not(Expression expression) { + requireNonNull(expression, "expression must not be null"); + + return new Expression(Operator.NOT, expression); + } + + /** + * Creates the {@code and} of the expressions. + * + * <p>For example: + * <pre>{@code + * and(columnValue("category", equalTo("toys")), columnValue("quantity", lessThan(20))) + * }</pre> + * + * @param expressions Expressions. + * @return The created {@code and} expression instance. + */ + static Expression and(Expression... expressions) { + if (expressions == null || expressions.length == 0 || Arrays.stream(expressions).anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("expressions must not be empty or null"); + } + + return new Expression(Operator.AND, expressions); + } + + /** + * Creates the {@code or} of the expressions. + * + * <p>For example: + * <pre>{@code + * or(columnValue("category", equalTo("toys")), columnValue("category", equalTo("games"))) + * }</pre> + * + * @param expressions Expressions. + * @return The created {@code or} expressions instance. + */ + static Expression or(Expression... expressions) { + if (expressions == null || expressions.length == 0 || Arrays.stream(expressions).anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("expressions must not be empty or null"); + } + + return new Expression(Operator.OR, expressions); + } + + /** + * Creates a condition that test the examined object is equal to the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("category", equalTo("toys")) + * }</pre> + * + * @param <T> Value type. + * @param value Target value. + */ + static <T> Condition equalTo(Comparable<T> value) { + if (value == null) { + return nullValue(); + } + + return new Condition(Operator.EQ, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is equal to the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("password", equalTo("MyPassword".getBytes())) + * }</pre> + * + * @param value Target value. + */ + static Condition equalTo(byte[] value) { + if (value == null) { + return nullValue(); + } + + return new Condition(Operator.EQ, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is not equal to the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("category", notEqualTo("toys")) + * }</pre> + * + * @param <T> Value type. + * @param value Target value. + */ + static <T> Condition notEqualTo(Comparable<T> value) { + if (value == null) { + return notNullValue(); + } + + return new Condition(Operator.NOT_EQ, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is not equal to the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("password", notEqualTo("MyPassword".getBytes())) + * }</pre> + * + * @param value Target value. + */ + static Condition notEqualTo(byte[] value) { + if (value == null) { + return notNullValue(); + } + + return new Condition(Operator.NOT_EQ, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is greater than the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("age", greaterThan(35)) + * }</pre> + * + * @param <T> Value type. + * @param value Target value. + */ + static <T> Condition greaterThan(Comparable<T> value) { + return new Condition(Operator.GT, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is greater than or equal than the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("age", greaterThanOrEqualTo(35)) + * }</pre> + * + * @param <T> Value type. + * @param value Target value. + */ + static <T> Condition greaterThanOrEqualTo(Comparable<T> value) { + return new Condition(Operator.GOE, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is less than the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("age", lessThan(35)) + * }</pre> + * + * @param <T> Value type. + * @param value Target value. + */ + static <T> Condition lessThan(Comparable<T> value) { + return new Condition(Operator.LT, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is less than or equal than the specified {@code value}. + * + * <p>For example: + * <pre>{@code + * columnValue("age", lessThanOrEqualTo(35)) + * }</pre> + * + * @param <T> Value type. + * @param value Target value. + */ + static <T> Condition lessThanOrEqualTo(Comparable<T> value) { + return new Condition(Operator.LOE, new Parameter<>(value)); + } + + /** + * Creates a condition that test the examined object is null. + * + * <p>For example: + * <pre>{@code + * columnValue("category", nullValue()) + * }</pre> + */ + static Condition nullValue() { + return new Condition(Operator.IS_NULL); + } + + /** + * Creates a condition that test the examined object is not null. + * + * <p>For example: + * <pre>{@code + * columnValue("category", notNullValue()) + * }</pre> + */ + static Condition notNullValue() { + return new Condition(Operator.IS_NOT_NULL); + } + + /** + * Creates a condition that test the examined object is is found within the specified {@code collection}. + * + * <p>For example: + * <pre>{@code + * columnValue("category", in("toys", "games")) + * }</pre> + * + * @param <T> Values type. + * @param values The collection in which matching items must be found. + */ + static <T> Condition in(Comparable<T>... values) { + if (values.length == 0) { + throw new IllegalArgumentException("values must not be empty or null"); + } + + if (values.length == 1) { + return equalTo(values[0]); + } + + Criteria[] args = Arrays.stream(values) + .map(Parameter::new) + .toArray(Criteria[]::new); + + return new Condition(Operator.IN, args); + } + + /** + * Creates a condition that test the examined object is is found within the specified {@code collection}. + * + * <p>For example: + * <pre>{@code + * columnValue("password", in("MyPassword".getBytes(), "MyOtherPassword".getBytes())) + * }</pre> + * + * @param values The collection in which matching items must be found. + */ + static Condition in(byte[]... values) { + if (values.length == 0) { + throw new IllegalArgumentException("values must not be empty or null"); + } + + if (values.length == 1) { + return equalTo(values[0]); + } + + Criteria[] args = Arrays.stream(values) + .map(Parameter::new) + .toArray(Criteria[]::new); + + return new Condition(Operator.IN, args); + } + + /** + * Creates a condition that test the examined object is is not found within the specified {@code collection}. + * + * <p>For example: + * <pre>{@code + * columnValue("category", notIn("toys", "games")) + * }</pre> + * + * @param <T> Values type. + * @param values The collection in which matching items must be not found. + */ + static <T> Condition notIn(Comparable<T>... values) { + if (values.length == 0) { + throw new IllegalArgumentException("values must not be empty or null"); + } + + if (values.length == 1) { + return notEqualTo(values[0]); + } + + Criteria[] args = Arrays.stream(values) + .map(Parameter::new) + .toArray(Criteria[]::new); + + return new Condition(Operator.NOT_IN, args); + } + + /** + * Creates a condition that test the examined object is is not found within the specified {@code collection}. + * + * <p>For example: + * <pre>{@code + * columnValue("password", notIn("MyPassword".getBytes(), "MyOtherPassword".getBytes())) + * }</pre> + * + * @param values The collection in which matching items must be not found. + */ + static Condition notIn(byte[]... values) { + if (values.length == 0) { + throw new IllegalArgumentException("values must not be empty or null"); + } + + if (values.length == 1) { + return notEqualTo(values[0]); + } + + Criteria[] args = Arrays.stream(values) + .map(Parameter::new) + .toArray(Criteria[]::new); + + return new Condition(Operator.NOT_IN, args); + } } diff --git a/modules/api/src/main/java/org/apache/ignite/table/criteria/CriteriaVisitor.java b/modules/api/src/main/java/org/apache/ignite/table/criteria/CriteriaVisitor.java new file mode 100644 index 0000000000..a40742b0cb --- /dev/null +++ b/modules/api/src/main/java/org/apache/ignite/table/criteria/CriteriaVisitor.java @@ -0,0 +1,59 @@ +/* + * 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.table.criteria; + +import org.jetbrains.annotations.Nullable; + +/** + * A visitor to traverse an criteria tree. + * + * @param <C> Context type. + */ +public interface CriteriaVisitor<C> { + /** + * Visit a {@link Parameter} instance with the given context. + * + * @param argument Parameter to visit + * @param context context of the visit or {@code null}, if not used + */ + <T> void visit(Parameter<T> argument, @Nullable C context); + + /** + * Visit a {@link Column} instance with the given context. + * + * @param column Column to visit + * @param context context of the visit or {@code null}, if not used + */ + <T> void visit(Column column, @Nullable C context); + + /** + * Visit a {@link Expression} instance with the given context. + * + * @param expression Expression to visit + * @param context context of the visit or {@code null}, if not used + */ + <T> void visit(Expression expression, @Nullable C context); + + /** + * Visit a {@link Criteria} instance with the given context. + * + * @param criteria Criteria to visit + * @param context context of the visit or {@code null}, if not used + */ + <T> void visit(Criteria criteria, @Nullable C context); +} diff --git a/modules/api/src/main/java/org/apache/ignite/table/criteria/Expression.java b/modules/api/src/main/java/org/apache/ignite/table/criteria/Expression.java new file mode 100644 index 0000000000..913f36f7f7 --- /dev/null +++ b/modules/api/src/main/java/org/apache/ignite/table/criteria/Expression.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.table.criteria; + +import org.jetbrains.annotations.Nullable; + +/** + * Defines a expression for a criteria query with operator and it's arguments. + * + * @see Criteria + */ +public final class Expression implements Criteria { + private final Operator operator; + + private final Criteria[] elements; + + /** + * Constructor. + * + * @param operator Operator. + * @param elements Criteria elements. + */ + Expression(Operator operator, Criteria... elements) { + this.operator = operator; + this.elements = elements; + } + + /** + * Get a operator. + * + * @return A operator. + */ + public Operator getOperator() { + return operator; + } + + /** + * Get a condition elements. + * + * @return A condition elements. + */ + public Criteria[] getElements() { + return elements; + } + + /** {@inheritDoc} */ + @Override + public <C> void accept(CriteriaVisitor<C> v, @Nullable C context) { + v.visit(this, context); + } +} diff --git a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java b/modules/api/src/main/java/org/apache/ignite/table/criteria/Operator.java similarity index 74% copy from modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java copy to modules/api/src/main/java/org/apache/ignite/table/criteria/Operator.java index 1734c30157..bf760fd4f0 100644 --- a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java +++ b/modules/api/src/main/java/org/apache/ignite/table/criteria/Operator.java @@ -18,8 +18,28 @@ package org.apache.ignite.table.criteria; /** - * Represents a criteria query predicate. + * Provides the operators for the criteria query grammar. + * + * @see Condition + * @see Expression */ -public interface Criteria { - // No-op. +public enum Operator { + // General + EQ, + NOT_EQ, + IS_NULL, + IS_NOT_NULL, + + // Comparable + GOE, + GT, + LOE, + LT, + IN, + NOT_IN, + + // Boolean + NOT, + AND, + OR } diff --git a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java b/modules/api/src/main/java/org/apache/ignite/table/criteria/Parameter.java similarity index 57% copy from modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java copy to modules/api/src/main/java/org/apache/ignite/table/criteria/Parameter.java index 1734c30157..07ce22b12a 100644 --- a/modules/api/src/main/java/org/apache/ignite/table/criteria/Criteria.java +++ b/modules/api/src/main/java/org/apache/ignite/table/criteria/Parameter.java @@ -17,9 +17,37 @@ package org.apache.ignite.table.criteria; +import org.jetbrains.annotations.Nullable; + /** - * Represents a criteria query predicate. + * Represents a parameter for criteria query. + * + * @param <T> Parameter type. */ -public interface Criteria { - // No-op. +public final class Parameter<T> implements Criteria { + private final T value; + + /** + * Constructor. + * + * @param value Parameter value. + */ + Parameter(T value) { + this.value = value; + } + + /** + * Gets parameter value. + * + * @return A value. + */ + public T getValue() { + return value; + } + + /** {@inheritDoc} */ + @Override + public <C> void accept(CriteriaVisitor<C> v, @Nullable C context) { + v.visit(this, context); + } } diff --git a/modules/client/src/main/java/org/apache/ignite/internal/client/table/AbstractClientView.java b/modules/client/src/main/java/org/apache/ignite/internal/client/table/AbstractClientView.java new file mode 100644 index 0000000000..c7eabdb65e --- /dev/null +++ b/modules/client/src/main/java/org/apache/ignite/internal/client/table/AbstractClientView.java @@ -0,0 +1,78 @@ +/* + * 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.client.table; + +import static java.util.stream.Collectors.toSet; +import static org.apache.ignite.internal.client.ClientUtils.sync; +import static org.apache.ignite.lang.util.IgniteNameUtils.parseSimpleName; + +import java.util.Arrays; +import java.util.Set; +import org.apache.ignite.internal.table.criteria.CursorAdapter; +import org.apache.ignite.internal.table.criteria.SqlSerializer; +import org.apache.ignite.lang.Cursor; +import org.apache.ignite.table.criteria.Criteria; +import org.apache.ignite.table.criteria.CriteriaQueryOptions; +import org.apache.ignite.table.criteria.CriteriaQuerySource; +import org.apache.ignite.tx.Transaction; +import org.jetbrains.annotations.Nullable; + +/** + * Base class for client views. + */ +abstract class AbstractClientView<T> implements CriteriaQuerySource<T> { + /** Underlying table. */ + protected final ClientTable tbl; + + /** + * Constructor. + * + * @param tbl Underlying table. + */ + AbstractClientView(ClientTable tbl) { + assert tbl != null; + + this.tbl = tbl; + } + + /** + * Construct SQL query and arguments for prepare statement. + * + * @param tableName Table name. + * @param columns Table columns. + * @param criteria The predicate to filter entries or {@code null} to return all entries from the underlying table. + * @return SQL query and it's arguments. + */ + protected static SqlSerializer createSqlSerializer(String tableName, ClientColumn[] columns, @Nullable Criteria criteria) { + Set<String> columnNames = Arrays.stream(columns) + .map(ClientColumn::name) + .collect(toSet()); + + return new SqlSerializer.Builder() + .tableName(parseSimpleName(tableName)) + .columns(columnNames) + .where(criteria) + .build(); + } + + /** {@inheritDoc} */ + @Override + public Cursor<T> query(@Nullable Transaction tx, @Nullable Criteria criteria, @Nullable CriteriaQueryOptions opts) { + return new CursorAdapter<>(sync(queryAsync(tx, criteria, opts))); + } +} diff --git a/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordBinaryView.java b/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordBinaryView.java index 1646367394..5fb64df33d 100644 --- a/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordBinaryView.java +++ b/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordBinaryView.java @@ -32,10 +32,9 @@ import org.apache.ignite.internal.client.proto.ClientOp; import org.apache.ignite.internal.client.sql.ClientSessionBuilder; import org.apache.ignite.internal.client.sql.ClientStatementBuilder; import org.apache.ignite.internal.streamer.StreamerBatchSender; -import org.apache.ignite.internal.table.criteria.CursorAdapter; import org.apache.ignite.internal.table.criteria.QueryCriteriaAsyncCursor; +import org.apache.ignite.internal.table.criteria.SqlSerializer; import org.apache.ignite.lang.AsyncCursor; -import org.apache.ignite.lang.Cursor; import org.apache.ignite.sql.Session; import org.apache.ignite.sql.Statement; import org.apache.ignite.table.DataStreamerOptions; @@ -49,10 +48,7 @@ import org.jetbrains.annotations.Nullable; /** * Client record view implementation for binary user-object representation. */ -public class ClientRecordBinaryView implements RecordView<Tuple> { - /** Underlying table. */ - private final ClientTable tbl; - +public class ClientRecordBinaryView extends AbstractClientView<Tuple> implements RecordView<Tuple> { /** Tuple serializer. */ private final ClientTupleSerializer ser; @@ -62,9 +58,8 @@ public class ClientRecordBinaryView implements RecordView<Tuple> { * @param tbl Table. */ public ClientRecordBinaryView(ClientTable tbl) { - assert tbl != null; + super(tbl); - this.tbl = tbl; ser = new ClientTupleSerializer(tbl.tableId()); } @@ -390,12 +385,6 @@ public class ClientRecordBinaryView implements RecordView<Tuple> { return ClientDataStreamer.streamData(publisher, opts, batchSender, provider, tbl); } - /** {@inheritDoc} */ - @Override - public Cursor<Tuple> query(@Nullable Transaction tx, @Nullable Criteria criteria, @Nullable CriteriaQueryOptions opts) { - return new CursorAdapter<>(sync(queryAsync(tx, criteria, opts))); - } - /** {@inheritDoc} */ @Override public CompletableFuture<AsyncCursor<Tuple>> queryAsync( @@ -403,14 +392,17 @@ public class ClientRecordBinaryView implements RecordView<Tuple> { @Nullable Criteria criteria, @Nullable CriteriaQueryOptions opts ) { - //TODO: implement serialization of criteria to SQL https://issues.apache.org/jira/browse/IGNITE-20879 - var query = "SELECT * FROM " + tbl.name(); var opts0 = opts == null ? CriteriaQueryOptions.DEFAULT : opts; - Statement statement = new ClientStatementBuilder().query(query).pageSize(opts0.pageSize()).build(); - Session session = new ClientSessionBuilder(tbl.channel()).build(); + return tbl.getLatestSchema() + .thenCompose((schema) -> { + SqlSerializer ser = createSqlSerializer(tbl.name(), schema.columns(), criteria); + + Statement statement = new ClientStatementBuilder().query(ser.toString()).pageSize(opts0.pageSize()).build(); + Session session = new ClientSessionBuilder(tbl.channel()).build(); - return session.executeAsync(tx, statement) - .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + return session.executeAsync(tx, statement, ser.getArguments()) + .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + }); } } diff --git a/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordView.java b/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordView.java index 5a2b7eee97..37a2507e71 100644 --- a/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordView.java +++ b/modules/client/src/main/java/org/apache/ignite/internal/client/table/ClientRecordView.java @@ -33,10 +33,9 @@ import org.apache.ignite.internal.client.proto.TuplePart; import org.apache.ignite.internal.client.sql.ClientSessionBuilder; import org.apache.ignite.internal.client.sql.ClientStatementBuilder; import org.apache.ignite.internal.streamer.StreamerBatchSender; -import org.apache.ignite.internal.table.criteria.CursorAdapter; import org.apache.ignite.internal.table.criteria.QueryCriteriaAsyncCursor; +import org.apache.ignite.internal.table.criteria.SqlSerializer; import org.apache.ignite.lang.AsyncCursor; -import org.apache.ignite.lang.Cursor; import org.apache.ignite.sql.Session; import org.apache.ignite.sql.Statement; import org.apache.ignite.table.DataStreamerOptions; @@ -50,10 +49,7 @@ import org.jetbrains.annotations.Nullable; /** * Client record view implementation. */ -public class ClientRecordView<R> implements RecordView<R> { - /** Underlying table. */ - private final ClientTable tbl; - +public class ClientRecordView<R> extends AbstractClientView<R> implements RecordView<R> { /** Serializer. */ private final ClientRecordSerializer<R> ser; @@ -64,7 +60,8 @@ public class ClientRecordView<R> implements RecordView<R> { * @param recMapper Mapper. */ ClientRecordView(ClientTable tbl, Mapper<R> recMapper) { - this.tbl = tbl; + super(tbl); + ser = new ClientRecordSerializer<>(tbl.tableId(), recMapper); } @@ -386,12 +383,6 @@ public class ClientRecordView<R> implements RecordView<R> { return ClientDataStreamer.streamData(publisher, opts, batchSender, provider, tbl); } - /** {@inheritDoc} */ - @Override - public Cursor<R> query(@Nullable Transaction tx, @Nullable Criteria criteria, @Nullable CriteriaQueryOptions opts) { - return new CursorAdapter<>(sync(queryAsync(tx, criteria, opts))); - } - /** {@inheritDoc} */ @Override public CompletableFuture<AsyncCursor<R>> queryAsync( @@ -399,14 +390,17 @@ public class ClientRecordView<R> implements RecordView<R> { @Nullable Criteria criteria, @Nullable CriteriaQueryOptions opts ) { - //TODO: implement serialization of criteria to SQL https://issues.apache.org/jira/browse/IGNITE-20879 - var query = "SELECT * FROM " + tbl.name(); var opts0 = opts == null ? CriteriaQueryOptions.DEFAULT : opts; - Statement statement = new ClientStatementBuilder().query(query).pageSize(opts0.pageSize()).build(); - Session session = new ClientSessionBuilder(tbl.channel()).build(); + return tbl.getLatestSchema() + .thenCompose((schema) -> { + SqlSerializer ser = createSqlSerializer(tbl.name(), schema.columns(), criteria); + + Statement statement = new ClientStatementBuilder().query(ser.toString()).pageSize(opts0.pageSize()).build(); + Session session = new ClientSessionBuilder(tbl.channel()).build(); - return session.executeAsync(tx, ser.mapper(), statement) - .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + return session.executeAsync(tx, this.ser.mapper(), statement, ser.getArguments()) + .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + }); } } diff --git a/modules/core/src/main/java/org/apache/ignite/internal/table/criteria/ColumnValidator.java b/modules/core/src/main/java/org/apache/ignite/internal/table/criteria/ColumnValidator.java new file mode 100644 index 0000000000..3ae839861e --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/table/criteria/ColumnValidator.java @@ -0,0 +1,64 @@ +/* + * 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.table.criteria; + +import static org.apache.ignite.internal.util.CollectionUtils.nullOrEmpty; + +import java.util.Collection; +import org.apache.ignite.table.criteria.Column; +import org.apache.ignite.table.criteria.Criteria; +import org.apache.ignite.table.criteria.CriteriaVisitor; +import org.apache.ignite.table.criteria.Expression; +import org.apache.ignite.table.criteria.Parameter; +import org.jetbrains.annotations.Nullable; + +/** + * Column validator. + */ +class ColumnValidator implements CriteriaVisitor<Collection<String>> { + static final ColumnValidator INSTANCE = new ColumnValidator(); + + /** {@inheritDoc} */ + @Override + public <T> void visit(Parameter<T> argument, @Nullable Collection<String> context) { + // No-op. + } + + /** {@inheritDoc} */ + @Override + public <T> void visit(Column column, @Nullable Collection<String> context) { + String colName = column.getName(); + + if (!nullOrEmpty(context) && !context.contains(colName)) { + throw new IllegalArgumentException("Unexpected column name: " + colName); + } + } + + /** {@inheritDoc} */ + @Override + public <T> void visit(Expression expression, @Nullable Collection<String> context) { + for (Criteria element : expression.getElements()) { + element.accept(this, context); + } + } + + @Override + public <T> void visit(Criteria criteria, @Nullable Collection<String> context) { + criteria.accept(this, context); + } +} diff --git a/modules/core/src/main/java/org/apache/ignite/internal/table/criteria/SqlSerializer.java b/modules/core/src/main/java/org/apache/ignite/internal/table/criteria/SqlSerializer.java new file mode 100644 index 0000000000..1b45243724 --- /dev/null +++ b/modules/core/src/main/java/org/apache/ignite/internal/table/criteria/SqlSerializer.java @@ -0,0 +1,240 @@ +/* + * 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.table.criteria; + +import static org.apache.ignite.internal.util.StringUtils.nullOrBlank; +import static org.apache.ignite.lang.util.IgniteNameUtils.quote; + +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.ignite.internal.util.CollectionUtils; +import org.apache.ignite.table.criteria.Column; +import org.apache.ignite.table.criteria.Criteria; +import org.apache.ignite.table.criteria.CriteriaVisitor; +import org.apache.ignite.table.criteria.Expression; +import org.apache.ignite.table.criteria.Operator; +import org.apache.ignite.table.criteria.Parameter; +import org.jetbrains.annotations.Nullable; + +/** + * Serializes {@link Criteria} into into SQL. + * + * <p>Note: Doesn't required any context to traverse an criteria tree, {@code null} can be used as initial context. + */ +public class SqlSerializer implements CriteriaVisitor<Void> { + private static final Map<Operator, String> ELEMENT_TEMPLATES = Map.of( + Operator.EQ, "{0} = {1}", + Operator.NOT_EQ, "{0} <> {1}", + Operator.IS_NULL, "{0} IS NULL", + Operator.IS_NOT_NULL, "{0} IS NOT NULL", + Operator.GOE, "{0} >= {1}", + Operator.GT, "{0} > {1}", + Operator.LOE, "{0} <= {1}", + Operator.LT, "{0} < {1}", + Operator.NOT, "NOT ({0})" + ); + + private final Pattern pattern = Pattern.compile("\\{(\\d+)\\}"); + + @SuppressWarnings("StringBufferField") + private final StringBuilder builder = new StringBuilder(128); + + private final List<Object> arguments = new LinkedList<>(); + + /** + * Get query arguments. + * + * @return Query arguments. + */ + public Object[] getArguments() { + return arguments.toArray(new Object[0]); + } + + /** {@inheritDoc} */ + @Override + public <T> void visit(Parameter<T> argument, @Nullable Void context) { + append("?"); + + arguments.add(argument.getValue()); + } + + /** {@inheritDoc} */ + @Override + public <T> void visit(Column column, @Nullable Void context) { + append(quoteIfNeeded(column.getName())); + } + + /** {@inheritDoc} */ + @Override + public <T> void visit(Expression expression, @Nullable Void context) { + Operator operator = expression.getOperator(); + Criteria[] elements = expression.getElements(); + + if (operator == Operator.AND || operator == Operator.OR) { + append(operator == Operator.AND ? ") AND (" : ") OR (", "(", ")", elements, context); + } else if (operator == Operator.IN || operator == Operator.NOT_IN) { + elements[0].accept(this, context); + append(operator == Operator.IN ? " IN " : " NOT IN "); + + Criteria[] tail = Arrays.copyOfRange(elements, 1, elements.length); + append(", ", "(", ")", tail, context); + } else { + String template = ELEMENT_TEMPLATES.get(operator); + + int end = 0; + Matcher matcher = pattern.matcher(template); + + while (matcher.find()) { + if (matcher.start() > end) { + append(template.substring(end, matcher.start())); + } + + int index = Integer.parseInt(matcher.group(1)); + elements[index].accept(this, context); + + end = matcher.end(); + } + + if (end < template.length()) { + append(template.substring(end)); + } + } + } + + /** {@inheritDoc} */ + @Override + public <T> void visit(Criteria criteria, @Nullable Void context) { + criteria.accept(this, context); + } + + /** {@inheritDoc} */ + @Override + public String toString() { + return builder.toString(); + } + + private SqlSerializer append(String str) { + builder.append(str); + + return this; + } + + private void append(String delimiter, String prefix, String suffix, Criteria[] elements, @Nullable Void context) { + if (elements.length > 1) { + append(prefix); + } + + for (int i = 0; i < elements.length; i++) { + elements[i].accept(this, context); + + if (i < elements.length - 1) { + append(delimiter); + } + } + + if (elements.length > 1) { + append(suffix); + } + } + + private static String quoteIfNeeded(String name) { + return name.chars().allMatch(Character::isUpperCase) ? name : quote(name); + } + + /** + * Builder. + */ + public static class Builder { + @Nullable + private String tableName; + + @Nullable + private Collection<String> columnNames; + + @Nullable + private Criteria where; + + /** + * Sets the table name. Must be unquoted name or name is cast to upper case. + * + * @param tableName Table name. + * @return This builder instance. + */ + public SqlSerializer.Builder tableName(String tableName) { + this.tableName = tableName; + + return this; + } + + /** + * Sets the valid table column names to prevent SQL injection. + * + * @param columnNames Acceptable columns names. Must be unquoted name or name is cast to upper case. + * @return This builder instance. + */ + public SqlSerializer.Builder columns(Collection<String> columnNames) { + this.columnNames = columnNames; + + return this; + } + + /** + * Set the given criteria. + * + * @param where The predicate to filter entries or {@code null} to return all entries from the underlying table. + */ + public SqlSerializer.Builder where(@Nullable Criteria where) { + this.where = where; + + return this; + } + + /** + * Builds the SQL query. + * + * @return SQL query text and arguments. + */ + public SqlSerializer build() { + if (nullOrBlank(tableName)) { + throw new IllegalArgumentException("Table name can't be null or blank"); + } + + SqlSerializer ser = new SqlSerializer() + .append("SELECT * ") + .append("FROM ").append(quoteIfNeeded(tableName)); + + if (where != null) { + if (CollectionUtils.nullOrEmpty(columnNames)) { + throw new IllegalArgumentException("The columns of the table must be specified to validate input"); + } + + ColumnValidator.INSTANCE.visit(where, columnNames); + + ser.append(" WHERE "); + ser.visit(where, null); + } + + return ser; + } + } +} diff --git a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerClassIntegrationTest.java b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerClassIntegrationTest.java index ff87690d65..c5772aad6b 100644 --- a/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerClassIntegrationTest.java +++ b/modules/runner/src/testFixtures/java/org/apache/ignite/internal/ClusterPerClassIntegrationTest.java @@ -353,38 +353,17 @@ public abstract class ClusterPerClassIntegrationTest extends IgniteIntegrationTe * {@link #deletePeople(String, int...)} to remove people. */ protected static class Person { - int id; + final int id; - String name; + final String name; - double salary; - - public Person() { - //No-op. - } + final double salary; public Person(int id, String name, double salary) { this.id = id; this.name = name; this.salary = salary; } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - Person person = (Person) o; - return id == person.id && Double.compare(salary, person.salary) == 0 && Objects.equals(name, person.name); - } - - @Override - public int hashCode() { - return Objects.hash(id, name, salary); - } } /** diff --git a/modules/table/src/integrationTest/java/org/apache/ignite/internal/table/ItCriteriaQueryTest.java b/modules/table/src/integrationTest/java/org/apache/ignite/internal/table/ItCriteriaQueryTest.java index 61ac09b90d..be646bc4a3 100644 --- a/modules/table/src/integrationTest/java/org/apache/ignite/internal/table/ItCriteriaQueryTest.java +++ b/modules/table/src/integrationTest/java/org/apache/ignite/internal/table/ItCriteriaQueryTest.java @@ -17,26 +17,53 @@ package org.apache.ignite.internal.table; +import static java.util.Spliterators.spliteratorUnknownSize; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toList; +import static org.apache.ignite.internal.lang.IgniteStringFormatter.format; import static org.apache.ignite.internal.testframework.IgniteTestUtils.await; import static org.apache.ignite.internal.testframework.matchers.TupleMatcher.tupleValue; +import static org.apache.ignite.lang.util.IgniteNameUtils.quote; +import static org.apache.ignite.table.criteria.Criteria.columnValue; +import static org.apache.ignite.table.criteria.Criteria.equalTo; +import static org.apache.ignite.table.criteria.Criteria.greaterThan; +import static org.apache.ignite.table.criteria.Criteria.greaterThanOrEqualTo; +import static org.apache.ignite.table.criteria.Criteria.in; +import static org.apache.ignite.table.criteria.Criteria.lessThan; +import static org.apache.ignite.table.criteria.Criteria.lessThanOrEqualTo; +import static org.apache.ignite.table.criteria.Criteria.notEqualTo; +import static org.apache.ignite.table.criteria.Criteria.notIn; +import static org.apache.ignite.table.criteria.Criteria.notNullValue; +import static org.apache.ignite.table.criteria.Criteria.nullValue; import static org.apache.ignite.table.criteria.CriteriaQueryOptions.builder; -import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.allOf; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.google.common.collect.Lists; +import java.util.List; +import java.util.Map; +import java.util.Spliterator; +import java.util.function.Function; import java.util.stream.Stream; -import org.apache.ignite.Ignite; +import java.util.stream.StreamSupport; import org.apache.ignite.client.IgniteClient; import org.apache.ignite.internal.ClusterPerClassIntegrationTest; +import org.apache.ignite.internal.testframework.IgniteTestUtils; import org.apache.ignite.internal.util.IgniteUtils; import org.apache.ignite.lang.AsyncCursor; import org.apache.ignite.lang.Cursor; +import org.apache.ignite.lang.IgniteException; import org.apache.ignite.table.RecordView; +import org.apache.ignite.table.Table; import org.apache.ignite.table.Tuple; +import org.apache.ignite.table.criteria.CriteriaQuerySource; +import org.apache.ignite.table.mapper.Mapper; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -49,6 +76,12 @@ import org.junit.jupiter.params.provider.MethodSource; * Tests for the criteria query API. */ public class ItCriteriaQueryTest extends ClusterPerClassIntegrationTest { + /** Table name. */ + private static final String TABLE_NAME = "tbl"; + + /** Table with quoted name. */ + private static final String QUOTED_TABLE_NAME = quote("TaBleName"); + private static IgniteClient CLIENT; /** {@inheritDoc} */ @@ -66,11 +99,25 @@ public class ItCriteriaQueryTest extends ClusterPerClassIntegrationTest { CLIENT = IgniteClient.builder() .addresses("127.0.0.1:" + CLUSTER.aliveNode().clientAddress().port()).build(); - createTable(DEFAULT_TABLE_NAME, 1, 8); + sql(format("CREATE TABLE {} (id INT PRIMARY KEY, name VARCHAR, salary DOUBLE, hash VARBINARY)", TABLE_NAME)); - for (int i = 0; i < 3; i++) { - insertPeople(DEFAULT_TABLE_NAME, new Person(i, "name" + i, 10.0d * i)); - } + insertData( + TABLE_NAME, + List.of("ID", "name", "salary", "hash"), + new Object[]{0, null, 0.0d, "hash0".getBytes()}, + new Object[]{1, "name1", 10.0d, "hash1".getBytes()}, + new Object[]{2, "name2", 20.0d, "hash2".getBytes()} + ); + + sql(format("CREATE TABLE {} (id INT PRIMARY KEY, \"colUmn\" VARCHAR)", QUOTED_TABLE_NAME)); + + insertData( + QUOTED_TABLE_NAME, + List.of("id", quote("colUmn")), + new Object[]{0, "name0"}, + new Object[]{1, "name1"}, + new Object[]{2, "name2"} + ); } @AfterAll @@ -78,57 +125,160 @@ public class ItCriteriaQueryTest extends ClusterPerClassIntegrationTest { IgniteUtils.closeAll(CLIENT); } - private static Stream<Arguments> testRecordBinaryView() { + private static Stream<Arguments> testRecordViewQuery() { + Table table = CLUSTER.aliveNode().tables().table(TABLE_NAME); + Table clientTable = CLIENT.tables().table(TABLE_NAME); + return Stream.of( - Arguments.of(CLIENT), - Arguments.of(CLUSTER.aliveNode()) + Arguments.of(table.recordView(), identity()), + Arguments.of(clientTable.recordView(), identity()), + Arguments.of(clientTable.recordView(TestObject.class), + (Function<TestObject, Tuple>) (obj) -> Tuple.create().set("id", obj.id).set("name", obj.name) + .set("salary", obj.salary).set("hash", obj.hash)) ); } - @ParameterizedTest(autoCloseArguments = false) + @ParameterizedTest @MethodSource - public void testRecordBinaryView(Ignite ignite) { - RecordView<Tuple> view = ignite.tables().table(DEFAULT_TABLE_NAME).recordView(); - - try (Cursor<Tuple> cur = view.query(null, null)) { - assertThat(Lists.newArrayList(cur), containsInAnyOrder( - allOf(tupleValue("id", is(0)), tupleValue("name", is("name0")), tupleValue("salary", is(0.0d))), - allOf(tupleValue("id", is(1)), tupleValue("name", is("name1")), tupleValue("salary", is(10.0d))), - allOf(tupleValue("id", is(2)), tupleValue("name", is("name2")), tupleValue("salary", is(20.0d))) - )); + public <T> void testRecordViewQuery(CriteriaQuerySource<T> view, Function<T, Tuple> mapper) { + IgniteTestUtils.assertThrows( + IgniteException.class, + () -> view.query(null, columnValue("id", equalTo("2"))), + "Dynamic parameter requires adding explicit type cast" + ); + + Matcher<Tuple> person0 = allOf(tupleValue("id", is(0)), tupleValue("name", Matchers.nullValue()), tupleValue("salary", is(0.0d)), + tupleValue("hash", is("hash0".getBytes()))); + Matcher<Tuple> person1 = allOf(tupleValue("id", is(1)), tupleValue("name", is("name1")), tupleValue("salary", is(10.0d)), + tupleValue("hash", is("hash1".getBytes()))); + Matcher<Tuple> person2 = allOf(tupleValue("id", is(2)), tupleValue("name", is("name2")), tupleValue("salary", is(20.0d)), + tupleValue("hash", is("hash2".getBytes()))); + + try (Cursor<T> cur = view.query(null, null)) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0, person1, person2)); + } + + try (Cursor<T> cur = view.query(null, columnValue("id", equalTo(2)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person2)); + } + + try (Cursor<T> cur = view.query(null, columnValue("hash", equalTo("hash2".getBytes())))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person2)); } + + try (Cursor<T> cur = view.query(null, columnValue("id", notEqualTo(2)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0, person1)); + } + + try (Cursor<T> cur = view.query(null, columnValue("hash", notEqualTo("hash2".getBytes())))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0, person1)); + } + + try (Cursor<T> cur = view.query(null, columnValue("id", greaterThan(1)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person2)); + } + + try (Cursor<T> cur = view.query(null, columnValue("id", greaterThanOrEqualTo(1)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person1, person2)); + } + + try (Cursor<T> cur = view.query(null, columnValue("id", lessThan(1)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0)); + } + + try (Cursor<T> cur = view.query(null, columnValue("id", lessThanOrEqualTo(1)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0, person1)); + } + + try (Cursor<T> cur = view.query(null, columnValue("name", nullValue()))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0)); + } + + try (Cursor<T> cur = view.query(null, columnValue("name", notNullValue()))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person1, person2)); + } + + try (Cursor<T> cur = view.query(null, columnValue("id", in(1, 2)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person1, person2)); + } + + try (Cursor<T> cur = view.query(null, columnValue("id", notIn(1, 2)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0)); + } + + try (Cursor<T> cur = view.query(null, columnValue("hash", in("hash1".getBytes(), "hash2".getBytes())))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person1, person2)); + } + + try (Cursor<T> cur = view.query(null, columnValue("hash", in((byte[]) null)))) { + assertThat(mapToTupleList(cur, mapper), empty()); + } + + try (Cursor<T> cur = view.query(null, columnValue("hash", notIn("hash1".getBytes(), "hash2".getBytes())))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0)); + } + + try (Cursor<T> cur = view.query(null, columnValue("hash", notIn((byte[]) null)))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder(person0, person1, person2)); + } + } + + @Test + public void testOptions() { + RecordView<TestObject> view = CLIENT.tables().table(TABLE_NAME).recordView(TestObject.class); + + AsyncCursor<TestObject> ars = await(view.queryAsync(null, null, builder().pageSize(2).build())); + + assertNotNull(ars); + assertEquals(2, ars.currentPageSize()); + await(ars.closeAsync()); } - private static Stream<Arguments> testRecordPojoView() { + private static Stream<Arguments> testRecordViewWithQuotes() { + Table table = CLUSTER.aliveNode().tables().table(QUOTED_TABLE_NAME); + Table clientTable = CLIENT.tables().table(QUOTED_TABLE_NAME); + + Mapper<QuotedObject> pojoMapper = Mapper.builder(QuotedObject.class) + .map("colUmn", quote("colUmn")) + .automap() + .build(); + return Stream.of( - // TODO https://issues.apache.org/jira/browse/IGNITE-20977 - //Arguments.of(CLUSTER.aliveNode()), - Arguments.of(CLIENT) + Arguments.of(table.recordView(), identity()), + Arguments.of(clientTable.recordView(), identity()), + Arguments.of(clientTable.recordView(pojoMapper), + (Function<QuotedObject, Tuple>) (obj) -> Tuple.create(Map.of("id", obj.id, quote("colUmn"), obj.colUmn))) ); } - @ParameterizedTest(autoCloseArguments = false) + @ParameterizedTest @MethodSource - public void testRecordPojoView(Ignite ignite) { - RecordView<Person> view = ignite.tables().table(DEFAULT_TABLE_NAME).recordView(Person.class); - - try (Cursor<Person> cur = view.query(null, null)) { - assertThat(Lists.newArrayList(cur), containsInAnyOrder( - new Person(0, "name0", 0.0d), - new Person(1, "name1", 10.0d), - new Person(2, "name2", 20.0d) + public <T> void testRecordViewWithQuotes(CriteriaQuerySource<T> view, Function<T, Tuple> mapper) { + try (Cursor<T> cur = view.query(null, columnValue(quote("colUmn"), equalTo("name1")))) { + assertThat(mapToTupleList(cur, mapper), containsInAnyOrder( + allOf(tupleValue("id", is(1)), tupleValue(quote("colUmn"), is("name1"))) )); } } - @Test - public void testOptions() { - RecordView<Person> view = CLIENT.tables().table(DEFAULT_TABLE_NAME).recordView(Person.class); + private static <T> List<Tuple> mapToTupleList(Cursor<T> cur, Function<T, Tuple> mapper) { + return StreamSupport.stream(spliteratorUnknownSize(cur, Spliterator.ORDERED), false) + .map(mapper) + .collect(toList()); + } - AsyncCursor<Person> ars = await(view.queryAsync(null, null, builder().pageSize(2).build())); + static class QuotedObject { + int id; + String colUmn; + } - assertNotNull(ars); - assertEquals(2, ars.currentPageSize()); - await(ars.closeAsync()); + static class TestObject { + int id; + + String name; + + double salary; + + byte[] hash; } } diff --git a/modules/table/src/main/java/org/apache/ignite/internal/table/AbstractTableView.java b/modules/table/src/main/java/org/apache/ignite/internal/table/AbstractTableView.java index 6de0d37fbd..01406d72c9 100644 --- a/modules/table/src/main/java/org/apache/ignite/internal/table/AbstractTableView.java +++ b/modules/table/src/main/java/org/apache/ignite/internal/table/AbstractTableView.java @@ -22,16 +22,19 @@ import static java.util.function.Function.identity; import static org.apache.ignite.internal.lang.IgniteExceptionMapperUtil.convertToPublicFuture; import static org.apache.ignite.internal.util.ExceptionUtils.sneakyThrow; +import java.util.Collection; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import org.apache.ignite.internal.lang.IgniteExceptionMapperUtil; import org.apache.ignite.internal.schema.SchemaRegistry; +import org.apache.ignite.internal.table.criteria.SqlSerializer; import org.apache.ignite.internal.table.distributed.replicator.InternalSchemaVersionMismatchException; import org.apache.ignite.internal.table.distributed.schema.SchemaVersions; import org.apache.ignite.internal.tx.InternalTransaction; import org.apache.ignite.internal.util.ExceptionUtils; import org.apache.ignite.sql.IgniteSql; +import org.apache.ignite.table.criteria.Criteria; import org.apache.ignite.tx.Transaction; import org.jetbrains.annotations.Nullable; @@ -137,6 +140,22 @@ abstract class AbstractTableView { return ex != null && (exceptionClass.isInstance(ex) || isOrCausedBy(exceptionClass, ex.getCause())); } + /** + * Construct SQL query and arguments for prepare statement. + * + * @param tableName Table name. + * @param columnNames Column names. + * @param criteria The predicate to filter entries or {@code null} to return all entries from the underlying table. + * @return SQL query and it's arguments. + */ + static SqlSerializer createSqlSerializer(String tableName, Collection<String> columnNames, @Nullable Criteria criteria) { + return new SqlSerializer.Builder() + .tableName(tableName) + .columns(columnNames) + .where(criteria) + .build(); + } + /** * Action representing some KV operation. When executed, the action is supplied with schema version corresponding * to the operation timestamp (see {@link #withSchemaSync(Transaction, KvAction)} for details). diff --git a/modules/table/src/main/java/org/apache/ignite/internal/table/RecordBinaryViewImpl.java b/modules/table/src/main/java/org/apache/ignite/internal/table/RecordBinaryViewImpl.java index 247ee7d45d..12368105c9 100644 --- a/modules/table/src/main/java/org/apache/ignite/internal/table/RecordBinaryViewImpl.java +++ b/modules/table/src/main/java/org/apache/ignite/internal/table/RecordBinaryViewImpl.java @@ -26,6 +26,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Flow.Publisher; import org.apache.ignite.internal.schema.BinaryRow; import org.apache.ignite.internal.schema.BinaryRowEx; +import org.apache.ignite.internal.schema.SchemaDescriptor; import org.apache.ignite.internal.schema.SchemaRegistry; import org.apache.ignite.internal.schema.marshaller.TupleMarshaller; import org.apache.ignite.internal.schema.marshaller.TupleMarshallerException; @@ -33,6 +34,7 @@ import org.apache.ignite.internal.schema.row.Row; import org.apache.ignite.internal.streamer.StreamerBatchSender; import org.apache.ignite.internal.table.criteria.CursorAdapter; import org.apache.ignite.internal.table.criteria.QueryCriteriaAsyncCursor; +import org.apache.ignite.internal.table.criteria.SqlSerializer; import org.apache.ignite.internal.table.distributed.schema.SchemaVersions; import org.apache.ignite.internal.tx.InternalTransaction; import org.apache.ignite.lang.AsyncCursor; @@ -458,14 +460,17 @@ public class RecordBinaryViewImpl extends AbstractTableView implements RecordVie @Nullable Criteria criteria, @Nullable CriteriaQueryOptions opts ) { - //TODO: implement serialization of criteria to SQL https://issues.apache.org/jira/browse/IGNITE-20879 - var query = "SELECT * FROM " + tbl.name(); var opts0 = opts == null ? CriteriaQueryOptions.DEFAULT : opts; - Statement statement = sql.statementBuilder().query(query).pageSize(opts0.pageSize()).build(); - Session session = sql.createSession(); + return withSchemaSync(tx, (schemaVersion) -> { + SchemaDescriptor schema = rowConverter.registry().schema(schemaVersion); + SqlSerializer ser = createSqlSerializer(tbl.name(), schema.columnNames(), criteria); + + Statement statement = sql.statementBuilder().query(ser.toString()).pageSize(opts0.pageSize()).build(); + Session session = sql.createSession(); - return session.executeAsync(tx, statement) - .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + return session.executeAsync(tx, statement, ser.getArguments()) + .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + }); } } diff --git a/modules/table/src/main/java/org/apache/ignite/internal/table/RecordViewImpl.java b/modules/table/src/main/java/org/apache/ignite/internal/table/RecordViewImpl.java index 4dcba9fb50..4b6e8b0ed6 100644 --- a/modules/table/src/main/java/org/apache/ignite/internal/table/RecordViewImpl.java +++ b/modules/table/src/main/java/org/apache/ignite/internal/table/RecordViewImpl.java @@ -35,6 +35,7 @@ import org.apache.ignite.internal.schema.row.Row; import org.apache.ignite.internal.streamer.StreamerBatchSender; import org.apache.ignite.internal.table.criteria.CursorAdapter; import org.apache.ignite.internal.table.criteria.QueryCriteriaAsyncCursor; +import org.apache.ignite.internal.table.criteria.SqlSerializer; import org.apache.ignite.internal.table.distributed.schema.SchemaVersions; import org.apache.ignite.internal.tx.InternalTransaction; import org.apache.ignite.lang.AsyncCursor; @@ -551,14 +552,17 @@ public class RecordViewImpl<R> extends AbstractTableView implements RecordView<R @Nullable Criteria criteria, @Nullable CriteriaQueryOptions opts ) { - //TODO: implement serialization of criteria to SQL https://issues.apache.org/jira/browse/IGNITE-20879 - var query = "SELECT * FROM " + tbl.name(); var opts0 = opts == null ? CriteriaQueryOptions.DEFAULT : opts; - Statement statement = sql.statementBuilder().query(query).pageSize(opts0.pageSize()).build(); - Session session = sql.createSession(); + return withSchemaSync(tx, (schemaVersion) -> { + SchemaDescriptor schema = rowConverter.registry().schema(schemaVersion); + SqlSerializer ser = createSqlSerializer(tbl.name(), schema.columnNames(), criteria); + + Statement statement = sql.statementBuilder().query(ser.toString()).pageSize(opts0.pageSize()).build(); + Session session = sql.createSession(); - return session.executeAsync(tx, mapper, statement) - .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + return session.executeAsync(tx, mapper, statement, ser.getArguments()) + .thenApply(resultSet -> new QueryCriteriaAsyncCursor<>(resultSet, session::close)); + }); } } diff --git a/modules/table/src/test/java/org/apache/ignite/internal/table/criteria/SqlSerializerTest.java b/modules/table/src/test/java/org/apache/ignite/internal/table/criteria/SqlSerializerTest.java new file mode 100644 index 0000000000..151052ef05 --- /dev/null +++ b/modules/table/src/test/java/org/apache/ignite/internal/table/criteria/SqlSerializerTest.java @@ -0,0 +1,208 @@ +/* + * 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.table.criteria; + +import static org.apache.ignite.internal.lang.IgniteStringFormatter.format; +import static org.apache.ignite.internal.util.ArrayUtils.OBJECT_EMPTY_ARRAY; +import static org.apache.ignite.lang.util.IgniteNameUtils.quote; +import static org.apache.ignite.table.criteria.Criteria.and; +import static org.apache.ignite.table.criteria.Criteria.columnValue; +import static org.apache.ignite.table.criteria.Criteria.equalTo; +import static org.apache.ignite.table.criteria.Criteria.greaterThan; +import static org.apache.ignite.table.criteria.Criteria.greaterThanOrEqualTo; +import static org.apache.ignite.table.criteria.Criteria.in; +import static org.apache.ignite.table.criteria.Criteria.lessThan; +import static org.apache.ignite.table.criteria.Criteria.lessThanOrEqualTo; +import static org.apache.ignite.table.criteria.Criteria.not; +import static org.apache.ignite.table.criteria.Criteria.notEqualTo; +import static org.apache.ignite.table.criteria.Criteria.notIn; +import static org.apache.ignite.table.criteria.Criteria.notNullValue; +import static org.apache.ignite.table.criteria.Criteria.nullValue; +import static org.apache.ignite.table.criteria.Criteria.or; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.endsWith; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.params.provider.Arguments.of; + +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Stream; +import org.apache.ignite.table.criteria.Criteria; +import org.apache.ignite.table.criteria.Expression; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * SQL generation test. + */ +class SqlSerializerTest { + static Stream<Arguments> testCondition() { + byte[] arr1 = "arr1".getBytes(); + byte[] arr2 = "arr2".getBytes(); + + return Stream.of( + of(columnValue("a", equalTo("a1")), "A = ?", new Object[]{"a1"}), + of(columnValue("a", equalTo(arr1)), "A = ?", new Object[]{arr1}), + of(columnValue("a", equalTo((Comparable<Object>) null)), "A IS NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", equalTo((byte[]) null)), "A IS NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", notEqualTo("a1")), "A <> ?", new Object[]{"a1"}), + of(columnValue("a", notEqualTo(arr1)), "A <> ?", new Object[]{arr1}), + of(columnValue("a", notEqualTo((Comparable<Object>) null)), "A IS NOT NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", notEqualTo((byte[]) null)), "A IS NOT NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", greaterThan("a1")), "A > ?", new Object[]{"a1"}), + of(columnValue("a", greaterThanOrEqualTo("a1")), "A >= ?", new Object[]{"a1"}), + of(columnValue("a", lessThan("a1")), "A < ?", new Object[]{"a1"}), + of(columnValue("a", lessThanOrEqualTo("a1")), "A <= ?", new Object[]{"a1"}), + of(columnValue("a", nullValue()), "A IS NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", notNullValue()), "A IS NOT NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", in("a1", "a2")), "A IN (?, ?)", new Object[]{"a1", "a2"}), + of(columnValue("a", in("a1")), "A = ?", new Object[]{"a1"}), + of(columnValue("a", in(arr1, arr2)), "A IN (?, ?)", new Object[]{arr1, arr2}), + of(columnValue("a", in(arr1)), "A = ?", new Object[]{arr1}), + of(columnValue("a", in((Comparable<Object>) null)), "A IS NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", in((byte[]) null)), "A IS NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", notIn("a1", "a2")), "A NOT IN (?, ?)", new Object[]{"a1", "a2"}), + of(columnValue("a", notIn("a1")), "A <> ?", new Object[]{"a1"}), + of(columnValue("a", notIn("a1")), "A <> ?", new Object[]{"a1"}), + of(columnValue("a", notIn((Comparable<Object>) null)), "A IS NOT NULL", OBJECT_EMPTY_ARRAY), + of(columnValue("a", notIn((byte[]) null)), "A IS NOT NULL", OBJECT_EMPTY_ARRAY) + ); + } + + @ParameterizedTest + @MethodSource + void testCondition(Criteria criteria, String wherePart, Object[] arguments) { + SqlSerializer ser = new SqlSerializer.Builder() + .tableName("test") + .columns(Set.of("A")) + .where(criteria) + .build(); + + assertThat(ser.toString(), endsWith(wherePart)); + assertArrayEquals(arguments, ser.getArguments()); + } + + @Test + void testInvalidCondition() { + assertThrows(IllegalArgumentException.class, () -> in(new Comparable[0]), "values must not be empty or null"); + assertThrows(IllegalArgumentException.class, () -> in(new byte[0][0]), "values must not be empty or null"); + assertThrows(IllegalArgumentException.class, () -> notIn(new Comparable[0]), "values must not be empty or null"); + assertThrows(IllegalArgumentException.class, () -> notIn(new byte[0][0]), "values must not be empty or null"); + } + + static Stream<Arguments> testExpression() { + return Stream.of( + of(and(columnValue("a", nullValue())), "A IS NULL", OBJECT_EMPTY_ARRAY), + of( + and(columnValue("a", nullValue()), columnValue("b", notIn("b1", "b2", "b3"))), + "(A IS NULL) AND (B NOT IN (?, ?, ?))", + new Object[] {"b1", "b2", "b3"} + ), + of( + and(columnValue("a", nullValue()), columnValue("b", notIn("b1", "b2", "b3")), columnValue("c", equalTo("c1"))), + "(A IS NULL) AND (B NOT IN (?, ?, ?)) AND (C = ?)", + new Object[] {"b1", "b2", "b3", "c1"} + ), + of(or(columnValue("a", nullValue())), "A IS NULL", OBJECT_EMPTY_ARRAY), + of( + or(columnValue("a", nullValue()), columnValue("b", notIn("b1", "b2", "b3"))), + "(A IS NULL) OR (B NOT IN (?, ?, ?))", + new Object[] {"b1", "b2", "b3"} + ), + of( + or(columnValue("a", nullValue()), columnValue("b", notIn("b1", "b2", "b3")), columnValue("c", equalTo("c1"))), + "(A IS NULL) OR (B NOT IN (?, ?, ?)) OR (C = ?)", + new Object[] {"b1", "b2", "b3", "c1"} + ), + of( + not(and(columnValue("a", equalTo("a1")), columnValue("b", equalTo("b1")))), + "NOT ((A = ?) AND (B = ?))", + new Object[] {"a1", "b1"} + ) + ); + } + + @ParameterizedTest + @MethodSource + void testExpression(Criteria criteria, String wherePart, Object[] arguments) { + SqlSerializer ser = new SqlSerializer.Builder() + .tableName("test") + .columns(Set.of("A", "B", "C")) + .where(criteria) + .build(); + + assertThat(ser.toString(), endsWith(wherePart)); + assertArrayEquals(arguments, ser.getArguments()); + } + + static Stream<Arguments> testInvalidExpression() { + return Stream.<Consumer<Expression[]>>of( + Criteria::and, + Criteria::or + ).map(Arguments::of); + } + + @ParameterizedTest + @MethodSource + void testInvalidExpression(Consumer<Expression[]> consumer) { + assertThrows(IllegalArgumentException.class, () -> consumer.accept(null), "expressions must not be empty or null"); + assertThrows(IllegalArgumentException.class, () -> consumer.accept(new Expression[0]), "expressions must not be empty or null"); + assertThrows(IllegalArgumentException.class, () -> consumer.accept(new Expression[]{columnValue("a", equalTo("a1")), null}), + "expressions must not be empty or null"); + } + + @Test + void testColumnNameValidation() { + IllegalArgumentException iae = assertThrows( + IllegalArgumentException.class, + () -> new SqlSerializer.Builder() + .tableName("test") + .where(columnValue("a", equalTo("a"))) + .build() + ); + + assertThat(iae.getMessage(), containsString("The columns of the table must be specified to validate input")); + + iae = assertThrows( + IllegalArgumentException.class, + () -> new SqlSerializer.Builder() + .tableName("test") + .columns(Set.of("B")) + .where(columnValue("a", equalTo("a"))) + .build() + ); + + assertThat(iae.getMessage(), containsString("Unexpected column name: A")); + } + + @Test + void testQuote() { + SqlSerializer ser = new SqlSerializer.Builder() + .tableName("Test") + .columns(Set.of("Aa")) + .where(columnValue(quote("Aa"), equalTo(1))) + .build(); + + assertThat(ser.toString(), endsWith(format("FROM {} WHERE {} = ?", quote("Test"), quote("Aa")))); + assertArrayEquals(new Object[]{1}, ser.getArguments()); + } +}