diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java index 9361a50a9f..66e10bef88 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java @@ -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; @@ -69,6 +69,7 @@ * @author Greg Turnquist * @author Christoph Strobl * @author Jinmyeong Kim + * @author Sangjun Park */ public class JpaQueryCreator extends AbstractQueryCreator implements JpqlQueryCreator { @@ -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)); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java index 2122e250e5..e216751f88 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java @@ -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; @@ -48,6 +48,7 @@ * @author Thomas Darimont * @author Mark Paluch * @author Christoph Strobl + * @author Sangjun Park */ public class ParameterBinding { @@ -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) { @@ -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) { @@ -331,6 +336,53 @@ public JpqlQueryTemplates getTemplates() { return Collections.singleton(value); } + + /** + * Pattern to detect unsupported regex syntax that cannot be converted to SQL LIKE. + *

+ * Unsupported patterns include: + *

+ */ + private static final Pattern UNSUPPORTED_REGEX_SYNTAX = Pattern + .compile("\\[|]|\\(|\\)|\\{|}|\\||\\?|(? + * Supported conversions: + * + * + * @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("\\$$", ""); + } } /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java index adec5f86b6..5a93f482da 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java @@ -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; @@ -53,6 +53,7 @@ * @author Yuriy Tsarkov * @author Donghun Shin * @author Greg Turnquist + * @author Sangjun Park */ public class ParameterMetadataProvider { @@ -172,6 +173,29 @@ PartTreeParameterBinding next(Part part, Class type) { return next(part, typeToUse, parameter); } + /** + * Builds a new {@link PartTreeParameterBinding} for given {@link Part} accepting any of the given types. + * + * @param 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. + */ + 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. * @@ -203,8 +227,8 @@ private PartTreeParameterBinding next(Part part, Class 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); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java index b88c876a90..52cd69fe06 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java @@ -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; @@ -65,6 +67,7 @@ * * @author Christoph Strobl * @author Mark Paluch + * @author Sangjun Park */ class JpaQueryCreatorTests { @@ -352,6 +355,35 @@ void like(String parameterValue) { .validateQuery(); } + static List 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() {