Skip to content

Commit 878db1d

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). See #2939 See #2760 Original Pull Request: #2940 Superceding Pull Request: #2944
1 parent d2fa85a commit 878db1d

File tree

4 files changed

+82
-34
lines changed

4 files changed

+82
-34
lines changed

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

+39-19
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,43 @@ private static String replaceFirst(String text, String substring, String replace
334334
return text;
335335
}
336336

337-
return text.substring(0, index) + replacement + text.substring(index + substring.length());
337+
return text.substring(0, index) + potentiallyWrapWithWildcards(replacement, substring)
338+
+ text.substring(index + substring.length());
339+
}
340+
341+
/**
342+
* If there are any pre- or post-wildcards ({@literal %}), replace them with a {@literal CONCAT} function and proper
343+
* wildcards as string literals. NOTE: {@literal CONCAT} appears to be a standard function across relational
344+
* databases as well as JPA providers.
345+
*
346+
* @param replacement
347+
* @param substring
348+
* @return the replacement string properly wrapped in a {@literal CONCAT} function with wildcards applied.
349+
* @since 3.1
350+
*/
351+
private static String potentiallyWrapWithWildcards(String replacement, String substring) {
352+
353+
boolean wildcards = substring.startsWith("%") || substring.endsWith("%");
354+
355+
if (!wildcards) {
356+
return replacement;
357+
}
358+
359+
StringBuilder concatWrapper = new StringBuilder("CONCAT(");
360+
361+
if (substring.startsWith("%")) {
362+
concatWrapper.append("'%',");
363+
}
364+
365+
concatWrapper.append(replacement);
366+
367+
if (substring.endsWith("%")) {
368+
concatWrapper.append(",'%'");
369+
}
370+
371+
concatWrapper.append(")");
372+
373+
return concatWrapper.toString();
338374
}
339375

340376
@Nullable
@@ -708,28 +744,12 @@ public Type getType() {
708744
}
709745

710746
/**
711-
* Prepares the given raw keyword according to the like type.
747+
* Extracts the raw value properly.
712748
*/
713749
@Nullable
714750
@Override
715751
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-
}
752+
return PersistenceProvider.unwrapTypedParameterValue(value);
733753
}
734754

735755
@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 // GH-2939
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 // GH-2939
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 // GH-2939
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)