Skip to content

Commit a0994c8

Browse files
committed
Add regex-based Pattern support for LIKE queries.
Introduced `java.util.regex.Pattern` as a parameter type for LIKE and NOT LIKE queries, with automatic conversion to SQL LIKE syntax. Unsupported regex patterns now throw a clear `IllegalArgumentException`. Added `.→ _` and `.+ → _%` conversion for finer pattern control. Signed-off-by: 박상준 <[email protected]>
1 parent dcf45d1 commit a0994c8

File tree

4 files changed

+117
-8
lines changed

4 files changed

+117
-8
lines changed

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@
3333
import java.util.Iterator;
3434
import java.util.List;
3535
import java.util.Map;
36+
import java.util.regex.Pattern;
3637
import java.util.stream.Collectors;
3738

3839
import org.jspecify.annotations.Nullable;
39-
4040
import org.springframework.dao.InvalidDataAccessApiUsageException;
4141
import org.springframework.data.core.PropertyPath;
4242
import org.springframework.data.core.PropertyReferenceException;
@@ -69,6 +69,7 @@
6969
* @author Greg Turnquist
7070
* @author Christoph Strobl
7171
* @author Jinmyeong Kim
72+
* @author Sangjun Park
7273
*/
7374
public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuilder.Predicate>
7475
implements JpqlQueryCreator {
@@ -490,7 +491,7 @@ public JpqlQueryBuilder.Predicate build() {
490491
case LIKE:
491492
case NOT_LIKE:
492493

493-
PartTreeParameterBinding parameter = provider.next(part, String.class);
494+
PartTreeParameterBinding parameter = provider.next(part, String.class, Pattern.class);
494495
JpqlQueryBuilder.Expression parameterExpression = potentiallyIgnoreCase(part.getProperty().getLeafProperty(),
495496
placeholder(parameter));
496497

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@
2424
import java.util.Collections;
2525
import java.util.List;
2626
import java.util.function.Function;
27+
import java.util.regex.Pattern;
2728
import java.util.stream.Collectors;
2829

2930
import org.jspecify.annotations.Nullable;
30-
3131
import org.springframework.data.domain.Score;
3232
import org.springframework.data.domain.Vector;
3333
import org.springframework.data.expression.ValueExpression;
@@ -48,6 +48,7 @@
4848
* @author Thomas Darimont
4949
* @author Mark Paluch
5050
* @author Christoph Strobl
51+
* @author Sangjun Park
5152
*/
5253
public class ParameterBinding {
5354

@@ -274,6 +275,11 @@ public JpqlQueryTemplates getTemplates() {
274275
return value;
275276
}
276277

278+
// Pattern -> SQL LIKE conversion
279+
if (Pattern.class.isAssignableFrom(parameterType) && value instanceof Pattern pattern) {
280+
return convertPatternToLike(pattern);
281+
}
282+
277283
if (String.class.equals(parameterType) && !noWildcards) {
278284

279285
return switch (type) {
@@ -289,7 +295,6 @@ public JpqlQueryTemplates getTemplates() {
289295
: value;
290296
}
291297

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

332337
return Collections.singleton(value);
333338
}
339+
340+
/**
341+
* Pattern to detect unsupported regex syntax that cannot be converted to SQL LIKE.
342+
* <p>
343+
* Unsupported patterns include:
344+
* <ul>
345+
* <li>Character classes: {@code [...]}</li>
346+
* <li>Groups: {@code (...)}</li>
347+
* <li>Quantifiers: {@code {n,m}}</li>
348+
* <li>Alternation: {@code |}</li>
349+
* <li>Optional: {@code ?}</li>
350+
* <li>One or more (standalone): {@code +}</li>
351+
* <li>Character shortcuts: {@code \d, \D, \w, \W, \s, \S}</li>
352+
* <li>Word boundary: {@code \b}</li>
353+
* </ul>
354+
*/
355+
private static final Pattern UNSUPPORTED_REGEX_SYNTAX = Pattern
356+
.compile("\\[|]|\\(|\\)|\\{|}|\\||\\?|(?<!\\.)\\+|\\\\[dDwWsSbB]");
357+
358+
/**
359+
* Converts a Java regex {@link Pattern} to an SQL LIKE pattern.
360+
* <p>
361+
* Supported conversions:
362+
* <ul>
363+
* <li>{@code .*} → {@code %} (matches any sequence)</li>
364+
* <li>{@code .+} → {@code _%} (matches one or more characters)</li>
365+
* <li>{@code .} → {@code _} (matches single character)</li>
366+
* <li>{@code ^} (start anchor) → removed</li>
367+
* <li>{@code $} (end anchor) → removed</li>
368+
* </ul>
369+
*
370+
* @param pattern the regex pattern to convert.
371+
* @return the SQL LIKE pattern string.
372+
* @throws IllegalArgumentException if the pattern contains unsupported regex syntax.
373+
*/
374+
private String convertPatternToLike(Pattern pattern) {
375+
String regex = pattern.pattern();
376+
377+
if (UNSUPPORTED_REGEX_SYNTAX.matcher(regex).find()) {
378+
throw new IllegalArgumentException(String.format(
379+
"Unsupported regex pattern '%s'. Only '^', '$', '.', '.*', '.+', and literal characters are supported for LIKE conversion.",
380+
regex));
381+
}
382+
383+
return regex.replace(".*", "%").replace(".+", "_%").replace(".", "_").replaceFirst("^\\^", "")
384+
.replaceFirst("\\$$", "");
385+
}
334386
}
335387

336388
/**

spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@
2020
import jakarta.persistence.criteria.CriteriaBuilder;
2121

2222
import java.util.ArrayList;
23+
import java.util.Arrays;
2324
import java.util.Iterator;
2425
import java.util.LinkedHashSet;
2526
import java.util.List;
2627
import java.util.Set;
2728

2829
import org.jspecify.annotations.Nullable;
29-
3030
import org.springframework.data.domain.Range;
3131
import org.springframework.data.domain.Score;
3232
import org.springframework.data.domain.ScoringFunction;
@@ -53,6 +53,7 @@
5353
* @author Yuriy Tsarkov
5454
* @author Donghun Shin
5555
* @author Greg Turnquist
56+
* @author Sangjun Park
5657
*/
5758
public class ParameterMetadataProvider {
5859

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

176+
/**
177+
* Builds a new {@link PartTreeParameterBinding} for given {@link Part} accepting any of the given types.
178+
*
179+
* @param <T> type parameter for the returned {@link PartTreeParameterBinding}.
180+
* @param part must not be {@literal null}.
181+
* @param allowedTypes must not be {@literal null}.
182+
* @return a new {@link PartTreeParameterBinding} for the given types.
183+
*/
184+
<T> PartTreeParameterBinding next(Part part, Class<?>... allowedTypes) {
185+
186+
Parameter parameter = parameters.next();
187+
Class<?> actualType = parameter.getType();
188+
189+
for (Class<?> allowedType : allowedTypes) {
190+
if (ClassUtils.isAssignable(allowedType, actualType)) {
191+
return next(part, actualType, parameter);
192+
}
193+
}
194+
195+
throw new IllegalArgumentException(String.format("No allowed type found for parameter %s. Allowed types: %s",
196+
parameter, Arrays.toString(allowedTypes)));
197+
}
198+
175199
/**
176200
* Builds a new {@link PartTreeParameterBinding} for the given type and name.
177201
*
@@ -203,8 +227,8 @@ private <T> PartTreeParameterBinding next(Part part, Class<T> type, Parameter pa
203227

204228
/* identifier refers to bindable parameters, not _all_ parameters index */
205229
MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(origin);
206-
PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier,
207-
methodParameter, reifiedType, part, value, templates, escape);
230+
PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier, methodParameter, reifiedType,
231+
part, value, templates, escape);
208232

209233
// PartTreeParameterBinding is more expressive than a potential ParameterBinding for Vector.
210234
bindings.add(binding);

spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,16 @@
3333
import java.util.List;
3434
import java.util.Map;
3535
import java.util.function.Function;
36+
import java.util.regex.Pattern;
3637

3738
import org.jspecify.annotations.Nullable;
3839
import org.junit.jupiter.api.Disabled;
3940
import org.junit.jupiter.api.Test;
4041
import org.junit.jupiter.params.ParameterizedTest;
42+
import org.junit.jupiter.params.provider.Arguments;
4143
import org.junit.jupiter.params.provider.FieldSource;
44+
import org.junit.jupiter.params.provider.MethodSource;
4245
import org.junit.jupiter.params.provider.ValueSource;
43-
4446
import org.springframework.data.domain.Pageable;
4547
import org.springframework.data.domain.Range;
4648
import org.springframework.data.domain.Score;
@@ -65,6 +67,7 @@
6567
*
6668
* @author Christoph Strobl
6769
* @author Mark Paluch
70+
* @author Sangjun Park
6871
*/
6972
class JpaQueryCreatorTests {
7073

@@ -352,6 +355,35 @@ void like(String parameterValue) {
352355
.validateQuery();
353356
}
354357

358+
static List<Arguments> likeWithPatternParameters() {
359+
return List.of(
360+
// LIKE tests
361+
Arguments.of("findProductByNameLike", ".*spring.*", "%spring%", "LIKE"),
362+
Arguments.of("findProductByNameLike", "^spring.*", "spring%", "LIKE"),
363+
Arguments.of("findProductByNameLike", ".*spring$", "%spring", "LIKE"),
364+
Arguments.of("findProductByNameLike", "^spring$", "spring", "LIKE"),
365+
Arguments.of("findProductByNameLike", ".+spring.+", "_%spring_%", "LIKE"),
366+
Arguments.of("findProductByNameLike", "^.+spring", "_%spring", "LIKE"),
367+
// NOT LIKE tests
368+
Arguments.of("findProductByNameNotLike", ".*spring.*", "%spring%", "NOT LIKE"),
369+
Arguments.of("findProductByNameNotLike", "^spring.*", "spring%", "NOT LIKE"),
370+
Arguments.of("findProductByNameNotLike", ".*spring$", "%spring", "NOT LIKE"));
371+
}
372+
373+
@ParameterizedTest
374+
@MethodSource("likeWithPatternParameters")
375+
void likeAndNotLikeWithPattern(String methodName, String regex, String expectedLike, String likeOperator) {
376+
377+
queryCreator(ORDER) //
378+
.forTree(Product.class, methodName) //
379+
.withParameters(Pattern.compile(regex)) //
380+
.as(QueryCreatorTester::create) //
381+
.expectJpql("SELECT p FROM %s p WHERE p.name " + likeOperator + " ?1 ESCAPE '\\'",
382+
DefaultJpaEntityMetadata.unqualify(Product.class)) //
383+
.expectPlaceholderValue("?1", expectedLike) //
384+
.validateQuery();
385+
}
386+
355387
@Test // GH-3588
356388
void containingString() {
357389

0 commit comments

Comments
 (0)