From aa6a809c31263f4ef96b8ad387249955f82550d0 Mon Sep 17 00:00:00 2001 From: "Greg L. Turnquist" Date: Mon, 11 Jul 2022 14:31:16 -0500 Subject: [PATCH] Introduce @Meta data support for repository methods. Closes #775. --- .../jpa/provider/PersistenceProvider.java | 41 ++- .../data/jpa/provider/QueryComment.java | 37 +++ .../data/jpa/repository/Meta.java | 43 +++ .../repository/query/AbstractJpaQuery.java | 9 + .../jpa/repository/query/JpaQueryMethod.java | 53 ++++ .../data/jpa/repository/query/Meta.java | 118 ++++++++ .../support/CrudMethodMetadata.java | 13 +- .../CrudMethodMetadataPostProcessor.java | 21 +- .../support/SimpleJpaRepository.java | 44 ++- .../query/JpaQueryMethodUnitTests.java | 6 +- ...ueryMethodEclipseLinkIntegrationTests.java | 247 +++++++++++++++++ ...dQueryMethodHibernateIntegrationTests.java | 254 ++++++++++++++++++ .../MetaAnnotatedQueryMethodUnitTests.java | 65 +++++ .../sample/RoleRepositoryWithMeta.java | 101 +++++++ src/main/asciidoc/jpa.adoc | 133 +++++++++ 15 files changed, 1164 insertions(+), 21 deletions(-) create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodEclipseLinkIntegrationTests.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodHibernateIntegrationTests.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java 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 73a09f631..f2d90b0c8 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 @@ -18,22 +18,22 @@ package org.springframework.data.jpa.provider; import static org.springframework.data.jpa.provider.JpaClassUtils.*; import static org.springframework.data.jpa.provider.PersistenceProvider.Constants.*; -import java.util.Collections; -import java.util.NoSuchElementException; -import java.util.Set; - import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import jakarta.persistence.metamodel.IdentifiableType; import jakarta.persistence.metamodel.Metamodel; import jakarta.persistence.metamodel.SingularAttribute; +import java.util.Collections; +import java.util.NoSuchElementException; +import java.util.Set; + +import org.eclipse.persistence.config.QueryHints; import org.eclipse.persistence.jpa.JpaQuery; import org.eclipse.persistence.queries.ScrollableCursor; import org.hibernate.ScrollMode; import org.hibernate.ScrollableResults; import org.hibernate.proxy.HibernateProxy; - import org.springframework.data.jpa.repository.query.JpaParameters; import org.springframework.data.jpa.repository.query.JpaParametersParameterAccessor; import org.springframework.data.util.CloseableIterator; @@ -49,8 +49,9 @@ import org.springframework.util.ConcurrentReferenceHashMap; * @author Thomas Darimont * @author Mark Paluch * @author Jens Schauder + * @author Greg Turnquist */ -public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor { +public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor, QueryComment { /** * Hibernate persistence provider. @@ -102,9 +103,15 @@ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor { } @Override - public JpaParametersParameterAccessor getParameterAccessor(JpaParameters parameters, Object[] values, EntityManager em) { + public JpaParametersParameterAccessor getParameterAccessor(JpaParameters parameters, Object[] values, + EntityManager em) { return new HibernateJpaParametersParameterAccessor(parameters, values, em); } + + @Override + public String getCommentHintKey() { + return "org.hibernate.comment"; + } }, /** @@ -133,6 +140,16 @@ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor { public CloseableIterator executeQueryWithResultStream(Query jpaQuery) { return new EclipseLinkScrollableResultsIterator<>(jpaQuery); } + + @Override + public String getCommentHintKey() { + return QueryHints.HINT; + } + + @Override + public String getCommentHintValue(String comment) { + return "/* " + comment + " */"; + } }, /** @@ -161,11 +178,18 @@ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor { public Object getIdentifierFrom(Object entity) { return null; } + + @Nullable + @Override + public String getCommentHintKey() { + return null; + } }; static ConcurrentReferenceHashMap, PersistenceProvider> CACHE = new ConcurrentReferenceHashMap<>(); private final Iterable entityManagerClassNames; private final Iterable metamodelClassNames; + /** * Creates a new {@link PersistenceProvider}. * @@ -249,7 +273,8 @@ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor { return cacheAndReturn(metamodelType, GENERIC_JPA); } - public JpaParametersParameterAccessor getParameterAccessor(JpaParameters parameters, Object[] values, EntityManager em) { + public JpaParametersParameterAccessor getParameterAccessor(JpaParameters parameters, Object[] values, + EntityManager em) { return new JpaParametersParameterAccessor(parameters, values); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java new file mode 100644 index 000000000..0f5004828 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java @@ -0,0 +1,37 @@ +/* + * Copyright 2013-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.provider; + +import jakarta.persistence.Query; + +import org.springframework.lang.Nullable; + +/** + * Interface to hide different implementations of query hints that insert comments into a {@link Query}. + * + * @author Greg Turnquist + * @since 3.0 + */ +public interface QueryComment { + + @Nullable + String getCommentHintKey(); + + @Nullable + default String getCommentHintValue(String comment) { + return comment; + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java new file mode 100644 index 000000000..c5ec418fb --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java @@ -0,0 +1,43 @@ +/* + * Copyright 2014-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; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to assign metadata to repository operations. + * + * @author Greg Turnquist + * @since 3.0 + * @see org.springframework.data.jpa.repository.query.Meta + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Documented +public @interface Meta { + + /** + * Add a comment to the query. + * + * @return empty {@link String} by default. + */ + String comment() default ""; + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java index 7fe6bafde..3e3dbd7aa 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java @@ -187,6 +187,15 @@ public abstract class AbstractJpaQuery implements RepositoryQuery { } } + // Apply any meta-attributes that exist + if (method.hasQueryMetaAttributes()) { + + if (provider.getCommentHintKey() != null) { + query.setHint( // + provider.getCommentHintKey(), provider.getCommentHintValue(method.getQueryMetaAttributes().getComment())); + } + } + return query; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java index d510577d3..883e60021 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java @@ -24,6 +24,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; @@ -32,6 +33,7 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.jpa.provider.QueryExtractor; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Meta; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; @@ -46,6 +48,7 @@ import org.springframework.data.util.Lazy; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.StringUtils; /** @@ -94,6 +97,7 @@ public class JpaQueryMethod extends QueryMethod { private final Lazy isCollectionQuery; private final Lazy isProcedureQuery; private final Lazy> entityMetadata; + private final Map, Optional> annotationCache; /** * Creates a {@link JpaQueryMethod}. @@ -135,6 +139,7 @@ public class JpaQueryMethod extends QueryMethod { this.isCollectionQuery = Lazy.of(() -> super.isCollectionQuery() && !NATIVE_ARRAY_TYPES.contains(this.returnType)); this.isProcedureQuery = Lazy.of(() -> AnnotationUtils.findAnnotation(method, Procedure.class) != null); this.entityMetadata = Lazy.of(() -> new DefaultJpaEntityMetadata<>(getDomainClass())); + this.annotationCache = new ConcurrentReferenceHashMap<>(); Assert.isTrue(!(isModifyingQuery() && getParameters().hasSpecialParameter()), String.format("Modifying method must not contain %s", Parameters.TYPES)); @@ -193,6 +198,13 @@ public class JpaQueryMethod extends QueryMethod { return modifying.getNullable() != null; } + @SuppressWarnings("unchecked") + private Optional doFindAnnotation(Class annotationType) { + + return (Optional) this.annotationCache.computeIfAbsent(annotationType, + it -> Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(method, it))); + } + /** * Returns all {@link QueryHint}s annotated at this class. Note, that {@link QueryHints} * @@ -259,6 +271,47 @@ public class JpaQueryMethod extends QueryMethod { return returnType; } + /** + * @return return true if {@link Meta} annotation is available. + * @since 3.0 + */ + public boolean hasQueryMetaAttributes() { + return getMetaAnnotation() != null; + } + + /** + * Returns the {@link Meta} annotation that is applied to the method or {@code null} if not available. + * + * @return + * @since 3.0 + */ + @Nullable + Meta getMetaAnnotation() { + return doFindAnnotation(Meta.class).orElse(null); + } + + /** + * Returns the {@link org.springframework.data.jpa.repository.query.Meta} attributes to be applied. + * + * @return never {@literal null}. + * @since 1.6 + */ + public org.springframework.data.jpa.repository.query.Meta getQueryMetaAttributes() { + + Meta meta = getMetaAnnotation(); + if (meta == null) { + return new org.springframework.data.jpa.repository.query.Meta(); + } + + org.springframework.data.jpa.repository.query.Meta metaAttributes = new org.springframework.data.jpa.repository.query.Meta(); + + if (StringUtils.hasText(meta.comment())) { + metaAttributes.setComment(meta.comment()); + } + + return metaAttributes; + } + /** * Returns the query string declared in a {@link Query} annotation or {@literal null} if neither the annotation found * nor the attribute was specified. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java new file mode 100644 index 000000000..73eafc218 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java @@ -0,0 +1,118 @@ +/* + * Copyright 2014-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 java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Value object to hold metadata about repository methods. + * + * @author Greg Turnquist + * @since 3.0 + * @see org.springframework.data.jpa.repository.Meta + */ +public class Meta { + + private enum MetaKey { + COMMENT("comment"); + + private String key; + + MetaKey(String key) { + this.key = key; + } + } + + private Map values = Collections.emptyMap(); + + public Meta() {} + + /** + * Copy a {@link Meta} object. + * + * @since 3.0 + * @param source + */ + Meta(Meta source) { + this.values = new LinkedHashMap<>(source.values); + } + + /** + * Add a comment to the query that is propagated to the profile log. + * + * @param comment + */ + public void setComment(String comment) { + setValue(MetaKey.COMMENT.key, comment); + } + + /** + * @return {@literal null} if not set. + */ + @Nullable + public String getComment() { + return getValue(MetaKey.COMMENT.key); + } + + /** + * @return + */ + public boolean hasValues() { + return !this.values.isEmpty(); + } + + /** + * Get {@link Iterable} of set meta values. + * + * @return + */ + public Iterable> values() { + return Collections.unmodifiableSet(this.values.entrySet()); + } + + /** + * Sets or removes the value in case of {@literal null} or empty {@link String}. + * + * @param key must not be {@literal null} or empty. + * @param value + */ + void setValue(String key, @Nullable Object value) { + + Assert.hasText(key, "Meta key must not be 'null' or blank"); + + if (values == Collections.EMPTY_MAP) { + values = new LinkedHashMap<>(2); + } + + if (value == null || (value instanceof String && !StringUtils.hasText((String) value))) { + this.values.remove(key); + } + this.values.put(key, value); + } + + @Nullable + @SuppressWarnings("unchecked") + private T getValue(String key) { + return (T) this.values.get(key); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java index 4aabf7d24..e5c2dbf6a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java @@ -15,11 +15,11 @@ */ package org.springframework.data.jpa.repository.support; +import jakarta.persistence.LockModeType; + import java.lang.reflect.Method; import java.util.Optional; -import jakarta.persistence.LockModeType; - import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.lang.Nullable; @@ -32,6 +32,7 @@ import org.springframework.lang.Nullable; * @author Christoph Strobl * @author Mark Paluch * @author Jens Schauder + * @author Greg Turnquist */ public interface CrudMethodMetadata { @@ -59,6 +60,14 @@ public interface CrudMethodMetadata { */ QueryHints getQueryHintsForCount(); + /** + * Returns query comment to be applied to query. + * + * @return + * @since 3.0 + */ + String getComment(); + /** * Returns the {@link EntityGraph} to be used. * diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java index 5b286814a..006e883ff 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java @@ -15,6 +15,9 @@ */ package org.springframework.data.jpa.repository.support; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; + import java.lang.reflect.Method; import java.util.HashSet; import java.util.Optional; @@ -23,9 +26,6 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Predicate; -import jakarta.persistence.LockModeType; -import jakarta.persistence.QueryHint; - import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.springframework.aop.TargetSource; @@ -36,6 +36,7 @@ import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Meta; import org.springframework.data.jpa.repository.QueryHints; import org.springframework.data.repository.core.RepositoryInformation; import org.springframework.data.repository.core.support.RepositoryProxyPostProcessor; @@ -180,6 +181,7 @@ class CrudMethodMetadataPostProcessor implements RepositoryProxyPostProcessor, B private final @Nullable LockModeType lockModeType; private final org.springframework.data.jpa.repository.support.QueryHints queryHints; private final org.springframework.data.jpa.repository.support.QueryHints queryHintsForCount; + private final String comment; private final Optional entityGraph; private final Method method; @@ -195,6 +197,7 @@ class CrudMethodMetadataPostProcessor implements RepositoryProxyPostProcessor, B this.lockModeType = findLockModeType(method); this.queryHints = findQueryHints(method, it -> true); this.queryHintsForCount = findQueryHints(method, QueryHints::forCounting); + this.comment = findComment(method); this.entityGraph = findEntityGraph(method); this.method = method; } @@ -233,6 +236,13 @@ class CrudMethodMetadataPostProcessor implements RepositoryProxyPostProcessor, B return queryHints; } + @Nullable + private static String findComment(Method method) { + + Meta annotation = AnnotatedElementUtils.findMergedAnnotation(method, Meta.class); + return annotation == null ? null : (String) AnnotationUtils.getValue(annotation, "comment"); + } + @Nullable @Override public LockModeType getLockModeType() { @@ -249,6 +259,11 @@ class CrudMethodMetadataPostProcessor implements RepositoryProxyPostProcessor, B return queryHintsForCount; } + @Override + public String getComment() { + return comment; + } + @Override public Optional getEntityGraph() { return entityGraph; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java index f968c86ff..0b0125bc5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java @@ -238,6 +238,8 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation implements JpaRepositoryImplementation implements JpaRepositoryImplementation hints = new HashMap<>(); + getQueryHints().withFetchGraphs(em).forEach(hints::put); + if (metadata.getComment() != null && provider.getCommentHintKey() != null) { + hints.put(provider.getCommentHintKey(), provider.getCommentHintValue(metadata.getComment())); + } + return Optional.ofNullable(type == null ? em.find(domainType, id, hints) : em.find(domainType, id, type, hints)); } @@ -355,6 +367,15 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation query = em.createQuery(existsQuery, Long.class); + Map hints = new HashMap<>(); + getQueryHints().withFetchGraphs(em).forEach(hints::put); + + if (metadata.getComment() != null && provider.getCommentHintKey() != null) { + hints.put(provider.getCommentHintKey(), provider.getCommentHintValue(metadata.getComment())); + } + + hints.forEach(query::setHint); + if (!entityInformation.hasCompositeId()) { query.setParameter(idAttributeNames.iterator().next(), id); return query.getSingleResult() == 1L; @@ -556,9 +577,7 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation> finder = sort -> { - return getQuery(spec, getDomainClass(), sort); - }; + Function> finder = sort -> getQuery(spec, getDomainClass(), sort); FetchableFluentQuery fluentQuery = new FetchableFluentQueryBySpecification(spec, getDomainClass(), Sort.unsorted(), null, finder, this::count, this::exists, this.em); @@ -568,7 +587,12 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation query = em.createQuery(getCountQueryString(), Long.class); + + applyQueryHintsForCount(query); + + return query.getSingleResult(); } @Override @@ -804,7 +828,12 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation TypedQuery applyRepositoryMethodMetadataForCount(TypedQuery query) { @@ -819,7 +848,12 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation testAppender; + + @BeforeEach + void setUp() { + + testAppender = new ListAppender<>(); + testAppender.start(); + testLogger.setLevel(Level.DEBUG); + testLogger.addAppender(testAppender); + } + + @AfterEach + void clearUp() { + testLogger.detachAppender(testAppender); + } + + @Test // GH-775 + void findAllShouldLogAComment() { + + repository.findAll(); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void findByIdShouldNotLogAComment() { + + repository.findById(0); + + assertNoComments(); + } + + @Test // GH-775 + void existsByIdShouldLogAComment() { + + repository.existsById(0); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void customFinderShouldLogAComment() { + + repository.findByName("name"); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void findOneWithExampleShouldLogAComment() { + + repository.findOne(Example.of(new Role())); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void findAllWithExampleShouldLogAComment() { + + repository.findAll(Example.of(new Role())); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void findAllWithExampleAndSortShouldLogAComment() { + + repository.findAll(Example.of(new Role()), Sort.by("name")); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void findByFluentDslWithExampleShouldLogAComment() { + + repository.findBy(Example.of(new Role()), FluentQuery.FetchableFluentQuery::all); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void existsByExampleShouldLogAComment() { + + repository.exists(Example.of(new Role())); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void countShouldLogAComment() { + + repository.count(); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void customCountShouldLogAComment() { + + repository.countByName("name"); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void deleteAllByIdInBatchShouldLogAComment() { + + repository.deleteAllByIdInBatch(List.of(0, 1, 2)); + + assertAtLeastOneComment(); + } + + @Test // GH-775 + void deleteAllInBatchShouldLogAComment() { + + repository.deleteAllInBatch(); + + assertAtLeastOneComment(); + } + + private final static Predicate hasComment = s -> s.startsWith("/* foobar */"); + + private void assertAtLeastOneComment() { + assertThat(testAppender.list).extracting(ILoggingEvent::getFormattedMessage) + .haveAtLeastOne(new Condition(hasComment, "SQL contains a comment")); + } + + private void assertNoComments() { + assertThat(testAppender.list).extracting(ILoggingEvent::getFormattedMessage).noneMatch(hasComment); + } + + @Configuration + @EnableJpaRepositories(basePackages = "org.springframework.data.jpa.repository.sample") + static class Config { + + @Bean + public DataSource dataSource() { + return new EmbeddedDatabaseBuilder().generateUniqueName(true).build(); + } + + @Bean + public Properties jpaProperties() { + + Properties properties = new Properties(); + properties.setProperty("hibernate.use_sql_comments", "true"); + return properties; + } + + @Bean + public AbstractJpaVendorAdapter vendorAdaptor() { + + HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + vendorAdapter.setGenerateDdl(true); + vendorAdapter.setDatabase(Database.HSQL); + return vendorAdapter; + } + + @Bean + public EntityManagerFactory entityManagerFactory() { + + LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); + factory.setDataSource(dataSource()); + factory.setPersistenceUnitName("spring-data-jpa"); + factory.setJpaVendorAdapter(vendorAdaptor()); + factory.setJpaProperties(jpaProperties()); + factory.afterPropertiesSet(); + return factory.getObject(); + } + + @Bean + public JpaDialect jpaDialect() { + return new HibernateJpaDialect(); + } + + @Bean + public PlatformTransactionManager transactionManager() { + return new JpaTransactionManager(entityManagerFactory()); + } + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java new file mode 100644 index 000000000..b2ecca217 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2013-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 java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.jpa.provider.QueryExtractor; +import org.springframework.data.jpa.repository.Meta; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; + +/** + * Verify that {@link Meta}-annotated methods property capture comments. + * + * @author Greg Turnquist + * @since 3.0 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class MetaAnnotatedQueryMethodUnitTests { + + @Mock QueryExtractor extractor; + + private ProjectionFactory factory = new SpelAwareProxyProjectionFactory(); + + @Test // GH-775 + void metaAnnotationCommentsAreCapturedInJpaQueryMethod() throws Exception { + + Method method = UserRepository.class.getMethod("metaMethod"); + DefaultRepositoryMetadata repositoryMetadata = new DefaultRepositoryMetadata(UserRepository.class); + JpaQueryMethod jpaQueryMethod = new JpaQueryMethod(method, repositoryMetadata, factory, extractor); + + assertThat(jpaQueryMethod.getQueryMetaAttributes().getComment()).isEqualTo("Comments embedded in SQL"); + } + + interface UserRepository extends CrudRepository { + + @Meta(comment = "Comments embedded in SQL") + void metaMethod(); + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java new file mode 100644 index 000000000..79b4dbe13 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java @@ -0,0 +1,101 @@ +/* + * Copyright 2008-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.sample; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.sample.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Meta; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.query.FluentQuery; + +import com.querydsl.core.types.Predicate; + +/** + * Typed repository for {@link Role} but with {@link Meta} annotations applied. + * + * @author Greg Turnquist + * @since 3.0 + */ +public interface RoleRepositoryWithMeta extends JpaRepository, QuerydslPredicateExecutor { + + // Finders + + @Override + @Meta(comment = "foobar") + List findAll(); + + @Override + @Meta(comment = "foobar") + Optional findById(Integer id); + + @Override + @Meta(comment = "foobar") + Optional findOne(Predicate predicate); + + @Meta(comment = "foobar") + List findByName(String name); + + @Override + @Meta(comment = "foobar") + Optional findOne(Example example); + + @Override + @Meta(comment = "foobar") + List findAll(Example example); + + @Override + @Meta(comment = "foobar") + List findAll(Example example, Sort sort); + + @Override + @Meta(comment = "foobar") + R findBy(Example example, Function, R> queryFunction); + + // counters + + @Override + @Meta(comment = "foobar") + long count(); + + @Meta(comment = "foobar") + long countByName(String name); + + // exists + + @Override + @Meta(comment = "foobar") + boolean existsById(Integer integer); + + @Override + @Meta(comment = "foobar") + boolean exists(Example example); + + // delete + + @Override + @Meta(comment = "foobar") + void deleteAllInBatch(); + + @Override + @Meta(comment = "foobar") + void deleteAllByIdInBatch(Iterable integers); +} diff --git a/src/main/asciidoc/jpa.adoc b/src/main/asciidoc/jpa.adoc index 125dd5094..3688ca1a5 100644 --- a/src/main/asciidoc/jpa.adoc +++ b/src/main/asciidoc/jpa.adoc @@ -726,6 +726,139 @@ public interface UserRepository extends Repository { ==== The preceding declaration would apply the configured `@QueryHint` for that actually query but omit applying it to the count query triggered to calculate the total number of pages. +[[jpa.query-hints.comments]] +==== Adding Comments to Queries +Sometimes, you need to debug a query based upon database performance. +The query your database administrator shows you may look VERY different than what you wrote using `@Query`, or it may look +nothing like what you presume Spring Data JPA has generated regarding a custom finder or if you used query by example. + +To make this process easier, you can insert custom comments into almost any JPA operation, whether its a query or other operation +by applying the `@Meta` annotation. + +.Apply `@Meta` annotation to repository operations +==== +[source, java] +---- +public interface RoleRepository extends JpaRepository { + + @Meta(comment = "find roles by name") + List findByName(String name); + + @Override + @Meta(comment = "find roles using QBE") + List findAll(Example example); + + @Meta(comment = "count roles for a given name") + long countByName(String name); + + @Override + @Meta(comment = "exists based on QBE") + boolean exists(Example example); +} +---- +==== + +This sample repository has a mixture of custom finders as well as overriding the inherited operations from `JpaRepository`. +Either way, the `@Meta` annotation lets you add a `comment` that will be inserted into queries before they are sent to the database. + +It's also important to note that this feature isn't confined solely to queries. It extends to the `count` and `exists` operations. +And while not shown, it also extends to certain `delete` operations. + +IMPORTANT: While we have attempted to apply this feature everywhere possible, some operations of the underlying `EntityManager` don't support comments. For example, `entityManager.createQuery()` is clearly documented as supporting comments, but `entityManager.find()` operations do not. + +Neither JPQL logging nor SQL logging is a standard in JPA, so each provider requires custom configuration, as shown the sections below. + +===== Activating Hibernate comments +To activate query comments in Hibernate, you must set `hibernate.use_sql_comments` to `true`. + +If you are using Java-based configuration settings, this can be done like this: + +.Java-based JPA configuration +==== +[source, java] +---- +@Bean +public Properties jpaProperties() { + + Properties properties = new Properties(); + properties.setProperty("hibernate.use_sql_comments", "true"); + return properties; +} +---- +==== + +If you have a `persistence.xml` file, you can apply it there: + +.`persistence.xml`-based configuration +==== +[source, xml] +---- + + + ...registered classes... + + + + + +---- +==== + +Finally, if you are using Spring Boot, then you can set it up inside your `application.properties` file: + +.Spring Boot property-based configuration +==== +---- +spring.jpa.properties.hibernate.use_sql_comments=true +---- +==== + +===== Activating EclipseLink comments +To activate query comments in EclipseLink, you must set `eclipselink.logging.level.sql` to `FINE`. + +If you are using Java-based configuration settings, this can be done like this: + +.Java-based JPA configuration +==== +[source, java] +---- +@Bean +public Properties jpaProperties() { + + Properties properties = new Properties(); + properties.setProperty("eclipselink.logging.level.sql", "FINE"); + return properties; +} +---- +==== + +If you have a `persistence.xml` file, you can apply it there: + +.`persistence.xml`-based configuration +==== +[source, xml] +---- + + + ...registered classes... + + + + + +---- +==== + +Finally, if you are using Spring Boot, then you can set it up inside your `application.properties` file: + +.Spring Boot property-based configuration +==== +---- +spring.jpa.properties.eclipselink.logging.level.sql=FINE +---- +==== + + [[jpa.entity-graph]] === Configuring Fetch- and LoadGraphs