diff --git a/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java b/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java index fa1a41c55..19c1fbfc9 100644 --- a/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java +++ b/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java @@ -40,6 +40,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; /** @@ -329,6 +330,35 @@ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor { } } + /** + * 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.jpa.TypedParameterValue", classLoader)) { + + try { + + Class typeParameterValue = ClassUtils.forName("org.hibernate.jpa.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/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java b/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java index 10bca7571..05e2c45bd 100644 --- a/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java +++ b/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java @@ -15,12 +15,7 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; +import java.util.*; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -241,14 +236,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/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java b/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java index 5f6e7153f..ce80e2409 100644 --- a/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java +++ b/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; @@ -689,7 +690,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}. */ @@ -700,7 +701,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}. @@ -770,14 +771,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/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java b/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java new file mode 100644 index 000000000..adb6f071c --- /dev/null +++ b/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java @@ -0,0 +1,43 @@ +/* + * 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 lombok.AccessLevel; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +/** + * @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/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeHibernateIntegrationTests.java b/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeHibernateIntegrationTests.java new file mode 100644 index 000000000..a5fac8892 --- /dev/null +++ b/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.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Properties; + +import javax.persistence.EntityManagerFactory; +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(Arrays.asList( // + 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/src/test/resources/META-INF/persistence.xml b/src/test/resources/META-INF/persistence.xml index 30dcdadf0..8c197441f 100644 --- a/src/test/resources/META-INF/persistence.xml +++ b/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/src/test/resources/logback.xml b/src/test/resources/logback.xml index 2bb9893fd..5e46aa5e3 100644 --- a/src/test/resources/logback.xml +++ b/src/test/resources/logback.xml @@ -18,4 +18,4 @@ - \ No newline at end of file +