Skip to content

Commit 964b3f8

Browse files
committed
Rewrite LIKE clauses with wildcards as CONCAT functions.
We support wrapping parameters (named or positional) with optional wildcards when doing LIKE patterns. This is out-of-band and requires moving the wildcards into the bindings. To stop doing this and causing race conditions, we can instead rewrite the queries using the CONCAT function. This function is standard across relational database (native queries) as well as JPA providers (Hibernate and EclipseLink). Resolves #2939. Related: #2760.
1 parent bf7af54 commit 964b3f8

File tree

4 files changed

+90
-34
lines changed

4 files changed

+90
-34
lines changed

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

+47-19
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import java.util.regex.Matcher;
2828
import java.util.regex.Pattern;
2929

30+
import org.apache.commons.logging.Log;
31+
import org.apache.commons.logging.LogFactory;
3032
import org.springframework.data.jpa.provider.PersistenceProvider;
3133
import org.springframework.data.repository.query.SpelQueryContext;
3234
import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor;
@@ -53,6 +55,8 @@
5355
*/
5456
class StringQuery implements DeclaredQuery {
5557

58+
private static final Log LOGGER = LogFactory.getLog(StringQuery.class);
59+
5660
private final String query;
5761
private final List<ParameterBinding> bindings;
5862
private final @Nullable String alias;
@@ -334,7 +338,47 @@ private static String replaceFirst(String text, String substring, String replace
334338
return text;
335339
}
336340

337-
return text.substring(0, index) + replacement + text.substring(index + substring.length());
341+
return text.substring(0, index) + potentiallyWrapWithWildcards(replacement, substring)
342+
+ text.substring(index + substring.length());
343+
}
344+
345+
/**
346+
* If there are any pre- or post-wildcards ({@literal %}), replace them with a {@literal CONCAT} function and proper
347+
* wildcards as string literals. NOTE: {@literal CONCAT} appears to be a standard function across relational
348+
* databases as well as JPA providers.
349+
*
350+
* @param replacement
351+
* @param substring
352+
* @return the replacement string properly wrapped in a {@literal CONCAT} function with wildcards applied.
353+
* @since 3.1
354+
*/
355+
private static String potentiallyWrapWithWildcards(String replacement, String substring) {
356+
357+
boolean wildcards = substring.startsWith("%") || substring.endsWith("%");
358+
359+
if (!wildcards) {
360+
return replacement;
361+
}
362+
363+
StringBuilder concatWrapper = new StringBuilder("CONCAT(");
364+
365+
if (substring.startsWith("%")) {
366+
concatWrapper.append("'%',");
367+
}
368+
369+
concatWrapper.append(replacement);
370+
371+
if (substring.endsWith("%")) {
372+
concatWrapper.append(",'%'");
373+
}
374+
375+
concatWrapper.append(")");
376+
377+
LOGGER.warn(
378+
"You are using a non-standard query feature that may not be supported in the future (LIKE with '%' wildcards). "
379+
+ "We suggest you rewrite [" + substring + "] as [" + concatWrapper + "]");
380+
381+
return concatWrapper.toString();
338382
}
339383

340384
@Nullable
@@ -708,28 +752,12 @@ public Type getType() {
708752
}
709753

710754
/**
711-
* Prepares the given raw keyword according to the like type.
755+
* Extracts the raw value properly.
712756
*/
713757
@Nullable
714758
@Override
715759
public Object prepare(@Nullable Object value) {
716-
717-
Object unwrapped = PersistenceProvider.unwrapTypedParameterValue(value);
718-
if (unwrapped == null) {
719-
return null;
720-
}
721-
722-
switch (type) {
723-
case STARTING_WITH:
724-
return String.format("%s%%", unwrapped);
725-
case ENDING_WITH:
726-
return String.format("%%%s", unwrapped);
727-
case CONTAINING:
728-
return String.format("%%%s%%", unwrapped);
729-
case LIKE:
730-
default:
731-
return unwrapped;
732-
}
760+
return PersistenceProvider.unwrapTypedParameterValue(value);
733761
}
734762

735763
@Override

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

-10
Original file line numberDiff line numberDiff line change
@@ -84,14 +84,4 @@ void setsUpInstanceForIndex() {
8484
assertThat(binding.hasPosition(1)).isTrue();
8585
assertThat(binding.getType()).isEqualTo(Type.CONTAINING);
8686
}
87-
88-
@Test
89-
void augmentsValueCorrectly() {
90-
91-
assertAugmentedValue(Type.CONTAINING, "%value%");
92-
assertAugmentedValue(Type.ENDING_WITH, "%value");
93-
assertAugmentedValue(Type.STARTING_WITH, "value%");
94-
95-
assertThat(new LikeParameterBinding(1, Type.CONTAINING).prepare(null)).isNull();
96-
}
9787
}

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

+38-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616
package org.springframework.data.jpa.repository.query;
1717

18-
import static org.assertj.core.api.Assertions.assertThat;
18+
import static org.assertj.core.api.Assertions.*;
1919

2020
import jakarta.persistence.EntityManagerFactory;
2121

@@ -102,6 +102,40 @@ void customQueryWithNullMatch() {
102102
assertThat(Employees).extracting(EmployeeWithName::getName).isEmpty();
103103
}
104104

105+
@Test
106+
void customQueryWithMultipleMatchInNative() {
107+
108+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative("Baggins");
109+
110+
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins",
111+
"Bilbo Baggins");
112+
}
113+
114+
@Test
115+
void customQueryWithSingleMatchInNative() {
116+
117+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative("Frodo");
118+
119+
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins");
120+
}
121+
122+
@Test
123+
void customQueryWithEmptyStringMatchInNative() {
124+
125+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative("");
126+
127+
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins",
128+
"Bilbo Baggins");
129+
}
130+
131+
@Test
132+
void customQueryWithNullMatchInNative() {
133+
134+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative(null);
135+
136+
assertThat(Employees).extracting(EmployeeWithName::getName).isEmpty();
137+
}
138+
105139
@Test
106140
void derivedQueryStartsWithSingleMatch() {
107141

@@ -235,6 +269,9 @@ public interface EmployeeWithNullLikeRepository extends JpaRepository<EmployeeWi
235269
@Query("select e from EmployeeWithName e where e.name like %:partialName%")
236270
List<EmployeeWithName> customQueryWithNullableParam(@Nullable @Param("partialName") String partialName);
237271

272+
@Query(value = "select * from EmployeeWithName as e where e.name like %:partialName%", nativeQuery = true)
273+
List<EmployeeWithName> customQueryWithNullableParamInNative(@Nullable @Param("partialName") String partialName);
274+
238275
List<EmployeeWithName> findByNameStartsWith(@Nullable String partialName);
239276

240277
List<EmployeeWithName> findByNameEndsWith(@Nullable String partialName);

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

+5-4
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ void detectsPositionalLikeBindings() {
6565

6666
assertThat(query.hasParameterBindings()).isTrue();
6767
assertThat(query.getQueryString())
68-
.isEqualTo("select u from User u where u.firstname like ?1 or u.lastname like ?2");
68+
.isEqualTo("select u from User u where u.firstname like CONCAT('%',?1,'%') or u.lastname like CONCAT('%',?2)");
6969

7070
List<ParameterBinding> bindings = query.getParameterBindings();
7171
assertThat(bindings).hasSize(2);
@@ -87,7 +87,7 @@ void detectsNamedLikeBindings() {
8787
StringQuery query = new StringQuery("select u from User u where u.firstname like %:firstname", true);
8888

8989
assertThat(query.hasParameterBindings()).isTrue();
90-
assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like :firstname");
90+
assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like CONCAT('%',:firstname)");
9191

9292
List<ParameterBinding> bindings = query.getParameterBindings();
9393
assertThat(bindings).hasSize(1);
@@ -199,8 +199,9 @@ void removesLikeBindingsFromQueryIfQueryContainsSimpleBinding() {
199199
assertNamedBinding(LikeParameterBinding.class, "escapedWord", bindings.get(0));
200200
assertNamedBinding(ParameterBinding.class, "word", bindings.get(1));
201201

202-
assertThat(query.getQueryString()).isEqualTo("SELECT a FROM Article a WHERE a.overview LIKE :escapedWord ESCAPE '~'"
203-
+ " OR a.content LIKE :escapedWord ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC");
202+
assertThat(query.getQueryString())
203+
.isEqualTo("SELECT a FROM Article a WHERE a.overview LIKE CONCAT('%',:escapedWord,'%') ESCAPE '~'"
204+
+ " OR a.content LIKE CONCAT('%',:escapedWord,'%') ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC");
204205
}
205206

206207
@Test // DATAJPA-483

0 commit comments

Comments
 (0)