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
This commit is contained in:
Greg L. Turnquist
2023-05-09 15:48:07 -05:00
parent 0f09803337
commit 4605cd2fc0
4 changed files with 83 additions and 37 deletions

View File

@@ -15,9 +15,8 @@
*/
package org.springframework.data.jpa.repository.query;
import static java.util.regex.Pattern.CASE_INSENSITIVE;
import static org.springframework.util.ObjectUtils.nullSafeEquals;
import static org.springframework.util.ObjectUtils.nullSafeHashCode;
import static java.util.regex.Pattern.*;
import static org.springframework.util.ObjectUtils.*;
import java.lang.reflect.Array;
import java.util.ArrayList;
@@ -371,7 +370,43 @@ class StringQuery implements DeclaredQuery {
return text;
}
return text.substring(0, index) + replacement + text.substring(index + substring.length());
return text.substring(0, index) + potentiallyWrapWithWildcards(replacement, substring)
+ text.substring(index + substring.length());
}
/**
* If there are any pre- or post-wildcards ({@literal %}), replace them with a {@literal CONCAT} function and proper
* wildcards as string literals. NOTE: {@literal CONCAT} appears to be a standard function across relational
* databases as well as JPA providers.
*
* @param replacement
* @param substring
* @return the replacement string properly wrapped in a {@literal CONCAT} function with wildcards applied.
* @since 3.1
*/
private static String potentiallyWrapWithWildcards(String replacement, String substring) {
boolean wildcards = substring.startsWith("%") || substring.endsWith("%");
if (!wildcards) {
return replacement;
}
StringBuilder concatWrapper = new StringBuilder("CONCAT(");
if (substring.startsWith("%")) {
concatWrapper.append("'%',");
}
concatWrapper.append(replacement);
if (substring.endsWith("%")) {
concatWrapper.append(",'%'");
}
concatWrapper.append(")");
return concatWrapper.toString();
}
@Nullable
@@ -761,28 +796,12 @@ class StringQuery implements DeclaredQuery {
}
/**
* Prepares the given raw keyword according to the like type.
* Extracts the raw value properly.
*/
@Nullable
@Override
public Object prepare(@Nullable Object value) {
Object condensedValue = PersistenceProvider.condense(value);
if (condensedValue == null) {
return null;
}
switch (type) {
case STARTING_WITH:
return String.format("%s%%", condensedValue);
case ENDING_WITH:
return String.format("%%%s", condensedValue);
case CONTAINING:
return String.format("%%%s%%", condensedValue);
case LIKE:
default:
return condensedValue;
}
return PersistenceProvider.condense(value);
}
/*

View File

@@ -84,14 +84,4 @@ class LikeBindingUnitTests {
assertThat(binding.hasPosition(1)).isTrue();
assertThat(binding.getType()).isEqualTo(Type.CONTAINING);
}
@Test
void augmentsValueCorrectly() {
assertAugmentedValue(Type.CONTAINING, "%value%");
assertAugmentedValue(Type.ENDING_WITH, "%value");
assertAugmentedValue(Type.STARTING_WITH, "value%");
assertThat(new LikeParameterBinding(1, Type.CONTAINING).prepare(null)).isNull();
}
}

View File

@@ -15,7 +15,7 @@
*/
package org.springframework.data.jpa.repository.query;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.*;
import java.util.Arrays;
import java.util.List;
@@ -102,6 +102,40 @@ public class QueryWithNullLikeHibernateIntegrationTests {
assertThat(Employees).extracting(EmployeeWithName::getName).isEmpty();
}
@Test // GH-2939
void customQueryWithMultipleMatchInNative() {
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative("Baggins");
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins",
"Bilbo Baggins");
}
@Test // GH-2939
void customQueryWithSingleMatchInNative() {
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative("Frodo");
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins");
}
@Test
void customQueryWithEmptyStringMatchInNative() {
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative("");
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins",
"Bilbo Baggins");
}
@Test // GH-2939
void customQueryWithNullMatchInNative() {
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamInNative(null);
assertThat(Employees).extracting(EmployeeWithName::getName).isEmpty();
}
@Test
void derivedQueryStartsWithSingleMatch() {
@@ -235,6 +269,9 @@ public class QueryWithNullLikeHibernateIntegrationTests {
@Query("select e from EmployeeWithName e where e.name like %:partialName%")
List<EmployeeWithName> customQueryWithNullableParam(@Nullable @Param("partialName") String partialName);
@Query(value = "select * from EmployeeWithName as e where e.name like %:partialName%", nativeQuery = true)
List<EmployeeWithName> customQueryWithNullableParamInNative(@Nullable @Param("partialName") String partialName);
List<EmployeeWithName> findByNameStartsWith(@Nullable String partialName);
List<EmployeeWithName> findByNameEndsWith(@Nullable String partialName);

View File

@@ -68,7 +68,7 @@ class StringQueryUnitTests {
assertThat(query.hasParameterBindings()).isTrue();
assertThat(query.getQueryString())
.isEqualTo("select u from User u where u.firstname like ?1 or u.lastname like ?2");
.isEqualTo("select u from User u where u.firstname like CONCAT('%',?1,'%') or u.lastname like CONCAT('%',?2)");
List<ParameterBinding> bindings = query.getParameterBindings();
assertThat(bindings).hasSize(2);
@@ -90,7 +90,7 @@ class StringQueryUnitTests {
StringQuery query = new StringQuery("select u from User u where u.firstname like %:firstname", true);
assertThat(query.hasParameterBindings()).isTrue();
assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like :firstname");
assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like CONCAT('%',:firstname)");
List<ParameterBinding> bindings = query.getParameterBindings();
assertThat(bindings).hasSize(1);
@@ -209,8 +209,8 @@ class StringQueryUnitTests {
assertNamedBinding(ParameterBinding.class, "word", bindings.get(1));
softly.assertThat(query.getQueryString())
.isEqualTo("SELECT a FROM Article a WHERE a.overview LIKE :escapedWord ESCAPE '~'"
+ " OR a.content LIKE :escapedWord ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC");
.isEqualTo("SELECT a FROM Article a WHERE a.overview LIKE CONCAT('%',:escapedWord,'%') ESCAPE '~'"
+ " OR a.content LIKE CONCAT('%',:escapedWord,'%') ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC");
softly.assertAll();
}