Skip to content

Commit cb5141e

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 a52bfba commit cb5141e

File tree

5 files changed

+99
-34
lines changed

5 files changed

+99
-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
@@ -335,7 +335,43 @@ private static String replaceFirst(String text, String substring, String replace
335335
return text;
336336
}
337337

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

341377
@Nullable
@@ -709,28 +745,12 @@ public Type getType() {
709745
}
710746

711747
/**
712-
* Prepares the given raw keyword according to the like type.
748+
* Extracts the raw value properly.
713749
*/
714750
@Nullable
715751
@Override
716752
public Object prepare(@Nullable Object value) {
717-
718-
Object unwrapped = PersistenceProvider.unwrapTypedParameterValue(value);
719-
if (unwrapped == null) {
720-
return null;
721-
}
722-
723-
switch (type) {
724-
case STARTING_WITH:
725-
return String.format("%s%%", unwrapped);
726-
case ENDING_WITH:
727-
return String.format("%%%s", unwrapped);
728-
case CONTAINING:
729-
return String.format("%%%s%%", unwrapped);
730-
case LIKE:
731-
default:
732-
return unwrapped;
733-
}
753+
return PersistenceProvider.unwrapTypedParameterValue(value);
734754
}
735755

736756
@Override

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

+18
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,22 @@ void executesNotInQueryCorrectly() {}
3434
@Disabled
3535
@Override
3636
void executesInKeywordForPageCorrectly() {}
37+
38+
@Disabled("Can't get ESCAPE clause working with Hibernate. See #2954") // GH-2939 backport
39+
@Override
40+
void escapingInLikeSpels() {
41+
super.escapingInLikeSpels();
42+
}
43+
44+
@Disabled("Can't get ESCAPE clause working with Hibernate. See #2954") // GH-2939 backport
45+
@Override
46+
void escapingInLikeSpelsInThePresenceOfEscapeCharacters() {
47+
super.escapingInLikeSpelsInThePresenceOfEscapeCharacters();
48+
}
49+
50+
@Disabled("Can't get ESCAPE clause working with Hibernate. See #2954") // GH-2939 backport
51+
@Override
52+
void escapingInLikeSpelsInThePresenceOfEscapedWildcards() {
53+
super.escapingInLikeSpelsInThePresenceOfEscapedWildcards();
54+
}
3755
}

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/QueryWithNullLikeHibernateIntegrationTests.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

+4-4
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ void detectsPositionalLikeBindings() {
6868

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

7373
List<ParameterBinding> bindings = query.getParameterBindings();
7474
assertThat(bindings).hasSize(2);
@@ -90,7 +90,7 @@ void detectsNamedLikeBindings() {
9090
StringQuery query = new StringQuery("select u from User u where u.firstname like %:firstname", true);
9191

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

9595
List<ParameterBinding> bindings = query.getParameterBindings();
9696
assertThat(bindings).hasSize(1);
@@ -209,8 +209,8 @@ void removesLikeBindingsFromQueryIfQueryContainsSimpleBinding() {
209209
assertNamedBinding(ParameterBinding.class, "word", bindings.get(1));
210210

211211
softly.assertThat(query.getQueryString())
212-
.isEqualTo("SELECT a FROM Article a WHERE a.overview LIKE :escapedWord ESCAPE '~'"
213-
+ " OR a.content LIKE :escapedWord ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC");
212+
.isEqualTo("SELECT a FROM Article a WHERE a.overview LIKE CONCAT('%',:escapedWord,'%') ESCAPE '~'"
213+
+ " OR a.content LIKE CONCAT('%',:escapedWord,'%') ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC");
214214

215215
softly.assertAll();
216216
}

0 commit comments

Comments
 (0)