From c399ca2b54bcf2ff10a777ca81ea6ed53ac40050 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Mon, 24 Mar 2025 15:52:23 +0100 Subject: [PATCH] Refactoring. Introduce AotRepositoryFragmentSupport, adopt to FragmentCreationContext. Reduce visibility. Refactor CodeBlocks builder. Simplify query rewriting and use base class methods. Use typed verifier through a JDK proxy to avoid reflective frontend. Revise testing to a plain old Spring test but testing the AOT fragment through its interface by forwarding reflective calls to the AOT fragment. Refactor AotQuery into AotQueries to support a wider range of possible queries. See #3830 --- .../aot/generated/AotMetaModel.java | 4 +- .../repository/aot/generated/AotQueries.java | 58 ++ .../repository/aot/generated/AotQuery.java | 61 ++ .../aot/generated/AotQueryCreator.java | 30 +- .../AotRepositoryFragmentSupport.java | 118 ++++ .../aot/generated/AotStringQuery.java | 106 --- .../aot/generated/JpaCodeBlocks.java | 165 ++--- .../generated/JpaRepositoryContributor.java | 206 ++++++ .../generated/JpaRepsoitoryContributor.java | 115 ---- .../aot/generated/StringAotQuery.java | 131 ++++ .../config/JpaRepositoryConfigExtension.java | 8 +- .../query/JpaCountQueryCreator.java | 19 + .../repository/query/PreprocessedQuery.java | 6 +- .../AotFragmentTestConfigurationSupport.java | 140 ++++ ...RepositoryContributorIntegrationTests.java | 356 ++++++++++ .../JpaRepositoryContributorUnitTests.java | 614 ------------------ .../generated/StubRepositoryInformation.java | 3 +- ....java => TestJpaAotRepositoryContext.java} | 10 +- .../aot/generated}/UserDtoProjection.java | 2 +- .../aot/generated}/UserRepository.java | 15 +- 20 files changed, 1200 insertions(+), 967 deletions(-) create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueries.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQuery.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotRepositoryFragmentSupport.java delete mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotStringQuery.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributor.java delete mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepsoitoryContributor.java create mode 100644 spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/StringAotQuery.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/AotFragmentTestConfigurationSupport.java create mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorIntegrationTests.java delete mode 100644 spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorUnitTests.java rename spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/{TestJpaAotRepsitoryContext.java => TestJpaAotRepositoryContext.java} (89%) rename spring-data-jpa/src/test/java/{com/example => org/springframework/data/jpa/repository/aot/generated}/UserDtoProjection.java (94%) rename spring-data-jpa/src/test/java/{com/example => org/springframework/data/jpa/repository/aot/generated}/UserRepository.java (92%) diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotMetaModel.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotMetaModel.java index 98929eead..797e7a45a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotMetaModel.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotMetaModel.java @@ -37,7 +37,7 @@ import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo; /** * @author Christoph Strobl */ -public class AotMetaModel implements Metamodel { +class AotMetaModel implements Metamodel { private final String persistenceUnit; private final Set> managedTypes; @@ -105,7 +105,7 @@ public class AotMetaModel implements Metamodel { @Override public void addTransformer(ClassTransformer classTransformer) { - // just ingnore it + // just ignore it } }; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueries.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueries.java new file mode 100644 index 000000000..c7d8051bd --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueries.java @@ -0,0 +1,58 @@ +/* + * 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.generated; + +import jakarta.validation.constraints.Null; + +import org.springframework.data.jpa.repository.query.DeclaredQuery; +import org.springframework.data.jpa.repository.query.QueryEnhancer; +import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; +import org.springframework.util.StringUtils; + +/** + * Value object capturing queries used for repository query methods. + * + * @author Mark Paluch + * @since 4.0 + */ +record AotQueries(AotQuery result, AotQuery count) { + + /** + * Derive a count query from the given query. + */ + public static AotQueries from(StringAotQuery query, @Null String countProjection, QueryEnhancerSelector selector) { + + QueryEnhancer queryEnhancer = selector.select(query.getQuery()).create(query.getQuery()); + + String derivedCountQuery = queryEnhancer + .createCountQueryFor(StringUtils.hasText(countProjection) ? countProjection : null); + + DeclaredQuery countQuery = query.getQuery().rewrite(derivedCountQuery); + return new AotQueries(query, StringAotQuery.of(countQuery)); + } + + /** + * Create new {@code AotQueries} for the given queries. + */ + public static AotQueries from(AotQuery result, AotQuery count) { + return new AotQueries(result, count); + } + + public boolean isNative() { + return result().isNative(); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQuery.java new file mode 100644 index 000000000..2f48b4388 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQuery.java @@ -0,0 +1,61 @@ +/* + * 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.generated; + +import java.util.List; + +import org.springframework.data.domain.Limit; +import org.springframework.data.jpa.repository.query.ParameterBinding; + +/** + * AOT query value object along with its parameter bindings. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.0 + */ +abstract class AotQuery { + + private final List parameterBindings; + + AotQuery(List parameterBindings) { + this.parameterBindings = parameterBindings; + } + + /** + * @return whether the query is a {@link jakarta.persistence.EntityManager#createNativeQuery native} one. + */ + public abstract boolean isNative(); + + public List getParameterBindings() { + return parameterBindings; + } + + /** + * @return the preliminary query limit. + */ + public Limit getLimit() { + return Limit.unlimited(); + } + + /** + * @return whether the query is limited (e.g. {@code findTop10By}). + */ + public boolean isLimited() { + return getLimit().isLimited(); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueryCreator.java index f6c22ec8f..98254aff9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueryCreator.java @@ -17,22 +17,11 @@ package org.springframework.data.jpa.repository.aot.generated; import jakarta.persistence.metamodel.Metamodel; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.query.EscapeCharacter; -import org.springframework.data.jpa.repository.query.JpaParameters; -import org.springframework.data.jpa.repository.query.JpaQueryCreator; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider; -import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; -import org.springframework.data.repository.aot.generate.AotRepositoryMethodGenerationContext; -import org.springframework.data.repository.query.ParametersSource; -import org.springframework.data.repository.query.ReturnedType; -import org.springframework.data.repository.query.parser.PartTree; - /** * @author Christoph Strobl * @since 2025/01 */ -public class AotQueryCreator { +class AotQueryCreator { Metamodel metamodel; @@ -40,23 +29,6 @@ public class AotQueryCreator { this.metamodel = metamodel; } - AotStringQuery createQuery(PartTree partTree, ReturnedType returnedType, - AotRepositoryMethodGenerationContext context) { - ParametersSource parametersSource = ParametersSource.of(context.getRepositoryInformation(), context.getMethod()); - JpaParameters parameters = new JpaParameters(parametersSource); - ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT, - JpqlQueryTemplates.UPPER); - - JpaQueryCreator queryCreator = new JpaQueryCreator(partTree, returnedType, metadataProvider, - JpqlQueryTemplates.UPPER, metamodel); - AotStringQuery query = AotStringQuery.bindable(queryCreator.createQuery(), metadataProvider.getBindings()); - - if (partTree.isLimiting()) { - query.setLimit(partTree.getResultLimit()); - } - query.setCountQuery(context.annotationValue(Query.class, "countQuery")); - return query; - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotRepositoryFragmentSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotRepositoryFragmentSupport.java new file mode 100644 index 000000000..dd1deeec2 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotRepositoryFragmentSupport.java @@ -0,0 +1,118 @@ +/* + * 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.generated; + +import java.lang.reflect.Method; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Sort; +import org.springframework.data.expression.ValueEvaluationContextProvider; +import org.springframework.data.expression.ValueExpression; +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.projection.ProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.ParametersSource; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.util.ConcurrentLruCache; + +/** + * @author Mark Paluch + */ +public class AotRepositoryFragmentSupport { + + private final RepositoryMetadata repositoryMetadata; + + private final ValueExpressionDelegate valueExpressions; + + private final ProjectionFactory projectionFactory; + + private final ConcurrentLruCache enhancers; + + private final ConcurrentLruCache expressions; + + private final ConcurrentLruCache contextProviders; + + protected AotRepositoryFragmentSupport(QueryEnhancerSelector selector, + RepositoryFactoryBeanSupport.FragmentCreationContext context) { + this(selector, context.getRepositoryMetadata(), context.getValueExpressionDelegate(), + context.getProjectionFactory()); + } + + protected AotRepositoryFragmentSupport(QueryEnhancerSelector selector, RepositoryMetadata repositoryMetadata, + ValueExpressionDelegate valueExpressions, ProjectionFactory projectionFactory) { + + this.repositoryMetadata = repositoryMetadata; + this.valueExpressions = valueExpressions; + this.projectionFactory = projectionFactory; + this.enhancers = new ConcurrentLruCache<>(32, query -> selector.select(query).create(query)); + this.expressions = new ConcurrentLruCache<>(32, valueExpressions::parse); + this.contextProviders = new ConcurrentLruCache<>(32, it -> valueExpressions + .createValueContextProvider(new JpaParameters(ParametersSource.of(repositoryMetadata, it)))); + } + + /** + * Rewrite a {@link DeclaredQuery} to apply {@link Sort} and {@link Class} projection. + * + * @param query + * @param sort + * @param returnedType + * @return + */ + protected String rewriteQuery(DeclaredQuery query, Sort sort, Class returnedType) { + + QueryEnhancer queryStringEnhancer = this.enhancers.get(query); + return queryStringEnhancer.rewrite(new DefaultQueryRewriteInformation(sort, + ReturnedType.of(returnedType, repositoryMetadata.getDomainType(), projectionFactory))); + } + + /** + * Evaluate a Value Expression. + * + * @param method + * @param expressionString + * @param args + * @return + */ + protected @Nullable Object evaluateExpression(Method method, String expressionString, Object... args) { + + ValueExpression expression = this.expressions.get(expressionString); + ValueEvaluationContextProvider contextProvider = this.contextProviders.get(method); + + return expression.evaluate(contextProvider.getEvaluationContext(args, expression.getExpressionDependencies())); + } + + private record DefaultQueryRewriteInformation(Sort sort, + ReturnedType returnedType) implements QueryEnhancer.QueryRewriteInformation { + + @Override + public Sort getSort() { + return sort(); + } + + @Override + public ReturnedType getReturnedType() { + return returnedType(); + } + + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotStringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotStringQuery.java deleted file mode 100644 index 147eb0a37..000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotStringQuery.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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.generated; - -import java.util.ArrayList; -import java.util.List; - -import org.springframework.data.domain.Limit; -import org.springframework.data.jpa.repository.query.ParameterBinding; -import org.springframework.data.jpa.repository.query.ParameterBindingParser; -import org.springframework.data.jpa.repository.query.ParameterBindingParser.Metadata; -import org.springframework.data.jpa.repository.query.QueryUtils; -import org.springframework.lang.Nullable; -import org.springframework.util.StringUtils; - -/** - * @author Christoph Strobl - * @since 2025/01 - */ -class AotStringQuery { - - private final String raw; - private final String sanitized; - private @Nullable String countQuery; - private final List parameterBindings; - private final Metadata parameterMetadata; - private Limit limit; - private boolean nativeQuery; - - public AotStringQuery(String raw, String sanitized, List parameterBindings, - Metadata parameterMetadata) { - this.raw = raw; - this.sanitized = sanitized; - this.parameterBindings = parameterBindings; - this.parameterMetadata = parameterMetadata; - } - - static AotStringQuery of(String raw) { - - List bindings = new ArrayList<>(); - Metadata metadata = new Metadata(); - String targetQuery = ParameterBindingParser.INSTANCE - .parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(raw, bindings, metadata); - - return new AotStringQuery(raw, targetQuery, bindings, metadata); - } - - static AotStringQuery nativeQuery(String raw) { - AotStringQuery q = of(raw); - q.nativeQuery = true; - return q; - } - - static AotStringQuery bindable(String query, List bindings) { - return new AotStringQuery(query, query, bindings, new Metadata()); - } - - public String getQueryString() { - return sanitized; - } - - public String getCountQuery(@Nullable String projection) { - - if (StringUtils.hasText(countQuery)) { - return countQuery; - } - return QueryUtils.createCountQueryFor(sanitized, StringUtils.hasText(projection) ? projection : null, nativeQuery); - } - - public List parameterBindings() { - return this.parameterBindings; - } - - boolean isLimited() { - return limit != null && limit.isLimited(); - } - - Limit getLimit() { - return limit; - } - - public void setLimit(Limit limit) { - this.limit = limit; - } - - public boolean isNativeQuery() { - return nativeQuery; - } - - public void setCountQuery(@Nullable String countQuery) { - this.countQuery = StringUtils.hasText(countQuery) ? countQuery : null; - } -} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaCodeBlocks.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaCodeBlocks.java index cf1489a78..7bfdb0717 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaCodeBlocks.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaCodeBlocks.java @@ -24,15 +24,9 @@ import java.util.function.LongSupplier; import java.util.regex.Pattern; import org.springframework.data.domain.SliceImpl; -import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.query.DeclaredQuery; import org.springframework.data.jpa.repository.query.ParameterBinding; -import org.springframework.data.jpa.repository.query.QueryEnhancer; -import org.springframework.data.jpa.repository.query.QueryEnhancer.QueryRewriteInformation; -import org.springframework.data.jpa.repository.query.QueryEnhancerFactory; -import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.aot.generate.AotRepositoryMethodGenerationContext; -import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.javapoet.CodeBlock; import org.springframework.javapoet.CodeBlock.Builder; @@ -60,7 +54,7 @@ public class JpaCodeBlocks { static class QueryExecutionBlockBuilder { AotRepositoryMethodGenerationContext context; - private String queryVariableName; + private String queryVariableName = "query"; public QueryExecutionBlockBuilder(AotRepositoryMethodGenerationContext context) { this.context = context; @@ -130,11 +124,14 @@ public class JpaCodeBlocks { } } + /** + * Builder for the actual query code block. + */ static class QueryBlockBuilder { private final AotRepositoryMethodGenerationContext context; - private String queryVariableName; - private AotStringQuery query; + private String queryVariableName = "query"; + private AotQueries queries; public QueryBlockBuilder(AotRepositoryMethodGenerationContext context) { this.context = context; @@ -146,106 +143,59 @@ public class JpaCodeBlocks { return this; } - QueryBlockBuilder filter(String queryString) { - return filter(AotStringQuery.of(queryString)); - } - - QueryBlockBuilder filter(AotStringQuery query) { - this.query = query; + QueryBlockBuilder filter(AotQueries query) { + this.queries = query; return this; } CodeBlock build() { boolean isProjecting = context.getActualReturnType() != null - && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), - context.getActualReturnType()); + && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), + context.getActualReturnType()); Object actualReturnType = isProjecting ? context.getActualReturnType() - : context.getRepositoryInformation().getDomainType(); + : context.getRepositoryInformation().getDomainType(); CodeBlock.Builder builder = CodeBlock.builder(); builder.add("\n"); String queryStringNameVariableName = "%sString".formatted(queryVariableName); + + StringAotQuery query = (StringAotQuery) queries.result(); builder.addStatement("$T $L = $S", String.class, queryStringNameVariableName, query.getQueryString()); String countQueryStringNameVariableName = null; String countQuyerVariableName = null; + if (context.returnsPage()) { + countQueryStringNameVariableName = "count%sString".formatted(StringUtils.capitalize(queryVariableName)); countQuyerVariableName = "count%s".formatted(StringUtils.capitalize(queryVariableName)); - String projection = context.annotationValue(org.springframework.data.jpa.repository.Query.class, - "countProjection"); + + StringAotQuery countQuery = (StringAotQuery) queries.count(); builder.addStatement("$T $L = $S", String.class, countQueryStringNameVariableName, - query.getCountQuery(projection)); + countQuery.getQueryString()); } // sorting // TODO: refactor into sort builder - { - String sortParameterName = context.getSortParameterName(); - if (sortParameterName == null && context.getPageableParameterName() != null) { - sortParameterName = "%s.getSort()".formatted(context.getPageableParameterName()); - } - if (StringUtils.hasText(sortParameterName)) { - builder.beginControlFlow("if($L.isSorted())", sortParameterName); - - if(query.isNativeQuery()) { - builder.addStatement("$T declaredQuery = $T.nativeQuery($L)", DeclaredQuery.class, DeclaredQuery.class, - queryStringNameVariableName); - } else { - builder.addStatement("$T declaredQuery = $T.jpqlQuery($L)", DeclaredQuery.class, DeclaredQuery.class, - queryStringNameVariableName); - } - - String enhancerVarName = "%sEnhancer".formatted(queryStringNameVariableName); - builder.addStatement("$T $L = $T.forQuery(declaredQuery).create(declaredQuery)", QueryEnhancer.class, enhancerVarName, QueryEnhancerFactory.class); - - builder.addStatement("$L = $L.rewrite(new $T() { public $T getSort() { return $L; } public $T getReturnedType() { return $T.of($T.class, $T.class, new $T());} })", queryStringNameVariableName, enhancerVarName, QueryRewriteInformation.class, - Sort.class, sortParameterName, ReturnedType.class, ReturnedType.class, - context.getRepositoryInformation().getDomainType(), actualReturnType, SpelAwareProxyProjectionFactory.class); - - builder.endControlFlow(); - } + String sortParameterName = context.getSortParameterName(); + if (sortParameterName == null && context.getPageableParameterName() != null) { + sortParameterName = "%s.getSort()".formatted(context.getPageableParameterName()); } - addQueryBlock(builder, queryVariableName, queryStringNameVariableName, query.isNativeQuery()); - - if (context.isExistsMethod()) { - builder.addStatement("$L.setMaxResults(1)", queryVariableName); - } else { - - { - String limitParameterName = context.getLimitParameterName(); - - if (StringUtils.hasText(limitParameterName)) { - builder.beginControlFlow("if($L.isLimited())", limitParameterName); - builder.addStatement("$L.setMaxResults($L.max())", queryVariableName, limitParameterName); - builder.endControlFlow(); - } else if (query.isLimited()) { - builder.addStatement("$L.setMaxResults($L)", queryVariableName, query.getLimit().max()); - } - } - - { - String pageableParamterName = context.getPageableParameterName(); - if (StringUtils.hasText(pageableParamterName)) { - builder.beginControlFlow("if($L.isPaged())", pageableParamterName); - builder.addStatement("$L.setFirstResult(Long.valueOf($L.getOffset()).intValue())", queryVariableName, - pageableParamterName); - if (context.returnsSlice() && !context.returnsPage()) { - builder.addStatement("$L.setMaxResults($L.getPageSize() + 1)", queryVariableName, pageableParamterName); - } else { - builder.addStatement("$L.setMaxResults($L.getPageSize())", queryVariableName, pageableParamterName); - } - builder.endControlFlow(); - } - } + if (StringUtils.hasText(sortParameterName)) { + applySorting(builder, sortParameterName, queryStringNameVariableName, actualReturnType); } + addQueryBlock(builder, queryVariableName, queryStringNameVariableName, queries.result()); + + applyLimits(builder); + if (StringUtils.hasText(countQueryStringNameVariableName)) { + builder.beginControlFlow("$T $L = () ->", LongSupplier.class, "countAll"); - addQueryBlock(builder, countQuyerVariableName, countQueryStringNameVariableName, query.isNativeQuery()); + addQueryBlock(builder, countQuyerVariableName, countQueryStringNameVariableName, queries.count()); builder.addStatement("return ($T) $L.getSingleResult()", Long.class, countQuyerVariableName); // end control flow does not work well with lambdas @@ -256,16 +206,67 @@ public class JpaCodeBlocks { return builder.build(); } + private void applySorting(Builder builder, String sort, String queryString, Object actualReturnType) { + + builder.beginControlFlow("if ($L.isSorted())", sort); + + if (queries.isNative()) { + builder.addStatement("$T declaredQuery = $T.nativeQuery($L)", DeclaredQuery.class, DeclaredQuery.class, + queryString); + } else { + builder.addStatement("$T declaredQuery = $T.jpqlQuery($L)", DeclaredQuery.class, DeclaredQuery.class, + queryString); + } + + builder.addStatement("$L = rewriteQuery(declaredQuery, $L, $T.class)", queryString, sort, actualReturnType); + + builder.endControlFlow(); + } + + private void applyLimits(Builder builder) { + + if (context.isExistsMethod()) { + builder.addStatement("$L.setMaxResults(1)", queryVariableName); + + return; + } + + String limit = context.getLimitParameterName(); + + if (StringUtils.hasText(limit)) { + builder.beginControlFlow("if ($L.isLimited())", limit); + builder.addStatement("$L.setMaxResults($L.max())", queryVariableName, limit); + builder.endControlFlow(); + } else if (queries.result().isLimited()) { + builder.addStatement("$L.setMaxResults($L)", queryVariableName, queries.result().getLimit().max()); + } + + String pageable = context.getPageableParameterName(); + + if (StringUtils.hasText(pageable)) { + + builder.beginControlFlow("if ($L.isPaged())", pageable); + builder.addStatement("$L.setFirstResult(Long.valueOf($L.getOffset()).intValue())", queryVariableName, pageable); + if (context.returnsSlice() && !context.returnsPage()) { + builder.addStatement("$L.setMaxResults($L.getPageSize() + 1)", queryVariableName, pageable); + } else { + builder.addStatement("$L.setMaxResults($L.getPageSize())", queryVariableName, pageable); + } + builder.endControlFlow(); + } + } + private void addQueryBlock(Builder builder, String queryVariableName, String queryStringNameVariableName, - boolean nativeQuery) { + AotQuery query) { builder.addStatement("$T $L = this.$L.$L($L)", Query.class, queryVariableName, - context.fieldNameOf(EntityManager.class), nativeQuery ? "createNativeQuery" : "createQuery", + context.fieldNameOf(EntityManager.class), query.isNative() ? "createNativeQuery" : "createQuery", queryStringNameVariableName); - for (ParameterBinding binding : query.parameterBindings()) { + for (ParameterBinding binding : query.getParameterBindings()) { Object prepare = binding.prepare("s"); + if (prepare instanceof String prepared && !prepared.equals("s")) { String format = prepared.replaceAll("%", "%%").replace("s", "%s"); if (binding.getIdentifier().hasPosition()) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributor.java new file mode 100644 index 000000000..2d4a92bac --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributor.java @@ -0,0 +1,206 @@ +/* + * Copyright 2024 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.generated; + +import jakarta.persistence.EntityManager; + +import java.util.function.Function; +import java.util.regex.Pattern; + +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.data.jpa.projection.CollectionAwareProjectionFactory; +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.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.ParameterMetadataProvider; +import org.springframework.data.jpa.repository.query.Procedure; +import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; +import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder; +import org.springframework.data.repository.aot.generate.AotRepositoryImplementationMetadata; +import org.springframework.data.repository.aot.generate.AotRepositoryMethodBuilder; +import org.springframework.data.repository.aot.generate.AotRepositoryMethodGenerationContext; +import org.springframework.data.repository.aot.generate.RepositoryContributor; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.ParametersSource; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.javapoet.TypeName; +import org.springframework.javapoet.TypeSpec; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * @author Christoph Strobl + * @author Mark Paluch + */ +public class JpaRepositoryContributor extends RepositoryContributor { + + private final CollectionAwareProjectionFactory projectionFactory = new CollectionAwareProjectionFactory(); + private final AotQueryCreator queryCreator; + private final AotMetaModel metaModel; + + public JpaRepositoryContributor(AotRepositoryContext repositoryContext) { + super(repositoryContext); + + this.metaModel = new AotMetaModel(repositoryContext.getResolvedTypes()); + this.queryCreator = new AotQueryCreator(metaModel); + } + + @Override + protected void customizeFile(RepositoryInformation information, AotRepositoryImplementationMetadata metadata, + TypeSpec.Builder builder) { + builder.superclass(TypeName.get(AotRepositoryFragmentSupport.class)); + } + + @Override + protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { + + constructorBuilder.addParameter("entityManager", EntityManager.class); + constructorBuilder.addParameter("context", RepositoryFactoryBeanSupport.FragmentCreationContext.class); + + // TODO: Pick up the configured QueryEnhancerSelector + constructorBuilder.customize((repositoryInformation, builder) -> { + builder.addStatement("super($T.DEFAULT_SELECTOR, context)", QueryEnhancerSelector.class); + }); + } + + @Override + protected AotRepositoryMethodBuilder contributeRepositoryMethod( + AotRepositoryMethodGenerationContext generationContext) { + + QueryEnhancerSelector selector = QueryEnhancerSelector.DEFAULT_SELECTOR; + + // no stored procedures for now. + if (AnnotatedElementUtils.findMergedAnnotation(generationContext.getMethod(), Procedure.class) != null) { + return null; + } + + Query queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(generationContext.getMethod(), Query.class); + if (queryAnnotation != null) { + if (StringUtils.hasText(queryAnnotation.value()) + && Pattern.compile("[\\?:][#$]\\{.*\\}").matcher(queryAnnotation.value()).find()) { + return null; + } + } + + // TODO: Named query via EntityManager, NamedQuery via properties, also for count queries. + + return new AotRepositoryMethodBuilder(generationContext).customize((context, body) -> { + + MergedAnnotations annotations = MergedAnnotations.from(context.getMethod()); + + MergedAnnotation query = annotations.get(Query.class); + MergedAnnotation nativeQuery = annotations.get(NativeQuery.class); + MergedAnnotation queryHints = annotations.get(QueryHints.class); + + body.addCode(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); + + AotQueries aotQueries; + if (query.isPresent() && StringUtils.hasText(query.getString("value"))) { + aotQueries = buildStringQuery(selector, query); + } else { + aotQueries = buildPartTreeQuery(context, query); + } + + body.addCode(JpaCodeBlocks.queryBlockBuilder(context).filter(aotQueries).build()); + body.addCode(JpaCodeBlocks.queryExecutionBlockBuilder(context).build()); + }); + } + + private AotQueries buildStringQuery(QueryEnhancerSelector selector, MergedAnnotation query) { + + Function queryFunction = query.getBoolean("nativeQuery") ? StringAotQuery::nativeQuery + : StringAotQuery::jpqlQuery; + + StringAotQuery aotStringQuery = queryFunction.apply(query.getString("value")); + String countQuery = query.getString("countQuery"); + + if (StringUtils.hasText(countQuery)) { + return AotQueries.from(aotStringQuery, queryFunction.apply(countQuery)); + } + + String countProjection = query.getString("countProjection"); + return AotQueries.from(aotStringQuery, countProjection, selector); + } + + private AotQueries buildPartTreeQuery(AotRepositoryMethodGenerationContext context, MergedAnnotation query) { + + PartTree partTree = new PartTree(context.getMethod().getName(), context.getRepositoryInformation().getDomainType()); + // TODO make configurable + JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER; + + boolean isProjecting = context.getActualReturnType() != null + && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), + context.getActualReturnType()); + + Class actualReturnType; + try { + actualReturnType = isProjecting + ? ClassUtils.forName(context.getActualReturnType().toString(), context.getClass().getClassLoader()) + : context.getRepositoryInformation().getDomainType(); + } catch (ClassNotFoundException e) { + throw new RuntimeException(e); + } + + ReturnedType returnedType = ReturnedType.of(actualReturnType, context.getRepositoryInformation().getDomainType(), + projectionFactory); + + ParametersSource parametersSource = ParametersSource.of(context.getRepositoryInformation(), context.getMethod()); + JpaParameters parameters = new JpaParameters(parametersSource); + + AotQuery partTreeQuery = createQuery(partTree, returnedType, parameters, templates); + + if (query.isPresent() && StringUtils.hasText(query.getString("countQuery"))) { + return AotQueries.from(partTreeQuery, StringAotQuery.jpqlQuery(query.getString("countQuery"))); + } + + AotQuery partTreeCountQuery = createCountQuery(partTree, returnedType, parameters, templates); + return AotQueries.from(partTreeQuery, 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()); + } + + 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); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepsoitoryContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepsoitoryContributor.java deleted file mode 100644 index 57660bccc..000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepsoitoryContributor.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2024 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.generated; - -import jakarta.persistence.EntityManager; - -import java.util.regex.Pattern; - -import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.data.jpa.projection.CollectionAwareProjectionFactory; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder; -import org.springframework.data.repository.aot.generate.AotRepositoryMethodBuilder; -import org.springframework.data.repository.aot.generate.AotRepositoryMethodGenerationContext; -import org.springframework.data.repository.aot.generate.RepositoryContributor; -import org.springframework.data.repository.config.AotRepositoryContext; -import org.springframework.data.repository.query.ReturnedType; -import org.springframework.data.repository.query.parser.PartTree; -import org.springframework.javapoet.TypeName; -import org.springframework.util.ClassUtils; -import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; - -/** - * @author Christoph Strobl - */ -public class JpaRepsoitoryContributor extends RepositoryContributor { - - AotQueryCreator queryCreator; - AotMetaModel metaModel; - - public JpaRepsoitoryContributor(AotRepositoryContext repositoryContext) { - super(repositoryContext); - - metaModel = new AotMetaModel(repositoryContext.getResolvedTypes()); - this.queryCreator = new AotQueryCreator(metaModel); - } - - @Override - protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { - constructorBuilder.addParameter("entityManager", TypeName.get(EntityManager.class)); - } - - @Override - protected AotRepositoryMethodBuilder contributeRepositoryMethod( - AotRepositoryMethodGenerationContext generationContext) { - - { - Query queryAnnotation = AnnotatedElementUtils.findMergedAnnotation(generationContext.getMethod(), Query.class); - if (queryAnnotation != null) { - if (StringUtils.hasText(queryAnnotation.value()) - && Pattern.compile("[\\?:][#$]\\{.*\\}").matcher(queryAnnotation.value()).find()) { - return null; - } - } - } - - return new AotRepositoryMethodBuilder(generationContext).customize((context, body) -> { - - Query query = AnnotatedElementUtils.findMergedAnnotation(context.getMethod(), Query.class); - if (query != null && StringUtils.hasText(query.value())) { - - AotStringQuery aotStringQuery = query.nativeQuery() ? AotStringQuery.nativeQuery(query.value()) - : AotStringQuery.of(query.value()); - aotStringQuery.setCountQuery(query.countQuery()); - body.addCode(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); - - body.addCode( - - JpaCodeBlocks.queryBlockBuilder(context).usingQueryVariableName("query").filter(aotStringQuery).build()); - } else { - - PartTree partTree = new PartTree(context.getMethod().getName(), - context.getRepositoryInformation().getDomainType()); - - CollectionAwareProjectionFactory projectionFactory = new CollectionAwareProjectionFactory(); - - boolean isProjecting = context.getActualReturnType() != null - && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), - context.getActualReturnType()); - - Class actualReturnType = context.getRepositoryInformation().getDomainType(); - try { - actualReturnType = isProjecting - ? ClassUtils.forName(context.getActualReturnType().toString(), context.getClass().getClassLoader()) - : context.getRepositoryInformation().getDomainType(); - } catch (ClassNotFoundException e) { - throw new RuntimeException(e); - } - - ReturnedType returnedType = ReturnedType.of(actualReturnType, - context.getRepositoryInformation().getDomainType(), projectionFactory); - AotStringQuery stringQuery = queryCreator.createQuery(partTree, returnedType, context); - - body.addCode(context.codeBlocks().logDebug("invoking [%s]".formatted(context.getMethod().getName()))); - body.addCode( - JpaCodeBlocks.queryBlockBuilder(context).usingQueryVariableName("query").filter(stringQuery).build()); - } - body.addCode(JpaCodeBlocks.queryExecutionBlockBuilder(context).referencing("query").build()); - }); - } -} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/StringAotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/StringAotQuery.java new file mode 100644 index 000000000..c9a0d318f --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/StringAotQuery.java @@ -0,0 +1,131 @@ +/* + * 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.generated; + +import java.util.List; + +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; + +/** + * An AOT query represented by a string. + * + * @author Mark Paluch + * @since 4.0 + */ +abstract class StringAotQuery extends AotQuery { + + private StringAotQuery(List parameterBindings) { + super(parameterBindings); + } + + static StringAotQuery of(DeclaredQuery query) { + + if (query instanceof PreprocessedQuery pq) { + return new DeclaredAotQuery(pq); + } + + return new DeclaredAotQuery(PreprocessedQuery.parse(query)); + } + + static StringAotQuery jpqlQuery(String queryString) { + return of(DeclaredQuery.jpqlQuery(queryString)); + } + + public static StringAotQuery jpqlQuery(String queryString, List bindings, Limit resultLimit) { + return new LimitedAotQuery(queryString, bindings, resultLimit); + } + + static StringAotQuery nativeQuery(String queryString) { + return of(DeclaredQuery.nativeQuery(queryString)); + } + + public abstract DeclaredQuery getQuery(); + + public abstract String getQueryString(); + + @Override + public String toString() { + return getQueryString(); + } + + /** + * @author Christoph Strobl + * @author Mark Paluch + */ + static class DeclaredAotQuery extends StringAotQuery { + + private final PreprocessedQuery query; + + DeclaredAotQuery(PreprocessedQuery query) { + super(query.getBindings()); + this.query = query; + } + + @Override + public String getQueryString() { + return query.getQueryString(); + } + + @Override + public boolean isNative() { + return query.isNative(); + } + + public PreprocessedQuery getQuery() { + return query; + } + + } + + /** + * @author Mark Paluch + */ + static class LimitedAotQuery extends StringAotQuery { + + private final String queryString; + private final Limit limit; + + LimitedAotQuery(String queryString, List parameterBindings, Limit limit) { + super(parameterBindings); + this.queryString = queryString; + this.limit = limit; + } + + @Override + public DeclaredQuery getQuery() { + return DeclaredQuery.jpqlQuery(queryString); + } + + @Override + public String getQueryString() { + return queryString; + } + + @Override + public boolean isNative() { + return false; + } + + @Override + public Limit getLimit() { + return limit; + } + + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java index 44127c452..742387add 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java @@ -15,9 +15,7 @@ */ package org.springframework.data.jpa.repository.config; -import static org.springframework.data.jpa.repository.config.BeanDefinitionNames.EM_BEAN_DEFINITION_REGISTRAR_POST_PROCESSOR_BEAN_NAME; -import static org.springframework.data.jpa.repository.config.BeanDefinitionNames.JPA_CONTEXT_BEAN_NAME; -import static org.springframework.data.jpa.repository.config.BeanDefinitionNames.JPA_MAPPING_CONTEXT_BEAN_NAME; +import static org.springframework.data.jpa.repository.config.BeanDefinitionNames.*; import jakarta.persistence.Entity; import jakarta.persistence.MappedSuperclass; @@ -54,7 +52,7 @@ import org.springframework.dao.DataAccessException; import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor; import org.springframework.data.aot.AotContext; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.aot.generated.JpaRepsoitoryContributor; +import org.springframework.data.jpa.repository.aot.generated.JpaRepositoryContributor; import org.springframework.data.jpa.repository.support.DefaultJpaContext; import org.springframework.data.jpa.repository.support.EntityManagerBeanDefinitionRegistrarPostProcessor; import org.springframework.data.jpa.repository.support.JpaEvaluationContextExtension; @@ -335,7 +333,7 @@ public class JpaRepositoryConfigExtension extends RepositoryConfigurationExtensi return null; } - return new JpaRepsoitoryContributor(repositoryContext); + return new JpaRepositoryContributor(repositoryContext); } @Nullable diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java index 886cb5b4d..c0f5c49d7 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java @@ -16,6 +16,7 @@ package org.springframework.data.jpa.repository.query; import jakarta.persistence.EntityManager; +import jakarta.persistence.metamodel.Metamodel; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; @@ -53,6 +54,24 @@ public class JpaCountQueryCreator extends JpaQueryCreator { this.returnedType = returnedType; } + /** + * Creates a new {@link JpaCountQueryCreator} + * + * @param tree + * @param returnedType + * @param provider + * @param templates + * @param metamodel + */ + public JpaCountQueryCreator(PartTree tree, ReturnedType returnedType, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, Metamodel metamodel) { + + super(tree, returnedType, provider, templates, metamodel); + + this.distinct = tree.isDistinct(); + this.returnedType = returnedType; + } + @Override protected JpqlQueryBuilder.Select buildQuery(Sort sort) { JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(returnedType.getDomainType()); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java index c9171b203..6f36ac80a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java @@ -15,7 +15,7 @@ */ package org.springframework.data.jpa.repository.query; -import static java.util.regex.Pattern.CASE_INSENSITIVE; +import static java.util.regex.Pattern.*; import java.util.ArrayList; import java.util.Collection; @@ -60,7 +60,7 @@ import org.springframework.util.StringUtils; * @author Mark Paluch * @since 4.0 */ -final class PreprocessedQuery implements DeclaredQuery { +public final class PreprocessedQuery implements DeclaredQuery { private final DeclaredQuery source; private final List bindings; @@ -127,7 +127,7 @@ final class PreprocessedQuery implements DeclaredQuery { return usesJdbcStyleParameters; } - List getBindings() { + public List getBindings() { return Collections.unmodifiableList(bindings); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/AotFragmentTestConfigurationSupport.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/AotFragmentTestConfigurationSupport.java new file mode 100644 index 000000000..15e260611 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,140 @@ +/* + * 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.generated; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; + +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; + +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanFactoryPostProcessor; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ImportResource; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.orm.jpa.SharedEntityManagerCreator; +import org.springframework.util.ReflectionUtils; + +/** + * Test Configuration Support Class for generated AOT Repository Fragments based on a Repository Interface. + *

+ * This configuration generates the AOT repository, compiles sources and configures a BeanFactory to contain the AOT + * fragment. Additionally, the fragment is exposed through a {@code repositoryInterface} JDK proxy forwarding method + * invocations to the backing AOT fragment. Note that {@code repositoryInterface} is not a repository proxy. + * + * @author Mark Paluch + */ +@ImportResource("classpath:/infrastructure.xml") +class AotFragmentTestConfigurationSupport implements BeanFactoryPostProcessor { + + private final Class repositoryInterface; + private final TestJpaAotRepositoryContext repositoryContext; + + public AotFragmentTestConfigurationSupport(Class repositoryInterface) { + this.repositoryInterface = repositoryInterface; + this.repositoryContext = new TestJpaAotRepositoryContext<>(UserRepository.class, null); + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + + TestGenerationContext generationContext = new TestGenerationContext(UserRepository.class); + + new JpaRepositoryContributor(repositoryContext).contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition(repositoryInterface.getName() + "Impl__Aot") + .addConstructorArgReference("jpaSharedEM_entityManagerFactory") + .addConstructorArgValue(getCreationContext(repositoryContext)).getBeanDefinition(); + + TestCompiler.forSystem().with(generationContext).compile(compiled -> { + beanFactory.setBeanClassLoader(compiled.getClassLoader()); + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragment", aotGeneratedRepository); + }); + + BeanDefinition fragmentFacade = BeanDefinitionBuilder.rootBeanDefinition((Class) repositoryInterface, () -> { + + Object fragment = beanFactory.getBean("fragment"); + Object proxy = getFragmentFacadeProxy(fragment); + + return repositoryInterface.cast(proxy); + }).getBeanDefinition(); + + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragmentFacade", fragmentFacade); + } + + private Object getFragmentFacadeProxy(Object fragment) { + + return Proxy.newProxyInstance(repositoryInterface.getClassLoader(), new Class[] { repositoryInterface }, + (p, method, args) -> { + + Method target = ReflectionUtils.findMethod(fragment.getClass(), method.getName(), method.getParameterTypes()); + + if (target == null) { + throw new NoSuchMethodException("Method [%s] is not implemented by [%s]".formatted(method, target)); + } + + try { + return target.invoke(fragment, args); + } catch (ReflectiveOperationException e) { + ReflectionUtils.handleReflectionException(e); + } + + return null; + }); + } + + @Bean("jpaSharedEM_entityManagerFactory") + EntityManager sharedEntityManagerCreator(EntityManagerFactory emf) { + return SharedEntityManagerCreator.createSharedEntityManager(emf); + } + + private RepositoryFactoryBeanSupport.FragmentCreationContext getCreationContext( + TestJpaAotRepositoryContext repositoryContext) { + + RepositoryFactoryBeanSupport.FragmentCreationContext creationContext = new RepositoryFactoryBeanSupport.FragmentCreationContext() { + @Override + public RepositoryMetadata getRepositoryMetadata() { + return repositoryContext.getRepositoryInformation(); + } + + @Override + public ValueExpressionDelegate getValueExpressionDelegate() { + return ValueExpressionDelegate.create(); + } + + @Override + public ProjectionFactory getProjectionFactory() { + return new SpelAwareProxyProjectionFactory(); + } + }; + + return creationContext; + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorIntegrationTests.java new file mode 100644 index 000000000..582b47627 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorIntegrationTests.java @@ -0,0 +1,356 @@ +/* + * Copyright 2024 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.generated; + +import static org.assertj.core.api.Assertions.*; + +import jakarta.persistence.EntityManager; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +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.User; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration tests for the {@link UserRepository} AOT fragment. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +@SpringJUnitConfig(classes = JpaRepositoryContributorIntegrationTests.JpaRepositoryContributorConfiguration.class) +@Transactional +class JpaRepositoryContributorIntegrationTests { + + @Autowired UserRepository fragment; + @Autowired EntityManager em; + + @Configuration + static class JpaRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + public JpaRepositoryContributorConfiguration() { + super(UserRepository.class); + } + } + + @BeforeEach + void beforeEach() { + + em.createQuery("DELETE FROM %s".formatted(User.class.getName())).executeUpdate(); + + User luke = new User("Luke", "Skywalker", "luke@jedi.org"); + em.persist(luke); + + User leia = new User("Leia", "Organa", "leia@resistance.gov"); + em.persist(leia); + + User han = new User("Han", "Solo", "han@smuggler.net"); + em.persist(han); + + User chewbacca = new User("Chewbacca", "n/a", "chewie@smuggler.net"); + em.persist(chewbacca); + + User yoda = new User("Yoda", "n/a", "yoda@jedi.org"); + em.persist(yoda); + + User vader = new User("Anakin", "Skywalker", "vader@empire.com"); + em.persist(vader); + + User kylo = new User("Ben", "Solo", "kylo@new-empire.com"); + em.persist(kylo); + } + + @Test + void testFindDerivedFinderSingleEntity() { + + User user = fragment.findByEmailAddress("luke@jedi.org"); + assertThat(user.getLastname()).isEqualTo("Skywalker"); + } + + @Test + void testFindDerivedFinderOptionalEntity() { + + Optional user = fragment.findOptionalOneByEmailAddress("yoda@jedi.org"); + assertThat(user).isNotNull().containsInstanceOf(User.class) + .hasValueSatisfying(it -> assertThat(it).extracting(User::getFirstname).isEqualTo("Yoda")); + } + + @Test + void testDerivedCount() { + + Long value = fragment.countUsersByLastname("Skywalker"); + assertThat(value).isEqualTo(2L); + } + + @Test + void testDerivedExists() { + + Boolean exists = fragment.existsUserByLastname("Skywalker"); + assertThat(exists).isTrue(); + } + + @Test + void testDerivedFinderWithoutArguments() { + + List users = fragment.findUserNoArgumentsBy(); + assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class); + } + + @Test + void testDerivedFinderReturningList() { + + List users = fragment.findByLastnameStartingWith("S"); + assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("luke@jedi.org", "vader@empire.com", + "kylo@new-empire.com", "han@smuggler.net"); + } + + @Test + void testLimitedDerivedFinder() { + + List users = fragment.findTop2ByLastnameStartingWith("S"); + assertThat(users).hasSize(2); + } + + @Test + void testSortedDerivedFinder() { + + List users = fragment.findByLastnameStartingWithOrderByEmailAddress("S"); + assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com", + "luke@jedi.org", "vader@empire.com"); + } + + @Test + void testDerivedFinderWithLimitArgument() { + + List users = fragment.findByLastnameStartingWith("S", Limit.of(2)); + assertThat(users).hasSize(2); + } + + @Test + void testDerivedFinderWithSort() { + + List users = fragment.findByLastnameStartingWith("S", Sort.by("emailAddress")); + assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com", + "luke@jedi.org", "vader@empire.com"); + } + + @Test + void testDerivedFinderWithSortAndLimit() { + + List users = fragment.findByLastnameStartingWith("S", Sort.by("emailAddress"), Limit.of(2)); + assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); + } + + @Test + void testDerivedFinderReturningListWithPageable() { + + List users = fragment.findByLastnameStartingWith("S", PageRequest.of(0, 2, Sort.by("emailAddress"))); + assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); + } + + @Test + void testDerivedFinderReturningPage() { + + Page page = fragment.findPageOfUsersByLastnameStartingWith("S", + PageRequest.of(0, 2, Sort.by("emailAddress"))); + + assertThat(page.getTotalElements()).isEqualTo(4); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", + "kylo@new-empire.com"); + } + + @Test + void testDerivedFinderReturningSlice() { + + Slice slice = fragment.findSliceOfUserByLastnameStartingWith("S", + PageRequest.of(0, 2, Sort.by("emailAddress"))); + + assertThat(slice.hasNext()).isTrue(); + assertThat(slice.getSize()).isEqualTo(2); + assertThat(slice.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", + "kylo@new-empire.com"); + } + + @Test + void testAnnotatedFinderReturningSingleValueWithQuery() { + + User user = fragment.findAnnotatedQueryByEmailAddress("yoda@jedi.org"); + assertThat(user).isNotNull().extracting(User::getFirstname).isEqualTo("Yoda"); + } + + @Test + void testAnnotatedFinderReturningListWithQuery() { + + List users = fragment.findAnnotatedQueryByLastname("S"); + assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", + "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); + } + + @Test + void testAnnotatedFinderUsingNamedParameterPlaceholderReturningListWithQuery() { + + List users = fragment.findAnnotatedQueryByLastnameParameter("S"); + assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", + "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); + } + + @Test + void testAnnotatedMultilineFinderWithQuery() { + + List users = fragment.findAnnotatedMultilineQueryByLastname("S"); + assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", + "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); + } + + @Test + void testAnnotatedFinderWithQueryAndLimit() { + + List users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2)); + assertThat(users).hasSize(2); + } + + @Test + void testAnnotatedFinderWithQueryAndSort() { + + List users = fragment.findAnnotatedQueryByLastname("S", Sort.by("emailAddress")); + assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com", + "luke@jedi.org", "vader@empire.com"); + } + + @Test + void testAnnotatedFinderWithQueryLimitAndSort() { + + List users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2), Sort.by("emailAddress")); + assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); + } + + @Test + void testAnnotatedFinderReturningListWithPageable() { + + List users = fragment.findAnnotatedQueryByLastname("S", PageRequest.of(0, 2, Sort.by("emailAddress"))); + assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); + } + + @Test + void testAnnotatedFinderReturningPage() { + + Page page = fragment.findAnnotatedQueryPageOfUsersByLastname("S", + PageRequest.of(0, 2, Sort.by("emailAddress"))); + + assertThat(page.getTotalElements()).isEqualTo(4); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", + "kylo@new-empire.com"); + } + + @Test + void testPagingAnnotatedQueryWithSort() { + + Page page = fragment.findAnnotatedQueryPageWithStaticSort("S", PageRequest.of(0, 2, Sort.unsorted())); + + assertThat(page.getTotalElements()).isEqualTo(4); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("luke@jedi.org", + "vader@empire.com"); + } + + @Test + void testAnnotatedFinderReturningSlice() { + + Slice slice = fragment.findAnnotatedQuerySliceOfUsersByLastname("S", + PageRequest.of(0, 2, Sort.by("emailAddress"))); + assertThat(slice.hasNext()).isTrue(); + assertThat(slice.getSize()).isEqualTo(2); + assertThat(slice.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", + "kylo@new-empire.com"); + } + + @Test + void testDerivedFinderReturningListOfProjections() { + + List users = fragment.findUserProjectionByLastnameStartingWith("S"); + assertThat(users).extracting(UserDtoProjection::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", + "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); + } + + @Test + void testDerivedFinderReturningPageOfProjections() { + + // TODO: query.setParameter(1, "%s%%".formatted(lastname)); + Page page = fragment.findUserProjectionByLastnameStartingWith("S", + PageRequest.of(0, 2, Sort.by("emailAddress"))); + + assertThat(page.getTotalElements()).isEqualTo(4); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getContent()).extracting(UserDtoProjection::getEmailAddress).containsExactly("han@smuggler.net", + "kylo@new-empire.com"); + } + + // modifying + + @Test + void testDerivedDeleteSingle() { + + User result = fragment.deleteByEmailAddress("yoda@jedi.org"); + + assertThat(result).isNotNull().extracting(User::getEmailAddress).isEqualTo("yoda@jedi.org"); + + Object yodaShouldBeGone = em + .createQuery("SELECT u FROM %s u WHERE u.emailAddress = 'yoda@jedi.org'".formatted(User.class.getName())) + .getSingleResultOrNull(); + assertThat(yodaShouldBeGone).isNull(); + } + + // native queries + + @Test + void nativeQuery() { + + Page page = fragment.findByNativeQueryWithPageable(PageRequest.of(0, 2)); + + assertThat(page.getTotalElements()).isEqualTo(7); + assertThat(page.getSize()).isEqualTo(2); + assertThat(page.getContent()).containsExactly("Anakin", "Ben"); + } + + // old stuff below + + // TODO: + void todo() { + + // interface projections + // named queries + + // query hints + // entity graphs + // native queries + // delete + // @Modifying + // flush / clear + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorUnitTests.java deleted file mode 100644 index b6471ea1a..000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorUnitTests.java +++ /dev/null @@ -1,614 +0,0 @@ -/* - * Copyright 2024 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.generated; - -import static org.assertj.core.api.Assertions.assertThat; - -import jakarta.persistence.EntityManager; - -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Map.Entry; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Supplier; - -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.aot.test.generate.TestGenerationContext; -import org.springframework.beans.factory.config.BeanDefinition; -import org.springframework.beans.factory.support.AbstractBeanDefinition; -import org.springframework.beans.factory.support.BeanDefinitionBuilder; -import org.springframework.beans.factory.support.DefaultListableBeanFactory; -import org.springframework.beans.factory.xml.XmlBeanDefinitionReader; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.Resource; -import org.springframework.core.test.tools.TestCompiler; -import org.springframework.data.domain.Limit; -import org.springframework.data.domain.Page; -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.User; -import org.springframework.data.util.Lazy; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.support.TransactionTemplate; - -import com.example.UserDtoProjection; -import com.example.UserRepository; - -/** - * @author Christoph Strobl - */ -class JpaRepositoryContributorUnitTests { - - private static Verifyer generated; - - @BeforeAll - static void beforeAll() { - - TestJpaAotRepsitoryContext aotContext = new TestJpaAotRepsitoryContext(UserRepository.class, null); - TestGenerationContext generationContext = new TestGenerationContext(UserRepository.class); - - new JpaRepsoitoryContributor(aotContext).contribute(generationContext); - - AbstractBeanDefinition emBeanDefinition = BeanDefinitionBuilder - .rootBeanDefinition("org.springframework.orm.jpa.SharedEntityManagerCreator") - .setFactoryMethod("createSharedEntityManager").addConstructorArgReference("entityManagerFactory") - .setLazyInit(true).getBeanDefinition(); - - AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder - .genericBeanDefinition("com.example.UserRepositoryImpl__Aot") - .addConstructorArgReference("jpaSharedEM_entityManagerFactory").getBeanDefinition(); - - - /* - alter the RepositoryFactory so we can write generated calsses into a supplier and then write some custom code for instantiation - on JpaRepositoryFactoryBean - - beanDefinition.getPropertyValues().addPropertyValue("aotImplementation", new Function() { - - public Instance apply(BeanFactory beanFactor) { - EntityManager em = beanFactory.getBean(EntityManger.class); - return new com.example.UserRepositoryImpl__Aot(em); - } - }); - */ - - // register a dedicated factory that can read stuff - // don't write to spring.factories or uas another name for it - // maybe write the code directly to a repo fragment - // repo does not have to be a bean, but can be a method called by some component - // pass list to entiy manager to have stuff in memory have to list written out directly when creating the bean - - generated = generateContext(generationContext) // - .registerBeansFrom(new ClassPathResource("infrastructure.xml")) - .register("jpaSharedEM_entityManagerFactory", emBeanDefinition) - .register("aotUserRepository", aotGeneratedRepository); - } - - @BeforeEach - public void beforeEach() { - - generated.doWithBean(EntityManager.class, em -> { - - em.createQuery("DELETE FROM %s".formatted(User.class.getName())).executeUpdate(); - - User luke = new User("Luke", "Skywalker", "luke@jedi.org"); - em.persist(luke); - - User leia = new User("Leia", "Organa", "leia@resistance.gov"); - em.persist(leia); - - User han = new User("Han", "Solo", "han@smuggler.net"); - em.persist(han); - - User chewbacca = new User("Chewbacca", "n/a", "chewie@smuggler.net"); - em.persist(chewbacca); - - User yoda = new User("Yoda", "n/a", "yoda@jedi.org"); - em.persist(yoda); - - User vader = new User("Anakin", "Skywalker", "vader@empire.com"); - em.persist(vader); - - User kylo = new User("Ben", "Solo", "kylo@new-empire.com"); - em.persist(kylo); - }); - } - - @Test - void testFindDerivedFinderSingleEntity() { - - generated.verify(methodInvoker -> { - - User user = methodInvoker.invoke("findByEmailAddress", "luke@jedi.org").onBean("aotUserRepository"); - assertThat(user.getLastname()).isEqualTo("Skywalker"); - }); - } - - @Test - void testFindDerivedFinderOptionalEntity() { - - generated.verify(methodInvoker -> { - - Optional user = methodInvoker.invoke("findOptionalOneByEmailAddress", "yoda@jedi.org") - .onBean("aotUserRepository"); - assertThat(user).isNotNull().containsInstanceOf(User.class) - .hasValueSatisfying(it -> assertThat(it).extracting(User::getFirstname).isEqualTo("Yoda")); - }); - } - - @Test - void testDerivedCount() { - - generated.verify(methodInvoker -> { - - Long value = methodInvoker.invoke("countUsersByLastname", "Skywalker").onBean("aotUserRepository"); - assertThat(value).isEqualTo(2L); - }); - } - - @Test - void testDerivedExists() { - - generated.verify(methodInvoker -> { - - Boolean exists = methodInvoker.invoke("existsUserByLastname", "Skywalker").onBean("aotUserRepository"); - assertThat(exists).isTrue(); - }); - } - - @Test - void testDerivedFinderWithoutArguments() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findUserNoArgumentsBy").onBean("aotUserRepository"); - assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class); - }); - } - - @Test - void testDerivedFinderReturningList() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S").onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("luke@jedi.org", "vader@empire.com", - "kylo@new-empire.com", "han@smuggler.net"); - }); - } - - @Test - void testLimitedDerivedFinder() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findTop2ByLastnameStartingWith", "S").onBean("aotUserRepository"); - assertThat(users).hasSize(2); - }); - } - - @Test - void testSortedDerivedFinder() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWithOrderByEmailAddress", "S") - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com", - "luke@jedi.org", "vader@empire.com"); - }); - } - - @Test - void testDerivedFinderWithLimitArgument() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S", Limit.of(2)) - .onBean("aotUserRepository"); - assertThat(users).hasSize(2); - }); - } - - @Test - void testDerivedFinderWithSort() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S", Sort.by("emailAddress")) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com", - "luke@jedi.org", "vader@empire.com"); - }); - } - - @Test - void testDerivedFinderWithSortAndLimit() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findByLastnameStartingWith", "S", Sort.by("emailAddress"), Limit.of(2)) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); - }); - } - - @Test - void testDerivedFinderReturningListWithPageable() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker - .invoke("findByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("emailAddress"))) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); - }); - } - - @Test - void testDerivedFinderReturningPage() { - - generated.verify(methodInvoker -> { - - Page page = methodInvoker - .invoke("findPageOfUsersByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("emailAddress"))) - .onBean("aotUserRepository"); - assertThat(page.getTotalElements()).isEqualTo(4); - assertThat(page.getSize()).isEqualTo(2); - assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", - "kylo@new-empire.com"); - }); - } - - @Test - void testDerivedFinderReturningSlice() { - - generated.verify(methodInvoker -> { - - Slice slice = methodInvoker - .invoke("findSliceOfUserByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("emailAddress"))) - .onBean("aotUserRepository"); - assertThat(slice.hasNext()).isTrue(); - assertThat(slice.getSize()).isEqualTo(2); - assertThat(slice.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", - "kylo@new-empire.com"); - }); - } - - @Test - void testAnnotatedFinderReturningSingleValueWithQuery() { - - generated.verify(methodInvoker -> { - - User user = methodInvoker.invoke("findAnnotatedQueryByEmailAddress", "yoda@jedi.org").onBean("aotUserRepository"); - assertThat(user).isNotNull().extracting(User::getFirstname).isEqualTo("Yoda"); - }); - } - - @Test - void testAnnotatedFinderReturningListWithQuery() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S").onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", - "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); - }); - } - - @Test - void testAnnotatedFinderUsingNamedParameterPlaceholderReturningListWithQuery() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastnameParamter", "S").onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", - "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); - }); - } - - @Test - void testAnnotatedMultilineFinderWithQuery() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedMultilineQueryByLastname", "S").onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", - "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); - }); - } - - @Test - void testAnnotatedFinderWithQueryAndLimit() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S", Limit.of(2)) - .onBean("aotUserRepository"); - assertThat(users).hasSize(2); - }); - } - - @Test - void testAnnotatedFinderWithQueryAndSort() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S", Sort.by("emailAddress")) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com", - "luke@jedi.org", "vader@empire.com"); - }); - } - - @Test - void testAnnotatedFinderWithQueryLimitAndSort() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findAnnotatedQueryByLastname", "S", Limit.of(2), Sort.by("emailAddress")) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); - }); - } - - @Test - void testAnnotatedFinderReturningListWithPageable() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker - .invoke("findAnnotatedQueryByLastname", "S", PageRequest.of(0, 2, Sort.by("emailAddress"))) - .onBean("aotUserRepository"); - assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com"); - }); - } - - @Test - void testAnnotatedFinderReturningPage() { - - generated.verify(methodInvoker -> { - - Page page = methodInvoker - .invoke("findAnnotatedQueryPageOfUsersByLastname", "S", PageRequest.of(0, 2, Sort.by("emailAddress"))) - .onBean("aotUserRepository"); - assertThat(page.getTotalElements()).isEqualTo(4); - assertThat(page.getSize()).isEqualTo(2); - assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", - "kylo@new-empire.com"); - }); - } - - @Test - void testAnnotatedFinderReturningSlice() { - - generated.verify(methodInvoker -> { - - Slice slice = methodInvoker - .invoke("findAnnotatedQuerySliceOfUsersByLastname", "S", PageRequest.of(0, 2, Sort.by("emailAddress"))) - .onBean("aotUserRepository"); - assertThat(slice.hasNext()).isTrue(); - assertThat(slice.getSize()).isEqualTo(2); - assertThat(slice.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", - "kylo@new-empire.com"); - }); - } - - @Test - void testDerivedFinderReturningListOfProjections() { - - generated.verify(methodInvoker -> { - - List users = methodInvoker.invoke("findUserProjectionByLastnameStartingWith", "S") - .onBean("aotUserRepository"); - assertThat(users).extracting(UserDtoProjection::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net", - "kylo@new-empire.com", "luke@jedi.org", "vader@empire.com"); - }); - } - - @Test - void testDerivedFinderReturningPageOfProjections() { - - generated.verify(methodInvoker -> { - - Page page = methodInvoker - .invoke("findUserProjectionByLastnameStartingWith", "S", PageRequest.of(0, 2, Sort.by("emailAddress"))) - .onBean("aotUserRepository"); - - assertThat(page.getTotalElements()).isEqualTo(4); - assertThat(page.getSize()).isEqualTo(2); - assertThat(page.getContent()).extracting(UserDtoProjection::getEmailAddress).containsExactly("han@smuggler.net", - "kylo@new-empire.com"); - }); - } - - // modifying - - @Test - void testDerivedDeleteSingle() { - - generated.verifyInTx(methodInvoker -> { - - User result = methodInvoker.invoke("deleteByEmailAddress", "yoda@jedi.org").onBean("aotUserRepository"); - - assertThat(result).isNotNull().extracting(User::getEmailAddress).isEqualTo("yoda@jedi.org"); - }).doWithBean(EntityManager.class, em -> { - Object yodaShouldBeGone = em - .createQuery("SELECT u FROM %s u WHERE u.emailAddress = 'yoda@jedi.org'".formatted(User.class.getName())) - .getSingleResultOrNull(); - assertThat(yodaShouldBeGone).isNull(); - }); - } - - // native queries - - @Test - void nativeQuery() { - - generated.verify(methodInvoker -> { - - Page page = methodInvoker - .invoke("findByNativeQueryWithPageable", PageRequest.of(0, 2)) - .onBean("aotUserRepository"); - - assertThat(page.getTotalElements()).isEqualTo(7); - assertThat(page.getSize()).isEqualTo(2); - assertThat(page.getContent()).containsExactly("Anakin", "Ben"); - }); - } - - // old stuff below - - // TODO: - void todo() { - - // Query q; - // q.setMaxResults() - // q.setFirstResult() - - // 1 build some more stuff from below - // 2 set up boot sample project in data samples - - // query hints - // first and max result for pagination - // entity graphs - // native queries - // delete - // @Modifying - // flush / clear - } - - static GeneratedContextBuilder generateContext(TestGenerationContext generationContext) { - return new GeneratedContextBuilder(generationContext); - } - - static class GeneratedContextBuilder implements Verifyer { - - TestGenerationContext generationContext; - Map beanDefinitions = new LinkedHashMap<>(); - Resource xmlBeanDefinitions; - Lazy lazyFactory; - - public GeneratedContextBuilder(TestGenerationContext generationContext) { - - this.generationContext = generationContext; - this.lazyFactory = Lazy.of(() -> { - DefaultListableBeanFactory freshBeanFactory = new DefaultListableBeanFactory(); - TestCompiler.forSystem().with(generationContext).compile(compiled -> { - - freshBeanFactory.setBeanClassLoader(compiled.getClassLoader()); - if (xmlBeanDefinitions != null) { - XmlBeanDefinitionReader beanDefinitionReader = new XmlBeanDefinitionReader(freshBeanFactory); - beanDefinitionReader.loadBeanDefinitions(xmlBeanDefinitions); - } - - for (Entry entry : beanDefinitions.entrySet()) { - freshBeanFactory.registerBeanDefinition(entry.getKey(), entry.getValue()); - } - }); - return freshBeanFactory; - }); - } - - GeneratedContextBuilder register(String name, BeanDefinition beanDefinition) { - this.beanDefinitions.put(name, beanDefinition); - return this; - } - - GeneratedContextBuilder registerBeansFrom(Resource xmlBeanDefinitions) { - this.xmlBeanDefinitions = xmlBeanDefinitions; - return this; - } - - public Verifyer verify(Consumer methodInvoker) { - methodInvoker.accept(new GeneratedContext(lazyFactory)); - return this; - } - - } - - interface Verifyer { - Verifyer verify(Consumer methodInvoker); - - default Verifyer verifyInTx(Consumer methodInvoker) { - - verify(ctx -> { - - PlatformTransactionManager txMgr = ctx.delegate.get().getBean(PlatformTransactionManager.class); - new TransactionTemplate(txMgr).execute(action -> { - verify(methodInvoker); - return "ok"; - }); - }); - - return this; - } - - default void doWithBean(Class type, Consumer runit) { - verify(ctx -> { - - boolean isEntityManager = type == EntityManager.class; - T bean = ctx.delegate.get().getBean(type); - - if (!isEntityManager) { - runit.accept(bean); - } else { - - PlatformTransactionManager txMgr = ctx.delegate.get().getBean(PlatformTransactionManager.class); - new TransactionTemplate(txMgr).execute(action -> { - runit.accept(bean); - return "ok"; - }); - - } - }); - } - } - - static class GeneratedContext { - - private Supplier delegate; - - public GeneratedContext(Supplier defaultListableBeanFactory) { - this.delegate = defaultListableBeanFactory; - } - - InvocationBuilder invoke(String method, Object... arguments) { - - return new InvocationBuilder() { - @Override - public T onBean(String beanName) { - DefaultListableBeanFactory defaultListableBeanFactory = delegate.get(); - - Object bean = defaultListableBeanFactory.getBean(beanName); - return ReflectionTestUtils.invokeMethod(bean, method, arguments); - } - }; - } - - interface InvocationBuilder { - T onBean(String beanName); - } - - } - -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/StubRepositoryInformation.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/StubRepositoryInformation.java index ad1273b8c..e90ce0aae 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/StubRepositoryInformation.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/StubRepositoryInformation.java @@ -18,6 +18,8 @@ package org.springframework.data.jpa.repository.aot.generated; import java.lang.reflect.Method; import java.util.Set; +import org.jspecify.annotations.Nullable; + import org.springframework.data.jpa.repository.support.SimpleJpaRepository; import org.springframework.data.repository.core.CrudMethods; import org.springframework.data.repository.core.RepositoryInformation; @@ -27,7 +29,6 @@ import org.springframework.data.repository.core.support.RepositoryComposition; import org.springframework.data.repository.core.support.RepositoryFragment; import org.springframework.data.util.Streamable; import org.springframework.data.util.TypeInformation; -import org.springframework.lang.Nullable; /** * @author Christoph Strobl diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/TestJpaAotRepsitoryContext.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/TestJpaAotRepositoryContext.java similarity index 89% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/TestJpaAotRepsitoryContext.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/TestJpaAotRepositoryContext.java index 433a6e602..df4e62a87 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/TestJpaAotRepsitoryContext.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/TestJpaAotRepositoryContext.java @@ -37,14 +37,20 @@ import org.springframework.lang.Nullable; /** * @author Christoph Strobl */ -class TestJpaAotRepsitoryContext implements AotRepositoryContext { +class TestJpaAotRepositoryContext implements AotRepositoryContext { private final StubRepositoryInformation repositoryInformation; + private final Class repositoryInterface; - public TestJpaAotRepsitoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition) { + public TestJpaAotRepositoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition) { + this.repositoryInterface = repositoryInterface; this.repositoryInformation = new StubRepositoryInformation(repositoryInterface, composition); } + public Class getRepositoryInterface() { + return repositoryInterface; + } + @Override public ConfigurableListableBeanFactory getBeanFactory() { return null; diff --git a/spring-data-jpa/src/test/java/com/example/UserDtoProjection.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/UserDtoProjection.java similarity index 94% rename from spring-data-jpa/src/test/java/com/example/UserDtoProjection.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/UserDtoProjection.java index 2605f553f..bc8d8f578 100644 --- a/spring-data-jpa/src/test/java/com/example/UserDtoProjection.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/UserDtoProjection.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.example; +package org.springframework.data.jpa.repository.aot.generated; /** * @author Christoph Strobl diff --git a/spring-data-jpa/src/test/java/com/example/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/UserRepository.java similarity index 92% rename from spring-data-jpa/src/test/java/com/example/UserRepository.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/UserRepository.java index 8c3e9135e..4e8088fa3 100644 --- a/spring-data-jpa/src/test/java/com/example/UserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/UserRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 the original author or authors. + * 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.example; +package org.springframework.data.jpa.repository.aot.generated; import java.util.List; import java.util.Optional; @@ -27,7 +27,6 @@ import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; -import org.springframework.data.repository.query.Param; /** * @author Christoph Strobl @@ -42,7 +41,7 @@ public interface UserRepository extends CrudRepository { Long countUsersByLastname(String lastname); - Boolean existsUserByLastname(String lastname); + boolean existsUserByLastname(String lastname); List findByLastnameStartingWith(String lastname); @@ -71,7 +70,7 @@ public interface UserRepository extends CrudRepository { List findAnnotatedQueryByLastname(String lastname); @Query("select u from User u where u.lastname like :lastname%") - List findAnnotatedQueryByLastnameParamter(String lastname); + List findAnnotatedQueryByLastnameParameter(String lastname); @Query(""" select u @@ -94,6 +93,9 @@ public interface UserRepository extends CrudRepository { @Query("select u from User u where u.lastname like ?1%") Page findAnnotatedQueryPageOfUsersByLastname(String lastname, Pageable pageable); + @Query("select u from User u where u.lastname like ?1% ORDER BY u.lastname") + Page findAnnotatedQueryPageWithStaticSort(String lastname, Pageable pageable); + @Query("select u from User u where u.lastname like ?1%") Slice findAnnotatedQuerySliceOfUsersByLastname(String lastname, Pageable pageable); @@ -115,8 +117,6 @@ public interface UserRepository extends CrudRepository { // projections - - List findUserProjectionByLastnameStartingWith(String lastname); Page findUserProjectionByLastnameStartingWith(String lastname, Pageable page); @@ -137,4 +137,5 @@ public interface UserRepository extends CrudRepository { List findByLastnameOrderByFirstname(String lastname); User findByEmailAddress(String emailAddress); + }