diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java index b9b3eeb1a..6bf3a5186 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java @@ -40,6 +40,9 @@ abstract class AotQuery { */ public abstract boolean isNative(); + /** + * @return the list of parameter bindings. + */ public List getParameterBindings() { return parameterBindings; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java index 8c0abf97a..f5c9d16ed 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java @@ -15,10 +15,16 @@ */ package org.springframework.data.jpa.repository.aot; +import jakarta.persistence.Tuple; + import java.lang.reflect.Method; +import java.util.Collection; +import java.util.stream.Stream; import org.jspecify.annotations.Nullable; +import org.springframework.core.CollectionFactory; +import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.expression.ValueEvaluationContextProvider; import org.springframework.data.expression.ValueExpression; @@ -26,6 +32,7 @@ import org.springframework.data.jpa.repository.query.DeclaredQuery; import org.springframework.data.jpa.repository.query.JpaParameters; import org.springframework.data.jpa.repository.query.QueryEnhancer; import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; +import org.springframework.data.jpa.util.TupleBackedMap; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; @@ -104,6 +111,52 @@ public class AotRepositoryFragmentSupport { return expression.evaluate(contextProvider.getEvaluationContext(args, expression.getExpressionDependencies())); } + protected @Nullable T convertOne(@Nullable Object result, boolean nativeQuery, Class projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return projection.cast(result); + } + + return projectionFactory.createProjection(projection, + result instanceof Tuple t ? new TupleBackedMap(nativeQuery ? TupleBackedMap.underscoreAware(t) : t) : result); + } + + protected @Nullable Object convertMany(@Nullable Object result, boolean nativeQuery, Class projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return result; + } + + if (result instanceof Stream stream) { + return stream.map(it -> convertOne(it, nativeQuery, projection)); + } + + if (result instanceof Slice slice) { + return slice.map(it -> convertOne(it, nativeQuery, projection)); + } + + if (result instanceof Collection collection) { + + Collection<@Nullable Object> target = CollectionFactory.createCollection(collection.getClass(), + collection.size()); + for (Object o : collection) { + target.add(convertOne(o, nativeQuery, projection)); + } + + return target; + } + + throw new UnsupportedOperationException("Cannot create projection for %s".formatted(result)); + } + private record DefaultQueryRewriteInformation(Sort sort, ReturnedType returnedType) implements QueryEnhancer.QueryRewriteInformation { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/EntityGraphLookup.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/EntityGraphLookup.java new file mode 100644 index 000000000..7e715f9e2 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/EntityGraphLookup.java @@ -0,0 +1,114 @@ +/* + * Copyright 2025 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.aot; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManagerFactory; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.query.JpaQueryMethod; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.util.StringUtils; + +/** + * Factory for {@link AotEntityGraph}. + * + * @author Mark Paluch + * @since 4.0 + */ +class EntityGraphLookup { + + private final EntityManagerFactory entityManagerFactory; + + public EntityGraphLookup(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + @SuppressWarnings("unchecked") + public @Nullable AotEntityGraph findEntityGraph(MergedAnnotation entityGraph, + RepositoryInformation information, ReturnedType returnedType, JpaQueryMethod queryMethod) { + + if (!entityGraph.isPresent()) { + return null; + } + + EntityGraph.EntityGraphType type = entityGraph.getEnum("type", EntityGraph.EntityGraphType.class); + String[] attributePaths = entityGraph.getStringArray("attributePaths"); + Collection entityGraphNames = getEntityGraphNames(entityGraph, information, queryMethod); + List> candidates = Arrays.asList(returnedType.getDomainType(), returnedType.getReturnedType(), + returnedType.getTypeToRead()); + + for (Class candidate : candidates) { + + Map> namedEntityGraphs = entityManagerFactory + .getNamedEntityGraphs(Class.class.cast(candidate)); + + if (namedEntityGraphs.isEmpty()) { + continue; + } + + for (String entityGraphName : entityGraphNames) { + if (namedEntityGraphs.containsKey(entityGraphName)) { + return new AotEntityGraph(entityGraphName, type, Collections.emptyList()); + } + } + } + + if (attributePaths.length > 0) { + return new AotEntityGraph(null, type, Arrays.asList(attributePaths)); + } + + return null; + } + + private Set getEntityGraphNames(MergedAnnotation entityGraph, RepositoryInformation information, + JpaQueryMethod queryMethod) { + + Set entityGraphNames = new LinkedHashSet<>(); + String value = entityGraph.getString("value"); + + if (StringUtils.hasText(value)) { + entityGraphNames.add(value); + } + entityGraphNames.add(queryMethod.getNamedQueryName()); + entityGraphNames.add(getFallbackEntityGraphName(information, queryMethod)); + return entityGraphNames; + } + + private String getFallbackEntityGraphName(RepositoryInformation information, JpaQueryMethod queryMethod) { + + Class domainType = information.getDomainType(); + Entity entity = AnnotatedElementUtils.findMergedAnnotation(domainType, Entity.class); + String entityName = entity != null && StringUtils.hasText(entity.name()) ? entity.name() + : domainType.getSimpleName(); + + return entityName + "." + queryMethod.getName(); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java index 7b906e937..8dba82710 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java @@ -18,6 +18,7 @@ package org.springframework.data.jpa.repository.aot; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import jakarta.persistence.QueryHint; +import jakarta.persistence.Tuple; import java.lang.reflect.Type; import java.util.List; @@ -30,6 +31,7 @@ import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.data.jpa.repository.QueryHints; @@ -37,6 +39,7 @@ import org.springframework.data.jpa.repository.query.DeclaredQuery; import org.springframework.data.jpa.repository.query.JpaQueryMethod; import org.springframework.data.jpa.repository.query.ParameterBinding; import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.CodeBlock.Builder; @@ -134,6 +137,11 @@ class JpaCodeBlocks { Class actualReturnType = isProjecting ? context.getActualReturnType().toClass() : context.getRepositoryInformation().getDomainType(); + String dynamicReturnType = null; + if (queryMethod.getParameters().hasDynamicProjection()) { + dynamicReturnType = context.getParameterName(queryMethod.getParameters().getDynamicProjectionIndex()); + } + CodeBlock.Builder builder = CodeBlock.builder(); builder.add("\n"); @@ -159,15 +167,16 @@ class JpaCodeBlocks { sortParameterName = "%s.getSort()".formatted(context.getPageableParameterName()); } - if (StringUtils.hasText(sortParameterName) && queries.result() instanceof StringAotQuery) { - builder.add(applySorting(sortParameterName, queryStringNameVariableName, actualReturnType)); + if ((StringUtils.hasText(sortParameterName) || StringUtils.hasText(dynamicReturnType)) + && queries.result() instanceof StringAotQuery) { + builder.add(applyRewrite(sortParameterName, dynamicReturnType, queryStringNameVariableName, actualReturnType)); } if (queries.result().hasExpression() || queries.count().hasExpression()) { builder.addStatement("class ExpressionMarker{}"); } - builder.add(createQuery(queryVariableName, queryStringNameVariableName, queries.result(), + builder.add(createQuery(false, queryVariableName, queryStringNameVariableName, queries.result(), this.sqlResultSetMapping, this.queryHints, this.entityGraph, this.queryReturnType)); builder.add(applyLimits(queries.result().isExists())); @@ -178,7 +187,7 @@ class JpaCodeBlocks { boolean queryHints = this.queryHints.isPresent() && this.queryHints.getBoolean("forCounting"); - builder.add(createQuery(countQueryVariableName, countQueryStringNameVariableName, queries.count(), null, + builder.add(createQuery(true, countQueryVariableName, countQueryStringNameVariableName, queries.count(), null, queryHints ? this.queryHints : MergedAnnotation.missing(), null, Long.class)); builder.addStatement("return ($T) $L.getSingleResult()", Long.class, countQueryVariableName); @@ -190,16 +199,33 @@ class JpaCodeBlocks { return builder.build(); } - private CodeBlock applySorting(String sort, String queryString, Class actualReturnType) { + private CodeBlock applyRewrite(@Nullable String sort, @Nullable String dynamicReturnType, String queryString, + Class actualReturnType) { Builder builder = CodeBlock.builder(); - builder.beginControlFlow("if ($L.isSorted())", sort); + + boolean hasSort = StringUtils.hasText(sort); + if (hasSort) { + builder.beginControlFlow("if ($L.isSorted())", sort); + } builder.addStatement("$T declaredQuery = $T.$L($L)", DeclaredQuery.class, DeclaredQuery.class, queries != null && queries.isNative() ? "nativeQuery" : "jpqlQuery", queryString); - builder.addStatement("$L = rewriteQuery(declaredQuery, $L, $T.class)", queryString, sort, actualReturnType); - builder.endControlFlow(); + boolean hasDynamicReturnType = StringUtils.hasText(dynamicReturnType); + + if (hasSort && hasDynamicReturnType) { + builder.addStatement("$L = rewriteQuery(declaredQuery, $L, $L)", queryString, sort, dynamicReturnType); + } else if (hasSort) { + builder.addStatement("$L = rewriteQuery(declaredQuery, $L, $T.class)", queryString, sort, actualReturnType); + } else if (hasDynamicReturnType) { + builder.addStatement("$L = rewriteQuery(declaredQuery, $T.unsorted(), $L)", queryString, Sort.class, + dynamicReturnType); + } + + if (hasSort) { + builder.endControlFlow(); + } return builder.build(); } @@ -241,14 +267,14 @@ class JpaCodeBlocks { return builder.build(); } - private CodeBlock createQuery(String queryVariableName, @Nullable String queryStringNameVariableName, + private CodeBlock createQuery(boolean count, String queryVariableName, @Nullable String queryStringNameVariableName, AotQuery query, @Nullable String sqlResultSetMapping, MergedAnnotation queryHints, @Nullable AotEntityGraph entityGraph, @Nullable Class queryReturnType) { Builder builder = CodeBlock.builder(); - builder.add( - doCreateQuery(queryVariableName, queryStringNameVariableName, query, sqlResultSetMapping, queryReturnType)); + builder.add(doCreateQuery(count, queryVariableName, queryStringNameVariableName, query, sqlResultSetMapping, + queryReturnType)); if (entityGraph != null) { builder.add(applyEntityGraph(entityGraph, queryVariableName)); @@ -279,12 +305,14 @@ class JpaCodeBlocks { return builder.build(); } - private CodeBlock doCreateQuery(String queryVariableName, @Nullable String queryStringNameVariableName, - AotQuery query, @Nullable String sqlResultSetMapping, @Nullable Class queryReturnType) { + private CodeBlock doCreateQuery(boolean count, String queryVariableName, + @Nullable String queryStringNameVariableName, AotQuery query, @Nullable String sqlResultSetMapping, + @Nullable Class queryReturnType) { + ReturnedType returnedType = context.getReturnedType(); Builder builder = CodeBlock.builder(); - if (query instanceof StringAotQuery) { + if (query instanceof StringAotQuery sq) { if (StringUtils.hasText(sqlResultSetMapping)) { @@ -294,24 +322,48 @@ class JpaCodeBlocks { return builder.build(); } - if (query.isNative() && queryReturnType != null) { + if (query.isNative()) { - builder.addStatement("$T $L = this.$L.createNativeQuery($L, $T.class)", Query.class, queryVariableName, - context.fieldNameOf(EntityManager.class), queryStringNameVariableName, queryReturnType); + if (queryReturnType != null) { + + builder.addStatement("$T $L = this.$L.createNativeQuery($L, $T.class)", Query.class, queryVariableName, + context.fieldNameOf(EntityManager.class), queryStringNameVariableName, queryReturnType); + } else { + builder.addStatement("$T $L = this.$L.createNativeQuery($L)", Query.class, queryVariableName, + context.fieldNameOf(EntityManager.class), queryStringNameVariableName); + } return builder.build(); } - builder.addStatement("$T $L = this.$L.$L($L)", Query.class, queryVariableName, - context.fieldNameOf(EntityManager.class), query.isNative() ? "createNativeQuery" : "createQuery", - queryStringNameVariableName); + if (sq.hasConstructorExpressionOrDefaultProjection() && !count && returnedType.isProjecting() + && returnedType.getReturnedType().isInterface()) { + builder.addStatement("$T $L = this.$L.createQuery($L)", Query.class, queryVariableName, + context.fieldNameOf(EntityManager.class), queryStringNameVariableName); + } else { + + String createQueryMethod = query.isNative() ? "createNativeQuery" : "createQuery"; + + if (!sq.hasConstructorExpressionOrDefaultProjection() && !count && returnedType.isProjecting() + && returnedType.getReturnedType().isInterface()) { + builder.addStatement("$T $L = this.$L.$L($L, $T.class)", Query.class, queryVariableName, + context.fieldNameOf(EntityManager.class), createQueryMethod, queryStringNameVariableName, Tuple.class); + } else { + builder.addStatement("$T $L = this.$L.$L($L)", Query.class, queryVariableName, + context.fieldNameOf(EntityManager.class), createQueryMethod, queryStringNameVariableName); + } + } return builder.build(); } if (query instanceof NamedAotQuery nq) { - if (queryReturnType != null) { + if (!count && returnedType.isProjecting() && returnedType.getReturnedType().isInterface()) { + builder.addStatement("$T $L = this.$L.createNamedQuery($S)", Query.class, queryVariableName, + context.fieldNameOf(EntityManager.class), nq.getName()); + return builder.build(); + } else if (queryReturnType != null) { builder.addStatement("$T $L = this.$L.createNamedQuery($S, $T.class)", Query.class, queryVariableName, context.fieldNameOf(EntityManager.class), nq.getName(), queryReturnType); @@ -512,30 +564,68 @@ class JpaCodeBlocks { builder.addStatement("return !$L.getResultList().isEmpty()", queryVariableName); } else { - if (queryMethod.isCollectionQuery()) { - builder.addStatement("return ($T) query.getResultList()", context.getReturnTypeName()); - } else if (queryMethod.isStreamQuery()) { - builder.addStatement("return ($T) query.getResultStream()", context.getReturnTypeName()); - } else if (queryMethod.isPageQuery()) { - builder.addStatement("return $T.getPage(($T<$T>) $L.getResultList(), $L, countAll)", - PageableExecutionUtils.class, List.class, actualReturnType, queryVariableName, - context.getPageableParameterName()); - } else if (queryMethod.isSliceQuery()) { - builder.addStatement("$T<$T> resultList = $L.getResultList()", List.class, actualReturnType, - queryVariableName); - builder.addStatement("boolean hasNext = $L.isPaged() && resultList.size() > $L.getPageSize()", - context.getPageableParameterName(), context.getPageableParameterName()); - builder.addStatement( - "return new $T<>(hasNext ? resultList.subList(0, $L.getPageSize()) : resultList, $L, hasNext)", - SliceImpl.class, context.getPageableParameterName(), context.getPageableParameterName()); + if (context.getReturnedType().isProjecting()) { + + TypeName queryResultType = TypeName.get(context.getActualReturnType().toClass()); + + if (queryMethod.isCollectionQuery()) { + builder.addStatement("return ($T) convertMany(query.getResultList(), $L, $T.class)", + context.getReturnTypeName(), aotQuery.isNative(), queryResultType); + } else if (queryMethod.isStreamQuery()) { + builder.addStatement("return ($T) convertMany(query.getResultStream(), $L, $T.class)", + context.getReturnTypeName(), aotQuery.isNative(), queryResultType); + } else if (queryMethod.isPageQuery()) { + builder.addStatement( + "return $T.getPage(($T<$T>) convertMany($L.getResultList(), $L, $T.class), $L, countAll)", + PageableExecutionUtils.class, List.class, actualReturnType, queryVariableName, aotQuery.isNative(), + queryResultType, context.getPageableParameterName()); + } else if (queryMethod.isSliceQuery()) { + builder.addStatement("$T<$T> resultList = ($T<$T>) convertMany($L.getResultList(), $L, $T.class)", + List.class, actualReturnType, List.class, actualReturnType, queryVariableName, aotQuery.isNative(), + queryResultType); + builder.addStatement("boolean hasNext = $L.isPaged() && resultList.size() > $L.getPageSize()", + context.getPageableParameterName(), context.getPageableParameterName()); + builder.addStatement( + "return new $T<>(hasNext ? resultList.subList(0, $L.getPageSize()) : resultList, $L, hasNext)", + SliceImpl.class, context.getPageableParameterName(), context.getPageableParameterName()); + } else { + + if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) { + builder.addStatement("return $T.ofNullable(($T) convertOne($L.getSingleResultOrNull(), $L, $T.class))", + Optional.class, actualReturnType, queryVariableName, aotQuery.isNative(), queryResultType); + } else { + builder.addStatement("return ($T) convertOne($L.getSingleResultOrNull(), $L, $T.class)", + context.getReturnTypeName(), queryVariableName, aotQuery.isNative(), queryResultType); + } + } + } else { - if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) { - builder.addStatement("return $T.ofNullable(($T) $L.getSingleResultOrNull())", Optional.class, - actualReturnType, queryVariableName); - } else { - builder.addStatement("return ($T) $L.getSingleResultOrNull()", context.getReturnTypeName(), + if (queryMethod.isCollectionQuery()) { + builder.addStatement("return ($T) query.getResultList()", context.getReturnTypeName()); + } else if (queryMethod.isStreamQuery()) { + builder.addStatement("return ($T) query.getResultStream()", context.getReturnTypeName()); + } else if (queryMethod.isPageQuery()) { + builder.addStatement("return $T.getPage(($T<$T>) $L.getResultList(), $L, countAll)", + PageableExecutionUtils.class, List.class, actualReturnType, queryVariableName, + context.getPageableParameterName()); + } else if (queryMethod.isSliceQuery()) { + builder.addStatement("$T<$T> resultList = $L.getResultList()", List.class, actualReturnType, queryVariableName); + builder.addStatement("boolean hasNext = $L.isPaged() && resultList.size() > $L.getPageSize()", + context.getPageableParameterName(), context.getPageableParameterName()); + builder.addStatement( + "return new $T<>(hasNext ? resultList.subList(0, $L.getPageSize()) : resultList, $L, hasNext)", + SliceImpl.class, context.getPageableParameterName(), context.getPageableParameterName()); + } else { + + if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) { + builder.addStatement("return $T.ofNullable(($T) $L.getSingleResultOrNull())", Optional.class, + actualReturnType, queryVariableName); + } else { + builder.addStatement("return ($T) $L.getSingleResultOrNull()", context.getReturnTypeName(), + queryVariableName); + } } } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java index 732441f3a..157e77c5e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java @@ -15,46 +15,22 @@ */ package org.springframework.data.jpa.repository.aot; -import jakarta.persistence.Entity; import jakarta.persistence.EntityManager; -import jakarta.persistence.EntityManagerFactory; -import jakarta.persistence.Tuple; -import jakarta.persistence.TypedQueryReference; -import jakarta.persistence.metamodel.Metamodel; import java.lang.reflect.Method; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.function.Function; -import java.util.function.UnaryOperator; import org.jspecify.annotations.Nullable; -import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.data.jpa.provider.PersistenceProvider; -import org.springframework.data.jpa.provider.QueryExtractor; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryHints; -import org.springframework.data.jpa.repository.query.DeclaredQuery; -import org.springframework.data.jpa.repository.query.EntityQuery; -import org.springframework.data.jpa.repository.query.EscapeCharacter; -import org.springframework.data.jpa.repository.query.JpaCountQueryCreator; import org.springframework.data.jpa.repository.query.JpaParameters; -import org.springframework.data.jpa.repository.query.JpaQueryCreator; import org.springframework.data.jpa.repository.query.JpaQueryMethod; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider; import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; -import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; -import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder; import org.springframework.data.repository.aot.generate.AotRepositoryFragmentMetadata; import org.springframework.data.repository.aot.generate.MethodContributor; @@ -64,14 +40,11 @@ import org.springframework.data.repository.core.RepositoryInformation; import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; import org.springframework.data.repository.query.QueryMethod; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.data.repository.query.parser.PartTree; import org.springframework.data.util.TypeInformation; import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.TypeName; import org.springframework.javapoet.TypeSpec; -import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.StringUtils; /** * JPA-specific {@link RepositoryContributor} contributing an AOT repository fragment using the {@link EntityManager} @@ -85,23 +58,18 @@ import org.springframework.util.StringUtils; */ public class JpaRepositoryContributor extends RepositoryContributor { - private final EntityManagerFactory emf; - private final Metamodel metaModel; private final PersistenceProvider persistenceProvider; + private final QueriesFactory queriesFactory; + private final EntityGraphLookup entityGraphLookup; public JpaRepositoryContributor(AotRepositoryContext repositoryContext) { super(repositoryContext); - AotMetamodel amm = new AotMetamodel(repositoryContext.getResolvedTypes()); - this.metaModel = amm; - this.emf = amm.getEntityManagerFactory(); - this.persistenceProvider = PersistenceProvider.fromEntityManagerFactory(amm.getEntityManagerFactory()); - } - public JpaRepositoryContributor(AotRepositoryContext repositoryContext, EntityManagerFactory entityManagerFactory) { - super(repositoryContext); - this.emf = entityManagerFactory; - this.metaModel = entityManagerFactory.getMetamodel(); - this.persistenceProvider = PersistenceProvider.fromEntityManagerFactory(entityManagerFactory); + AotMetamodel amm = new AotMetamodel(repositoryContext.getResolvedTypes()); + + this.persistenceProvider = PersistenceProvider.fromEntityManagerFactory(amm.getEntityManagerFactory()); + this.queriesFactory = new QueriesFactory(amm, amm.getEntityManagerFactory()); + this.entityGraphLookup = new EntityGraphLookup(amm.getEntityManagerFactory()); } @Override @@ -138,18 +106,15 @@ public class JpaRepositoryContributor extends RepositoryContributor { } ReturnedType returnedType = queryMethod.getResultProcessor().getReturnedType(); - - // no interface/dynamic projections for now. - if (returnedType.isProjecting() && returnedType.getReturnedType().isInterface()) { - return null; - } - - if (queryMethod.getParameters().hasDynamicProjection()) { - return null; - } + JpaParameters parameters = queryMethod.getParameters(); // no KeysetScrolling for now. - if (queryMethod.getParameters().hasScrollPositionParameter()) { + if (parameters.hasScrollPositionParameter()) { + return null; + } + + // no dynamic projections. + if (parameters.hasDynamicProjection()) { return null; } @@ -178,12 +143,13 @@ public class JpaRepositoryContributor extends RepositoryContributor { body.add(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); - AotQueries aotQueries = getQueries(context, query, selector, queryMethod, returnedType); - AotEntityGraph aotEntityGraph = getAotEntityGraph(entityGraph, repositoryInformation, returnedType, queryMethod); + AotQueries aotQueries = queriesFactory.createQueries(context, query, selector, queryMethod, returnedType); + AotEntityGraph aotEntityGraph = entityGraphLookup.findEntityGraph(entityGraph, repositoryInformation, + returnedType, queryMethod); body.add(JpaCodeBlocks.queryBuilder(context, queryMethod).filter(aotQueries) - .queryReturnType(getQueryReturnType(aotQueries.result(), returnedType, context)).nativeQuery(nativeQuery) - .queryHints(queryHints).entityGraph(aotEntityGraph).build()); + .queryReturnType(QueriesFactory.getQueryReturnType(aotQueries.result(), returnedType, context)) + .nativeQuery(nativeQuery).queryHints(queryHints).entityGraph(aotEntityGraph).build()); body.add( JpaCodeBlocks.executionBuilder(context, queryMethod).modifying(modifying).query(aotQueries.result()).build()); @@ -192,242 +158,4 @@ public class JpaRepositoryContributor extends RepositoryContributor { }); } - private AotQueries getQueries(AotQueryMethodGenerationContext context, MergedAnnotation query, - QueryEnhancerSelector selector, JpaQueryMethod queryMethod, ReturnedType returnedType) { - - if (query.isPresent() && StringUtils.hasText(query.getString("value"))) { - return buildStringQuery(context.getRepositoryInformation().getDomainType(), returnedType, selector, query, - queryMethod); - } - - TypedQueryReference namedQuery = getNamedQuery(returnedType, queryMethod.getNamedQueryName()); - if (namedQuery != null) { - return buildNamedQuery(returnedType, selector, namedQuery, query, queryMethod); - } - - return buildPartTreeQuery(returnedType, context, query, queryMethod); - } - - private AotQueries buildStringQuery(Class domainType, ReturnedType returnedType, QueryEnhancerSelector selector, - MergedAnnotation query, JpaQueryMethod queryMethod) { - - UnaryOperator operator = s -> s.replaceAll("#\\{#entityName}", domainType.getName()); - boolean isNative = query.getBoolean("nativeQuery"); - Function queryFunction = isNative ? StringAotQuery::nativeQuery : StringAotQuery::jpqlQuery; - queryFunction = operator.andThen(queryFunction); - - String queryString = query.getString("value"); - - StringAotQuery aotStringQuery = queryFunction.apply(queryString); - String countQuery = query.getString("countQuery"); - - EntityQuery entityQuery = EntityQuery.create(aotStringQuery.getQuery(), selector); - if (entityQuery.hasConstructorExpression() || entityQuery.isDefaultProjection()) { - aotStringQuery = aotStringQuery.withReturnsDeclaredMethodType(); - } - - if (StringUtils.hasText(countQuery)) { - return AotQueries.from(aotStringQuery, queryFunction.apply(countQuery)); - } - - String namedCountQueryName = queryMethod.getNamedCountQueryName(); - TypedQueryReference namedCountQuery = getNamedQuery(returnedType, namedCountQueryName); - if (namedCountQuery != null) { - return AotQueries.from(aotStringQuery, buildNamedAotQuery(namedCountQuery, queryMethod, isNative)); - } - - String countProjection = query.getString("countProjection"); - return AotQueries.from(aotStringQuery, countProjection, selector); - } - - private AotQueries buildNamedQuery(ReturnedType returnedType, QueryEnhancerSelector selector, - TypedQueryReference namedQuery, MergedAnnotation query, JpaQueryMethod queryMethod) { - - NamedAotQuery aotQuery = buildNamedAotQuery(namedQuery, queryMethod, - query.isPresent() && query.getBoolean("nativeQuery")); - - String countQuery = query.isPresent() ? query.getString("countQuery") : null; - if (StringUtils.hasText(countQuery)) { - return AotQueries.from(aotQuery, - aotQuery.isNative() ? StringAotQuery.nativeQuery(countQuery) : StringAotQuery.jpqlQuery(countQuery)); - } - - TypedQueryReference namedCountQuery = getNamedQuery(returnedType, queryMethod.getNamedCountQueryName()); - - if (namedCountQuery != null) { - return AotQueries.from(aotQuery, buildNamedAotQuery(namedCountQuery, queryMethod, aotQuery.isNative())); - } - - String countProjection = query.isPresent() ? query.getString("countProjection") : null; - return AotQueries.from(aotQuery, it -> { - return StringAotQuery.of(aotQuery.getQueryString()).getQuery(); - }, countProjection, selector); - } - - private NamedAotQuery buildNamedAotQuery(TypedQueryReference namedQuery, JpaQueryMethod queryMethod, - boolean isNative) { - - QueryExtractor queryExtractor = queryMethod.getQueryExtractor(); - String queryString = queryExtractor.extractQueryString(namedQuery); - - if (!isNative) { - isNative = queryExtractor.isNativeQuery(namedQuery); - } - - Assert.hasText(queryString, () -> "Cannot extract Query from named query [%s]".formatted(namedQuery.getName())); - - return NamedAotQuery.named(namedQuery.getName(), - isNative ? DeclaredQuery.nativeQuery(queryString) : DeclaredQuery.jpqlQuery(queryString)); - } - - private @Nullable TypedQueryReference getNamedQuery(ReturnedType returnedType, String queryName) { - - List> candidates = Arrays.asList(Object.class, returnedType.getDomainType(), - returnedType.getReturnedType(), returnedType.getTypeToRead(), void.class, null, Long.class, Integer.class, - Long.TYPE, Integer.TYPE, Number.class); - - for (Class candidate : candidates) { - - Map> namedQueries = emf.getNamedQueries(candidate); - - if (namedQueries.containsKey(queryName)) { - return namedQueries.get(queryName); - } - } - - return null; - } - - private AotQueries buildPartTreeQuery(ReturnedType returnedType, AotQueryMethodGenerationContext context, - MergedAnnotation query, JpaQueryMethod queryMethod) { - - PartTree partTree = new PartTree(context.getMethod().getName(), context.getRepositoryInformation().getDomainType()); - // TODO make configurable - JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER; - - AotQuery aotQuery = createQuery(partTree, returnedType, queryMethod.getParameters(), templates); - - if (query.isPresent() && StringUtils.hasText(query.getString("countQuery"))) { - return AotQueries.from(aotQuery, StringAotQuery.jpqlQuery(query.getString("countQuery"))); - } - - TypedQueryReference namedCountQuery = getNamedQuery(returnedType, queryMethod.getNamedCountQueryName()); - if (namedCountQuery != null) { - return AotQueries.from(aotQuery, buildNamedAotQuery(namedCountQuery, queryMethod, false)); - } - - AotQuery partTreeCountQuery = createCountQuery(partTree, returnedType, queryMethod.getParameters(), templates); - return AotQueries.from(aotQuery, partTreeCountQuery); - } - - private AotQuery createQuery(PartTree partTree, ReturnedType returnedType, JpaParameters parameters, - JpqlQueryTemplates templates) { - - ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT, - templates); - JpaQueryCreator queryCreator = new JpaQueryCreator(partTree, returnedType, metadataProvider, templates, metaModel); - - return StringAotQuery.jpqlQuery(queryCreator.createQuery(), metadataProvider.getBindings(), - partTree.getResultLimit(), partTree.isDelete(), partTree.isExistsProjection()); - } - - private AotQuery createCountQuery(PartTree partTree, ReturnedType returnedType, JpaParameters parameters, - JpqlQueryTemplates templates) { - - ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT, - templates); - JpaQueryCreator queryCreator = new JpaCountQueryCreator(partTree, returnedType, metadataProvider, templates, - metaModel); - - return StringAotQuery.jpqlQuery(queryCreator.createQuery(), metadataProvider.getBindings(), null, false, false); - } - - private static @Nullable Class getQueryReturnType(AotQuery query, ReturnedType returnedType, - AotQueryMethodGenerationContext context) { - - Method method = context.getMethod(); - RepositoryInformation repositoryInformation = context.getRepositoryInformation(); - - Class methodReturnType = repositoryInformation.getReturnedDomainClass(method); - boolean queryForEntity = repositoryInformation.getDomainType().isAssignableFrom(methodReturnType); - - Class result = queryForEntity ? returnedType.getDomainType() : null; - - if (query instanceof StringAotQuery sq && sq.returnsDeclaredMethodType()) { - return result; - } - - if (returnedType.isProjecting()) { - - if (returnedType.getReturnedType().isInterface()) { - return Tuple.class; - } - - return returnedType.getReturnedType(); - } - - return result; - } - - @SuppressWarnings("unchecked") - private @Nullable AotEntityGraph getAotEntityGraph(MergedAnnotation entityGraph, - RepositoryInformation information, ReturnedType returnedType, JpaQueryMethod queryMethod) { - - if (!entityGraph.isPresent()) { - return null; - } - - EntityGraph.EntityGraphType type = entityGraph.getEnum("type", EntityGraph.EntityGraphType.class); - String[] attributePaths = entityGraph.getStringArray("attributePaths"); - Collection entityGraphNames = getEntityGraphNames(entityGraph, information, queryMethod); - List> candidates = Arrays.asList(returnedType.getDomainType(), returnedType.getReturnedType(), - returnedType.getTypeToRead()); - - for (Class candidate : candidates) { - - Map> namedEntityGraphs = emf - .getNamedEntityGraphs(Class.class.cast(candidate)); - - if (namedEntityGraphs.isEmpty()) { - continue; - } - - for (String entityGraphName : entityGraphNames) { - if (namedEntityGraphs.containsKey(entityGraphName)) { - return new AotEntityGraph(entityGraphName, type, Collections.emptyList()); - } - } - } - - if (attributePaths.length > 0) { - return new AotEntityGraph(null, type, Arrays.asList(attributePaths)); - } - - return null; - } - - private Set getEntityGraphNames(MergedAnnotation entityGraph, RepositoryInformation information, - JpaQueryMethod queryMethod) { - - Set entityGraphNames = new LinkedHashSet<>(); - String value = entityGraph.getString("value"); - - if (StringUtils.hasText(value)) { - entityGraphNames.add(value); - } - entityGraphNames.add(queryMethod.getNamedQueryName()); - entityGraphNames.add(getFallbackEntityGraphName(information, queryMethod)); - return entityGraphNames; - } - - private String getFallbackEntityGraphName(RepositoryInformation information, JpaQueryMethod queryMethod) { - - Class domainType = information.getDomainType(); - Entity entity = AnnotatedElementUtils.findMergedAnnotation(domainType, Entity.class); - String entityName = entity != null && StringUtils.hasText(entity.name()) ? entity.name() - : domainType.getSimpleName(); - - return entityName + "." + queryMethod.getName(); - } - } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java new file mode 100644 index 000000000..1188ec7d2 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java @@ -0,0 +1,268 @@ +/* + * Copyright 2025 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.aot; + +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Tuple; +import jakarta.persistence.TypedQueryReference; +import jakarta.persistence.metamodel.Metamodel; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.provider.QueryExtractor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.query.*; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; +import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Factory for {@link AotQueries}. + * + * @author Mark Paluch + * @since 4.0 + */ +class QueriesFactory { + + private final Metamodel metamodel; + private final EntityManagerFactory emf; + + public QueriesFactory(AotMetamodel metamodel, EntityManagerFactory emf) { + this.metamodel = metamodel; + this.emf = emf; + } + + /** + * Creates the {@link AotQueries} used within a specific {@link JpaQueryMethod}. + * + * @param context + * @param query + * @param selector + * @param queryMethod + * @param returnedType + * @return + */ + public AotQueries createQueries(AotQueryMethodGenerationContext context, MergedAnnotation query, + QueryEnhancerSelector selector, JpaQueryMethod queryMethod, ReturnedType returnedType) { + + if (query.isPresent() && StringUtils.hasText(query.getString("value"))) { + return buildStringQuery(context.getRepositoryInformation().getDomainType(), returnedType, selector, query, + queryMethod); + } + + TypedQueryReference namedQuery = getNamedQuery(returnedType, queryMethod.getNamedQueryName()); + if (namedQuery != null) { + return buildNamedQuery(returnedType, selector, namedQuery, query, queryMethod); + } + + return buildPartTreeQuery(returnedType, context, query, queryMethod); + } + + private AotQueries buildStringQuery(Class domainType, ReturnedType returnedType, QueryEnhancerSelector selector, + MergedAnnotation query, JpaQueryMethod queryMethod) { + + UnaryOperator operator = s -> s.replaceAll("#\\{#entityName}", domainType.getName()); + boolean isNative = query.getBoolean("nativeQuery"); + Function queryFunction = isNative ? StringAotQuery::nativeQuery : StringAotQuery::jpqlQuery; + queryFunction = operator.andThen(queryFunction); + + String queryString = query.getString("value"); + + StringAotQuery aotStringQuery = queryFunction.apply(queryString); + String countQuery = query.getString("countQuery"); + + EntityQuery entityQuery = EntityQuery.create(aotStringQuery.getQuery(), selector); + if (entityQuery.hasConstructorExpression() || entityQuery.isDefaultProjection()) { + aotStringQuery = aotStringQuery.withConstructorExpressionOrDefaultProjection(); + } + + if (returnedType.isProjecting() && returnedType.hasInputProperties() + && !returnedType.getReturnedType().isInterface()) { + + QueryProvider rewritten = entityQuery.rewrite(new QueryEnhancer.QueryRewriteInformation() { + @Override + public Sort getSort() { + return Sort.unsorted(); + } + + @Override + public ReturnedType getReturnedType() { + return returnedType; + } + }); + + aotStringQuery = aotStringQuery.rewrite(rewritten); + } + + if (StringUtils.hasText(countQuery)) { + return AotQueries.from(aotStringQuery, queryFunction.apply(countQuery)); + } + + String namedCountQueryName = queryMethod.getNamedCountQueryName(); + TypedQueryReference namedCountQuery = getNamedQuery(returnedType, namedCountQueryName); + if (namedCountQuery != null) { + return AotQueries.from(aotStringQuery, buildNamedAotQuery(namedCountQuery, queryMethod, isNative)); + } + + String countProjection = query.getString("countProjection"); + return AotQueries.from(aotStringQuery, countProjection, selector); + } + + private AotQueries buildNamedQuery(ReturnedType returnedType, QueryEnhancerSelector selector, + TypedQueryReference namedQuery, MergedAnnotation query, JpaQueryMethod queryMethod) { + + NamedAotQuery aotQuery = buildNamedAotQuery(namedQuery, queryMethod, + query.isPresent() && query.getBoolean("nativeQuery")); + + String countQuery = query.isPresent() ? query.getString("countQuery") : null; + if (StringUtils.hasText(countQuery)) { + return AotQueries.from(aotQuery, + aotQuery.isNative() ? StringAotQuery.nativeQuery(countQuery) : StringAotQuery.jpqlQuery(countQuery)); + } + + TypedQueryReference namedCountQuery = getNamedQuery(returnedType, queryMethod.getNamedCountQueryName()); + + if (namedCountQuery != null) { + return AotQueries.from(aotQuery, buildNamedAotQuery(namedCountQuery, queryMethod, aotQuery.isNative())); + } + + String countProjection = query.isPresent() ? query.getString("countProjection") : null; + return AotQueries.from(aotQuery, it -> { + return StringAotQuery.of(aotQuery.getQueryString()).getQuery(); + }, countProjection, selector); + } + + private NamedAotQuery buildNamedAotQuery(TypedQueryReference namedQuery, JpaQueryMethod queryMethod, + boolean isNative) { + + QueryExtractor queryExtractor = queryMethod.getQueryExtractor(); + String queryString = queryExtractor.extractQueryString(namedQuery); + + if (!isNative) { + isNative = queryExtractor.isNativeQuery(namedQuery); + } + + Assert.hasText(queryString, () -> "Cannot extract Query from named query [%s]".formatted(namedQuery.getName())); + + return NamedAotQuery.named(namedQuery.getName(), + isNative ? DeclaredQuery.nativeQuery(queryString) : DeclaredQuery.jpqlQuery(queryString)); + } + + private @Nullable TypedQueryReference getNamedQuery(ReturnedType returnedType, String queryName) { + + List> candidates = Arrays.asList(Object.class, returnedType.getDomainType(), + returnedType.getReturnedType(), returnedType.getTypeToRead(), void.class, null, Long.class, Integer.class, + Long.TYPE, Integer.TYPE, Number.class); + + for (Class candidate : candidates) { + + Map> namedQueries = emf.getNamedQueries(candidate); + + if (namedQueries.containsKey(queryName)) { + return namedQueries.get(queryName); + } + } + + return null; + } + + private AotQueries buildPartTreeQuery(ReturnedType returnedType, AotQueryMethodGenerationContext context, + MergedAnnotation query, JpaQueryMethod queryMethod) { + + PartTree partTree = new PartTree(context.getMethod().getName(), context.getRepositoryInformation().getDomainType()); + // TODO make configurable + JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER; + + AotQuery aotQuery = createQuery(partTree, returnedType, queryMethod.getParameters(), templates); + + if (query.isPresent() && StringUtils.hasText(query.getString("countQuery"))) { + return AotQueries.from(aotQuery, StringAotQuery.jpqlQuery(query.getString("countQuery"))); + } + + TypedQueryReference namedCountQuery = getNamedQuery(returnedType, queryMethod.getNamedCountQueryName()); + if (namedCountQuery != null) { + return AotQueries.from(aotQuery, buildNamedAotQuery(namedCountQuery, queryMethod, false)); + } + + AotQuery partTreeCountQuery = createCountQuery(partTree, returnedType, queryMethod.getParameters(), templates); + return AotQueries.from(aotQuery, partTreeCountQuery); + } + + private AotQuery createQuery(PartTree partTree, ReturnedType returnedType, JpaParameters parameters, + JpqlQueryTemplates templates) { + + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT, + templates); + JpaQueryCreator queryCreator = new JpaQueryCreator(partTree, returnedType, metadataProvider, templates, metamodel); + + return StringAotQuery.jpqlQuery(queryCreator.createQuery(), metadataProvider.getBindings(), + partTree.getResultLimit(), partTree.isDelete(), partTree.isExistsProjection()); + } + + private AotQuery createCountQuery(PartTree partTree, ReturnedType returnedType, JpaParameters parameters, + JpqlQueryTemplates templates) { + + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT, + templates); + JpaQueryCreator queryCreator = new JpaCountQueryCreator(partTree, returnedType, metadataProvider, templates, + metamodel); + + return StringAotQuery.jpqlQuery(queryCreator.createQuery(), metadataProvider.getBindings(), Limit.unlimited(), + false, false); + } + + public static @Nullable Class getQueryReturnType(AotQuery query, ReturnedType returnedType, + AotQueryMethodGenerationContext context) { + + Method method = context.getMethod(); + RepositoryInformation repositoryInformation = context.getRepositoryInformation(); + + Class methodReturnType = repositoryInformation.getReturnedDomainClass(method); + boolean queryForEntity = repositoryInformation.getDomainType().isAssignableFrom(methodReturnType); + + Class result = queryForEntity ? returnedType.getDomainType() : null; + + if (query instanceof StringAotQuery sq && sq.hasConstructorExpressionOrDefaultProjection()) { + return result; + } + + if (returnedType.isProjecting()) { + + if (returnedType.getReturnedType().isInterface()) { + return Tuple.class; + } + + return returnedType.getReturnedType(); + } + + return result; + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java index 499f1d6c6..b30f0118c 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java @@ -21,6 +21,7 @@ import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.query.DeclaredQuery; import org.springframework.data.jpa.repository.query.ParameterBinding; import org.springframework.data.jpa.repository.query.PreprocessedQuery; +import org.springframework.data.jpa.repository.query.QueryProvider; /** * An AOT query represented by a string. @@ -59,7 +60,7 @@ abstract class StringAotQuery extends AotQuery { */ public static StringAotQuery jpqlQuery(String queryString, List bindings, Limit resultLimit, boolean delete, boolean exists) { - return new LimitedAotQuery(queryString, bindings, resultLimit, delete, exists); + return new DerivedAotQuery(queryString, bindings, resultLimit, delete, exists); } /** @@ -83,28 +84,34 @@ abstract class StringAotQuery extends AotQuery { * @return {@literal true} if query is expected to return the declared method type directly; {@literal false} if the * result requires projection post-processing. See also {@code NativeJpaQuery#getTypeToQueryFor}. */ - public abstract boolean returnsDeclaredMethodType(); + public abstract boolean hasConstructorExpressionOrDefaultProjection(); - public abstract StringAotQuery withReturnsDeclaredMethodType(); + /** + * @return a new {@link StringAotQuery} using constructor expressions or containing the default (primary alias) + * projection. + */ + public abstract StringAotQuery withConstructorExpressionOrDefaultProjection(); @Override public String toString() { return getQueryString(); } + public abstract StringAotQuery rewrite(QueryProvider rewritten); + /** * @author Christoph Strobl * @author Mark Paluch */ - static class DeclaredAotQuery extends StringAotQuery { + private static class DeclaredAotQuery extends StringAotQuery { private final PreprocessedQuery query; - private final boolean returnsDeclaredMethodType; + private final boolean constructorExpressionOrDefaultProjection; - DeclaredAotQuery(PreprocessedQuery query, boolean returnsDeclaredMethodType) { + DeclaredAotQuery(PreprocessedQuery query, boolean constructorExpressionOrDefaultProjection) { super(query.getBindings()); this.query = query; - this.returnsDeclaredMethodType = returnsDeclaredMethodType; + this.constructorExpressionOrDefaultProjection = constructorExpressionOrDefaultProjection; } @Override @@ -123,30 +130,35 @@ abstract class StringAotQuery extends AotQuery { } @Override - public boolean returnsDeclaredMethodType() { - return returnsDeclaredMethodType; + public boolean hasConstructorExpressionOrDefaultProjection() { + return constructorExpressionOrDefaultProjection; } @Override - public StringAotQuery withReturnsDeclaredMethodType() { - return new DeclaredAotQuery(query, returnsDeclaredMethodType); + public StringAotQuery withConstructorExpressionOrDefaultProjection() { + return new DeclaredAotQuery(query, true); + } + + @Override + public StringAotQuery rewrite(QueryProvider rewritten) { + return new DeclaredAotQuery(query.rewrite(rewritten.getQueryString()), constructorExpressionOrDefaultProjection); } } /** - * Query with a limit associated. + * PartTree (derived) Query with a limit associated. * * @author Mark Paluch */ - static class LimitedAotQuery extends StringAotQuery { + private static class DerivedAotQuery extends StringAotQuery { private final String queryString; private final Limit limit; private final boolean delete; private final boolean exists; - LimitedAotQuery(String queryString, List parameterBindings, Limit limit, boolean delete, + DerivedAotQuery(String queryString, List parameterBindings, Limit limit, boolean delete, boolean exists) { super(parameterBindings); this.queryString = queryString; @@ -186,14 +198,19 @@ abstract class StringAotQuery extends AotQuery { } @Override - public boolean returnsDeclaredMethodType() { - return true; + public boolean hasConstructorExpressionOrDefaultProjection() { + return false; } @Override - public StringAotQuery withReturnsDeclaredMethodType() { + public StringAotQuery withConstructorExpressionOrDefaultProjection() { return this; } + @Override + public StringAotQuery rewrite(QueryProvider rewritten) { + return new DerivedAotQuery(rewritten.getQueryString(), this.getParameterBindings(), getLimit(), delete, exists); + } + } } 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 2d75b3970..4e672ccc8 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 @@ -25,18 +25,13 @@ import jakarta.persistence.TypedQuery; import java.lang.reflect.Constructor; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.function.UnaryOperator; import java.util.stream.Collectors; -import org.springframework.beans.BeanUtils; - import org.jspecify.annotations.Nullable; + +import org.springframework.beans.BeanUtils; import org.springframework.core.MethodParameter; import org.springframework.core.convert.converter.Converter; import org.springframework.data.jpa.provider.PersistenceProvider; @@ -50,13 +45,13 @@ import org.springframework.data.jpa.repository.query.JpaQueryExecution.SlicedExe import org.springframework.data.jpa.repository.query.JpaQueryExecution.StreamExecution; import org.springframework.data.jpa.repository.support.QueryHints; import org.springframework.data.jpa.util.JpaMetamodel; +import org.springframework.data.jpa.util.TupleBackedMap; import org.springframework.data.mapping.PreferredConstructor; import org.springframework.data.mapping.model.PreferredConstructorDiscoverer; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.util.Lazy; -import org.springframework.jdbc.support.JdbcUtils; import org.springframework.lang.Contract; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -344,7 +339,7 @@ public abstract class AbstractJpaQuery implements RepositoryQuery { Assert.notNull(type, "Returned type must not be null"); this.type = type; - this.tupleWrapper = nativeQuery ? FallbackTupleWrapper::new : UnaryOperator.identity(); + this.tupleWrapper = nativeQuery ? TupleBackedMap::underscoreAware : UnaryOperator.identity(); this.dtoProjection = type.isProjecting() && !type.getReturnedType().isInterface() && !type.getInputProperties().isEmpty(); @@ -468,180 +463,6 @@ public abstract class AbstractJpaQuery implements RepositoryQuery { return ClassUtils.isAssignable(to, from); } - /** - * A {@link Map} implementation which delegates all calls to a {@link Tuple}. Depending on the provided - * {@link Tuple} implementation it might return the same value for various keys of which only one will appear in the - * key/entry set. - * - * @author Jens Schauder - */ - private static class TupleBackedMap implements Map { - - private static final String UNMODIFIABLE_MESSAGE = "A TupleBackedMap cannot be modified"; - - private final Tuple tuple; - - TupleBackedMap(Tuple tuple) { - this.tuple = tuple; - } - - @Override - public int size() { - return tuple.getElements().size(); - } - - @Override - public boolean isEmpty() { - return tuple.getElements().isEmpty(); - } - - /** - * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code false}. - * Otherwise this returns {@code true} even when the value from the backing {@code Tuple} is {@code null}. - * - * @param key the key for which to get the value from the map. - * @return whether the key is an element of the backing tuple. - */ - @Override - public boolean containsKey(Object key) { - - try { - tuple.get((String) key); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - - @Override - public boolean containsValue(Object value) { - return Arrays.asList(tuple.toArray()).contains(value); - } - - /** - * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code null}. - * Otherwise the value from the backing {@code Tuple} is returned, which also might be {@code null}. - * - * @param key the key for which to get the value from the map. - * @return the value of the backing {@link Tuple} for that key or {@code null}. - */ - @Override - public @Nullable Object get(Object key) { - - if (!(key instanceof String)) { - return null; - } - - try { - return tuple.get((String) key); - } catch (IllegalArgumentException e) { - return null; - } - } - - @Override - public Object put(String key, Object value) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } - - @Override - public Object remove(Object key) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } - - @Override - public void putAll(Map m) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } - - @Override - public void clear() { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } - - @Override - public Set keySet() { - - return tuple.getElements().stream() // - .map(TupleElement::getAlias) // - .collect(Collectors.toSet()); - } - - @Override - public Collection values() { - return Arrays.asList(tuple.toArray()); - } - - @Override - public Set> entrySet() { - - return tuple.getElements().stream() // - .map(e -> new HashMap.SimpleEntry(e.getAlias(), tuple.get(e))) // - .collect(Collectors.toSet()); - } - } } - private static class FallbackTupleWrapper implements Tuple { - - private final Tuple delegate; - private final UnaryOperator fallbackNameTransformer = JdbcUtils::convertPropertyNameToUnderscoreName; - - FallbackTupleWrapper(Tuple delegate) { - this.delegate = delegate; - } - - @Override - public X get(TupleElement tupleElement) { - return get(tupleElement.getAlias(), tupleElement.getJavaType()); - } - - @Override - public X get(String s, Class type) { - try { - return delegate.get(s, type); - } catch (IllegalArgumentException original) { - try { - return delegate.get(fallbackNameTransformer.apply(s), type); - } catch (IllegalArgumentException next) { - original.addSuppressed(next); - throw original; - } - } - } - - @Override - public Object get(String s) { - try { - return delegate.get(s); - } catch (IllegalArgumentException original) { - try { - return delegate.get(fallbackNameTransformer.apply(s)); - } catch (IllegalArgumentException next) { - original.addSuppressed(next); - throw original; - } - } - } - - @Override - public X get(int i, Class aClass) { - return delegate.get(i, aClass); - } - - @Override - public Object get(int i) { - return delegate.get(i); - } - - @Override - public Object[] toArray() { - return delegate.toArray(); - } - - @Override - public List> getElements() { - return delegate.getElements(); - } - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java new file mode 100644 index 000000000..1c6c6927f --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java @@ -0,0 +1,219 @@ +/* + * Copyright 2025 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.util; + +import jakarta.persistence.Tuple; +import jakarta.persistence.TupleElement; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; + +import org.springframework.jdbc.support.JdbcUtils; + +/** + * A {@link Map} implementation which delegates all calls to a {@link Tuple}. Depending on the provided {@link Tuple} + * implementation it might return the same value for various keys of which only one will appear in the key/entry set. + * + * @author Jens Schauder + * @since 4.0 + */ +public class TupleBackedMap implements Map { + + private static final String UNMODIFIABLE_MESSAGE = "A TupleBackedMap cannot be modified"; + + private final Tuple tuple; + + public TupleBackedMap(Tuple tuple) { + this.tuple = tuple; + } + + /** + * Creates a underscore-aware {@link Tuple} wrapper applying {@link JdbcUtils#convertPropertyNameToUnderscoreName} + * conversion to leniently look up properties from query results whose columns follow snake-case syntax. + * + * @param delegate the tuple to wrap. + * @return + */ + public static Tuple underscoreAware(Tuple delegate) { + return new FallbackTupleWrapper(delegate); + } + + @Override + public int size() { + return tuple.getElements().size(); + } + + @Override + public boolean isEmpty() { + return tuple.getElements().isEmpty(); + } + + /** + * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code false}. Otherwise + * this returns {@code true} even when the value from the backing {@code Tuple} is {@code null}. + * + * @param key the key for which to get the value from the map. + * @return whether the key is an element of the backing tuple. + */ + @Override + public boolean containsKey(Object key) { + + try { + tuple.get((String) key); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } + + @Override + public boolean containsValue(Object value) { + return Arrays.asList(tuple.toArray()).contains(value); + } + + /** + * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code null}. Otherwise + * the value from the backing {@code Tuple} is returned, which also might be {@code null}. + * + * @param key the key for which to get the value from the map. + * @return the value of the backing {@link Tuple} for that key or {@code null}. + */ + @Override + public @Nullable Object get(Object key) { + + if (!(key instanceof String)) { + return null; + } + + try { + return tuple.get((String) key); + } catch (IllegalArgumentException e) { + return null; + } + } + + @Override + public Object put(String key, Object value) { + throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); + } + + @Override + public Object remove(Object key) { + throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); + } + + @Override + public void putAll(Map m) { + throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); + } + + @Override + public Set keySet() { + + return tuple.getElements().stream() // + .map(TupleElement::getAlias) // + .collect(Collectors.toSet()); + } + + @Override + public Collection values() { + return Arrays.asList(tuple.toArray()); + } + + @Override + public Set> entrySet() { + + return tuple.getElements().stream() // + .map(e -> new HashMap.SimpleEntry(e.getAlias(), tuple.get(e))) // + .collect(Collectors.toSet()); + } + + static class FallbackTupleWrapper implements Tuple { + + private final Tuple delegate; + private final UnaryOperator fallbackNameTransformer = JdbcUtils::convertPropertyNameToUnderscoreName; + + FallbackTupleWrapper(Tuple delegate) { + this.delegate = delegate; + } + + @Override + public X get(TupleElement tupleElement) { + return get(tupleElement.getAlias(), tupleElement.getJavaType()); + } + + @Override + public X get(String s, Class type) { + try { + return delegate.get(s, type); + } catch (IllegalArgumentException original) { + try { + return delegate.get(fallbackNameTransformer.apply(s), type); + } catch (IllegalArgumentException next) { + original.addSuppressed(next); + throw original; + } + } + } + + @Override + public Object get(String s) { + try { + return delegate.get(s); + } catch (IllegalArgumentException original) { + try { + return delegate.get(fallbackNameTransformer.apply(s)); + } catch (IllegalArgumentException next) { + original.addSuppressed(next); + throw original; + } + } + } + + @Override + public X get(int i, Class aClass) { + return delegate.get(i, aClass); + } + + @Override + public Object get(int i) { + return delegate.get(i); + } + + @Override + public Object[] toArray() { + return delegate.toArray(); + } + + @Override + public List> getElements() { + return delegate.getElements(); + } + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java index 5f609bad6..aa2f2f0c5 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java @@ -24,6 +24,7 @@ import java.util.Optional; import java.util.stream.Stream; import org.hibernate.proxy.HibernateProxy; +import org.hibernate.query.QueryTypeMismatchException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -35,6 +36,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.sample.Role; +import org.springframework.data.jpa.domain.sample.SpecialUser; import org.springframework.data.jpa.domain.sample.User; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.transaction.annotation.Transactional; @@ -100,6 +102,13 @@ class JpaRepositoryContributorIntegrationTests { em.clear(); } + @Test + void testDerivedFinderWithoutArguments() { + + List users = fragment.findUserNoArgumentsBy(); + assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class); + } + @Test void testFindDerivedQuerySingleEntity() { @@ -107,40 +116,6 @@ class JpaRepositoryContributorIntegrationTests { assertThat(user.getLastname()).isEqualTo("Skywalker"); } - @Test - void shouldUseNamedQuery() { - - User user = fragment.findByEmailAddress("luke@jedi.org"); - assertThat(user.getLastname()).isEqualTo("Skywalker"); - } - - @Test - void shouldUseNamedQueryAndDeriveCountQuery() { - - Page user = fragment.findPagedByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); - - assertThat(user).hasSize(1); - assertThat(user.getTotalElements()).isEqualTo(1); - } - - @Test - void shouldUseNamedQueryAndProvidedCountQuery() { - - Page user = fragment.findPagedWithCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); - - assertThat(user).hasSize(1); - assertThat(user.getTotalElements()).isEqualTo(1); - } - - @Test - void shouldUseNamedQueryAndNamedCountQuery() { - - Page user = fragment.findPagedWithNamedCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); - - assertThat(user).hasSize(1); - assertThat(user.getTotalElements()).isEqualTo(1); - } - @Test void testFindDerivedFinderOptionalEntity() { @@ -163,13 +138,6 @@ class JpaRepositoryContributorIntegrationTests { assertThat(exists).isTrue(); } - @Test - void testDerivedFinderWithoutArguments() { - - List users = fragment.findUserNoArgumentsBy(); - assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class); - } - @Test void testDerivedFinderReturningList() { @@ -397,33 +365,6 @@ class JpaRepositoryContributorIntegrationTests { "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); } - @Test - void shouldApplyQueryHints() { - assertThatIllegalArgumentException().isThrownBy(() -> fragment.findHintedByLastname("Skywalker")) - .withMessageContaining("No enum constant jakarta.persistence.CacheStoreMode.foo"); - } - - @Test - void shouldApplyNamedEntityGraph() { - - User chewie = fragment.findWithNamedEntityGraphByFirstname("Chewbacca"); - - assertThat(chewie.getManager()).isInstanceOf(HibernateProxy.class); - assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class); - } - - @Test - void shouldApplyDeclaredEntityGraph() { - - User chewie = fragment.findWithDeclaredEntityGraphByFirstname("Chewbacca"); - - assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class); - - User han = chewie.getManager(); - assertThat(han.getRoles()).isNotInstanceOf(HibernateProxy.class); - assertThat(han.getManager()).isInstanceOf(HibernateProxy.class); - } - @Test void testDerivedFinderReturningPageOfProjections() { @@ -441,7 +382,128 @@ class JpaRepositoryContributorIntegrationTests { assertThat(noResults).isEmpty(); } - // modifying + @Test + void shouldApplySqlResultSetMapping() { + + User.EmailDto result = fragment.findEmailDtoByNativeQuery(kylo.getId()); + + assertThat(result.getOne()).isEqualTo(kylo.getEmailAddress()); + } + + @Test + void shouldApplyNamedDto() { + + // named queries cannot be rewritten + assertThatExceptionOfType(QueryTypeMismatchException.class) + .isThrownBy(() -> fragment.findNamedDtoEmailAddress(kylo.getEmailAddress())); + } + + @Test + void shouldApplyDerivedDto() { + + UserRepository.Names names = fragment.findDtoByEmailAddress(kylo.getEmailAddress()); + + assertThat(names.lastname()).isEqualTo(kylo.getLastname()); + assertThat(names.firstname()).isEqualTo(kylo.getFirstname()); + } + + @Test + void shouldApplyDerivedDtoPage() { + + Page names = fragment.findDtoPageByEmailAddress(kylo.getEmailAddress(), PageRequest.of(0, 1)); + + assertThat(names).hasSize(1); + assertThat(names.getContent().get(0).lastname()).isEqualTo(kylo.getLastname()); + } + + @Test + void shouldApplyAnnotatedDto() { + + UserRepository.Names names = fragment.findAnnotatedDtoEmailAddress(kylo.getEmailAddress()); + + assertThat(names.lastname()).isEqualTo(kylo.getLastname()); + assertThat(names.firstname()).isEqualTo(kylo.getFirstname()); + } + + @Test + void shouldApplyAnnotatedDtoPage() { + + Page names = fragment.findAnnotatedDtoPageByEmailAddress(kylo.getEmailAddress(), + PageRequest.of(0, 1)); + + assertThat(names).hasSize(1); + assertThat(names.getContent().get(0).lastname()).isEqualTo(kylo.getLastname()); + } + + @Test + void shouldApplyDerivedQueryInterfaceProjection() { + + UserRepository.EmailOnly result = fragment.findEmailProjectionById(kylo.getId()); + + assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); + } + + @Test + void shouldApplyInterfaceProjectionPage() { + + Page result = fragment.findProjectedPageByEmailAddress(kylo.getEmailAddress(), + PageRequest.of(0, 1)); + + assertThat(result).hasSize(1); + assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress()); + } + + @Test + void shouldApplyInterfaceProjectionSlice() { + + Slice result = fragment.findProjectedSliceByEmailAddress(kylo.getEmailAddress(), + PageRequest.of(0, 1)); + + assertThat(result).hasSize(1); + assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress()); + } + + @Test + void shouldApplyInterfaceProjectionToDerivedQueryStream() { + + Stream result = fragment.streamProjectedByEmailAddress(kylo.getEmailAddress()); + + assertThat(result).hasSize(1).map(UserRepository.EmailOnly::getEmailAddress).contains(kylo.getEmailAddress()); + } + + @Test + void shouldApplyAnnotatedQueryInterfaceProjection() { + + UserRepository.EmailOnly result = fragment.findAnnotatedEmailProjectionByEmailAddress(kylo.getEmailAddress()); + + assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); + } + + @Test + void shouldApplyAnnotatedInterfaceProjectionQueryPage() { + + Page result = fragment.findAnnotatedProjectedPageByEmailAddress(kylo.getEmailAddress(), + PageRequest.of(0, 1)); + + assertThat(result).hasSize(1); + assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress()); + } + + @Test + void shouldApplyNativeInterfaceProjection() { + + UserRepository.EmailOnly result = fragment.findEmailProjectionByNativeQuery(kylo.getId()); + + assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); + } + + @Test + void shouldApplyNamedQueryInterfaceProjection() { + + UserRepository.EmailOnly result = fragment.findNamedProjectionEmailAddress(kylo.getEmailAddress()); + + assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress()); + } @Test void testDerivedDeleteSingle() { @@ -476,8 +538,6 @@ class JpaRepositoryContributorIntegrationTests { assertThat(yodaShouldBeGone).isNull(); } - // native queries - @Test void nativeQuery() { @@ -489,22 +549,86 @@ class JpaRepositoryContributorIntegrationTests { } @Test - void shouldApplySqlResultSetMapping() { + void shouldUseNamedQuery() { - User.EmailDto result = fragment.findEmailDtoByNativeQuery(kylo.getId()); - - assertThat(result.getOne()).isEqualTo(kylo.getEmailAddress()); + User user = fragment.findByEmailAddress("luke@jedi.org"); + assertThat(user.getLastname()).isEqualTo("Skywalker"); } - // old stuff below + @Test + void shouldUseNamedQueryAndDeriveCountQuery() { + + Page user = fragment.findPagedByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); + + assertThat(user).hasSize(1); + assertThat(user.getTotalElements()).isEqualTo(1); + } + + @Test + void shouldUseNamedQueryAndProvidedCountQuery() { + + Page user = fragment.findPagedWithCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); + + assertThat(user).hasSize(1); + assertThat(user.getTotalElements()).isEqualTo(1); + } + + @Test + void shouldUseNamedQueryAndNamedCountQuery() { + + Page user = fragment.findPagedWithNamedCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org"); + + assertThat(user).hasSize(1); + assertThat(user.getTotalElements()).isEqualTo(1); + } + + @Test + void shouldApplyQueryHints() { + assertThatIllegalArgumentException().isThrownBy(() -> fragment.findHintedByLastname("Skywalker")) + .withMessageContaining("No enum constant jakarta.persistence.CacheStoreMode.foo"); + } + + @Test + void shouldApplyNamedEntityGraph() { + + User chewie = fragment.findWithNamedEntityGraphByFirstname("Chewbacca"); + + assertThat(chewie.getManager()).isInstanceOf(HibernateProxy.class); + assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class); + } + + @Test + void shouldApplyDeclaredEntityGraph() { + + User chewie = fragment.findWithDeclaredEntityGraphByFirstname("Chewbacca"); + + assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class); + + User han = chewie.getManager(); + assertThat(han.getRoles()).isNotInstanceOf(HibernateProxy.class); + assertThat(han.getManager()).isInstanceOf(HibernateProxy.class); + } + + @Test + void shouldQuerySubtype() { + + SpecialUser snoopy = new SpecialUser(); + snoopy.setFirstname("Snoopy"); + snoopy.setLastname("n/a"); + snoopy.setEmailAddress("dog@home.com"); + em.persist(snoopy); + + SpecialUser result = fragment.findByEmailAddress("dog@home.com", SpecialUser.class); + + assertThat(result).isNotNull(); + assertThat(result).isInstanceOf(SpecialUser.class); + } void todo() { - // interface projections - // dynamic projections - // class type parameter + // dynamic projections: Not implemented + // keyset scrolling - // synthetic parameters (keyset scrolling! yuck!) } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java index 1326960db..de4ae656d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java @@ -38,7 +38,6 @@ import org.springframework.data.repository.CrudRepository; * @author Christoph Strobl * @author Mark Paluch */ -// TODO: Querydsl, query by example interface UserRepository extends CrudRepository { List findUserNoArgumentsBy(); @@ -71,7 +70,9 @@ interface UserRepository extends CrudRepository { Stream streamByLastnameLike(String lastname); - /* Annotated Queries */ + // ------------------------------------------------------------------------- + // Declared Queries + // ------------------------------------------------------------------------- @Query("select u from User u where u.emailAddress = ?1") User findAnnotatedQueryByEmailAddress(String username); @@ -112,7 +113,9 @@ interface UserRepository extends CrudRepository { @Query("select u from User u where u.lastname like ?1%") Slice findAnnotatedQuerySliceOfUsersByLastname(String lastname, Pageable pageable); + // ------------------------------------------------------------------------- // Value Expressions + // ------------------------------------------------------------------------- @Query("select u from #{#entityName} u where u.emailAddress = ?1") User findTemplatedByEmailAddress(String emailAddress); @@ -123,11 +126,58 @@ interface UserRepository extends CrudRepository { @Query("select u from User u where u.emailAddress = ?#{[0]} or u.firstname = ?${user.dir}") User findValueExpressionPositionalByEmailAddress(String emailAddress); + // ------------------------------------------------------------------------- + // Projections: DTO + // ------------------------------------------------------------------------- + + List findUserProjectionByLastnameStartingWith(String lastname); + + Page findUserProjectionByLastnameStartingWith(String lastname, Pageable page); + + Names findDtoByEmailAddress(String emailAddress); + + Page findDtoPageByEmailAddress(String emailAddress, Pageable pageable); + + @Query("select u from User u where u.emailAddress = ?1") + Names findAnnotatedDtoEmailAddress(String emailAddress); + + @Query("select u from User u where u.emailAddress = ?1") + Page findAnnotatedDtoPageByEmailAddress(String emailAddress, Pageable pageable); + @NativeQuery(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1", sqlResultSetMapping = "emailDto") User.EmailDto findEmailDtoByNativeQuery(Integer id); - // modifying + @Query(name = "User.findByEmailAddress") + Names findNamedDtoEmailAddress(String emailAddress); + + // ------------------------------------------------------------------------- + // Projections: Interface + // ------------------------------------------------------------------------- + + EmailOnly findEmailProjectionById(Integer id); + + Page findProjectedPageByEmailAddress(String emailAddress, Pageable page); + + Slice findProjectedSliceByEmailAddress(String lastname, Pageable page); + + Stream streamProjectedByEmailAddress(String lastname); + + @Query("select u from User u where u.emailAddress = ?1") + EmailOnly findAnnotatedEmailProjectionByEmailAddress(String emailAddress); + + @Query("select u from User u where u.emailAddress = ?1") + Page findAnnotatedProjectedPageByEmailAddress(String emailAddress, Pageable page); + + @NativeQuery(value = "SELECT emailaddress as emailAddress FROM SD_User WHERE id = ?1") + EmailOnly findEmailProjectionByNativeQuery(Integer id); + + @Query(name = "User.findByEmailAddress") + EmailOnly findNamedProjectionEmailAddress(String emailAddress); + + // ------------------------------------------------------------------------- + // Modifying + // ------------------------------------------------------------------------- User deleteByEmailAddress(String username); @@ -136,46 +186,22 @@ interface UserRepository extends CrudRepository { @Query("delete from User u where u.emailAddress = ?1") User deleteAnnotatedQueryByEmailAddress(String username); - // native queries + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update User u set u.lastname = ?1") + int renameAllUsersTo(String lastname); + + // ------------------------------------------------------------------------- + // Native Queries + // ------------------------------------------------------------------------- @Query(value = "SELECT firstname FROM SD_User ORDER BY UCASE(firstname)", countQuery = "SELECT count(*) FROM SD_User", nativeQuery = true) Page findByNativeQueryWithPageable(Pageable pageable); - // projections + // ------------------------------------------------------------------------- + // Named Queries + // ------------------------------------------------------------------------- - List findUserProjectionByLastnameStartingWith(String lastname); - - Page findUserProjectionByLastnameStartingWith(String lastname, Pageable page); - - // old ones - - @Query("select u from User u where u.firstname = ?1") - List findAllUsingAnnotatedJpqlQuery(String firstname); - - List findByLastname(String lastname); - - @QueryHints(value = { @QueryHint(name = "jakarta.persistence.cache.storeMode", value = "foo") }, forCounting = false) - List findHintedByLastname(String lastname); - - @EntityGraph(type = EntityGraph.EntityGraphType.FETCH, value = "User.overview") - User findWithNamedEntityGraphByFirstname(String firstname); - - @EntityGraph(type = EntityGraph.EntityGraphType.FETCH, attributePaths = { "roles", "manager.roles" }) - User findWithDeclaredEntityGraphByFirstname(String firstname); - - List findByLastnameStartingWithOrderByFirstname(String lastname, Limit limit); - - List findByLastname(String lastname, Sort sort); - - List findByLastname(String lastname, Pageable page); - - List findByLastnameOrderByFirstname(String lastname); - - /** - * Retrieve users by their email address. The finder {@literal User.findByEmailAddress} is declared as annotation at - * {@code User}. - */ User findByEmailAddress(String emailAddress); @Query(name = "User.findByEmailAddress") @@ -187,8 +213,27 @@ interface UserRepository extends CrudRepository { @Query(name = "User.findByEmailAddress", countName = "User.findByEmailAddress.count-provided") Page findPagedWithNamedCountByEmailAddress(Pageable pageable, String emailAddress); - @Modifying(flushAutomatically = true, clearAutomatically = true) - @Query("update User u set u.lastname = ?1") - int renameAllUsersTo(String lastname); + // ------------------------------------------------------------------------- + // Query Hints + // ------------------------------------------------------------------------- + + @QueryHints(value = { @QueryHint(name = "jakarta.persistence.cache.storeMode", value = "foo") }, forCounting = false) + List findHintedByLastname(String lastname); + + @EntityGraph(type = EntityGraph.EntityGraphType.FETCH, value = "User.overview") + User findWithNamedEntityGraphByFirstname(String firstname); + + @EntityGraph(type = EntityGraph.EntityGraphType.FETCH, attributePaths = { "roles", "manager.roles" }) + User findWithDeclaredEntityGraphByFirstname(String firstname); + + @Query("select u from User u where u.emailAddress = ?1 AND TYPE(u) = ?2") + T findByEmailAddress(String emailAddress, Class type); + + interface EmailOnly { + String getEmailAddress(); + } + + record Names(String firstname, String lastname) { + } }