diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java index f3ef2f047..8dfa22e47 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java @@ -42,6 +42,7 @@ import org.springframework.data.util.CloseableIterator; import org.springframework.lang.Nullable; import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.ConcurrentReferenceHashMap; /** @@ -310,6 +311,35 @@ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor, Quer } } + /** + * Because Hibernate's {@literal TypedParameterValue} is only used to wrap a {@literal null}, swap it out with an + * empty string for query creation. + * + * @param value + * @return the original value or an empty string. + * @since 3.0 + */ + public static Object condense(Object value) { + + ClassLoader classLoader = PersistenceProvider.class.getClassLoader(); + + if (ClassUtils.isPresent("org.hibernate.query.TypedParameterValue", classLoader)) { + + try { + + Class typeParameterValue = ClassUtils.forName("org.hibernate.query.TypedParameterValue", classLoader); + + if (typeParameterValue.isInstance(value)) { + return ""; + } + } catch (ClassNotFoundException | LinkageError o_O) { + return value; + } + } + + return value; + } + /** * Holds the PersistenceProvider specific interface names. * diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java index 97696f1cf..1064c2058 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java @@ -15,6 +15,9 @@ */ package org.springframework.data.jpa.repository.query; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.ParameterExpression; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -24,9 +27,6 @@ import java.util.List; import java.util.function.Supplier; import java.util.stream.Collectors; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.ParameterExpression; - import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; @@ -245,14 +245,14 @@ class ParameterMetadataProvider { switch (type) { case STARTING_WITH: - return String.format("%s%%", escape.escape(value.toString())); + return String.format("%s%%", escape.escape(PersistenceProvider.condense(value).toString())); case ENDING_WITH: - return String.format("%%%s", escape.escape(value.toString())); + return String.format("%%%s", escape.escape(PersistenceProvider.condense(value).toString())); case CONTAINING: case NOT_CONTAINING: - return String.format("%%%s%%", escape.escape(value.toString())); + return String.format("%%%s%%", escape.escape(PersistenceProvider.condense(value).toString())); default: - return value; + return PersistenceProvider.condense(value); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java index 36ec1ca17..56e2d4618 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java @@ -27,6 +27,7 @@ import java.util.function.BiFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.repository.query.SpelQueryContext; import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor; import org.springframework.data.repository.query.parser.Part.Type; @@ -637,7 +638,7 @@ class StringQuery implements DeclaredQuery { /** * Creates a new {@link LikeParameterBinding} for the parameter with the given name and {@link Type}. - * + * * @param name must not be {@literal null} or empty. * @param type must not be {@literal null}. */ @@ -648,7 +649,7 @@ class StringQuery implements DeclaredQuery { /** * Creates a new {@link LikeParameterBinding} for the parameter with the given name and {@link Type} and parameter * binding input. - * + * * @param name must not be {@literal null} or empty. * @param type must not be {@literal null}. * @param expression may be {@literal null}. @@ -718,14 +719,14 @@ class StringQuery implements DeclaredQuery { switch (type) { case STARTING_WITH: - return String.format("%s%%", value); + return String.format("%s%%", PersistenceProvider.condense(value)); case ENDING_WITH: - return String.format("%%%s", value); + return String.format("%%%s", PersistenceProvider.condense(value)); case CONTAINING: - return String.format("%%%s%%", value); + return String.format("%%%s%%", PersistenceProvider.condense(value)); case LIKE: default: - return value; + return PersistenceProvider.condense(value); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java new file mode 100644 index 000000000..377059840 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.domain.sample; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * @author Greg Turnquist + */ +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Data +public class EmployeeWithName { + + @Id + @GeneratedValue private Integer id; + private String name; + + public EmployeeWithName(String name) { + + this(); + this.name = name; + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeHibernateIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeHibernateIntegrationTests.java new file mode 100644 index 000000000..9c3f60d9d --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeHibernateIntegrationTests.java @@ -0,0 +1,280 @@ +/* + * Copyright 2012-2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import static org.assertj.core.api.Assertions.*; + +import jakarta.persistence.EntityManagerFactory; + +import java.util.List; +import java.util.Properties; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.jpa.domain.sample.EmployeeWithName; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.query.Param; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.lang.Nullable; +import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; + +/** + * Verify that {@literal LIKE}s mixed with {@literal NULL}s work properly. + * + * @author Greg Turnquist + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = QueryWithNullLikeHibernateIntegrationTests.Config.class) +@Transactional +public class QueryWithNullLikeHibernateIntegrationTests { + + @Autowired EmpoyeeWithNullLikeRepository repository; + + @BeforeEach + void setUp() { + repository.saveAllAndFlush(List.of( // + new EmployeeWithName("Frodo Baggins"), // + new EmployeeWithName("Bilbo Baggins"))); + } + + @Test + void customQueryWithMultipleMatch() { + + List Employees = repository.customQueryWithNullableParam("Baggins"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void customQueryWithSingleMatch() { + + List Employees = repository.customQueryWithNullableParam("Frodo"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins"); + } + + @Test + void customQueryWithEmptyStringMatch() { + + List Employees = repository.customQueryWithNullableParam(""); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void customQueryWithNullMatch() { + + List Employees = repository.customQueryWithNullableParam(null); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryStartsWithSingleMatch() { + + List Employees = repository.findByNameStartsWith("Frodo"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins"); + } + + @Test + void derivedQueryStartsWithNoMatch() { + + List Employees = repository.findByNameStartsWith("Baggins"); + + assertThat(Employees).extracting(EmployeeWithName::getName).isEmpty(); + } + + @Test + void derivedQueryStartsWithWithEmptyStringMatch() { + + List Employees = repository.findByNameStartsWith(""); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryStartsWithWithNullMatch() { + + List Employees = repository.findByNameStartsWith(null); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryEndsWithWithMultipleMatch() { + + List Employees = repository.findByNameEndsWith("Baggins"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryEndsWithWithSingleMatch() { + + List Employees = repository.findByNameEndsWith("Frodo"); + + assertThat(Employees).extracting(EmployeeWithName::getName).isEmpty(); + } + + @Test + void derivedQueryEndsWithWithEmptyStringMatch() { + + List Employees = repository.findByNameEndsWith(""); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryEndsWithWithNullMatch() { + + List Employees = repository.findByNameEndsWith(null); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryContainsWithMultipleMatch() { + + List Employees = repository.findByNameContains("Baggins"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryContainsWithSingleMatch() { + + List Employees = repository.findByNameContains("Frodo"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactly("Frodo Baggins"); + } + + @Test + void derivedQueryContainsWithEmptyStringMatch() { + + List Employees = repository.findByNameContains(""); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryContainsWithNullMatch() { + + List Employees = repository.findByNameContains(null); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryLikeWithMultipleMatch() { + + List Employees = repository.findByNameLike("%Baggins%"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Test + void derivedQueryLikeWithSingleMatch() { + + List Employees = repository.findByNameLike("%Frodo%"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactly("Frodo Baggins"); + } + + @Test + void derivedQueryLikeWithEmptyStringMatch() { + + List Employees = repository.findByNameLike("%%"); + + assertThat(Employees).extracting(EmployeeWithName::getName).containsExactlyInAnyOrder("Frodo Baggins", + "Bilbo Baggins"); + } + + @Transactional + public interface EmpoyeeWithNullLikeRepository extends JpaRepository { + + @Query("select e from EmployeeWithName e where e.name like %:partialName%") + List customQueryWithNullableParam(@Nullable @Param("partialName") String partialName); + + List findByNameStartsWith(@Nullable String partialName); + + List findByNameEndsWith(@Nullable String partialName); + + List findByNameContains(@Nullable String partialName); + + List findByNameLike(@Nullable String partialName); + } + + @EnableJpaRepositories(considerNestedRepositories = true, // + includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = EmpoyeeWithNullLikeRepository.class)) + @EnableTransactionManagement + static class Config { + + @Bean + DataSource dataSource() { + return new EmbeddedDatabaseBuilder().generateUniqueName(true).build(); + } + + @Bean + AbstractEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + + LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); + factoryBean.setDataSource(dataSource); + factoryBean.setPersistenceUnitName("spring-data-jpa"); + factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); + + Properties properties = new Properties(); + properties.setProperty("hibernate.hbm2ddl.auto", "create"); + factoryBean.setJpaProperties(properties); + + return factoryBean; + } + + @Bean + PlatformTransactionManager transactionManager(EntityManagerFactory emf) { + return new JpaTransactionManager(emf); + } + } +} diff --git a/spring-data-jpa/src/test/resources/META-INF/persistence.xml b/spring-data-jpa/src/test/resources/META-INF/persistence.xml index 7e55f6e60..280312885 100644 --- a/spring-data-jpa/src/test/resources/META-INF/persistence.xml +++ b/spring-data-jpa/src/test/resources/META-INF/persistence.xml @@ -22,6 +22,7 @@ org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployeePK org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployee org.springframework.data.jpa.domain.sample.EmbeddedIdExampleDepartment + org.springframework.data.jpa.domain.sample.EmployeeWithName org.springframework.data.jpa.domain.sample.IdClassExampleEmployee org.springframework.data.jpa.domain.sample.IdClassExampleDepartment org.springframework.data.jpa.domain.sample.Invoice diff --git a/spring-data-jpa/src/test/resources/logback.xml b/spring-data-jpa/src/test/resources/logback.xml index d7b83cb95..19bb933f9 100644 --- a/spring-data-jpa/src/test/resources/logback.xml +++ b/spring-data-jpa/src/test/resources/logback.xml @@ -13,6 +13,10 @@ + + + +