DATAJDBC-514 - Add infrastructure for query derivation in Spring Data Relational.

Original pull request: spring-projects/spring-data-r2dbc#295.
This commit is contained in:
Roman Chigvintsev
2020-03-27 12:27:43 +01:00
committed by Mark Paluch
parent 7802f6e19e
commit 74540327f6
6 changed files with 701 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
/*
* 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.relational.core.dialect;
import java.util.ArrayList;
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.
*
* @author Roman Chigvintsev
* @author Mark Paluch
* @since 2.0
*/
public class Escaper {
public static final Escaper DEFAULT = Escaper.of('\\');
private final char escapeCharacter;
private final List<String> toReplace;
private Escaper(char escapeCharacter, List<String> toReplace) {
if (toReplace.contains(Character.toString(escapeCharacter))) {
throw new IllegalArgumentException(
String.format("'%s' and cannot be used as escape character as it should be replaced", escapeCharacter));
}
this.escapeCharacter = escapeCharacter;
this.toReplace = toReplace;
}
/**
* Creates new instance of this class with the given escape character.
*
* @param escapeCharacter escape character
* @return new instance of {@link Escaper}.
* @throws IllegalArgumentException if escape character is one of special characters ('_' and '%')
*/
public static Escaper of(char escapeCharacter) {
return new Escaper(escapeCharacter, Arrays.asList("_", "%"));
}
/**
* Apply the {@link Escaper} to the given {@code chars}.
*
* @param chars characters/char sequences that should be escaped.
* @return
*/
public Escaper withRewriteFor(String... chars) {
List<String> toReplace = new ArrayList<>(this.toReplace.size() + chars.length);
toReplace.addAll(this.toReplace);
toReplace.addAll(Arrays.asList(chars));
return new Escaper(this.escapeCharacter, toReplace);
}
/**
* Returns the escape character.
*
* @return the escape character to use.
*/
public char getEscapeCharacter() {
return 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));
}
}

View File

@@ -0,0 +1,166 @@
/*
* 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.relational.repository.query;
import org.springframework.data.relational.core.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})
*/
public 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;
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.relational.repository.query;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Helper class for holding information about query parameter.
*
* @since 2.0
*/
class ParameterMetadata {
private final String name;
private final @Nullable 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;
}
}

View File

@@ -0,0 +1,161 @@
/*
* 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.relational.repository.query;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import org.springframework.data.relational.core.dialect.Escaper;
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.
* <p>
* This class is an adapted version of {@code org.springframework.data.jpa.repository.query.ParameterMetadataProvider}
* from Spring Data JPA project.
*
* @author Roman Chigvintsev
* @since 2.0
*/
public class ParameterMetadataProvider implements Iterable<ParameterMetadata> {
private static final Object VALUE_PLACEHOLDER = new Object();
private final Iterator<? extends Parameter> bindableParameterIterator;
private final Iterator<Object> bindableParameterValueIterator;
private final List<ParameterMetadata> parameterMetadata = new ArrayList<>();
private final Escaper escaper;
/**
* Creates new instance of this class with the given {@link RelationalParameterAccessor} and {@link Escaper}.
*
* @param accessor relational parameter accessor (must not be {@literal null}).
* @param escaper escaper for LIKE operator parameters (must not be {@literal null})
*/
public ParameterMetadataProvider(RelationalParameterAccessor accessor, Escaper escaper) {
this(accessor.getBindableParameters(), accessor.iterator(), escaper);
}
/**
* Creates new instance of this class with the given {@link Parameters} and {@link Escaper}.
*
* @param parameters method parameters (must not be {@literal null})
* @param escaper escaper for LIKE operator parameters (must not be {@literal null})
*/
public ParameterMetadataProvider(Parameters<?, ?> parameters, Escaper escaper) {
this(parameters, null, escaper);
}
/**
* Creates new instance of this class with the given {@link Parameters}, {@link Iterator} over all bindable parameter
* values and {@link Escaper}.
*
* @param bindableParameterValueIterator iterator over bindable parameter values
* @param parameters method parameters (must not be {@literal null})
* @param escaper escaper for LIKE operator parameters (must not be {@literal null})
*/
private ParameterMetadataProvider(Parameters<?, ?> parameters,
@Nullable Iterator<Object> bindableParameterValueIterator, Escaper escaper) {
Assert.notNull(parameters, "Parameters must not be null!");
Assert.notNull(escaper, "Like escaper must not be null!");
this.bindableParameterIterator = parameters.getBindableParameters().iterator();
this.bindableParameterValueIterator = bindableParameterValueIterator;
this.escaper = escaper;
}
@Override
public Iterator<ParameterMetadata> 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)) {
throw new IllegalArgumentException(
String.format("Value of parameter with name %s must not be null!", parameterName));
}
}
/**
* 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 escaper.escape(value.toString()) + "%";
case ENDING_WITH:
return "%" + escaper.escape(value.toString());
case CONTAINING:
case NOT_CONTAINING:
return "%" + escaper.escape(value.toString()) + "%";
}
}
return value;
}
}

View File

@@ -0,0 +1,148 @@
/*
* 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.relational.repository.query;
import java.util.Collection;
import java.util.Iterator;
import org.springframework.data.relational.core.query.Criteria;
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.data.util.Streamable;
import org.springframework.util.Assert;
/**
* Implementation of {@link AbstractQueryCreator} that creates {@link PreparedOperation} from a {@link PartTree}.
*
* @author Roman Chigvintsev
* @since 2.0
*/
public abstract class RelationalQueryCreator<T> extends AbstractQueryCreator<T, Criteria> {
private final CriteriaFactory criteriaFactory;
/**
* Creates new instance of this class with the given {@link PartTree}, {@link RelationalEntityMetadata} and
* {@link ParameterMetadataProvider}.
*
* @param tree part tree (must not be {@literal null})
* @param parameterMetadataProvider parameter metadata provider (must not be {@literal null})
*/
public RelationalQueryCreator(PartTree tree, ParameterMetadataProvider parameterMetadataProvider) {
super(tree);
Assert.notNull(parameterMetadataProvider, "Parameter metadata provider must not be null");
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<Object> 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<Object> 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);
}
/**
* Validate parameters for the derived query. Specifically checking that the query method defines scalar parameters
* and collection parameters where required and that invalid parameter declarations are rejected.
*
* @param tree
* @param parameters
*/
public static void validate(PartTree tree, RelationalParameters parameters) {
int argCount = 0;
Iterable<Part> parts = () -> tree.stream().flatMap(Streamable::stream).iterator();
for (Part part : parts) {
int numberOfArguments = part.getNumberOfArguments();
for (int i = 0; i < numberOfArguments; i++) {
throwExceptionOnArgumentMismatch(part, parameters, argCount);
argCount++;
}
}
}
private static void throwExceptionOnArgumentMismatch(Part part, RelationalParameters parameters, int index) {
Part.Type type = part.getType();
String property = part.getProperty().toDotPath();
if (!parameters.getBindableParameters().hasParameterAt(index)) {
String msgTemplate = "Query method 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, index + 1, index, type.name(), property);
throw new IllegalStateException(formattedMsg);
}
RelationalParameters.RelationalParameter parameter = parameters.getBindableParameter(index);
if (expectsCollection(type) && !parameterIsCollectionLike(parameter)) {
String message = wrongParameterTypeMessage(property, type, "Collection", parameter);
throw new IllegalStateException(message);
} else if (!expectsCollection(type) && !parameterIsScalarLike(parameter)) {
String message = wrongParameterTypeMessage(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 property, Part.Type operatorType, String expectedArgumentType,
RelationalParameters.RelationalParameter parameter) {
return String.format("Operator %s on %s requires a %s argument, found %s", operatorType.name(), property,
expectedArgumentType, parameter.getType());
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.relational.core.dialect;
import static org.assertj.core.api.Assertions.*;
import org.junit.Test;
/**
* Unit tests for {@link Escaper}.
*
* @author Roman Chigvintsev
* @author Mark Paluch
*/
public class EscaperUnitTests {
@Test // DATAJDBC-514
public void ignoresNulls() {
assertThat((Escaper.DEFAULT.escape(null))).isNull();
}
@Test // DATAJDBC-514
public void ignoresEmptyString() {
assertThat(Escaper.DEFAULT.escape("")).isEmpty();
}
@Test // DATAJDBC-514
public void ignoresBlankString() {
assertThat(Escaper.DEFAULT.escape(" ")).isEqualTo(" ");
}
@Test // DATAJDBC-514
public void throwsExceptionWhenEscapeCharacterIsUnderscore() {
assertThatIllegalArgumentException().isThrownBy(() -> Escaper.of('_'));
}
@Test // DATAJDBC-514
public void throwsExceptionWhenEscapeCharacterIsPercent() {
assertThatIllegalArgumentException().isThrownBy(() -> Escaper.of('%'));
}
@Test // DATAJDBC-514
public void escapesUnderscoresUsingDefaultEscapeCharacter() {
assertThat(Escaper.DEFAULT.escape("_test_")).isEqualTo("\\_test\\_");
}
@Test // DATAJDBC-514
public void escapesPercentsUsingDefaultEscapeCharacter() {
assertThat(Escaper.DEFAULT.escape("%test%")).isEqualTo("\\%test\\%");
}
@Test // DATAJDBC-514
public void escapesSpecialCharactersUsingCustomEscapeCharacter() {
assertThat(Escaper.of('$').escape("_%")).isEqualTo("$_$%");
}
@Test // DATAJDBC-514
public void escapesAdditionalCharacters() {
assertThat(Escaper.DEFAULT.withRewriteFor("[", "]").escape("Hello Wo[Rr]ld")).isEqualTo("Hello Wo\\[Rr\\]ld");
}
}