Skip to content

Commit a801aa1

Browse files
committed
Document how to properly use the same parameter more than once.
Include additional tests verifying this behavior. Resolves #2939. Related: #2760.
1 parent c364c82 commit a801aa1

File tree

2 files changed

+139
-1
lines changed

2 files changed

+139
-1
lines changed

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

+39-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 customQueryWithMultipleMatchAlternative() {
107+
108+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamExpandedVersion("Baggins");
109+
110+
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins",
111+
"Bilbo Baggins");
112+
}
113+
114+
@Test
115+
void customQueryWithSingleMatchAlternative() {
116+
117+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamExpandedVersion("Frodo");
118+
119+
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins");
120+
}
121+
122+
@Test
123+
void customQueryWithEmptyStringMatchAlternative() {
124+
125+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamExpandedVersion("");
126+
127+
assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins",
128+
"Bilbo Baggins");
129+
}
130+
131+
@Test
132+
void customQueryWithNullMatchAlternative() {
133+
134+
List<EmployeeWithName> Employees = repository.customQueryWithNullableParamExpandedVersion(null);
135+
136+
assertThat(Employees).extracting(EmployeeWithName::getName).isEmpty();
137+
}
138+
105139
@Test
106140
void derivedQueryStartsWithSingleMatch() {
107141

@@ -235,6 +269,10 @@ 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("select e from EmployeeWithName e where e.name like '%' || :partialName || '%'")
273+
List<EmployeeWithName> customQueryWithNullableParamExpandedVersion(
274+
@Nullable @Param("partialName") String partialName);
275+
238276
List<EmployeeWithName> findByNameStartsWith(@Nullable String partialName);
239277

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

src/main/asciidoc/jpa.adoc

+100
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,106 @@ public interface UserRepository extends JpaRepository<User, Long> {
355355
----
356356
====
357357

358+
It's possible to do much more complex ones, like this:
359+
360+
.Declare query using `@Query` that has `LIKE` and wildcards
361+
====
362+
[source, java]
363+
----
364+
public interface UserRepository extends JpaRepository<User, Long> {
365+
366+
@Query("select e from Employee e where e.name like %:partialName%")
367+
Employee findByPartialNameMatchWithWildcards(@Param("partialName") String partialName);
368+
}
369+
----
370+
* This example uses the <<jpa.named-parameters, named parameters>> instead of positional parameters.
371+
* This one also uses a pattern we often see, a combination of a `LIKE` along with `%` wildcards. This makes the query do partial matches (on each side).
372+
====
373+
374+
WARNING: There is a limit when using `like` with `%` wildcards like this last example. If the parameter is used more than once, there is a change the query will be incorrect. Please read the next section to see how to deal with this.
375+
376+
==== Using the same parameter more than once
377+
378+
You can use the same parameter more than once in a given query. For example, it's not uncommon to include an `is null` check. The the example below:
379+
380+
.An `@Query` with both a match and an `is null` check
381+
====
382+
[source, java]
383+
----
384+
public interface UserRepository extends JpaRepository<User, Long> {
385+
386+
@Query("select e from Employee e where e.name = :name or :name is null")
387+
Employee findByPartialNameMatchWithWildcards(@Param("name") String name);
388+
}
389+
----
390+
====
391+
392+
This is a convenient way to either match on the provided `name` or to simply skip this particular column if the provided input were `null`.
393+
However, note that this example doesn't have the `like` argument shown in the previous section.
394+
If you were to combine this pattern with the previous section's as shown below, you will run into an issue.
395+
396+
.An `@Query` with a like and an `is null` check
397+
====
398+
[source, java]
399+
----
400+
public interface UserRepository extends JpaRepository<User, Long> {
401+
402+
@Query("select e from Employee e where e.name like %:partialName% or :partialName is null")
403+
Employee findByPartialNameMatchWithWildcards(@Param("partialName") String partialName);
404+
}
405+
----
406+
WARNING: This query will NOT work as expected!
407+
====
408+
409+
This query will fail because of how Spring Data JPA works. Essentially, `like %:param%` is shorthand for `like :param` combined with tranforming your input argument from `Baggins` -> `%Baggins%`.
410+
Simply put, the wildcards are moved from the query into the parameter binding when the method is invoked.
411+
412+
That's because Hibernate and other providers don't recognize this combination.
413+
And so Spring Data JPA attempts to simplify.
414+
415+
NOTE: If you attempt to write something like `select e from Employee where e.name like '%:partialName%'` and submit that to the JPA provider, it won't work. The JPA provider won't recognize that `:partialName` is actually a parameter since it's inside a string literal.
416+
417+
Since there is only one argument provided (`partialName`) to the method, there is only one binding to issue to the JPA provider (`:partialName`).
418+
419+
* Spring Data JPA will walk through that last query and apply the `%` wildcards to the binding, just as it did in the previous example.
420+
* But when it gets to the second instance of `:partialName` with the `is null` check, it will reapply the bindings.
421+
422+
And since there is no `like` or wildcards, it will undo the wildcard wrappings.
423+
424+
If you write the query the other way around, e.g. `select e from Employee e where :partialName is null or e.name like %:partialName%`, then Spring Data JPA will again walk left to right, and do things in the opposite way.
425+
426+
* `:partialName` will first get bound to your method's input as provided.
427+
* But when it hits the second instance, it will adjust the binding to have the wildcards wrap your input value, thus making your `is null` check appear to be against `%null%`.
428+
429+
Essentially, the last usage of `:partialName` in the query is the variant sent to the JPA provider, which will leave out any wildcards. There is no combination that will send two different bindings for one parameter.
430+
431+
IMPORTANT: The solution is to rewrite your query in proper expanded form.
432+
433+
We said earlier that `like %:param%` is shorthand. To use the same parameter more than once in a given query, and have wildcards in differing places, you must write the query in expanded form as shown below:
434+
435+
.An `@Query` with a like and an `is null` check in expanded form
436+
====
437+
[source, java]
438+
----
439+
public interface UserRepository extends JpaRepository<User, Long> {
440+
441+
@Query("select e from Employee e where e.name like '%' || :partialName || '%' or :partialName is null")
442+
Employee findByPartialNameMatchWithWildcards(@Param("partialName") String partialName);
443+
}
444+
----
445+
NOTE: This will work as expected!
446+
====
447+
448+
In this version, the parameter and the wildcards are separated by concatenation (Hibernate concatenation in this case).
449+
This will make Spring Data JPA sidestep any migration of wildcards from the query to the bound parameters.
450+
451+
* Whatever value you supply to the method argument will be passed along to the JPA provider unchanged.
452+
* This lets you apply wildcards exactly as desired.
453+
* Simply reordering the clauses on each side of the `or` will not cause a strange change in behavior.
454+
455+
In short, if you are using `LIKE` combined with wildcards, and your query is not acting as expecting check out if rewriting it using the concatenation operators for your JPA provider solves the problem.
456+
457+
358458
[[jpa.query-methods.query-rewriter]]
359459
==== Applying a QueryRewriter
360460

0 commit comments

Comments
 (0)