PreprocessedQuery.java
/*
* Copyright 2025 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.jpa.repository.query;
import static java.util.regex.Pattern.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jspecify.annotations.Nullable;
import org.springframework.data.expression.ValueExpression;
import org.springframework.data.expression.ValueExpressionParser;
import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier;
import org.springframework.data.repository.query.ValueExpressionQueryRewriter;
import org.springframework.data.repository.query.parser.Part;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
/**
* A pre-parsed query implementing {@link DeclaredQuery} providing information about parameter bindings.
* <p>
* Query-preprocessing transforms queries using Spring Data-specific syntax such as {@link TemplatedQuery query
* templating}, extended {@code LIKE} syntax and usage of {@link ValueExpression value expressions} into a syntax that
* is valid for JPA queries (JPQL and native).
* <p>
* Preprocessing consists of parsing and rewriting so that no extension elements interfere with downstream parsers.
* However, pre-processing is a lossy procedure because the resulting {@link #getQueryString() query string} only
* contains parameter binding markers and so the original query cannot be restored. Any query derivation must align its
* {@link ParameterBinding parameter bindings} to ensure the derived query uses the same binding semantics instead of
* plain parameters. See {@link ParameterBinding#isCompatibleWith(ParameterBinding)} for further reference.
*
* @author Christoph Strobl
* @author Mark Paluch
* @since 4.0
*/
public final class PreprocessedQuery implements DeclaredQuery {
private final DeclaredQuery source;
private final List<ParameterBinding> bindings;
private final boolean usesJdbcStyleParameters;
private final boolean containsPageableInSpel;
private final boolean hasNamedBindings;
private PreprocessedQuery(DeclaredQuery query, List<ParameterBinding> bindings, boolean usesJdbcStyleParameters,
boolean containsPageableInSpel) {
this.source = query;
this.bindings = bindings;
this.usesJdbcStyleParameters = usesJdbcStyleParameters;
this.containsPageableInSpel = containsPageableInSpel;
this.hasNamedBindings = containsNamedParameter(bindings);
}
private static boolean containsNamedParameter(List<ParameterBinding> bindings) {
for (ParameterBinding parameterBinding : bindings) {
if (parameterBinding.getIdentifier().hasName() && parameterBinding.getOrigin()
.isMethodArgument()) {
return true;
}
}
return false;
}
/**
* Parse a {@link DeclaredQuery query} into its parametrized form by identifying anonymous, named, indexed and SpEL
* parameters. Query parsing applies special treatment to {@code IN} and {@code LIKE} parameter bindings.
*
* @param declaredQuery the source query to parse.
* @return a parsed {@link PreprocessedQuery}.
*/
public static PreprocessedQuery parse(DeclaredQuery declaredQuery) {
return ParameterBindingParser.INSTANCE.parse(declaredQuery.getQueryString(), declaredQuery::rewrite,
parameterBindings -> {
});
}
@Override
public String getQueryString() {
return source.getQueryString();
}
@Override
public boolean isNative() {
return source.isNative();
}
boolean hasBindings() {
return !bindings.isEmpty();
}
boolean hasNamedBindings() {
return this.hasNamedBindings;
}
public boolean containsPageableInSpel() {
return containsPageableInSpel;
}
boolean usesJdbcStyleParameters() {
return usesJdbcStyleParameters;
}
public List<ParameterBinding> getBindings() {
return Collections.unmodifiableList(bindings);
}
/**
* Derive a query (typically a count query) from the given query string. We need to copy expression bindings from the
* declared to the derived query as JPQL query derivation only sees JPA parameter markers and not the original
* expressions anymore.
*
* @return
*/
@Override
public PreprocessedQuery rewrite(String newQueryString) {
return ParameterBindingParser.INSTANCE.parse(newQueryString, source::rewrite, derivedBindings -> {
// need to copy expression bindings from the declared to the derived query as JPQL query derivation only sees
// JPA parameter markers and not the original expressions anymore.
if (this.hasBindings() && !this.bindings.equals(derivedBindings)) {
for (ParameterBinding binding : bindings) {
Predicate<ParameterBinding> identifier = binding::bindsTo;
Predicate<ParameterBinding> notCompatible = Predicate.not(binding::isCompatibleWith);
// replace incompatible bindings
if (derivedBindings.removeIf(it -> identifier.test(it) && notCompatible.test(it))) {
derivedBindings.add(binding);
}
}
}
});
}
@Override
public String toString() {
return "ParametrizedQuery[" + source + ", " + bindings + ']';
}
/**
* 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 Pattern JDBC_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?(?!\\d)"); // no \ and [no digit]
private static final Pattern NUMBERED_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?\\d"); // no \ and [digit]
private static final Pattern NAMED_STYLE_PARAM = Pattern.compile("(?!\\\\):\\w+"); // no \ and :[text]
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.
*/
PreprocessedQuery parse(String query, Function<String, DeclaredQuery> declaredQueryFactory,
Consumer<List<ParameterBinding>> parameterBindingPostProcessor) {
IndexedParameterLabels parameterLabels = new IndexedParameterLabels(findParameterIndices(query));
boolean parametersShouldBeAccessedByIndex = parameterLabels.hasLabels();
List<ParameterBinding> bindings = new ArrayList<>();
boolean jdbcStyle = false;
boolean containsPageableInSpel = query.contains("#pageable");
/*
* Prefer indexed access over named parameters if only SpEL Expression parameters are present.
*/
if (!parametersShouldBeAccessedByIndex && query.contains("?#{")) {
parametersShouldBeAccessedByIndex = true;
}
ValueExpressionQueryRewriter.ParsedQuery parsedQuery = createSpelExtractor(query,
parametersShouldBeAccessedByIndex, parameterLabels);
String resultingQuery = parsedQuery.getQueryString();
Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(resultingQuery);
ParameterBindings parameterBindings = new ParameterBindings(bindings, it -> checkAndRegister(it, bindings));
int currentIndex = 0;
boolean usesJpaStyleParameters = false;
while (matcher.find()) {
if (parsedQuery.isQuoted(matcher.start())) {
continue;
}
String parameterIndexString = matcher.group(INDEXED_PARAMETER_GROUP);
String parameterName = parameterIndexString != null ? null : matcher.group(NAMED_PARAMETER_GROUP);
Integer parameterIndex = getParameterIndex(parameterIndexString);
String match = matcher.group(0);
Matcher jdbcStyleMatcher = JDBC_STYLE_PARAM.matcher(match);
if (jdbcStyleMatcher.find()) {
jdbcStyle = true;
}
if (NUMBERED_STYLE_PARAM.matcher(match)
.find() || NAMED_STYLE_PARAM.matcher(match).find()) {
usesJpaStyleParameters = true;
}
if (usesJpaStyleParameters && jdbcStyle) {
throw new IllegalArgumentException("Mixing of ? parameters and other forms like ?1 is not supported");
}
String typeSource = matcher.group(COMPARISION_TYPE_GROUP);
Assert.isTrue(parameterIndexString != null || parameterName != null,
() -> String.format("We need either a name or an index; Offending query string: %s", query));
ValueExpression expression = parsedQuery
.getParameter(parameterName == null ? parameterIndexString : parameterName);
String replacement = null;
// this only happens for JDBC-style parameters.
if ("".equals(parameterIndexString)) {
parameterIndex = parameterLabels.allocate();
}
ParameterBinding.BindingIdentifier queryParameter;
if (parameterIndex != null) {
queryParameter = ParameterBinding.BindingIdentifier.of(parameterIndex);
}
else if (parameterName != null) {
queryParameter = ParameterBinding.BindingIdentifier.of(parameterName);
}
else {
throw new IllegalStateException("No bindable expression found");
}
ParameterBinding.ParameterOrigin origin = ObjectUtils.isEmpty(expression)
? ParameterBinding.ParameterOrigin.ofParameter(parameterName, parameterIndex)
: ParameterBinding.ParameterOrigin.ofExpression(expression);
ParameterBinding.BindingIdentifier targetBinding = queryParameter;
Function<ParameterBinding.BindingIdentifier, ParameterBinding> bindingFactory = switch (ParameterBindingType
.of(typeSource)) {
case LIKE -> {
Part.Type likeType = ParameterBinding.LikeParameterBinding.getLikeTypeFrom(matcher.group(2));
yield (identifier) -> new ParameterBinding.LikeParameterBinding(identifier, origin, likeType);
}
case IN ->
(identifier) -> new ParameterBinding.InParameterBinding(identifier, origin); // fall-through we
// don't need a special
// parameter queryParameter for the
// given parameter.
default -> (identifier) -> new ParameterBinding(identifier, origin);
};
if (origin.isExpression()) {
parameterBindings.register(bindingFactory.apply(queryParameter));
}
else {
targetBinding = parameterBindings.register(queryParameter, origin, bindingFactory, parameterLabels);
}
replacement = targetBinding.hasName() ? ":" + targetBinding.getName()
: ((!usesJpaStyleParameters && jdbcStyle) ? "?" : "?" + targetBinding.getPosition());
String result;
String substring = matcher.group(2);
int index = resultingQuery.indexOf(substring, currentIndex);
if (index < 0) {
result = resultingQuery;
}
else {
currentIndex = index + replacement.length();
result = resultingQuery.substring(0, index) + replacement
+ resultingQuery.substring(index + substring.length());
}
resultingQuery = result;
}
parameterBindingPostProcessor.accept(bindings);
return new PreprocessedQuery(declaredQueryFactory.apply(resultingQuery), bindings, jdbcStyle,
containsPageableInSpel);
}
private static ValueExpressionQueryRewriter.ParsedQuery createSpelExtractor(String queryWithSpel,
boolean parametersShouldBeAccessedByIndex, IndexedParameterLabels parameterLabels) {
/*
* 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.
*/
BiFunction<Integer, String, String> indexToParameterName = parametersShouldBeAccessedByIndex
? (index, expression) -> String.valueOf(parameterLabels.allocate())
: (index, expression) -> EXPRESSION_PARAMETER_PREFIX + (index + 1);
String fixedPrefix = parametersShouldBeAccessedByIndex ? "?" : ":";
BiFunction<String, String, String> parameterNameToReplacement = (prefix, name) -> fixedPrefix + name;
ValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter.of(ValueExpressionParser.create(),
indexToParameterName, parameterNameToReplacement);
return rewriter.parse(queryWithSpel);
}
private static @Nullable Integer getParameterIndex(@Nullable String parameterIndexString) {
if (parameterIndexString == null || parameterIndexString.isEmpty()) {
return null;
}
return Integer.valueOf(parameterIndexString);
}
private static Set<Integer> findParameterIndices(String query) {
Matcher parameterIndexMatcher = PARAMETER_BINDING_BY_INDEX.matcher(query);
Set<Integer> usedParameterIndices = new TreeSet<>();
while (parameterIndexMatcher.find()) {
String parameterIndexString = parameterIndexMatcher.group(1);
Integer parameterIndex = getParameterIndex(parameterIndexString);
if (parameterIndex != null) {
usedParameterIndices.add(parameterIndex);
}
}
return usedParameterIndices;
}
private static void checkAndRegister(ParameterBinding binding, List<ParameterBinding> bindings) {
bindings.stream() //
.filter(it -> it.bindsTo(binding)) //
.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);
private final @Nullable String keyword;
ParameterBindingType(@Nullable String keyword) {
this.keyword = keyword;
}
/**
* Returns the keyword that will trigger the binding type or {@literal null} if the type is not triggered by a
* keyword.
*
* @return the keyword
*/
public @Nullable 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));
}
}
}
/**
* Utility to create unique parameter bindings for LIKE that refer to the same underlying method parameter but are
* bound to potentially unique query parameters for {@link ParameterBinding.LikeParameterBinding#prepare(Object) LIKE
* rewrite}.
*
* @author Mark Paluch
* @since 3.1.2
*/
private static class ParameterBindings {
private final MultiValueMap<BindingIdentifier, ParameterBinding> methodArgumentToLikeBindings = new LinkedMultiValueMap<>();
private final Consumer<ParameterBinding> registration;
public ParameterBindings(List<ParameterBinding> bindings, Consumer<ParameterBinding> registration) {
for (ParameterBinding binding : bindings) {
this.methodArgumentToLikeBindings.put(binding.getIdentifier(), new ArrayList<>(List.of(binding)));
}
this.registration = registration;
}
/**
* @param identifier
* @return whether the identifier is already bound.
*/
public boolean isBound(ParameterBinding.BindingIdentifier identifier) {
return !getBindings(identifier).isEmpty();
}
ParameterBinding.BindingIdentifier register(ParameterBinding.BindingIdentifier identifier,
ParameterBinding.ParameterOrigin origin,
Function<ParameterBinding.BindingIdentifier, ParameterBinding> bindingFactory,
IndexedParameterLabels parameterLabels) {
Assert.isInstanceOf(ParameterBinding.MethodInvocationArgument.class, origin);
ParameterBinding.BindingIdentifier methodArgument = ((ParameterBinding.MethodInvocationArgument) origin)
.identifier();
List<ParameterBinding> bindingsForOrigin = getBindings(methodArgument);
if (!isBound(identifier)) {
ParameterBinding binding = bindingFactory.apply(identifier);
registration.accept(binding);
bindingsForOrigin.add(binding);
return binding.getIdentifier();
}
ParameterBinding binding = bindingFactory.apply(identifier);
for (ParameterBinding existing : bindingsForOrigin) {
if (existing.isCompatibleWith(binding)) {
return existing.getIdentifier();
}
}
ParameterBinding.BindingIdentifier syntheticIdentifier;
if (identifier.hasName() && methodArgument.hasName()) {
int index = 0;
String newName = methodArgument.getName();
while (existsBoundParameter(newName)) {
index++;
newName = methodArgument.getName() + "_" + index;
}
syntheticIdentifier = ParameterBinding.BindingIdentifier.of(newName);
}
else {
syntheticIdentifier = ParameterBinding.BindingIdentifier.of(parameterLabels.allocate());
}
ParameterBinding newBinding = bindingFactory.apply(syntheticIdentifier);
registration.accept(newBinding);
bindingsForOrigin.add(newBinding);
return newBinding.getIdentifier();
}
private boolean existsBoundParameter(String key) {
return methodArgumentToLikeBindings.values().stream()
.flatMap(Collection::stream)
.anyMatch(it -> key.equals(it.getName()));
}
private List<ParameterBinding> getBindings(ParameterBinding.BindingIdentifier identifier) {
return methodArgumentToLikeBindings.computeIfAbsent(identifier, s -> new ArrayList<>());
}
public void register(ParameterBinding parameterBinding) {
registration.accept(parameterBinding);
}
}
/**
* Value object to track and allocate used parameter index labels in a query.
*/
static class IndexedParameterLabels {
private final TreeSet<Integer> usedLabels;
private final boolean sequential;
public IndexedParameterLabels(Set<Integer> usedLabels) {
this.usedLabels = usedLabels instanceof TreeSet<Integer> ts ? ts : new TreeSet<Integer>(usedLabels);
this.sequential = isSequential(usedLabels);
}
private static boolean isSequential(Set<Integer> usedLabels) {
for (int i = 0; i < usedLabels.size(); i++) {
if (usedLabels.contains(i + 1)) {
continue;
}
return false;
}
return true;
}
/**
* Allocate the next index label (1-based).
*
* @return the next index label.
*/
public int allocate() {
if (sequential) {
int index = usedLabels.size() + 1;
usedLabels.add(index);
return index;
}
int attempts = usedLabels.last() + 1;
int index = attemptAllocate(attempts);
if (index == -1) {
throw new IllegalStateException(
"Unable to allocate a unique parameter label. All possible labels have been used.");
}
usedLabels.add(index);
return index;
}
private int attemptAllocate(int attempts) {
for (int i = 0; i < attempts; i++) {
if (usedLabels.contains(i + 1)) {
continue;
}
return i + 1;
}
return -1;
}
public boolean hasLabels() {
return !usedLabels.isEmpty();
}
}
}