blob: 8957e69402227d8586ef9dba2dc41f0da1a6a401 [file] [log] [blame]
/*
* Copyright 2013-2019 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
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.ignite.springdata22.repository.query;
import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.function.BiFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jetbrains.annotations.Nullable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Range.Bound;
import org.springframework.data.repository.query.SpelQueryContext;
import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor;
import org.springframework.data.repository.query.parser.Part.Type;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import static java.util.regex.Pattern.CASE_INSENSITIVE;
import static org.springframework.util.ObjectUtils.nullSafeEquals;
import static org.springframework.util.ObjectUtils.nullSafeHashCode;
/**
* Encapsulation of a JPA query String. Offers access to parameters as bindings. The internal query String is cleaned
* from decorated parameters like {@literal %:lastname%} and the matching bindings take care of applying the decorations
* in the {@link ParameterBinding#prepare(Object)} method. Note that this class also handles replacing SpEL expressions
* with synthetic bind parameters
*
* @author Oliver Gierke
* @author Thomas Darimont
* @author Oliver Wehrens
* @author Mark Paluch
* @author Jens Schauder
*/
class StringQuery implements DeclaredQuery {
/** */
private final String query;
/** */
private final List<ParameterBinding> bindings;
/** */
@Nullable
private final String alias;
/** */
private final boolean hasConstructorExpression;
/** */
private final boolean containsPageableInSpel;
/** */
private final boolean usesJdbcStyleParameters;
/**
* Creates a new {@link StringQuery} from the given JPQL query.
*
* @param query must not be {@literal null} or empty.
*/
StringQuery(String query) {
Assert.hasText(query, "Query must not be null or empty!");
bindings = new ArrayList<>();
containsPageableInSpel = query.contains("#pageable");
Metadata queryMeta = new Metadata();
this.query = ParameterBindingParser.INSTANCE
.parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(query, bindings,
queryMeta);
usesJdbcStyleParameters = queryMeta.usesJdbcStyleParameters;
alias = QueryUtils.detectAlias(query);
hasConstructorExpression = QueryUtils.hasConstructorExpression(query);
}
/**
* Returns whether we have found some like bindings.
*/
boolean hasParameterBindings() {
return !bindings.isEmpty();
}
/** */
String getProjection() {
return QueryUtils.getProjection(query);
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#getParameterBindings()
/** {@inheritDoc} */
@Override public List<ParameterBinding> getParameterBindings() {
return bindings;
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#deriveCountQuery(java.lang.String, java.lang
/** {@inheritDoc} */
@Override public DeclaredQuery deriveCountQuery(@Nullable String countQuery,
@Nullable String countQueryProjection) {
return DeclaredQuery
.of(countQuery != null ? countQuery : QueryUtils.createCountQueryFor(query, countQueryProjection));
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#usesJdbcStyleParameters()
/** */
@Override public boolean usesJdbcStyleParameters() {
return usesJdbcStyleParameters;
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#getQueryString()
/** {@inheritDoc} */
@Override public String getQueryString() {
return query;
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#getAlias()
/** {@inheritDoc} */
@Override @Nullable
public String getAlias() {
return alias;
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#hasConstructorExpression()
/** {@inheritDoc} */
@Override public boolean hasConstructorExpression() {
return hasConstructorExpression;
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#isDefaultProjection()
/** {@inheritDoc} */
@Override public boolean isDefaultProjection() {
return getProjection().equalsIgnoreCase(alias);
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#hasNamedParameter()
/** {@inheritDoc} */
@Override public boolean hasNamedParameter() {
return bindings.stream().anyMatch(b -> b.getName() != null);
}
// See org.springframework.data.jpa.repository.query.DeclaredQuery#usesPaging()
/** {@inheritDoc} */
@Override public boolean usesPaging() {
return containsPageableInSpel;
}
/**
* A parser that extracts the parameter bindings from a given query string.
*
* @author Thomas Darimont
*/
enum ParameterBindingParser {
/** */
INSTANCE;
/** */
private static final String EXPRESSION_PARAMETER_PREFIX = "__$synthetic$__";
/** */
public static final String POSITIONAL_OR_INDEXED_PARAMETER = "\\?(\\d*+(?![#\\w]))";
// .....................................................................^ not followed by a hash or a letter.
// .................................................................^ zero or more digits.
// .............................................................^ start with a question mark.
/** */
private static final Pattern PARAMETER_BINDING_BY_INDEX = Pattern.compile(POSITIONAL_OR_INDEXED_PARAMETER);
/** */
private static final Pattern PARAMETER_BINDING_PATTERN;
/** */
private static final String MESSAGE =
"Already found parameter binding with same index / parameter name but differing binding type! "
+ "Already have: %s, found %s! If you bind a parameter multiple times make sure they use the same "
+ "binding.";
/** */
private static final int INDEXED_PARAMETER_GROUP = 4;
/** */
private static final int NAMED_PARAMETER_GROUP = 6;
/** */
private static final int COMPARISION_TYPE_GROUP = 1;
static {
List<String> keywords = new ArrayList<>();
for (ParameterBindingType type : ParameterBindingType.values()) {
if (type.getKeyword() != null) {
keywords.add(type.getKeyword());
}
}
StringBuilder builder = new StringBuilder();
builder.append("(");
builder.append(StringUtils.collectionToDelimitedString(keywords, "|")); // keywords
builder.append(")?");
builder.append("(?: )?"); // some whitespace
builder.append("\\(?"); // optional braces around parameters
builder.append("(");
builder.append("%?(" + POSITIONAL_OR_INDEXED_PARAMETER + ")%?"); // position parameter and parameter index
builder.append("|"); // or
// named parameter and the parameter name
builder.append("%?(" + QueryUtils.COLON_NO_DOUBLE_COLON + QueryUtils.IDENTIFIER_GROUP + ")%?");
builder.append(")");
builder.append("\\)?"); // optional braces around parameters
PARAMETER_BINDING_PATTERN = Pattern.compile(builder.toString(), CASE_INSENSITIVE);
}
/**
* Parses {@link ParameterBinding} instances from the given query and adds them to the registered bindings.
* Returns the cleaned up query.
*/
private String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String query,
List<ParameterBinding> bindings,
Metadata queryMeta) {
int greatestParamIdx = tryFindGreatestParameterIndexIn(query);
boolean parametersShouldBeAccessedByIdx = greatestParamIdx != -1;
/*
* Prefer indexed access over named parameters if only SpEL Expression parameters are present.
*/
if (!parametersShouldBeAccessedByIdx && query.contains("?#{")) {
parametersShouldBeAccessedByIdx = true;
greatestParamIdx = 0;
}
SpelExtractor spelExtractor = createSpelExtractor(query, parametersShouldBeAccessedByIdx,
greatestParamIdx);
String resultingQry = spelExtractor.getQueryString();
Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(resultingQry);
QuotationMap quotedAreas = new QuotationMap(resultingQry);
int expressionParamIdx = parametersShouldBeAccessedByIdx ? greatestParamIdx : 0;
boolean usesJpaStyleParameters = false;
while (matcher.find()) {
if (quotedAreas.isQuoted(matcher.start()))
continue;
String paramIdxStr = matcher.group(INDEXED_PARAMETER_GROUP);
String paramName = paramIdxStr != null ? null : matcher.group(NAMED_PARAMETER_GROUP);
Integer paramIdx = getParameterIndex(paramIdxStr);
String typeSrc = matcher.group(COMPARISION_TYPE_GROUP);
String expression = spelExtractor
.getParameter(paramName == null ? paramIdxStr : paramName);
String replacement = null;
Assert.isTrue(paramIdxStr != null || paramName != null,
() -> String.format("We need either a name or an index! Offending query string: %s", query));
expressionParamIdx++;
if (paramIdxStr != null && paramIdxStr.isEmpty()) {
queryMeta.usesJdbcStyleParameters = true;
paramIdx = expressionParamIdx;
}
else
usesJpaStyleParameters = true;
// named parameters (:param) will be untouched by spelExtractor, so replace them by ? as we don't
// know position
if (paramName != null)
replacement = "?";
if (usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) {
throw new IllegalArgumentException(
"Mixing of ? (? or :myNamedParam) parameters and other forms like ?1 (SpEL espressions or "
+ "indexed) is not supported!. Please, if you are using expressions or "
+ "indexed params, replace all named parameters by expressions. Example :myNamedParam "
+ "by ?#{#myNamedParam}.");
}
switch (ParameterBindingType.of(typeSrc)) {
case LIKE:
Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2));
replacement = matcher.group(3);
if (paramIdx != null)
checkAndRegister(new LikeParameterBinding(paramIdx, likeType, expression), bindings);
else {
checkAndRegister(new LikeParameterBinding(paramName, likeType, expression), bindings);
replacement = expression != null ? ":" + paramName : matcher.group(5);
}
break;
case IN:
if (paramIdx != null)
checkAndRegister(new InParameterBinding(paramIdx, expression), bindings);
else
checkAndRegister(new InParameterBinding(paramName, expression), bindings);
break;
case AS_IS: // fall-through we don't need a special parameter binding for the given parameter.
default:
bindings.add(paramIdx != null
? new ParameterBinding(null, paramIdx, expression)
: new ParameterBinding(paramName, null, expression));
}
if (replacement != null)
resultingQry = replaceFirst(resultingQry, matcher.group(2), replacement);
}
return resultingQry;
}
/** */
private static SpelExtractor createSpelExtractor(String queryWithSpel,
boolean parametersShouldBeAccessedByIndex,
int greatestParameterIndex) {
/*
* If parameters need to be bound by index, we bind the synthetic expression parameters starting from
* position of the greatest discovered index parameter in order to
* not mix-up with the actual parameter indices.
*/
int expressionParameterIndex = parametersShouldBeAccessedByIndex ? greatestParameterIndex : 0;
BiFunction<Integer, String, String> indexToParameterName = parametersShouldBeAccessedByIndex
? (index, expression) -> String.valueOf(
index + expressionParameterIndex + 1)
: (index, expression) ->
EXPRESSION_PARAMETER_PREFIX + (index
+ 1);
String fixedPrefix = parametersShouldBeAccessedByIndex ? "?" : ":";
BiFunction<String, String, String> parameterNameToReplacement = (prefix, name) -> fixedPrefix + name;
return SpelQueryContext.of(indexToParameterName, parameterNameToReplacement).parse(queryWithSpel);
}
/** */
private static String replaceFirst(String text, String substring, String replacement) {
int index = text.indexOf(substring);
if (index < 0)
return text;
return text.substring(0, index) + replacement + text.substring(index + substring.length());
}
/** */
@Nullable
private static Integer getParameterIndex(@Nullable String parameterIndexString) {
if (parameterIndexString == null || parameterIndexString.isEmpty())
return null;
return Integer.valueOf(parameterIndexString);
}
/** */
private static int tryFindGreatestParameterIndexIn(String query) {
Matcher parameterIndexMatcher = PARAMETER_BINDING_BY_INDEX.matcher(query);
int greatestParameterIndex = -1;
while (parameterIndexMatcher.find()) {
String parameterIndexString = parameterIndexMatcher.group(1);
Integer parameterIndex = getParameterIndex(parameterIndexString);
if (parameterIndex != null)
greatestParameterIndex = Math.max(greatestParameterIndex, parameterIndex);
}
return greatestParameterIndex;
}
/** */
private static void checkAndRegister(ParameterBinding binding, List<ParameterBinding> bindings) {
bindings.stream() //
.filter(it -> it.hasName(binding.getName()) || it.hasPosition(binding.getPosition())) //
.forEach(it -> Assert.isTrue(it.equals(binding), String.format(MESSAGE, it, binding)));
if (!bindings.contains(binding))
bindings.add(binding);
}
/**
* An enum for the different types of bindings.
*
* @author Thomas Darimont
* @author Oliver Gierke
*/
private enum ParameterBindingType {
// Trailing whitespace is intentional to reflect that the keywords must be used with at least one whitespace
// character, while = does not.
/** */
LIKE("like "),
/** */
IN("in "),
/** */
AS_IS(null);
/** */
@Nullable
private final String keyword;
/** */
ParameterBindingType(@Nullable String keyword) {
this.keyword = keyword;
}
/**
* Returns the keyword that will tirgger the binding type or {@literal null} if the type is not triggered by
* a keyword.
*
* @return the keyword
*/
@Nullable
public String getKeyword() {
return keyword;
}
/**
* Return the appropriate {@link ParameterBindingType} for the given {@link String}. Returns {@literal
* #AS_IS} in case no other {@link ParameterBindingType} could be found.
*/
static ParameterBindingType of(String typeSource) {
if (!StringUtils.hasText(typeSource))
return AS_IS;
for (ParameterBindingType type : values()) {
if (type.name().equalsIgnoreCase(typeSource.trim()))
return type;
}
throw new IllegalArgumentException(String.format("Unsupported parameter binding type %s!", typeSource));
}
}
}
/**
* A generic parameter binding with name or position information.
*
* @author Thomas Darimont
*/
static class ParameterBinding {
/** */
@Nullable
private final String name;
/** */
@Nullable
private final String expression;
/** */
@Nullable
private final Integer position;
/**
* Creates a new {@link ParameterBinding} for the parameter with the given position.
*
* @param position must not be {@literal null}.
*/
ParameterBinding(Integer position) {
this(null, position, null);
}
/**
* Creates a new {@link ParameterBinding} for the parameter with the given name, position and expression
* information. Either {@literal name} or {@literal position} must be not {@literal null}.
*
* @param name of the parameter may be {@literal null}.
* @param position of the parameter may be {@literal null}.
* @param expression the expression to apply to any value for this parameter.
*/
ParameterBinding(@Nullable String name, @Nullable Integer position, @Nullable String expression) {
if (name == null)
Assert.notNull(position, "Position must not be null!");
if (position == null)
Assert.notNull(name, "Name must not be null!");
this.name = name;
this.position = position;
this.expression = expression;
}
/**
* Returns whether the binding has the given name. Will always be {@literal false} in case the {@link
* ParameterBinding} has been set up from a position.
*/
boolean hasName(@Nullable String name) {
return position == null && this.name != null && this.name.equals(name);
}
/**
* Returns whether the binding has the given position. Will always be {@literal false} in case the {@link
* ParameterBinding} has been set up from a name.
*/
boolean hasPosition(@Nullable Integer position) {
return position != null && name == null && position.equals(this.position);
}
/**
* @return the name
*/
@Nullable
public String getName() {
return name;
}
/**
* @return the name
* @throws IllegalStateException if the name is not available.
*/
String getRequiredName() throws IllegalStateException {
String name = getName();
if (name != null)
return name;
throw new IllegalStateException(String.format("Required name for %s not available!", this));
}
/**
* @return the position
*/
@Nullable
Integer getPosition() {
return position;
}
/**
* @return the position
* @throws IllegalStateException if the position is not available.
*/
int getRequiredPosition() throws IllegalStateException {
Integer position = getPosition();
if (position != null)
return position;
throw new IllegalStateException(String.format("Required position for %s not available!", this));
}
/**
* @return {@literal true} if this parameter binding is a synthetic SpEL expression.
*/
public boolean isExpression() {
return expression != null;
}
/** */
@Override public int hashCode() {
int result = 17;
result += nullSafeHashCode(name);
result += nullSafeHashCode(position);
result += nullSafeHashCode(expression);
return result;
}
/** */
@Override public boolean equals(Object obj) {
if (!(obj instanceof ParameterBinding))
return false;
ParameterBinding that = (ParameterBinding)obj;
return nullSafeEquals(name, that.name) && nullSafeEquals(position, that.position)
&& nullSafeEquals(expression, that.expression);
}
/** */
@Override public String toString() {
return String.format("ParameterBinding [name: %s, position: %d, expression: %s]", getName(), getPosition(),
getExpression());
}
/**
* @param valueToBind value to prepare
*/
@Nullable
public Object prepare(@Nullable Object valueToBind) {
return valueToBind;
}
/** */
@Nullable
public String getExpression() {
return expression;
}
}
/**
* Represents a {@link ParameterBinding} in a JPQL query augmented with instructions of how to apply a parameter as
* an {@code IN} parameter.
*
* @author Thomas Darimont
*/
static class InParameterBinding extends ParameterBinding {
/**
* Creates a new {@link InParameterBinding} for the parameter with the given name.
*/
InParameterBinding(String name, @Nullable String expression) {
super(name, null, expression);
}
/**
* Creates a new {@link InParameterBinding} for the parameter with the given position.
*/
InParameterBinding(int position, @Nullable String expression) {
super(null, position, expression);
}
/*
* (non-Javadoc)
* @see org.springframework.data.jpa.repository.query.StringQuery.ParameterBinding#prepare(java.lang.Object)
*/
@Override public Object prepare(@Nullable Object value) {
if (!ObjectUtils.isArray(value))
return value;
int length = Array.getLength(value);
Collection<Object> result = new ArrayList<>(length);
for (int i = 0; i < length; i++)
result.add(Array.get(value, i));
return result;
}
}
/**
* Represents a parameter binding in a JPQL query augmented with instructions of how to apply a parameter as LIKE
* parameter. This allows expressions like {@code …like %?1} in the JPQL query, which is not allowed by plain JPA.
*
* @author Oliver Gierke
* @author Thomas Darimont
*/
static class LikeParameterBinding extends ParameterBinding {
/** */
private static final List<Type> SUPPORTED_TYPES = Arrays.asList(Type.CONTAINING, Type.STARTING_WITH,
Type.ENDING_WITH, Type.LIKE);
/** */
private final Type type;
/**
* Creates a new {@link LikeParameterBinding} for the parameter with the given name and {@link Type}.
*
* @param name must not be {@literal null} or empty.
* @param type must not be {@literal null}.
*/
LikeParameterBinding(String name, Type type) {
this(name, type, null);
}
/**
* Creates a new {@link LikeParameterBinding} for the parameter with the given name and {@link Type} and
* parameter binding input.
*
* @param name must not be {@literal null} or empty.
* @param type must not be {@literal null}.
* @param expression may be {@literal null}.
*/
LikeParameterBinding(String name, Type type, @Nullable String expression) {
super(name, null, expression);
Assert.hasText(name, "Name must not be null or empty!");
Assert.notNull(type, "Type must not be null!");
Assert.isTrue(SUPPORTED_TYPES.contains(type), String.format("Type must be one of %s!",
StringUtils.collectionToCommaDelimitedString(SUPPORTED_TYPES)));
this.type = type;
}
/**
* Creates a new {@link LikeParameterBinding} for the parameter with the given position and {@link Type}.
*
* @param position position of the parameter in the query.
* @param type must not be {@literal null}.
*/
LikeParameterBinding(int position, Type type) {
this(position, type, null);
}
/**
* Creates a new {@link LikeParameterBinding} for the parameter with the given position and {@link Type}.
*
* @param position position of the parameter in the query.
* @param type must not be {@literal null}.
* @param expression may be {@literal null}.
*/
LikeParameterBinding(int position, Type type, @Nullable String expression) {
super(null, position, expression);
Assert.isTrue(position > 0, "Position must be greater than zero!");
Assert.notNull(type, "Type must not be null!");
Assert.isTrue(SUPPORTED_TYPES.contains(type), String.format("Type must be one of %s!",
StringUtils.collectionToCommaDelimitedString(SUPPORTED_TYPES)));
this.type = type;
}
/**
* Returns the {@link Type} of the binding.
*
* @return the type
*/
public Type getType() {
return type;
}
/**
* Prepares the given raw keyword according to the like type.
*/
@Nullable
@Override public Object prepare(@Nullable Object value) {
if (value == null)
return null;
switch (type) {
case STARTING_WITH:
return String.format("%s%%", value.toString());
case ENDING_WITH:
return String.format("%%%s", value.toString());
case CONTAINING:
return String.format("%%%s%%", value.toString());
case LIKE:
default:
return value;
}
}
/** */
@Override public boolean equals(Object obj) {
if (!(obj instanceof LikeParameterBinding))
return false;
LikeParameterBinding that = (LikeParameterBinding)obj;
return super.equals(obj) && type.equals(that.type);
}
/** */
@Override public int hashCode() {
int result = super.hashCode();
result += nullSafeHashCode(type);
return result;
}
/** */
@Override public String toString() {
return String.format("LikeBinding [name: %s, position: %d, type: %s]", getName(), getPosition(), type);
}
/**
* Extracts the like {@link Type} from the given JPA like expression.
*
* @param expression must not be {@literal null} or empty.
*/
private static Type getLikeTypeFrom(String expression) {
Assert.hasText(expression, "Expression must not be null or empty!");
if (expression.matches("%.*%"))
return Type.CONTAINING;
if (expression.startsWith("%"))
return Type.ENDING_WITH;
if (expression.endsWith("%"))
return Type.STARTING_WITH;
return Type.LIKE;
}
}
/** */
static class Metadata {
/**
* Uses jdbc style parameters.
*/
private boolean usesJdbcStyleParameters;
}
/**
* Value object to analyze a {@link String} to determine the parts of the {@link String} that are quoted and offers
* an API to query that information.
*
* @author Jens Schauder
* @author Oliver Gierke
*/
static class QuotationMap {
/** */
private static final Collection<Character> QUOTING_CHARACTERS = Arrays.asList('"', '\'');
/** */
private final List<Range<Integer>> quotedRanges = new ArrayList<>();
/**
* Creates a new instance for the query.
*
* @param query can be {@literal null}.
*/
public QuotationMap(@Nullable String query) {
if (query == null)
return;
Character inQuotation = null;
int start = 0;
for (int i = 0; i < query.length(); i++) {
char currentChar = query.charAt(i);
if (QUOTING_CHARACTERS.contains(currentChar)) {
if (inQuotation == null) {
inQuotation = currentChar;
start = i;
}
else if (currentChar == inQuotation) {
inQuotation = null;
quotedRanges.add(Range.from(Bound.inclusive(start)).to(Bound.inclusive(i)));
}
}
}
if (inQuotation != null) {
throw new IllegalArgumentException(
String.format("The string <%s> starts a quoted range at %d, but never ends it.", query, start));
}
}
/**
* Checks if a given index is within a quoted range.
*
* @param idx to check if it is part of a quoted range.
* @return whether the query contains a quoted range at {@literal index}.
*/
public boolean isQuoted(int idx) {
return quotedRanges.stream().anyMatch(r -> r.contains(idx));
}
}
}