Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.jspecify.annotations.Nullable;

import org.springframework.dao.InvalidDataAccessApiUsageException;
import org.springframework.data.core.PropertyPath;
import org.springframework.data.core.PropertyReferenceException;
Expand Down Expand Up @@ -69,6 +69,7 @@
* @author Greg Turnquist
* @author Christoph Strobl
* @author Jinmyeong Kim
* @author Sangjun Park
*/
public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuilder.Predicate>
implements JpqlQueryCreator {
Expand Down Expand Up @@ -490,7 +491,7 @@ public JpqlQueryBuilder.Predicate build() {
case LIKE:
case NOT_LIKE:

PartTreeParameterBinding parameter = provider.next(part, String.class);
PartTreeParameterBinding parameter = provider.next(part, String.class, Pattern.class);
JpqlQueryBuilder.Expression parameterExpression = potentiallyIgnoreCase(part.getProperty().getLeafProperty(),
placeholder(parameter));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@
import java.util.Collections;
import java.util.List;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.jspecify.annotations.Nullable;

import org.springframework.data.domain.Score;
import org.springframework.data.domain.Vector;
import org.springframework.data.expression.ValueExpression;
Expand All @@ -48,6 +48,7 @@
* @author Thomas Darimont
* @author Mark Paluch
* @author Christoph Strobl
* @author Sangjun Park
*/
public class ParameterBinding {

Expand Down Expand Up @@ -274,6 +275,11 @@ public JpqlQueryTemplates getTemplates() {
return value;
}

// Pattern -> SQL LIKE conversion
if (Pattern.class.isAssignableFrom(parameterType) && value instanceof Pattern pattern) {
return convertPatternToLike(pattern);
}

if (String.class.equals(parameterType) && !noWildcards) {

return switch (type) {
Expand All @@ -289,7 +295,6 @@ public JpqlQueryTemplates getTemplates() {
: value;
}


@SuppressWarnings("unchecked")
@Contract("false, _ -> param2; _, null -> null; true, !null -> new")
private @Nullable Collection<?> potentiallyIgnoreCase(boolean ignoreCase, @Nullable Collection<?> collection) {
Expand Down Expand Up @@ -331,6 +336,53 @@ public JpqlQueryTemplates getTemplates() {

return Collections.singleton(value);
}

/**
* Pattern to detect unsupported regex syntax that cannot be converted to SQL LIKE.
* <p>
* Unsupported patterns include:
* <ul>
* <li>Character classes: {@code [...]}</li>
* <li>Groups: {@code (...)}</li>
* <li>Quantifiers: {@code {n,m}}</li>
* <li>Alternation: {@code |}</li>
* <li>Optional: {@code ?}</li>
* <li>One or more (standalone): {@code +}</li>
* <li>Character shortcuts: {@code \d, \D, \w, \W, \s, \S}</li>
* <li>Word boundary: {@code \b}</li>
* </ul>
*/
private static final Pattern UNSUPPORTED_REGEX_SYNTAX = Pattern
.compile("\\[|]|\\(|\\)|\\{|}|\\||\\?|(?<!\\.)\\+|\\\\[dDwWsSbB]");

/**
* Converts a Java regex {@link Pattern} to an SQL LIKE pattern.
* <p>
* Supported conversions:
* <ul>
* <li>{@code .*} → {@code %} (matches any sequence)</li>
* <li>{@code .+} → {@code _%} (matches one or more characters)</li>
* <li>{@code .} → {@code _} (matches single character)</li>
* <li>{@code ^} (start anchor) → removed</li>
* <li>{@code $} (end anchor) → removed</li>
* </ul>
*
* @param pattern the regex pattern to convert.
* @return the SQL LIKE pattern string.
* @throws IllegalArgumentException if the pattern contains unsupported regex syntax.
*/
private String convertPatternToLike(Pattern pattern) {
String regex = pattern.pattern();

if (UNSUPPORTED_REGEX_SYNTAX.matcher(regex).find()) {
throw new IllegalArgumentException(String.format(
"Unsupported regex pattern '%s'. Only '^', '$', '.', '.*', '.+', and literal characters are supported for LIKE conversion.",
regex));
}

return regex.replace(".*", "%").replace(".+", "_%").replace(".", "_").replaceFirst("^\\^", "")
.replaceFirst("\\$$", "");
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@
import jakarta.persistence.criteria.CriteriaBuilder;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import org.jspecify.annotations.Nullable;

import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
import org.springframework.data.domain.ScoringFunction;
Expand All @@ -53,6 +53,7 @@
* @author Yuriy Tsarkov
* @author Donghun Shin
* @author Greg Turnquist
* @author Sangjun Park
*/
public class ParameterMetadataProvider {

Expand Down Expand Up @@ -172,6 +173,29 @@ <T> PartTreeParameterBinding next(Part part, Class<T> type) {
return next(part, typeToUse, parameter);
}

/**
* Builds a new {@link PartTreeParameterBinding} for given {@link Part} accepting any of the given types.
*
* @param <T> type parameter for the returned {@link PartTreeParameterBinding}.
* @param part must not be {@literal null}.
* @param allowedTypes must not be {@literal null}.
* @return a new {@link PartTreeParameterBinding} for the given types.
*/
<T> PartTreeParameterBinding next(Part part, Class<?>... allowedTypes) {

Parameter parameter = parameters.next();
Class<?> actualType = parameter.getType();

for (Class<?> allowedType : allowedTypes) {
if (ClassUtils.isAssignable(allowedType, actualType)) {
return next(part, actualType, parameter);
}
}

throw new IllegalArgumentException(String.format("No allowed type found for parameter %s. Allowed types: %s",
parameter, Arrays.toString(allowedTypes)));
}

/**
* Builds a new {@link PartTreeParameterBinding} for the given type and name.
*
Expand Down Expand Up @@ -203,8 +227,8 @@ private <T> PartTreeParameterBinding next(Part part, Class<T> type, Parameter pa

/* identifier refers to bindable parameters, not _all_ parameters index */
MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(origin);
PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier,
methodParameter, reifiedType, part, value, templates, escape);
PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier, methodParameter, reifiedType,
part, value, templates, escape);

// PartTreeParameterBinding is more expressive than a potential ParameterBinding for Vector.
bindings.add(binding);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,16 @@
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.regex.Pattern;

import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.FieldSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Range;
import org.springframework.data.domain.Score;
Expand All @@ -65,6 +67,7 @@
*
* @author Christoph Strobl
* @author Mark Paluch
* @author Sangjun Park
*/
class JpaQueryCreatorTests {

Expand Down Expand Up @@ -352,6 +355,35 @@ void like(String parameterValue) {
.validateQuery();
}

static List<Arguments> likeWithPatternParameters() {
return List.of(
// LIKE tests
Arguments.of("findProductByNameLike", ".*spring.*", "%spring%", "LIKE"),
Arguments.of("findProductByNameLike", "^spring.*", "spring%", "LIKE"),
Arguments.of("findProductByNameLike", ".*spring$", "%spring", "LIKE"),
Arguments.of("findProductByNameLike", "^spring$", "spring", "LIKE"),
Arguments.of("findProductByNameLike", ".+spring.+", "_%spring_%", "LIKE"),
Arguments.of("findProductByNameLike", "^.+spring", "_%spring", "LIKE"),
// NOT LIKE tests
Arguments.of("findProductByNameNotLike", ".*spring.*", "%spring%", "NOT LIKE"),
Arguments.of("findProductByNameNotLike", "^spring.*", "spring%", "NOT LIKE"),
Arguments.of("findProductByNameNotLike", ".*spring$", "%spring", "NOT LIKE"));
}

@ParameterizedTest
@MethodSource("likeWithPatternParameters")
void likeAndNotLikeWithPattern(String methodName, String regex, String expectedLike, String likeOperator) {

queryCreator(ORDER) //
.forTree(Product.class, methodName) //
.withParameters(Pattern.compile(regex)) //
.as(QueryCreatorTester::create) //
.expectJpql("SELECT p FROM %s p WHERE p.name " + likeOperator + " ?1 ESCAPE '\\'",
DefaultJpaEntityMetadata.unqualify(Product.class)) //
.expectPlaceholderValue("?1", expectedLike) //
.validateQuery();
}

@Test // GH-3588
void containingString() {

Expand Down