diff --git a/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java b/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java index 52a29e05..7eb3710f 100644 --- a/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java +++ b/src/main/java/org/springframework/data/r2dbc/core/DefaultStatementMapper.java @@ -226,6 +226,15 @@ class DefaultStatementMapper implements StatementMapper { return getMappedObject(deleteSpec, null); } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.StatementMapper#getRenderContext() + */ + @Override + public RenderContext getRenderContext() { + return renderContext; + } + private PreparedOperation getMappedObject(DeleteSpec deleteSpec, @Nullable RelationalPersistentEntity entity) { @@ -375,5 +384,14 @@ class DefaultStatementMapper implements StatementMapper { public PreparedOperation getMappedObject(DeleteSpec deleteSpec) { return DefaultStatementMapper.this.getMappedObject(deleteSpec, this.entity); } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.StatementMapper#getRenderContext() + */ + @Override + public RenderContext getRenderContext() { + return DefaultStatementMapper.this.getRenderContext(); + } } } diff --git a/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java b/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java index 3af2c6a2..4f2d25fc 100644 --- a/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java +++ b/src/main/java/org/springframework/data/r2dbc/core/StatementMapper.java @@ -34,6 +34,7 @@ import org.springframework.data.r2dbc.query.Update; import org.springframework.data.relational.core.sql.Expression; import org.springframework.data.relational.core.sql.SqlIdentifier; import org.springframework.data.relational.core.sql.Table; +import org.springframework.data.relational.core.sql.render.RenderContext; import org.springframework.lang.Nullable; /** @@ -177,6 +178,16 @@ public interface StatementMapper { return DeleteSpec.create(table); } + /** + * Returns {@link RenderContext}. + * + * @return {@link RenderContext} instance or {@literal null} if {@link RenderContext} is not available + */ + @Nullable + default RenderContext getRenderContext() { + return null; + } + /** * {@code SELECT} specification. */ diff --git a/src/main/java/org/springframework/data/r2dbc/query/Criteria.java b/src/main/java/org/springframework/data/r2dbc/query/Criteria.java index c86e0487..ece90ef6 100644 --- a/src/main/java/org/springframework/data/r2dbc/query/Criteria.java +++ b/src/main/java/org/springframework/data/r2dbc/query/Criteria.java @@ -57,13 +57,20 @@ public class Criteria { private final @Nullable SqlIdentifier column; private final @Nullable Comparator comparator; private final @Nullable Object value; + private final boolean ignoreCase; private Criteria(SqlIdentifier column, Comparator comparator, @Nullable Object value) { - this(null, Combinator.INITIAL, Collections.emptyList(), column, comparator, value); + this(null, Combinator.INITIAL, Collections.emptyList(), column, comparator, value, false); } private Criteria(@Nullable Criteria previous, Combinator combinator, List group, @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value) { + this(previous, combinator, group, column, comparator, value, false); + } + + private Criteria(@Nullable Criteria previous, Combinator combinator, List group, + @Nullable SqlIdentifier column, @Nullable Comparator comparator, @Nullable Object value, + boolean ignoreCase) { this.previous = previous; this.combinator = previous != null && previous.isEmpty() ? Combinator.INITIAL : combinator; @@ -71,6 +78,7 @@ public class Criteria { this.column = column; this.comparator = comparator; this.value = value; + this.ignoreCase = ignoreCase; } private Criteria(@Nullable Criteria previous, Combinator combinator, List group) { @@ -81,6 +89,7 @@ public class Criteria { this.column = null; this.comparator = null; this.value = null; + this.ignoreCase = false; } /** @@ -236,6 +245,19 @@ public class Criteria { return new Criteria(Criteria.this, Combinator.OR, criteria); } + /** + * Creates a new {@link Criteria} with the given "ignore case" flag. + * + * @param ignoreCase {@literal true} if comparison should be done in case-insensitive way + * @return a new {@link Criteria} object + */ + public Criteria ignoreCase(boolean ignoreCase) { + if (this.ignoreCase != ignoreCase) { + return new Criteria(previous, combinator, group, column, comparator, value, ignoreCase); + } + return this; + } + /** * @return the previous {@link Criteria} object. Can be {@literal null} if there is no previous {@link Criteria}. * @see #hasPrevious() @@ -338,8 +360,17 @@ public class Criteria { return value; } + /** + * Checks whether comparison should be done in case-insensitive way. + * + * @return {@literal true} if comparison should be done in case-insensitive way + */ + boolean isIgnoreCase() { + return ignoreCase; + } + enum Comparator { - INITIAL, EQ, NEQ, LT, LTE, GT, GTE, IS_NULL, IS_NOT_NULL, LIKE, NOT_IN, IN, + INITIAL, EQ, NEQ, LT, LTE, GT, GTE, IS_NULL, IS_NOT_NULL, LIKE, NOT_LIKE, NOT_IN, IN, IS_TRUE, IS_FALSE } enum Combinator { @@ -428,6 +459,14 @@ public class Criteria { */ Criteria like(Object value); + /** + * Creates a {@link Criteria} using {@code NOT LIKE}. + * + * @param value must not be {@literal null} + * @return a new {@link Criteria} object + */ + Criteria notLike(Object value); + /** * Creates a {@link Criteria} using {@code IS NULL}. */ @@ -437,6 +476,20 @@ public class Criteria { * Creates a {@link Criteria} using {@code IS NOT NULL}. */ Criteria isNotNull(); + + /** + * Creates a {@link Criteria} using {@code IS TRUE}. + * + * @return a new {@link Criteria} object + */ + Criteria isTrue(); + + /** + * Creates a {@link Criteria} using {@code IS FALSE}. + * + * @return a new {@link Criteria} object + */ + Criteria isFalse(); } /** @@ -596,6 +649,16 @@ public class Criteria { return createCriteria(Comparator.LIKE, value); } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.query.Criteria.CriteriaStep#notLike(java.lang.Object) + */ + @Override + public Criteria notLike(Object value) { + Assert.notNull(value, "Value must not be null!"); + return createCriteria(Comparator.NOT_LIKE, value); + } + /* * (non-Javadoc) * @see org.springframework.data.r2dbc.function.query.Criteria.CriteriaStep#isNull() @@ -614,6 +677,24 @@ public class Criteria { return createCriteria(Comparator.IS_NOT_NULL, null); } + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.query.Criteria.CriteriaStep#isTrue() + */ + @Override + public Criteria isTrue() { + return createCriteria(Comparator.IS_TRUE, null); + } + + /* + * (non-Javadoc) + * @see org.springframework.data.r2dbc.function.query.Criteria.CriteriaStep#isFalse() + */ + @Override + public Criteria isFalse() { + return createCriteria(Comparator.IS_FALSE, null); + } + protected Criteria createCriteria(Comparator comparator, Object value) { return new Criteria(this.property, comparator, value); } diff --git a/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java b/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java index 4b265619..6386c93a 100644 --- a/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java +++ b/src/main/java/org/springframework/data/r2dbc/query/QueryMapper.java @@ -320,7 +320,8 @@ public class QueryMapper { typeHint = actualType.getType(); } - return createCondition(column, mappedValue, typeHint, bindings, criteria.getComparator()); + return createCondition(column, mappedValue, typeHint, bindings, criteria.getComparator(), + criteria.isIgnoreCase()); } /** @@ -370,7 +371,7 @@ public class QueryMapper { } private Condition createCondition(Column column, @Nullable Object mappedValue, Class valueType, - MutableBindings bindings, Comparator comparator) { + MutableBindings bindings, Comparator comparator, boolean ignoreCase) { if (comparator.equals(Comparator.IS_NULL)) { return column.isNull(); @@ -380,6 +381,19 @@ public class QueryMapper { return column.isNotNull(); } + if (comparator == Comparator.IS_TRUE) { + return column.isEqualTo(SQL.literalOf((Object) ("TRUE"))); + } + + if (comparator == Comparator.IS_FALSE) { + return column.isEqualTo(SQL.literalOf((Object) ("FALSE"))); + } + + Expression columnExpression = column; + if (ignoreCase && String.class == valueType) { + columnExpression = new Upper(column); + } + if (comparator == Comparator.NOT_IN || comparator == Comparator.IN) { Condition condition; @@ -395,14 +409,14 @@ public class QueryMapper { expressions.add(bind(o, valueType, bindings, bindMarker)); } - condition = column.in(expressions.toArray(new Expression[0])); + condition = Conditions.in(columnExpression, expressions.toArray(new Expression[0])); } else { BindMarker bindMarker = bindings.nextMarker(column.getName().getReference()); Expression expression = bind(mappedValue, valueType, bindings, bindMarker); - condition = column.in(expression); + condition = Conditions.in(columnExpression, expression); } if (comparator == Comparator.NOT_IN) { @@ -413,23 +427,40 @@ public class QueryMapper { } BindMarker bindMarker = bindings.nextMarker(column.getName().getReference()); - Expression expression = bind(mappedValue, valueType, bindings, bindMarker); switch (comparator) { - case EQ: - return column.isEqualTo(expression); - case NEQ: - return column.isNotEqualTo(expression); - case LT: + case EQ: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); + return Conditions.isEqual(columnExpression, expression); + } + case NEQ: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); + return Conditions.isEqual(columnExpression, expression).not(); + } + case LT: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker); return column.isLess(expression); - case LTE: + } + case LTE: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker); return column.isLessOrEqualTo(expression); - case GT: + } + case GT: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker); return column.isGreater(expression); - case GTE: + } + case GTE: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker); return column.isGreaterOrEqualTo(expression); - case LIKE: - return column.like(expression); + } + case LIKE: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); + return Conditions.like(columnExpression, expression); + } + case NOT_LIKE: { + Expression expression = bind(mappedValue, valueType, bindings, bindMarker, ignoreCase); + return NotLike.create(columnExpression, expression); + } default: throw new UnsupportedOperationException("Comparator " + comparator + " not supported"); } @@ -459,6 +490,11 @@ public class QueryMapper { private Expression bind(@Nullable Object mappedValue, Class valueType, MutableBindings bindings, BindMarker bindMarker) { + return bind(mappedValue, valueType, bindings, bindMarker, false); + } + + private Expression bind(@Nullable Object mappedValue, Class valueType, MutableBindings bindings, + BindMarker bindMarker, boolean ignoreCase) { if (mappedValue != null) { bindings.bind(bindMarker, mappedValue); @@ -466,7 +502,8 @@ public class QueryMapper { bindings.bindNull(bindMarker, valueType); } - return SQL.bindMarker(bindMarker.getPlaceholder()); + return ignoreCase ? new Upper(SQL.bindMarker(bindMarker.getPlaceholder())) + : SQL.bindMarker(bindMarker.getPlaceholder()); } /** @@ -665,4 +702,89 @@ public class QueryMapper { return toSql(IdentifierProcessing.ANSI); } } + + // TODO: include support of NOT LIKE operator into spring-data-relational + /** + * Negated LIKE {@link Condition} comparing two {@link Expression}s. + *

+ * Results in a rendered condition: {@code NOT LIKE }. + */ + private static class NotLike implements Segment, Condition { + private final Comparison delegate; + + private NotLike(Expression leftColumnOrExpression, Expression rightColumnOrExpression) { + this.delegate = Comparison.create(leftColumnOrExpression, "NOT LIKE", rightColumnOrExpression); + } + + /** + * Creates new instance of this class with the given {@link Expression}s. + * + * @param leftColumnOrExpression the left {@link Expression} + * @param rightColumnOrExpression the right {@link Expression} + * @return {@link NotLike} condition + */ + public static NotLike create(Expression leftColumnOrExpression, Expression rightColumnOrExpression) { + Assert.notNull(leftColumnOrExpression, "Left expression must not be null!"); + Assert.notNull(rightColumnOrExpression, "Right expression must not be null!"); + return new NotLike(leftColumnOrExpression, rightColumnOrExpression); + } + + @Override + public void visit(Visitor visitor) { + Assert.notNull(visitor, "Visitor must not be null!"); + delegate.visit(visitor); + } + + @Override + public String toString() { + return delegate.toString(); + } + } + + // TODO: include support of functions in WHERE conditions into spring-data-relational + /** + * Models the ANSI SQL {@code UPPER} function. + *

+ * Results in a rendered function: {@code UPPER()}. + */ + private class Upper implements Expression { + private Literal delegate; + + /** + * Creates new instance of this class with the given expression. Only expressions of type {@link Column} and + * {@link org.springframework.data.relational.core.sql.BindMarker} are supported. + * + * @param expression expression to be uppercased (must not be {@literal null}) + */ + private Upper(Expression expression) { + Assert.notNull(expression, "Expression must not be null!"); + String functionArgument; + if (expression instanceof org.springframework.data.relational.core.sql.BindMarker) { + functionArgument = expression instanceof Named ? ((Named) expression).getName().getReference() + : expression.toString(); + } else if (expression instanceof Column) { + functionArgument = ""; + Table table = ((Column) expression).getTable(); + if (table != null) { + functionArgument = toSql(table.getName()) + "."; + } + functionArgument += toSql(((Column) expression).getName()); + } else { + throw new IllegalArgumentException("Unable to ignore case expression of type " + expression.getClass().getName() + + ". Only " + Column.class.getName() + " and " + + org.springframework.data.relational.core.sql.BindMarker.class.getName() + " types are supported"); + } + this.delegate = SQL.literalOf((Object) ("UPPER(" + functionArgument + ")")); + } + + @Override + public void visit(Visitor visitor) { + delegate.visit(visitor); + } + + @Override + public String toString() { + return delegate.toString(); + } + } } diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/CriteriaFactory.java b/src/main/java/org/springframework/data/r2dbc/repository/query/CriteriaFactory.java new file mode 100644 index 00000000..79420ede --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/CriteriaFactory.java @@ -0,0 +1,167 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import org.springframework.data.r2dbc.query.Criteria; +import org.springframework.data.relational.core.sql.Expression; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.util.Assert; + +/** + * Simple factory to contain logic to create {@link Criteria}s from {@link Part}s. + * + * @author Roman Chigvintsev + */ +class CriteriaFactory { + private final ParameterMetadataProvider parameterMetadataProvider; + + /** + * Creates new instance of this class with the given {@link ParameterMetadataProvider}. + * + * @param parameterMetadataProvider parameter metadata provider (must not be {@literal null}) + */ + CriteriaFactory(ParameterMetadataProvider parameterMetadataProvider) { + Assert.notNull(parameterMetadataProvider, "Parameter metadata provider must not be null!"); + this.parameterMetadataProvider = parameterMetadataProvider; + } + + /** + * Creates {@link Criteria} for the given {@link Part}. + * + * @param part method name part (must not be {@literal null}) + * @return {@link Criteria} instance + * @throws IllegalArgumentException if part type is not supported + */ + public Criteria createCriteria(Part part) { + Part.Type type = part.getType(); + + String propertyName = part.getProperty().getSegment(); + Class propertyType = part.getProperty().getType(); + + Criteria.CriteriaStep criteriaStep = Criteria.where(propertyName); + + if (type == Part.Type.IS_NULL || type == Part.Type.IS_NOT_NULL) { + return part.getType() == Part.Type.IS_NULL ? criteriaStep.isNull() : criteriaStep.isNotNull(); + } + + if (type == Part.Type.TRUE || type == Part.Type.FALSE) { + return part.getType() == Part.Type.TRUE ? criteriaStep.isTrue() : criteriaStep.isFalse(); + } + + switch (type) { + case BETWEEN: { + ParameterMetadata geParamMetadata = parameterMetadataProvider.next(part); + ParameterMetadata leParamMetadata = parameterMetadataProvider.next(part); + return criteriaStep.greaterThanOrEquals(geParamMetadata.getValue()) + .and(propertyName).lessThanOrEquals(leParamMetadata.getValue()); + } + case AFTER: + case GREATER_THAN: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + return criteriaStep.greaterThan(paramMetadata.getValue()); + } + case GREATER_THAN_EQUAL: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + return criteriaStep.greaterThanOrEquals(paramMetadata.getValue()); + } + case BEFORE: + case LESS_THAN: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + return criteriaStep.lessThan(paramMetadata.getValue()); + } + case LESS_THAN_EQUAL: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + return criteriaStep.lessThanOrEquals(paramMetadata.getValue()); + } + case IN: + case NOT_IN: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + Criteria criteria = part.getType() == Part.Type.IN + ? criteriaStep.in(paramMetadata.getValue()) + : criteriaStep.notIn(paramMetadata.getValue()); + return criteria.ignoreCase(shouldIgnoreCase(part) + && checkCanUpperCase(part, part.getProperty().getType())); + } + case STARTING_WITH: + case ENDING_WITH: + case CONTAINING: + case NOT_CONTAINING: + case LIKE: + case NOT_LIKE: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + Criteria criteria = part.getType() == Part.Type.NOT_LIKE || part.getType() == Part.Type.NOT_CONTAINING + ? criteriaStep.notLike(paramMetadata.getValue()) + : criteriaStep.like(paramMetadata.getValue()); + return criteria.ignoreCase(shouldIgnoreCase(part) + && checkCanUpperCase(part, propertyType, paramMetadata.getType())); + } + case SIMPLE_PROPERTY: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + if (paramMetadata.getValue() == null) { + return criteriaStep.isNull(); + } + return criteriaStep.is(paramMetadata.getValue()).ignoreCase(shouldIgnoreCase(part) + && checkCanUpperCase(part, propertyType, paramMetadata.getType())); + } + case NEGATING_SIMPLE_PROPERTY: { + ParameterMetadata paramMetadata = parameterMetadataProvider.next(part); + return criteriaStep.not(paramMetadata.getValue()).ignoreCase(shouldIgnoreCase(part) + && checkCanUpperCase(part, propertyType, paramMetadata.getType())); + } + default: + throw new IllegalArgumentException("Unsupported keyword " + type); + } + } + + /** + * Checks whether comparison should be done in case-insensitive way. + * + * @param part method name part (must not be {@literal null}) + * @return {@literal true} if comparison should be done in case-insensitive way + */ + private boolean shouldIgnoreCase(Part part) { + return part.shouldIgnoreCase() == Part.IgnoreCaseType.ALWAYS + || part.shouldIgnoreCase() == Part.IgnoreCaseType.WHEN_POSSIBLE; + } + + /** + * Checks whether "upper-case" conversion can be applied to the given {@link Expression}s in case the underlying + * {@link Part} requires ignoring case. + * + * @param part method name part (must not be {@literal null}) + * @param expressionTypes types of the given expressions (must not be {@literal null} or empty) + * @throws IllegalStateException if {@link Part} requires ignoring case but "upper-case" conversion cannot be + * applied to at least one of the given {@link Expression}s + */ + private boolean checkCanUpperCase(Part part, Class... expressionTypes) { + Assert.notEmpty(expressionTypes, "Expression types must not be null or empty"); + boolean strict = part.shouldIgnoreCase() == Part.IgnoreCaseType.ALWAYS; + for (Class expressionType : expressionTypes) { + if (!canUpperCase(expressionType)) { + if (strict) { + throw new IllegalStateException("Unable to ignore case of " + expressionType.getName() + + " type, the property '" + part.getProperty().getSegment() + "' must reference a string"); + } + return false; + } + } + return true; + } + + private boolean canUpperCase(Class expressionType) { + return expressionType == String.class; + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/LikeEscaper.java b/src/main/java/org/springframework/data/r2dbc/repository/query/LikeEscaper.java new file mode 100644 index 00000000..54a7674f --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/LikeEscaper.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import java.util.Arrays; +import java.util.List; + +import org.springframework.lang.Nullable; + +/** + * Helper class encapsulating an escape character for LIKE queries and the actually usage of it in escaping + * {@link String}s. + *

+ * This class is an adapted version of {@code org.springframework.data.jpa.repository.query.EscapeCharacter} from + * Spring Data JPA project. + * + * @author Roman Chigvintsev + */ +public class LikeEscaper { + public static final LikeEscaper DEFAULT = LikeEscaper.of('\\'); + + private final char escapeCharacter; + private final List toReplace; + + private LikeEscaper(char escapeCharacter) { + if (escapeCharacter == '_' || escapeCharacter == '%') { + throw new IllegalArgumentException("'_' and '%' are special characters and cannot be used as " + + "escape character"); + } + this.escapeCharacter = escapeCharacter; + this.toReplace = Arrays.asList(String.valueOf(escapeCharacter), "_", "%"); + } + + /** + * Creates new instance of this class with the given escape character. + * + * @param escapeCharacter escape character + * @return new instance of {@link LikeEscaper} + * @throws IllegalArgumentException if escape character is one of special characters ('_' and '%') + */ + public static LikeEscaper of(char escapeCharacter) { + return new LikeEscaper(escapeCharacter); + } + + /** + * Escapes all special like characters ({@code _}, {@code %}) using the configured escape character. + * + * @param value value to be escaped + * @return escaped value + */ + @Nullable + public String escape(@Nullable String value) { + if (value == null) { + return null; + } + return toReplace.stream().reduce(value, (it, character) -> it.replace(character, escapeCharacter + character)); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/ParameterMetadata.java b/src/main/java/org/springframework/data/r2dbc/repository/query/ParameterMetadata.java new file mode 100644 index 00000000..659c2be1 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/ParameterMetadata.java @@ -0,0 +1,48 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Helper class for holding information about query parameter. + */ +class ParameterMetadata { + private final String name; + @Nullable private final Object value; + private final Class type; + + public ParameterMetadata(String name, @Nullable Object value, Class type) { + Assert.notNull(type, "Parameter type must not be null"); + this.name = name; + this.value = value; + this.type = type; + } + + public String getName() { + return name; + } + + @Nullable + public Object getValue() { + return value; + } + + public Class getType() { + return type; + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/ParameterMetadataProvider.java b/src/main/java/org/springframework/data/r2dbc/repository/query/ParameterMetadataProvider.java new file mode 100644 index 00000000..9c4baaf0 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/ParameterMetadataProvider.java @@ -0,0 +1,158 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import org.jetbrains.annotations.NotNull; +import org.springframework.data.relational.repository.query.RelationalParameterAccessor; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Helper class to allow easy creation of {@link ParameterMetadata}s. + *

+ * This class is an adapted version of {@code org.springframework.data.jpa.repository.query.ParameterMetadataProvider} + * from Spring Data JPA project. + * + * @author Roman Chigvintsev + */ +class ParameterMetadataProvider implements Iterable { + private static final Object VALUE_PLACEHOLDER = new Object(); + + private final Iterator bindableParameterIterator; + @Nullable private final Iterator bindableParameterValueIterator; + private final List parameterMetadata = new ArrayList<>(); + private final LikeEscaper likeEscaper; + + /** + * Creates new instance of this class with the given {@link RelationalParameterAccessor} and {@link LikeEscaper}. + * + * @param accessor relational parameter accessor (must not be {@literal null}). + * @param likeEscaper escaper for LIKE operator parameters (must not be {@literal null}) + */ + ParameterMetadataProvider(RelationalParameterAccessor accessor, LikeEscaper likeEscaper) { + this(accessor.getBindableParameters(), accessor.iterator(), likeEscaper); + } + + /** + * Creates new instance of this class with the given {@link Parameters} and {@link LikeEscaper}. + * + * @param parameters method parameters (must not be {@literal null}) + * @param likeEscaper escaper for LIKE operator parameters (must not be {@literal null}) + */ + ParameterMetadataProvider(Parameters parameters, LikeEscaper likeEscaper) { + this(parameters, null, likeEscaper); + } + + /** + * Creates new instance of this class with the given {@link Parameters}, {@link Iterator} over all bindable + * parameter values and {@link LikeEscaper}. + * + * @param bindableParameterValueIterator iterator over bindable parameter values + * @param parameters method parameters (must not be {@literal null}) + * @param likeEscaper escaper for LIKE operator parameters (must not be {@literal null}) + */ + private ParameterMetadataProvider(Parameters parameters, + @Nullable Iterator bindableParameterValueIterator, LikeEscaper likeEscaper) { + Assert.notNull(parameters, "Parameters must not be null!"); + Assert.notNull(likeEscaper, "Like escaper must not be null!"); + + this.bindableParameterIterator = parameters.getBindableParameters().iterator(); + this.bindableParameterValueIterator = bindableParameterValueIterator; + this.likeEscaper = likeEscaper; + } + + @NotNull + @Override + public Iterator iterator() { + return parameterMetadata.iterator(); + } + + /** + * Creates new instance of {@link ParameterMetadata} for the given {@link Part} and next {@link Parameter}. + */ + public ParameterMetadata next(Part part) { + Assert.isTrue(bindableParameterIterator.hasNext(), + () -> String.format("No parameter available for part %s.", part)); + Parameter parameter = bindableParameterIterator.next(); + String parameterName = getParameterName(parameter, part.getProperty().getSegment()); + Object parameterValue = getParameterValue(); + Part.Type partType = part.getType(); + + checkNullIsAllowed(parameterName, parameterValue, partType); + Class parameterType = parameter.getType(); + Object preparedParameterValue = prepareParameterValue(parameterValue, parameterType, partType); + + ParameterMetadata metadata = new ParameterMetadata(parameterName, preparedParameterValue, parameterType); + parameterMetadata.add(metadata); + return metadata; + } + + private String getParameterName(Parameter parameter, String defaultName) { + if (parameter.isExplicitlyNamed()) { + return parameter.getName().orElseThrow(() -> new IllegalArgumentException("Parameter needs to be named")); + } + return defaultName; + } + + @Nullable + private Object getParameterValue() { + return bindableParameterValueIterator == null ? VALUE_PLACEHOLDER : bindableParameterValueIterator.next(); + } + + /** + * Checks whether {@literal null} is allowed as parameter value. + * + * @param parameterName parameter name + * @param parameterValue parameter value + * @param partType method name part type (must not be {@literal null}) + * @throws IllegalArgumentException if {@literal null} is not allowed as parameter value + */ + private void checkNullIsAllowed(String parameterName, @Nullable Object parameterValue, Part.Type partType) { + if (parameterValue == null && !Part.Type.SIMPLE_PROPERTY.equals(partType)) { + String message = String.format("Value of parameter with name %s must not be null!", parameterName); + throw new IllegalArgumentException(message); + } + } + + /** + * Prepares parameter value before it's actually bound to the query. + * + * @param value must not be {@literal null} + * @return prepared query parameter value + */ + @Nullable + protected Object prepareParameterValue(@Nullable Object value, Class valueType, Part.Type partType) { + if (value != null && String.class == valueType) { + switch (partType) { + case STARTING_WITH: + return likeEscaper.escape(value.toString()) + "%"; + case ENDING_WITH: + return "%" + likeEscaper.escape(value.toString()); + case CONTAINING: + case NOT_CONTAINING: + return "%" + likeEscaper.escape(value.toString()) + "%"; + } + } + return value; + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQuery.java b/src/main/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQuery.java new file mode 100644 index 00000000..8bc5647f --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQuery.java @@ -0,0 +1,146 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import java.util.Collection; + +import org.springframework.data.domain.Sort; +import org.springframework.data.r2dbc.convert.R2dbcConverter; +import org.springframework.data.r2dbc.core.DatabaseClient; +import org.springframework.data.r2dbc.core.PreparedOperation; +import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; +import org.springframework.data.relational.repository.query.RelationalEntityMetadata; +import org.springframework.data.relational.repository.query.RelationalParameterAccessor; +import org.springframework.data.relational.repository.query.RelationalParameters; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.util.Streamable; + +/** + * An {@link AbstractR2dbcQuery} implementation based on a {@link PartTree}. + *

+ * This class is an adapted version of {@code org.springframework.data.jpa.repository.query.PartTreeJpaQuery} from + * Spring Data JPA project. + * + * @author Roman Chigvintsev + */ +public class PartTreeR2dbcQuery extends AbstractR2dbcQuery { + private final ReactiveDataAccessStrategy dataAccessStrategy; + private final RelationalParameters parameters; + private final PartTree tree; + + private LikeEscaper likeEscaper = LikeEscaper.DEFAULT; + + /** + * Creates new instance of this class with the given {@link R2dbcQueryMethod}, {@link DatabaseClient}, + * {@link R2dbcConverter} and {@link ReactiveDataAccessStrategy}. + * + * @param method query method (must not be {@literal null}) + * @param databaseClient database client (must not be {@literal null}) + * @param converter converter (must not be {@literal null}) + * @param dataAccessStrategy data access strategy (must not be {@literal null}) + */ + public PartTreeR2dbcQuery(R2dbcQueryMethod method, DatabaseClient databaseClient, R2dbcConverter converter, + ReactiveDataAccessStrategy dataAccessStrategy) { + super(method, databaseClient, converter); + this.dataAccessStrategy = dataAccessStrategy; + this.parameters = method.getParameters(); + + try { + this.tree = new PartTree(method.getName(), method.getEntityInformation().getJavaType()); + validate(this.tree, this.parameters, method.getName()); + } catch (Exception e) { + String message = String.format("Failed to create query for method %s! %s", method, e.getMessage()); + throw new IllegalArgumentException(message, e); + } + } + + public void setLikeEscaper(LikeEscaper likeEscaper) { + this.likeEscaper = likeEscaper; + } + + /** + * Creates new {@link BindableQuery} for the given {@link RelationalParameterAccessor}. + * + * @param accessor query parameter accessor (must not be {@literal null}) + * @return new instance of {@link BindableQuery} + */ + @Override + protected BindableQuery createQuery(RelationalParameterAccessor accessor) { + RelationalEntityMetadata entityMetadata = getQueryMethod().getEntityInformation(); + ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider(accessor, likeEscaper); + R2dbcQueryCreator queryCreator = new R2dbcQueryCreator(tree, dataAccessStrategy, entityMetadata, + parameterMetadataProvider); + PreparedOperation preparedQuery = queryCreator.createQuery(getDynamicSort(accessor)); + return new PreparedOperationBindableQuery(preparedQuery); + } + + private Sort getDynamicSort(RelationalParameterAccessor accessor) { + return parameters.potentiallySortsDynamically() ? accessor.getSort() : Sort.unsorted(); + } + + private static void validate(PartTree tree, RelationalParameters parameters, String methodName) { + int argCount = 0; + Iterable parts = () -> tree.stream().flatMap(Streamable::stream).iterator(); + for (Part part : parts) { + int numberOfArguments = part.getNumberOfArguments(); + for (int i = 0; i < numberOfArguments; i++) { + throwExceptionOnArgumentMismatch(methodName, part, parameters, argCount); + argCount++; + } + } + } + + private static void throwExceptionOnArgumentMismatch(String methodName, Part part, RelationalParameters parameters, + int index) { + Part.Type type = part.getType(); + String property = part.getProperty().toDotPath(); + + if (!parameters.getBindableParameters().hasParameterAt(index)) { + String msgTemplate = "Method %s expects at least %d arguments but only found %d. " + + "This leaves an operator of type %s for property %s unbound."; + String formattedMsg = String.format(msgTemplate, methodName, index + 1, index, type.name(), property); + throw new IllegalStateException(formattedMsg); + } + + RelationalParameters.RelationalParameter parameter = parameters.getBindableParameter(index); + if (expectsCollection(type) && !parameterIsCollectionLike(parameter)) { + String message = wrongParameterTypeMessage(methodName, property, type, "Collection", parameter); + throw new IllegalStateException(message); + } else if (!expectsCollection(type) && !parameterIsScalarLike(parameter)) { + String message = wrongParameterTypeMessage(methodName, property, type, "scalar", parameter); + throw new IllegalStateException(message); + } + } + + private static boolean expectsCollection(Part.Type type) { + return type == Part.Type.IN || type == Part.Type.NOT_IN; + } + + private static boolean parameterIsCollectionLike(RelationalParameters.RelationalParameter parameter) { + return parameter.getType().isArray() || Collection.class.isAssignableFrom(parameter.getType()); + } + + private static boolean parameterIsScalarLike(RelationalParameters.RelationalParameter parameter) { + return !Collection.class.isAssignableFrom(parameter.getType()); + } + + private static String wrongParameterTypeMessage(String methodName, String property, Part.Type operatorType, + String expectedArgumentType, RelationalParameters.RelationalParameter parameter) { + return String.format("Operator %s on %s requires a %s argument, found %s in method %s.", operatorType.name(), + property, expectedArgumentType, parameter.getType(), methodName); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/PreparedOperationBindableQuery.java b/src/main/java/org/springframework/data/r2dbc/repository/query/PreparedOperationBindableQuery.java new file mode 100644 index 00000000..293fdb57 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/PreparedOperationBindableQuery.java @@ -0,0 +1,85 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import org.springframework.data.r2dbc.core.DatabaseClient; +import org.springframework.data.r2dbc.core.PreparedOperation; +import org.springframework.data.r2dbc.dialect.BindTarget; +import org.springframework.util.Assert; + +/** + * A {@link BindableQuery} implementation based on a {@link PreparedOperation}. + * + * @author Roman Chigvintsev + */ +class PreparedOperationBindableQuery implements BindableQuery { + private final PreparedOperation preparedQuery; + + /** + * Creates new instance of this class with the given {@link PreparedOperation}. + * + * @param preparedQuery prepared SQL query (must not be {@literal null}) + */ + PreparedOperationBindableQuery(PreparedOperation preparedQuery) { + Assert.notNull(preparedQuery, "Prepared query must not be null!"); + this.preparedQuery = preparedQuery; + } + + @SuppressWarnings("unchecked") + @Override + public > T bind(T bindSpec) { + BindSpecBindTargetAdapter bindTargetAdapter = new BindSpecBindTargetAdapter<>(bindSpec); + preparedQuery.bindTo(bindTargetAdapter); + return (T) bindTargetAdapter.bindSpec; + } + + @Override + public String get() { + return preparedQuery.get(); + } + + /** + * This class adapts {@link org.springframework.data.r2dbc.core.DatabaseClient.BindSpec} to {@link BindTarget} + * allowing easy binding of query parameters using {@link PreparedOperation}. + */ + private static class BindSpecBindTargetAdapter> implements BindTarget { + DatabaseClient.BindSpec bindSpec; + + private BindSpecBindTargetAdapter(DatabaseClient.BindSpec bindSpec) { + this.bindSpec = bindSpec; + } + + @Override + public void bind(String identifier, Object value) { + this.bindSpec = this.bindSpec.bind(identifier, value); + } + + @Override + public void bind(int index, Object value) { + this.bindSpec = this.bindSpec.bind(index, value); + } + + @Override + public void bindNull(String identifier, Class type) { + this.bindSpec = this.bindSpec.bindNull(identifier, type); + } + + @Override + public void bindNull(int index, Class type) { + this.bindSpec = this.bindSpec.bind(index, type); + } + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryCreator.java b/src/main/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryCreator.java new file mode 100644 index 00000000..2154f1a5 --- /dev/null +++ b/src/main/java/org/springframework/data/r2dbc/repository/query/R2dbcQueryCreator.java @@ -0,0 +1,160 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Sort; +import org.springframework.data.r2dbc.core.PreparedOperation; +import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.core.StatementMapper; +import org.springframework.data.r2dbc.query.Criteria; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.relational.repository.query.RelationalEntityMetadata; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.util.Assert; + +/** + * Implementation of {@link AbstractQueryCreator} that creates {@link PreparedOperation} from a {@link PartTree}. + * + * @author Roman Chigvintsev + */ +public class R2dbcQueryCreator extends AbstractQueryCreator, Criteria> { + private final PartTree tree; + private final ReactiveDataAccessStrategy dataAccessStrategy; + private final RelationalEntityMetadata entityMetadata; + private final CriteriaFactory criteriaFactory; + + /** + * Creates new instance of this class with the given {@link PartTree}, {@link ReactiveDataAccessStrategy}, + * {@link RelationalEntityMetadata} and {@link ParameterMetadataProvider}. + * + * @param tree part tree (must not be {@literal null}) + * @param dataAccessStrategy data access strategy (must not be {@literal null}) + * @param entityMetadata relational entity metadata (must not be {@literal null}) + * @param parameterMetadataProvider parameter metadata provider (must not be {@literal null}) + */ + public R2dbcQueryCreator(PartTree tree, ReactiveDataAccessStrategy dataAccessStrategy, + RelationalEntityMetadata entityMetadata, ParameterMetadataProvider parameterMetadataProvider) { + super(tree); + this.tree = tree; + + Assert.notNull(dataAccessStrategy, "Data access strategy must not be null"); + Assert.notNull(entityMetadata, "Relational entity metadata must not be null"); + Assert.notNull(parameterMetadataProvider, "Parameter metadata provider must not be null"); + + this.dataAccessStrategy = dataAccessStrategy; + this.entityMetadata = entityMetadata; + this.criteriaFactory = new CriteriaFactory(parameterMetadataProvider); + } + + /** + * Creates {@link Criteria} for the given method name part. + * + * @param part method name part (must not be {@literal null}) + * @param iterator iterator over query parameter values + * @return new instance of {@link Criteria} + */ + @Override + protected Criteria create(Part part, Iterator iterator) { + return criteriaFactory.createCriteria(part); + } + + /** + * Combines the given {@link Criteria} with the new one created for the given method name part using {@code AND}. + * + * @param part method name part (must not be {@literal null}) + * @param base {@link Criteria} to be combined (must not be {@literal null}) + * @param iterator iterator over query parameter values + * @return {@link Criteria} combination + */ + @Override + protected Criteria and(Part part, Criteria base, Iterator iterator) { + return base.and(criteriaFactory.createCriteria(part)); + } + + /** + * Combines two {@link Criteria}s using {@code OR}. + * + * @param base {@link Criteria} to be combined (must not be {@literal null}) + * @param criteria another {@link Criteria} to be combined (must not be {@literal null}) + * @return {@link Criteria} combination + */ + @Override + protected Criteria or(Criteria base, Criteria criteria) { + return base.or(criteria); + } + + /** + * Creates {@link PreparedOperation} applying the given {@link Criteria} and {@link Sort} definition. + * + * @param criteria {@link Criteria} to be applied to query + * @param sort sort option to be applied to query (must not be {@literal null}) + * @return instance of {@link PreparedOperation} + */ + @Override + protected PreparedOperation complete(Criteria criteria, Sort sort) { + StatementMapper statementMapper = dataAccessStrategy.getStatementMapper().forType(entityMetadata.getJavaType()); + StatementMapper.SelectSpec selectSpec = statementMapper.createSelect(entityMetadata.getTableName()) + .withProjection(getSelectProjection()); + + if (tree.isExistsProjection()) { + selectSpec = selectSpec.limit(1); + } else if (tree.isLimiting()) { + selectSpec = selectSpec.limit(tree.getMaxResults()); + } + + if (criteria != null) { + selectSpec = selectSpec.withCriteria(criteria); + } + + if (sort.isSorted()) { + selectSpec = selectSpec.withSort(getSort(sort)); + } + + return statementMapper.getMappedObject(selectSpec); + } + + private SqlIdentifier[] getSelectProjection() { + List columnNames; + if (tree.isExistsProjection()) { + columnNames = dataAccessStrategy.getIdentifierColumns(entityMetadata.getJavaType()); + } else { + columnNames = dataAccessStrategy.getAllColumns(entityMetadata.getJavaType()); + } + return columnNames.toArray(new SqlIdentifier[0]); + } + + private Sort getSort(Sort sort) { + RelationalPersistentEntity tableEntity = entityMetadata.getTableEntity(); + List orders = sort.get().map(order -> { + RelationalPersistentProperty property = tableEntity.getRequiredPersistentProperty(order.getProperty()); + String columnName = dataAccessStrategy.toSql(property.getColumnName()); + String orderProperty = entityMetadata.getTableName() + "." + columnName; + // TODO: org.springframework.data.relational.core.sql.render.OrderByClauseVisitor from + // spring-data-relational does not prepend column name with table name. It makes sense to render + // column names uniformly. + return order.isAscending() ? Sort.Order.asc(orderProperty) : Sort.Order.desc(orderProperty); + }).collect(Collectors.toList()); + return Sort.by(orders); + } +} diff --git a/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java b/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java index fccfb13a..f7c50d6c 100644 --- a/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java +++ b/src/main/java/org/springframework/data/r2dbc/repository/support/R2dbcRepositoryFactory.java @@ -25,6 +25,7 @@ import org.springframework.data.r2dbc.core.DatabaseClient; import org.springframework.data.r2dbc.core.R2dbcEntityTemplate; import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; import org.springframework.data.r2dbc.repository.R2dbcRepository; +import org.springframework.data.r2dbc.repository.query.PartTreeR2dbcQuery; import org.springframework.data.r2dbc.repository.query.R2dbcQueryMethod; import org.springframework.data.r2dbc.repository.query.StringBasedR2dbcQuery; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; @@ -104,7 +105,8 @@ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport { @Override protected Optional getQueryLookupStrategy(@Nullable Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) { - return Optional.of(new R2dbcQueryLookupStrategy(this.databaseClient, evaluationContextProvider, this.converter)); + return Optional.of(new R2dbcQueryLookupStrategy(this.databaseClient, evaluationContextProvider, this.converter, + this.dataAccessStrategy)); } /* @@ -134,12 +136,15 @@ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport { private final DatabaseClient databaseClient; private final QueryMethodEvaluationContextProvider evaluationContextProvider; private final R2dbcConverter converter; + private final ReactiveDataAccessStrategy dataAccessStrategy; R2dbcQueryLookupStrategy(DatabaseClient databaseClient, - QueryMethodEvaluationContextProvider evaluationContextProvider, R2dbcConverter converter) { + QueryMethodEvaluationContextProvider evaluationContextProvider, R2dbcConverter converter, + ReactiveDataAccessStrategy dataAccessStrategy) { this.databaseClient = databaseClient; this.evaluationContextProvider = evaluationContextProvider; this.converter = converter; + this.dataAccessStrategy = dataAccessStrategy; } /* @@ -161,9 +166,10 @@ public class R2dbcRepositoryFactory extends ReactiveRepositoryFactorySupport { } else if (queryMethod.hasAnnotatedQuery()) { return new StringBasedR2dbcQuery(queryMethod, this.databaseClient, this.converter, EXPRESSION_PARSER, this.evaluationContextProvider); + } else { + return new PartTreeR2dbcQuery(queryMethod, this.databaseClient, this.converter, + this.dataAccessStrategy); } - - throw new UnsupportedOperationException("Query derivation not yet supported!"); } } } diff --git a/src/test/java/org/springframework/data/r2dbc/query/CriteriaUnitTests.java b/src/test/java/org/springframework/data/r2dbc/query/CriteriaUnitTests.java index b2395bb0..2ea379d2 100644 --- a/src/test/java/org/springframework/data/r2dbc/query/CriteriaUnitTests.java +++ b/src/test/java/org/springframework/data/r2dbc/query/CriteriaUnitTests.java @@ -155,6 +155,16 @@ public class CriteriaUnitTests { assertThat(criteria.getValue()).isEqualTo("bar"); } + @Test + public void shouldBuildEqualsIgnoreCaseCriteria() { + Criteria criteria = where("foo").is("bar").ignoreCase(true); + + assertThat(criteria.getColumn()).isEqualTo(SqlIdentifier.unquoted("foo")); + assertThat(criteria.getComparator()).isEqualTo(Comparator.EQ); + assertThat(criteria.getValue()).isEqualTo("bar"); + assertThat(criteria.isIgnoreCase()).isTrue(); + } + @Test // gh-64 public void shouldBuildNotEqualsCriteria() { @@ -235,6 +245,15 @@ public class CriteriaUnitTests { assertThat(criteria.getValue()).isEqualTo("hello%"); } + @Test + public void shouldBuildNotLikeCriteria() { + Criteria criteria = where("foo").notLike("hello%"); + + assertThat(criteria.getColumn()).isEqualTo(SqlIdentifier.unquoted("foo")); + assertThat(criteria.getComparator()).isEqualTo(Comparator.NOT_LIKE); + assertThat(criteria.getValue()).isEqualTo("hello%"); + } + @Test // gh-64 public void shouldBuildIsNullCriteria() { @@ -252,4 +271,20 @@ public class CriteriaUnitTests { assertThat(criteria.getColumn()).isEqualTo(SqlIdentifier.unquoted("foo")); assertThat(criteria.getComparator()).isEqualTo(Comparator.IS_NOT_NULL); } + + @Test + public void shouldBuildIsTrueCriteria() { + Criteria criteria = where("foo").isTrue(); + + assertThat(criteria.getColumn()).isEqualTo(SqlIdentifier.unquoted("foo")); + assertThat(criteria.getComparator()).isEqualTo(Comparator.IS_TRUE); + } + + @Test + public void shouldBuildIsFalseCriteria() { + Criteria criteria = where("foo").isFalse(); + + assertThat(criteria.getColumn()).isEqualTo(SqlIdentifier.unquoted("foo")); + assertThat(criteria.getComparator()).isEqualTo(Comparator.IS_FALSE); + } } diff --git a/src/test/java/org/springframework/data/r2dbc/repository/query/LikeEscaperUnitTests.java b/src/test/java/org/springframework/data/r2dbc/repository/query/LikeEscaperUnitTests.java new file mode 100644 index 00000000..f86a8e82 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/repository/query/LikeEscaperUnitTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.Test; + +/** + * @author Roman Chigvintsev + */ +public class LikeEscaperUnitTests { + @Test + public void ignoresNulls() { + assertNull(LikeEscaper.DEFAULT.escape(null)); + } + + @Test + public void ignoresEmptyString() { + assertThat(LikeEscaper.DEFAULT.escape("")).isEqualTo(""); + } + + @Test + public void ignoresBlankString() { + assertThat(LikeEscaper.DEFAULT.escape(" ")).isEqualTo(" "); + } + + @Test(expected = IllegalArgumentException.class) + public void throwsExceptionWhenEscapeCharacterIsUnderscore() { + LikeEscaper.of('_'); + } + + @Test(expected = IllegalArgumentException.class) + public void throwsExceptionWhenEscapeCharacterIsPercent() { + LikeEscaper.of('%'); + } + + @Test + public void escapesUnderscoresUsingDefaultEscapeCharacter() { + assertThat(LikeEscaper.DEFAULT.escape("_test_")).isEqualTo("\\_test\\_"); + } + + @Test + public void escapesPercentsUsingDefaultEscapeCharacter() { + assertThat(LikeEscaper.DEFAULT.escape("%test%")).isEqualTo("\\%test\\%"); + } + + @Test + public void escapesSpecialCharactersUsingCustomEscapeCharacter() { + assertThat(LikeEscaper.of('$').escape("_%")).isEqualTo("$_$%"); + } + + @Test + public void doublesEscapeCharacter() { + assertThat(LikeEscaper.DEFAULT.escape("\\")).isEqualTo("\\\\"); + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryIntegrationTests.java b/src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryIntegrationTests.java new file mode 100644 index 00000000..f1fa1707 --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/repository/query/PartTreeR2dbcQueryIntegrationTests.java @@ -0,0 +1,672 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import io.r2dbc.spi.ConnectionFactory; +import io.r2dbc.spi.ConnectionFactoryMetadata; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.data.annotation.Id; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.r2dbc.convert.R2dbcConverter; +import org.springframework.data.r2dbc.core.DatabaseClient; +import org.springframework.data.r2dbc.core.DefaultReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.core.ReactiveDataAccessStrategy; +import org.springframework.data.r2dbc.dialect.DialectResolver; +import org.springframework.data.r2dbc.dialect.R2dbcDialect; +import org.springframework.data.r2dbc.mapping.R2dbcMappingContext; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.Table; +import org.springframework.data.relational.repository.query.RelationalParametersParameterAccessor; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +/** + * @author Roman Chigvintsev + */ +@RunWith(MockitoJUnitRunner.class) +public class PartTreeR2dbcQueryIntegrationTests { + private static final String TABLE = "users"; + private static final String ALL_FIELDS = TABLE + ".id, " + + TABLE + ".first_name, " + + TABLE + ".last_name, " + + TABLE + ".date_of_birth, " + + TABLE + ".age, " + + TABLE + ".active"; + + @Mock private ConnectionFactory connectionFactory; + @Mock private R2dbcConverter r2dbcConverter; + + @Rule public ExpectedException thrown = ExpectedException.none(); + + private RelationalMappingContext mappingContext; + private ReactiveDataAccessStrategy dataAccessStrategy; + private DatabaseClient databaseClient; + + @Before + public void setUp() { + ConnectionFactoryMetadata metadataMock = mock(ConnectionFactoryMetadata.class); + when(metadataMock.getName()).thenReturn("PostgreSQL"); + when(connectionFactory.getMetadata()).thenReturn(metadataMock); + + when(r2dbcConverter.writeValue(any(), any())).thenAnswer(invocation -> invocation.getArgument(0)); + + mappingContext = new R2dbcMappingContext(); + doReturn(mappingContext).when(r2dbcConverter).getMappingContext(); + + R2dbcDialect dialect = DialectResolver.getDialect(connectionFactory); + dataAccessStrategy = new DefaultReactiveDataAccessStrategy(dialect, r2dbcConverter); + + databaseClient = DatabaseClient.builder().connectionFactory(connectionFactory) + .dataAccessStrategy(dataAccessStrategy).build(); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttribute() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + BindableQuery bindableQuery = r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "John" })); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".first_name = $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryWithIsNullCondition() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + BindableQuery bindableQuery = r2dbcQuery.createQuery((getAccessor(queryMethod, new Object[] { null }))); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".first_name IS NULL"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryWithLimitForExistsProjection() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("existsByFirstName", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + BindableQuery query = r2dbcQuery.createQuery((getAccessor(queryMethod, new Object[] { "John" }))); + String expectedSql = "SELECT " + TABLE + ".id FROM " + TABLE + " WHERE " + TABLE + ".first_name = $1 LIMIT 1"; + assertThat(query.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByTwoStringAttributes() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameAndFirstName", String.class, String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + BindableQuery bindableQuery = r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "Doe", "John" })); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".last_name = $1 AND (" + TABLE + ".first_name = $2)"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByOneOfTwoStringAttributes() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameOrFirstName", String.class, String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + BindableQuery bindableQuery = r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[] { "Doe", "John" })); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".last_name = $1 OR (" + TABLE + ".first_name = $2)"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByDateAttributeBetween() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBetween", Date.class, Date.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, + new Object[] { new Date(), new Date() }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".date_of_birth >= $1 AND " + TABLE + ".date_of_birth <= $2"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeLessThan() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThan", Integer.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age < $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeLessThanEqual() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeLessThanEqual", Integer.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age <= $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThan() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThan", Integer.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age > $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeGreaterThanEqual() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeGreaterThanEqual", Integer.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { 30 }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age >= $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByDateAttributeAfter() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthAfter", Date.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { new Date() }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".date_of_birth > $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByDateAttributeBefore() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByDateOfBirthBefore", Date.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { new Date() }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".date_of_birth < $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeIsNull() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNull"); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age IS NULL"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeIsNotNull() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIsNotNull"); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age IS NOT NULL"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeLike() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameLike", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "%John%" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".first_name LIKE $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeNotLike() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotLike", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "%John%" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".first_name NOT LIKE $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeStartingWith() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Jo" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".first_name LIKE $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void appendsLikeOperatorParameterWithPercentSymbolForStartingWithQuery() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameStartingWith", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Jo" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + DatabaseClient.BindSpec bindSpecMock = mock(DatabaseClient.BindSpec.class); + bindableQuery.bind(bindSpecMock); + verify(bindSpecMock, times(1)).bind(0, "Jo%"); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeEndingWith() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "hn" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".first_name LIKE $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void prependsLikeOperatorParameterWithPercentSymbolForEndingWithQuery() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameEndingWith", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "hn" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + DatabaseClient.BindSpec bindSpecMock = mock(DatabaseClient.BindSpec.class); + bindableQuery.bind(bindSpecMock); + verify(bindSpecMock, times(1)).bind(0, "%hn"); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeContaining() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".first_name LIKE $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void wrapsLikeOperatorParameterWithPercentSymbolsForContainingQuery() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameContaining", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + DatabaseClient.BindSpec bindSpecMock = mock(DatabaseClient.BindSpec.class); + bindableQuery.bind(bindSpecMock); + verify(bindSpecMock, times(1)).bind(0, "%oh%"); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeNotContaining() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".first_name NOT LIKE $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void wrapsLikeOperatorParameterWithPercentSymbolsForNotContainingQuery() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameNotContaining", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + DatabaseClient.BindSpec bindSpecMock = mock(DatabaseClient.BindSpec.class); + bindableQuery.bind(bindSpecMock); + verify(bindSpecMock, times(1)).bind(0, "%oh%"); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeWithDescendingOrderingByStringAttribute() + throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameDesc", Integer.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".age = $1 ORDER BY users.last_name DESC"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeWithAscendingOrderingByStringAttribute() + throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeOrderByLastNameAsc", Integer.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "oh" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".age = $1 ORDER BY users.last_name ASC"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeNot() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByLastNameNot", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "Doe" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".last_name != $1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeIn() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeIn", Collection.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, + new Object[] { Collections.singleton(25) }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age IN ($1)"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByIntegerAttributeNotIn() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByAgeNotIn", Collection.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, + new Object[] { Collections.singleton(25) }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".age NOT IN ($1)"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByBooleanAttributeTrue() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByActiveTrue"); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".active = TRUE"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByBooleanAttributeFalse() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByActiveFalse"); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[0]); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + " WHERE " + TABLE + ".active = FALSE"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindAllEntitiesByStringAttributeIgnoringCase() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstNameIgnoreCase", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE UPPER(" + TABLE + ".first_name) = UPPER($1)"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void throwsExceptionWhenIgnoringCaseIsImpossible() throws Exception { + thrown.expect(IllegalStateException.class); + thrown.expectMessage("Unable to ignore case of java.lang.Long type, " + + "the property 'id' must reference a string"); + R2dbcQueryMethod queryMethod = getQueryMethod("findByIdIgnoringCase", Long.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[] { 1L })); + } + + @Test + public void throwsExceptionWhenInPredicateHasNonIterableParameter() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Operator IN on id requires a Collection argument, " + + "found class java.lang.Long in method findAllByIdIn."); + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByIdIn", Long.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[] { 1L })); + } + + @Test + public void throwsExceptionWhenSimplePropertyPredicateHasIterableParameter() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Operator SIMPLE_PROPERTY on id requires a scalar argument, " + + "found interface java.util.Collection in method findAllById."); + R2dbcQueryMethod queryMethod = getQueryMethod("findAllById", Collection.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[] { Collections.singleton(1L) })); + } + + @Test + public void throwsExceptionWhenConditionKeywordIsUnsupported() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Unsupported keyword IS_EMPTY"); + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByIdIsEmpty"); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[0])); + } + + @Test + public void throwsExceptionWhenInvalidNumberOfParameterIsGiven() throws Exception { + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("Invalid number of parameters given!"); + R2dbcQueryMethod queryMethod = getQueryMethod("findAllByFirstName", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + r2dbcQuery.createQuery(getAccessor(queryMethod, new Object[0])); + } + + @Test + public void createsQueryWithLimitToFindEntitiesByStringAttribute() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findTop3ByFirstName", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".first_name = $1 LIMIT 3"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + @Test + public void createsQueryToFindFirstEntityByStringAttribute() throws Exception { + R2dbcQueryMethod queryMethod = getQueryMethod("findFirstByFirstName", String.class); + PartTreeR2dbcQuery r2dbcQuery = new PartTreeR2dbcQuery(queryMethod, databaseClient, r2dbcConverter, + dataAccessStrategy); + RelationalParametersParameterAccessor accessor = getAccessor(queryMethod, new Object[] { "John" }); + BindableQuery bindableQuery = r2dbcQuery.createQuery(accessor); + String expectedSql = "SELECT " + ALL_FIELDS + " FROM " + TABLE + + " WHERE " + TABLE + ".first_name = $1 LIMIT 1"; + assertThat(bindableQuery.get()).isEqualTo(expectedSql); + } + + private R2dbcQueryMethod getQueryMethod(String methodName, Class... parameterTypes) throws Exception { + Method method = UserRepository.class.getMethod(methodName, parameterTypes); + return new R2dbcQueryMethod(method, new DefaultRepositoryMetadata(UserRepository.class), + new SpelAwareProxyProjectionFactory(), mappingContext); + } + + private RelationalParametersParameterAccessor getAccessor(R2dbcQueryMethod queryMethod, Object[] values) { + return new RelationalParametersParameterAccessor(queryMethod, values); + } + + private interface UserRepository extends Repository { + Flux findAllByFirstName(String firstName); + + Flux findAllByLastNameAndFirstName(String lastName, String firstName); + + Flux findAllByLastNameOrFirstName(String lastName, String firstName); + + Mono existsByFirstName(String firstName); + + Flux findAllByDateOfBirthBetween(Date from, Date to); + + Flux findAllByAgeLessThan(Integer age); + + Flux findAllByAgeLessThanEqual(Integer age); + + Flux findAllByAgeGreaterThan(Integer age); + + Flux findAllByAgeGreaterThanEqual(Integer age); + + Flux findAllByDateOfBirthAfter(Date date); + + Flux findAllByDateOfBirthBefore(Date date); + + Flux findAllByAgeIsNull(); + + Flux findAllByAgeIsNotNull(); + + Flux findAllByFirstNameLike(String like); + + Flux findAllByFirstNameNotLike(String like); + + Flux findAllByFirstNameStartingWith(String starting); + + Flux findAllByFirstNameEndingWith(String ending); + + Flux findAllByFirstNameContaining(String containing); + + Flux findAllByFirstNameNotContaining(String notContaining); + + Flux findAllByAgeOrderByLastNameAsc(Integer age); + + Flux findAllByAgeOrderByLastNameDesc(Integer age); + + Flux findAllByLastNameNot(String lastName); + + Flux findAllByAgeIn(Collection ages); + + Flux findAllByAgeNotIn(Collection ages); + + Flux findAllByActiveTrue(); + + Flux findAllByActiveFalse(); + + Flux findAllByFirstNameIgnoreCase(String firstName); + + Mono findByIdIgnoringCase(Long id); + + Flux findAllByIdIn(Long id); + + Flux findAllById(Collection ids); + + Flux findAllByIdIsEmpty(); + + Flux findTop3ByFirstName(String firstName); + + Mono findFirstByFirstName(String firstName); + } + + @Table("users") + private static class User { + @Id private Long id; + private String firstName; + private String lastName; + private Date dateOfBirth; + private Integer age; + private Boolean active; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public Date getDateOfBirth() { + return dateOfBirth; + } + + public void setDateOfBirth(Date dateOfBirth) { + this.dateOfBirth = dateOfBirth; + } + + public Integer getAge() { + return age; + } + + public void setAge(Integer age) { + this.age = age; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } + } +} diff --git a/src/test/java/org/springframework/data/r2dbc/repository/query/PreparedOperationBindableQueryUnitTests.java b/src/test/java/org/springframework/data/r2dbc/repository/query/PreparedOperationBindableQueryUnitTests.java new file mode 100644 index 00000000..6690286c --- /dev/null +++ b/src/test/java/org/springframework/data/r2dbc/repository/query/PreparedOperationBindableQueryUnitTests.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.data.r2dbc.repository.query; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.data.r2dbc.core.DatabaseClient; +import org.springframework.data.r2dbc.core.PreparedOperation; + +/** + * @author Roman Chigvintsev + */ +@RunWith(MockitoJUnitRunner.class) +@Ignore +public class PreparedOperationBindableQueryUnitTests { + @Mock private PreparedOperation preparedOperation; + + @Test(expected = IllegalArgumentException.class) + public void throwsExceptionWhenPreparedOperationIsNull() { + new PreparedOperationBindableQuery(null); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void bindsQueryParameterValues() { + DatabaseClient.BindSpec bindSpecMock = mock(DatabaseClient.BindSpec.class); + + PreparedOperationBindableQuery query = new PreparedOperationBindableQuery(preparedOperation); + query.bind(bindSpecMock); + verify(preparedOperation, times(1)).bindTo(any()); + } + + @Test + public void returnsSqlQuery() { + String sql = "SELECT * FROM test"; + when(preparedOperation.get()).thenReturn(sql); + + PreparedOperationBindableQuery query = new PreparedOperationBindableQuery(preparedOperation); + assertThat(query.get()).isEqualTo(sql); + } +}