diff --git a/pom.xml b/pom.xml index dd759f849..b63358380 100755 --- a/pom.xml +++ b/pom.xml @@ -145,6 +145,11 @@ ${spring} provided + + org.jboss.logging + jboss-logging + 3.6.1.Final + diff --git a/spring-data-envers/pom.xml b/spring-data-envers/pom.xml index 0bdf2c8e7..43c08369f 100755 --- a/spring-data-envers/pom.xml +++ b/spring-data-envers/pom.xml @@ -60,6 +60,12 @@ ${project.version} + + org.jboss.logging + jboss-logging + 3.6.1.Final + + org.hibernate.orm diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index b6470bdc8..12a089e3e 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -88,12 +88,16 @@ true + + org.junit.platform + junit-platform-launcher + test + - org.junit.platform - junit-platform-launcher + org.springframework + spring-core-test test - org.hsqldb hsqldb @@ -239,6 +243,12 @@ true + + org.jboss.logging + jboss-logging + 3.6.1.Final + + @@ -370,6 +380,11 @@ jakarta.persistence-api ${jakarta-persistence-api} + + org.jboss.logging + jboss-logging + 3.6.1.Final + 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 new file mode 100644 index 000000000..98929eead --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotMetaModel.java @@ -0,0 +1,124 @@ +/* + * Copyright 2024-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 jakarta.persistence.metamodel.EmbeddableType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.spi.ClassTransformer; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.hibernate.jpa.HibernatePersistenceProvider; +import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl; +import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor; +import org.springframework.data.util.Lazy; +import org.springframework.instrument.classloading.SimpleThrowawayClassLoader; +import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo; + +/** + * @author Christoph Strobl + */ +public class AotMetaModel implements Metamodel { + + private final String persistenceUnit; + private final Set> managedTypes; + private final Lazy entityManagerFactory = Lazy.of(this::init); + private final Lazy metamodel = Lazy.of(() -> entityManagerFactory.get().getMetamodel()); + private final Lazy entityManager = Lazy.of(() -> entityManagerFactory.get().createEntityManager()); + + public AotMetaModel(Set> managedTypes) { + this("dynamic-tests", managedTypes); + } + + private AotMetaModel(String persistenceUnit, Set> managedTypes) { + this.persistenceUnit = persistenceUnit; + this.managedTypes = managedTypes; + } + + public static AotMetaModel hibernateModel(Class... types) { + return new AotMetaModel(Set.of(types)); + } + + public static AotMetaModel hibernateModel(String persistenceUnit, Class... types) { + return new AotMetaModel(persistenceUnit, Set.of(types)); + } + + public EntityType entity(Class cls) { + return metamodel.get().entity(cls); + } + + @Override + public EntityType entity(String s) { + return metamodel.get().entity(s); + } + + public ManagedType managedType(Class cls) { + return metamodel.get().managedType(cls); + } + + public EmbeddableType embeddable(Class cls) { + return metamodel.get().embeddable(cls); + } + + public Set> getManagedTypes() { + return metamodel.get().getManagedTypes(); + } + + public Set> getEntities() { + return metamodel.get().getEntities(); + } + + public Set> getEmbeddables() { + return metamodel.get().getEmbeddables(); + } + + public EntityManager entityManager() { + return entityManager.get(); + } + + EntityManagerFactory init() { + + MutablePersistenceUnitInfo persistenceUnitInfo = new MutablePersistenceUnitInfo() { + @Override + public ClassLoader getNewTempClassLoader() { + return new SimpleThrowawayClassLoader(this.getClass().getClassLoader()); + } + + @Override + public void addTransformer(ClassTransformer classTransformer) { + // just ingnore it + } + }; + + persistenceUnitInfo.setPersistenceUnitName(persistenceUnit); + this.managedTypes.stream().map(Class::getName).forEach(persistenceUnitInfo::addManagedClassName); + + persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName()); + + return new EntityManagerFactoryBuilderImpl(new PersistenceUnitInfoDescriptor(persistenceUnitInfo) { + @Override + public List getManagedClassNames() { + return persistenceUnitInfo.getManagedClassNames(); + } + }, Map.of("hibernate.dialect", "org.hibernate.dialect.H2Dialect")).build(); + } +} 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 new file mode 100644 index 000000000..f6c22ec8f --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotQueryCreator.java @@ -0,0 +1,62 @@ +/* + * 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 + * + * http://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.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 { + + Metamodel metamodel; + + public AotQueryCreator(Metamodel metamodel) { + 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/AotStringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotStringQuery.java new file mode 100644 index 000000000..147eb0a37 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/AotStringQuery.java @@ -0,0 +1,106 @@ +/* + * 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 new file mode 100644 index 000000000..cf1489a78 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaCodeBlocks.java @@ -0,0 +1,291 @@ +/* + * 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.Query; + +import java.util.List; +import java.util.Optional; +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; +import org.springframework.javapoet.TypeName; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * @author Christoph Strobl + * @since 2025/01 + */ +public class JpaCodeBlocks { + + private static final Pattern PARAMETER_BINDING_PATTERN = Pattern.compile("\\?(\\d+)"); + + static QueryBlockBuilder queryBlockBuilder(AotRepositoryMethodGenerationContext context) { + return new QueryBlockBuilder(context); + } + + static QueryExecutionBlockBuilder queryExecutionBlockBuilder(AotRepositoryMethodGenerationContext context) { + return new QueryExecutionBlockBuilder(context); + } + + static class QueryExecutionBlockBuilder { + + AotRepositoryMethodGenerationContext context; + private String queryVariableName; + + public QueryExecutionBlockBuilder(AotRepositoryMethodGenerationContext context) { + this.context = context; + } + + QueryExecutionBlockBuilder referencing(String queryVariableName) { + + this.queryVariableName = queryVariableName; + return this; + } + + CodeBlock build() { + + Builder builder = CodeBlock.builder(); + + boolean isProjecting = context.getActualReturnType() != null + && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), + context.getActualReturnType()); + Object actualReturnType = isProjecting ? context.getActualReturnType() + : context.getRepositoryInformation().getDomainType(); + + builder.add("\n"); + + if (context.isDeleteMethod()) { + + builder.addStatement("$T<$T> resultList = $L.getResultList()", List.class, actualReturnType, queryVariableName); + builder.addStatement("resultList.forEach($L::remove)", context.fieldNameOf(EntityManager.class)); + if (context.returnsSingleValue()) { + if (ClassUtils.isAssignable(Number.class, context.getMethod().getReturnType())) { + builder.addStatement("return $T.valueOf(resultList.size())", context.getMethod().getReturnType()); + } else { + builder.addStatement("return resultList.isEmpty() ? null : resultList.iterator().next()"); + } + } else { + builder.addStatement("return resultList"); + } + } else if (context.isExistsMethod()) { + builder.addStatement("return !$L.getResultList().isEmpty()", queryVariableName); + } else { + + if (context.returnsSingleValue()) { + if (context.returnsOptionalValue()) { + builder.addStatement("return $T.ofNullable(($T) $L.getSingleResultOrNull())", Optional.class, + actualReturnType, queryVariableName); + } else { + builder.addStatement("return ($T) $L.getSingleResultOrNull()", context.getReturnType(), queryVariableName); + } + } else if (context.returnsPage()) { + builder.addStatement("return $T.getPage(($T<$T>) $L.getResultList(), $L, countAll)", + PageableExecutionUtils.class, List.class, actualReturnType, queryVariableName, + context.getPageableParameterName()); + } else if (context.returnsSlice()) { + builder.addStatement("$T<$T> resultList = $L.getResultList()", List.class, actualReturnType, + queryVariableName); + builder.addStatement("boolean hasNext = $L.isPaged() && resultList.size() > $L.getPageSize()", + context.getPageableParameterName(), context.getPageableParameterName()); + builder.addStatement( + "return new $T<>(hasNext ? resultList.subList(0, $L.getPageSize()) : resultList, $L, hasNext)", + SliceImpl.class, context.getPageableParameterName(), context.getPageableParameterName()); + } else { + builder.addStatement("return ($T) query.getResultList()", context.getReturnType()); + } + } + + return builder.build(); + + } + } + + static class QueryBlockBuilder { + + private final AotRepositoryMethodGenerationContext context; + private String queryVariableName; + private AotStringQuery query; + + public QueryBlockBuilder(AotRepositoryMethodGenerationContext context) { + this.context = context; + } + + QueryBlockBuilder usingQueryVariableName(String queryVariableName) { + + this.queryVariableName = queryVariableName; + return this; + } + + QueryBlockBuilder filter(String queryString) { + return filter(AotStringQuery.of(queryString)); + } + + QueryBlockBuilder filter(AotStringQuery query) { + this.query = query; + return this; + } + + CodeBlock build() { + + boolean isProjecting = context.getActualReturnType() != null + && !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()), + context.getActualReturnType()); + Object actualReturnType = isProjecting ? context.getActualReturnType() + : context.getRepositoryInformation().getDomainType(); + + CodeBlock.Builder builder = CodeBlock.builder(); + builder.add("\n"); + String queryStringNameVariableName = "%sString".formatted(queryVariableName); + 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"); + builder.addStatement("$T $L = $S", String.class, countQueryStringNameVariableName, + query.getCountQuery(projection)); + } + + // 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(); + } + } + + 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(countQueryStringNameVariableName)) { + builder.beginControlFlow("$T $L = () ->", LongSupplier.class, "countAll"); + addQueryBlock(builder, countQuyerVariableName, countQueryStringNameVariableName, query.isNativeQuery()); + builder.addStatement("return ($T) $L.getSingleResult()", Long.class, countQuyerVariableName); + + // end control flow does not work well with lambdas + builder.unindent(); + builder.add("};\n"); + } + + return builder.build(); + } + + private void addQueryBlock(Builder builder, String queryVariableName, String queryStringNameVariableName, + boolean nativeQuery) { + + builder.addStatement("$T $L = this.$L.$L($L)", Query.class, queryVariableName, + context.fieldNameOf(EntityManager.class), nativeQuery ? "createNativeQuery" : "createQuery", + queryStringNameVariableName); + + for (ParameterBinding binding : query.parameterBindings()) { + + Object prepare = binding.prepare("s"); + if (prepare instanceof String prepared && !prepared.equals("s")) { + String format = prepared.replaceAll("%", "%%").replace("s", "%s"); + if (binding.getIdentifier().hasPosition()) { + builder.addStatement("$L.setParameter($L, $S.formatted($L))", queryVariableName, + binding.getIdentifier().getPosition(), format, + context.getParameterNameOfPosition(binding.getIdentifier().getPosition() - 1)); + } else { + builder.addStatement("$L.setParameter($S, $S.formatted($L))", queryVariableName, + binding.getIdentifier().getName(), format, binding.getIdentifier().getName()); + } + } else { + if (binding.getIdentifier().hasPosition()) { + builder.addStatement("$L.setParameter($L, $L)", queryVariableName, binding.getIdentifier().getPosition(), + context.getParameterNameOfPosition(binding.getIdentifier().getPosition() - 1)); + } else { + builder.addStatement("$L.setParameter($S, $L)", queryVariableName, binding.getIdentifier().getName(), + binding.getIdentifier().getName()); + } + } + } + } + } +} 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 new file mode 100644 index 000000000..57660bccc --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/generated/JpaRepsoitoryContributor.java @@ -0,0 +1,115 @@ +/* + * 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/config/JpaRepositoryConfigExtension.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java index 7abdd4758..44127c452 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,7 +15,9 @@ */ package org.springframework.data.jpa.repository.config; -import static org.springframework.data.jpa.repository.config.BeanDefinitionNames.*; +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 jakarta.persistence.Entity; import jakarta.persistence.MappedSuperclass; @@ -41,23 +43,34 @@ 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.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.annotation.AnnotationConfigUtils; import org.springframework.core.annotation.AnnotationAttributes; import org.springframework.core.io.ResourceLoader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.filter.TypeFilter; 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.support.DefaultJpaContext; import org.springframework.data.jpa.repository.support.EntityManagerBeanDefinitionRegistrarPostProcessor; import org.springframework.data.jpa.repository.support.JpaEvaluationContextExtension; import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; +import org.springframework.data.repository.aot.generate.RepositoryContributor; import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.config.ImplementationDetectionConfiguration; +import org.springframework.data.repository.config.ImplementationLookupConfiguration; +import org.springframework.data.repository.config.RepositoryConfiguration; import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport; import org.springframework.data.repository.config.RepositoryConfigurationSource; import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor; import org.springframework.data.repository.config.XmlRepositoryConfigurationSource; +import org.springframework.data.util.Streamable; import org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -193,7 +206,6 @@ public class JpaRepositoryConfigExtension extends RepositoryConfigurationExtensi contextDefinition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_CONSTRUCTOR); return contextDefinition; - }, registry, JPA_CONTEXT_BEAN_NAME, source); registerIfNotAlreadyRegistered(() -> new RootBeanDefinition(JPA_METAMODEL_CACHE_CLEANUP_CLASSNAME), registry, @@ -211,7 +223,6 @@ public class JpaRepositoryConfigExtension extends RepositoryConfigurationExtensi builder.addConstructorArgValue(value); return builder.getBeanDefinition(); - }, registry, JpaEvaluationContextExtension.class.getName(), source); } @@ -316,8 +327,131 @@ public class JpaRepositoryConfigExtension extends RepositoryConfigurationExtensi */ public static class JpaRepositoryRegistrationAotProcessor extends RepositoryRegistrationAotProcessor { - protected void contribute(AotRepositoryContext repositoryContext, GenerationContext generationContext) { + protected RepositoryContributor contribute(AotRepositoryContext repositoryContext, GenerationContext generationContext) { + // don't register domain types nor annotations. + + if (!AotContext.aotGeneratedRepositoriesEnabled()) { + return null; + } + + return new JpaRepsoitoryContributor(repositoryContext); + } + + @Nullable + @Override + protected RepositoryConfiguration getRepositoryMetadata(RegisteredBean bean) { + RepositoryConfiguration configuration = super.getRepositoryMetadata(bean); + if (!configuration.getRepositoryBaseClassName().isEmpty()) { + return configuration; + } + return new Meh<>(configuration); + } + } + + /** + * I'm just a dirty hack so we can refine the {@link #getRepositoryBaseClassName()} method as we cannot instantiate + * the bean safely to extract it form the repository factory in data commons. So we either have a configurable + * {@link RepositoryConfiguration} return from + * {@link RepositoryRegistrationAotProcessor#getRepositoryMetadata(RegisteredBean)} or change the arrangement and + * maybe move the type out of the factoy. + * + * @param + */ + static class Meh implements RepositoryConfiguration { + + private RepositoryConfiguration configuration; + + public Meh(RepositoryConfiguration configuration) { + this.configuration = configuration; + } + + @Nullable + @Override + public Object getSource() { + return configuration.getSource(); + } + + @Override + public T getConfigurationSource() { + return (T) configuration.getConfigurationSource(); + } + + @Override + public boolean isLazyInit() { + return configuration.isLazyInit(); + } + + @Override + public boolean isPrimary() { + return configuration.isPrimary(); + } + + @Override + public Streamable getBasePackages() { + return configuration.getBasePackages(); + } + + @Override + public Streamable getImplementationBasePackages() { + return configuration.getImplementationBasePackages(); + } + + @Override + public String getRepositoryInterface() { + return configuration.getRepositoryInterface(); + } + + @Override + public Optional getQueryLookupStrategyKey() { + return Optional.ofNullable(configuration.getQueryLookupStrategyKey()); + } + + @Override + public Optional getNamedQueriesLocation() { + return configuration.getNamedQueriesLocation(); + } + + @Override + public Optional getRepositoryBaseClassName() { + String name = SimpleJpaRepository.class.getName(); + return Optional.of(name); + } + + @Override + public String getRepositoryFactoryBeanClassName() { + return configuration.getRepositoryFactoryBeanClassName(); + } + + @Override + public String getImplementationBeanName() { + return configuration.getImplementationBeanName(); + } + + @Override + public String getRepositoryBeanName() { + return configuration.getRepositoryBeanName(); + } + + @Override + public Streamable getExcludeFilters() { + return configuration.getExcludeFilters(); + } + + @Override + public ImplementationDetectionConfiguration toImplementationDetectionConfiguration(MetadataReaderFactory factory) { + return configuration.toImplementationDetectionConfiguration(factory); + } + + @Override + public ImplementationLookupConfiguration toLookupConfiguration(MetadataReaderFactory factory) { + return configuration.toLookupConfiguration(factory); + } + + @Nullable + @Override + public String getResourceDescription() { + return configuration.getResourceDescription(); } } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java index 9a828a9b3..3eec07e41 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java @@ -63,7 +63,7 @@ import org.springframework.util.Assert; * @author Christoph Strobl * @author Jinmyeong Kim */ -class JpaQueryCreator extends AbstractQueryCreator implements JpqlQueryCreator { +public class JpaQueryCreator extends AbstractQueryCreator implements JpqlQueryCreator { private final ReturnedType returnedType; private final ParameterMetadataProvider provider; @@ -86,15 +86,21 @@ class JpaQueryCreator extends AbstractQueryCreator getFrom() { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java index dda3211cd..8b40751cd 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java @@ -46,7 +46,7 @@ import org.springframework.util.StringUtils; * @author Mark Paluch * @author Christoph Strobl */ -class ParameterBinding { +public class ParameterBinding { private final BindingIdentifier identifier; private final ParameterOrigin origin; @@ -462,7 +462,7 @@ class ParameterBinding { * @author Mark Paluch * @since 3.1.2 */ - sealed interface BindingIdentifier permits Named, Indexed, NamedAndIndexed { + public sealed interface BindingIdentifier permits Named, Indexed, NamedAndIndexed { /** * Creates an identifier for the given {@code name}. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBindingParser.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBindingParser.java new file mode 100644 index 000000000..371016577 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBindingParser.java @@ -0,0 +1,427 @@ +/* + * 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.query; + +import static java.util.regex.Pattern.CASE_INSENSITIVE; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; +import org.springframework.data.jpa.repository.query.ParameterBinding.InParameterBinding; +import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding; +import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument; +import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; +import org.springframework.data.repository.query.ValueExpressionQueryRewriter; +import org.springframework.data.repository.query.parser.Part.Type; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * A parser that extracts the parameter bindings from a given query string. + * + * @author Thomas Darimont + */ +public enum ParameterBindingParser { + + INSTANCE; + + private static final String EXPRESSION_PARAMETER_PREFIX = "__$synthetic$__"; + public static final String POSITIONAL_OR_INDEXED_PARAMETER = "\\?(\\d*+(?![#\\w]))"; + // .....................................................................^ not followed by a hash or a letter. + // .................................................................^ zero or more digits. + // .............................................................^ start with a question mark. + private static final Pattern PARAMETER_BINDING_BY_INDEX = Pattern.compile(POSITIONAL_OR_INDEXED_PARAMETER); + private static final Pattern PARAMETER_BINDING_PATTERN; + private static final Pattern JDBC_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?(?!\\d)"); // no \ and [no digit] + private static final Pattern NUMBERED_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?\\d"); // no \ and [digit] + private static final Pattern NAMED_STYLE_PARAM = Pattern.compile("(?!\\\\):\\w+"); // no \ and :[text] + + private static final String MESSAGE = "Already found parameter binding with same index / parameter name but differing binding type; " + + "Already have: %s, found %s; If you bind a parameter multiple times make sure they use the same binding"; + private static final int INDEXED_PARAMETER_GROUP = 4; + private static final int NAMED_PARAMETER_GROUP = 6; + private static final int COMPARISION_TYPE_GROUP = 1; + + public static class Metadata { + private boolean usesJdbcStyleParameters = false; + + public boolean usesJdbcStyleParameters() { + return usesJdbcStyleParameters; + } + } + + /** + * Utility to create unique parameter bindings for LIKE that refer to the same underlying method parameter but are + * bound to potentially unique query parameters for {@link LikeParameterBinding#prepare(Object) LIKE rewrite}. + * + * @author Mark Paluch + * @since 3.1.2 + */ + static class ParameterBindings { + + private final MultiValueMap methodArgumentToLikeBindings = new LinkedMultiValueMap<>(); + + private final Consumer registration; + private int syntheticParameterIndex; + + public ParameterBindings(List bindings, Consumer registration, + int syntheticParameterIndex) { + + for (ParameterBinding binding : bindings) { + this.methodArgumentToLikeBindings.put(binding.getIdentifier(), new ArrayList<>(List.of(binding))); + } + + this.registration = registration; + this.syntheticParameterIndex = syntheticParameterIndex; + } + + /** + * Return whether the identifier is already bound. + * + * @param identifier + * @return + */ + public boolean isBound(BindingIdentifier identifier) { + return !getBindings(identifier).isEmpty(); + } + + BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin, + Function bindingFactory) { + + Assert.isInstanceOf(MethodInvocationArgument.class, origin); + + BindingIdentifier methodArgument = ((MethodInvocationArgument) origin).identifier(); + List bindingsForOrigin = getBindings(methodArgument); + + if (!isBound(identifier)) { + + ParameterBinding binding = bindingFactory.apply(identifier); + registration.accept(binding); + bindingsForOrigin.add(binding); + return binding.getIdentifier(); + } + + ParameterBinding binding = bindingFactory.apply(identifier); + + for (ParameterBinding existing : bindingsForOrigin) { + + if (existing.isCompatibleWith(binding)) { + return existing.getIdentifier(); + } + } + + BindingIdentifier syntheticIdentifier; + if (identifier.hasName() && methodArgument.hasName()) { + + int index = 0; + String newName = methodArgument.getName(); + while (existsBoundParameter(newName)) { + index++; + newName = methodArgument.getName() + "_" + index; + } + syntheticIdentifier = BindingIdentifier.of(newName); + } else { + syntheticIdentifier = BindingIdentifier.of(++syntheticParameterIndex); + } + + ParameterBinding newBinding = bindingFactory.apply(syntheticIdentifier); + registration.accept(newBinding); + bindingsForOrigin.add(newBinding); + return newBinding.getIdentifier(); + } + + private boolean existsBoundParameter(String key) { + return methodArgumentToLikeBindings.values().stream().flatMap(Collection::stream) + .anyMatch(it -> key.equals(it.getName())); + } + + private List getBindings(BindingIdentifier identifier) { + return methodArgumentToLikeBindings.computeIfAbsent(identifier, s -> new ArrayList<>()); + } + + public void register(ParameterBinding parameterBinding) { + registration.accept(parameterBinding); + } + } + + static { + + List keywords = new ArrayList<>(); + + for (ParameterBindingType type : ParameterBindingType.values()) { + if (type.getKeyword() != null) { + keywords.add(type.getKeyword()); + } + } + + StringBuilder builder = new StringBuilder(); + builder.append("("); + builder.append(StringUtils.collectionToDelimitedString(keywords, "|")); // keywords + builder.append(")?"); + builder.append("(?: )?"); // some whitespace + builder.append("\\(?"); // optional braces around parameters + builder.append("("); + builder.append("%?(" + POSITIONAL_OR_INDEXED_PARAMETER + ")%?"); // position parameter and parameter index + builder.append("|"); // or + + // named parameter and the parameter name + builder.append("%?(" + QueryUtils.COLON_NO_DOUBLE_COLON + QueryUtils.IDENTIFIER_GROUP + ")%?"); + + builder.append(")"); + builder.append("\\)?"); // optional braces around parameters + + PARAMETER_BINDING_PATTERN = Pattern.compile(builder.toString(), CASE_INSENSITIVE); + } + + /** + * Parses {@link ParameterBinding} instances from the given query and adds them to the registered bindings. Returns + * the cleaned up query. + */ + public String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String query, List bindings, + Metadata queryMeta) { + + int greatestParameterIndex = tryFindGreatestParameterIndexIn(query); + boolean parametersShouldBeAccessedByIndex = greatestParameterIndex != -1; + + /* + * Prefer indexed access over named parameters if only SpEL Expression parameters are present. + */ + if (!parametersShouldBeAccessedByIndex && query.contains("?#{")) { + parametersShouldBeAccessedByIndex = true; + greatestParameterIndex = 0; + } + + ValueExpressionQueryRewriter.ParsedQuery parsedQuery = createSpelExtractor(query, + parametersShouldBeAccessedByIndex, + greatestParameterIndex); + + String resultingQuery = parsedQuery.getQueryString(); + Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(resultingQuery); + + int expressionParameterIndex = parametersShouldBeAccessedByIndex ? greatestParameterIndex : 0; + int syntheticParameterIndex = expressionParameterIndex + parsedQuery.size(); + + ParameterBindings parameterBindings = new ParameterBindings(bindings, it -> checkAndRegister(it, bindings), + syntheticParameterIndex); + int currentIndex = 0; + + boolean usesJpaStyleParameters = false; + + while (matcher.find()) { + + if (parsedQuery.isQuoted(matcher.start())) { + continue; + } + + String parameterIndexString = matcher.group(INDEXED_PARAMETER_GROUP); + String parameterName = parameterIndexString != null ? null : matcher.group(NAMED_PARAMETER_GROUP); + Integer parameterIndex = getParameterIndex(parameterIndexString); + + String match = matcher.group(0); + if (JDBC_STYLE_PARAM.matcher(match).find()) { + queryMeta.usesJdbcStyleParameters = true; + } + + if (NUMBERED_STYLE_PARAM.matcher(match).find() || NAMED_STYLE_PARAM.matcher(match).find()) { + usesJpaStyleParameters = true; + } + + if (usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) { + throw new IllegalArgumentException("Mixing of ? parameters and other forms like ?1 is not supported"); + } + + String typeSource = matcher.group(COMPARISION_TYPE_GROUP); + Assert.isTrue(parameterIndexString != null || parameterName != null, + () -> String.format("We need either a name or an index; Offending query string: %s", query)); + ValueExpression expression = parsedQuery + .getParameter(parameterName == null ? parameterIndexString : parameterName); + String replacement = null; + + expressionParameterIndex++; + if ("".equals(parameterIndexString)) { + parameterIndex = expressionParameterIndex; + } + + BindingIdentifier queryParameter; + if (parameterIndex != null) { + queryParameter = BindingIdentifier.of(parameterIndex); + } else { + queryParameter = BindingIdentifier.of(parameterName); + } + ParameterOrigin origin = ObjectUtils.isEmpty(expression) + ? ParameterOrigin.ofParameter(parameterName, parameterIndex) + : ParameterOrigin.ofExpression(expression); + + BindingIdentifier targetBinding = queryParameter; + Function bindingFactory = switch (ParameterBindingType.of(typeSource)) { + case LIKE -> { + + Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2)); + yield (identifier) -> new LikeParameterBinding(identifier, origin, likeType); + } + case IN -> (identifier) -> new InParameterBinding(identifier, origin); // fall-through we don't need a special parameter queryParameter for the given parameter. + default -> (identifier) -> new ParameterBinding(identifier, origin); + }; + + if (origin.isExpression()) { + parameterBindings.register(bindingFactory.apply(queryParameter)); + } else { + targetBinding = parameterBindings.register(queryParameter, origin, bindingFactory); + } + + replacement = targetBinding.hasName() ? ":" + targetBinding.getName() + : ((!usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) ? "?" + : "?" + targetBinding.getPosition()); + String result; + String substring = matcher.group(2); + + int index = resultingQuery.indexOf(substring, currentIndex); + if (index < 0) { + result = resultingQuery; + } else { + currentIndex = index + replacement.length(); + result = resultingQuery.substring(0, index) + replacement + + resultingQuery.substring(index + substring.length()); + } + + resultingQuery = result; + } + + return resultingQuery; + } + + private static ValueExpressionQueryRewriter.ParsedQuery createSpelExtractor(String queryWithSpel, + boolean parametersShouldBeAccessedByIndex, + int greatestParameterIndex) { + + /* + * If parameters need to be bound by index, we bind the synthetic expression parameters starting from position of the greatest discovered index parameter in order to + * not mix-up with the actual parameter indices. + */ + int expressionParameterIndex = parametersShouldBeAccessedByIndex ? greatestParameterIndex : 0; + + BiFunction indexToParameterName = parametersShouldBeAccessedByIndex + ? (index, expression) -> String.valueOf(index + expressionParameterIndex + 1) + : (index, expression) -> EXPRESSION_PARAMETER_PREFIX + (index + 1); + + String fixedPrefix = parametersShouldBeAccessedByIndex ? "?" : ":"; + + BiFunction parameterNameToReplacement = (prefix, name) -> fixedPrefix + name; + ValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter.of(ValueExpressionParser.create(), + indexToParameterName, parameterNameToReplacement); + + return rewriter.parse(queryWithSpel); + } + + @Nullable + private static Integer getParameterIndex(@Nullable String parameterIndexString) { + + if (parameterIndexString == null || parameterIndexString.isEmpty()) { + return null; + } + return Integer.valueOf(parameterIndexString); + } + + private static int tryFindGreatestParameterIndexIn(String query) { + + Matcher parameterIndexMatcher = PARAMETER_BINDING_BY_INDEX.matcher(query); + + int greatestParameterIndex = -1; + while (parameterIndexMatcher.find()) { + + String parameterIndexString = parameterIndexMatcher.group(1); + Integer parameterIndex = getParameterIndex(parameterIndexString); + if (parameterIndex != null) { + greatestParameterIndex = Math.max(greatestParameterIndex, parameterIndex); + } + } + + return greatestParameterIndex; + } + + private static void checkAndRegister(ParameterBinding binding, List bindings) { + + bindings.stream() // + .filter(it -> it.bindsTo(binding)) // + .forEach(it -> Assert.isTrue(it.equals(binding), String.format(MESSAGE, it, binding))); + + if (!bindings.contains(binding)) { + bindings.add(binding); + } + } + + /** + * An enum for the different types of bindings. + * + * @author Thomas Darimont + * @author Oliver Gierke + */ + private enum ParameterBindingType { + + // Trailing whitespace is intentional to reflect that the keywords must be used with at least one whitespace + // character, while = does not. + LIKE("like "), IN("in "), AS_IS(null); + + private final @Nullable String keyword; + + ParameterBindingType(@Nullable String keyword) { + this.keyword = keyword; + } + + /** + * Returns the keyword that will trigger the binding type or {@literal null} if the type is not triggered by a + * keyword. + * + * @return the keyword + */ + @Nullable + public String getKeyword() { + return keyword; + } + + /** + * Return the appropriate {@link ParameterBindingType} for the given {@link String}. Returns {@literal #AS_IS} in + * case no other {@link ParameterBindingType} could be found. + */ + static ParameterBindingType of(String typeSource) { + + if (!StringUtils.hasText(typeSource)) { + return AS_IS; + } + + for (ParameterBindingType type : values()) { + if (type.name().equalsIgnoreCase(typeSource.trim())) { + return type; + } + } + + throw new IllegalArgumentException(String.format("Unsupported parameter binding type %s", typeSource)); + } + } +} 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 0c5061b52..c9171b203 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.*; +import static java.util.regex.Pattern.CASE_INSENSITIVE; import java.util.ArrayList; import java.util.Collection; @@ -34,6 +34,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.data.expression.ValueExpression; import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; import org.springframework.data.repository.query.ValueExpressionQueryRewriter; import org.springframework.data.repository.query.parser.Part; import org.springframework.util.Assert; @@ -463,7 +464,7 @@ final class PreprocessedQuery implements DeclaredQuery { */ private static class ParameterBindings { - private final MultiValueMap methodArgumentToLikeBindings = new LinkedMultiValueMap<>(); + private final MultiValueMap methodArgumentToLikeBindings = new LinkedMultiValueMap<>(); private final Consumer registration; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java index b16d2ef5d..749853f36 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java @@ -578,7 +578,7 @@ public abstract class QueryUtils { * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}. * @since 2.7.8 */ - static String createCountQueryFor(String originalQuery, @Nullable String countProjection, boolean nativeQuery) { + public static String createCountQueryFor(String originalQuery, @Nullable String countProjection, boolean nativeQuery) { Assert.hasText(originalQuery, "OriginalQuery must not be null or empty"); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java index ebb24268d..8e8200a37 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java @@ -105,11 +105,12 @@ public class JpaRepositoryFactoryBean, S, ID> * fallback to {@link org.springframework.data.jpa.repository.query.DefaultJpaQueryMethodFactory} in case none is * available. * - * @param factory may be {@literal null}. + * @param resolver may be {@literal null}. */ @Autowired - public void setQueryMethodFactory(@Nullable JpaQueryMethodFactory factory) { + public void setQueryMethodFactory(ObjectProvider resolver) { // TODO: nullable insteand of ObjectProvider + JpaQueryMethodFactory factory = resolver.getIfAvailable(); if (factory != null) { this.queryMethodFactory = factory; } diff --git a/spring-data-jpa/src/test/java/com/example/UserDtoProjection.java b/spring-data-jpa/src/test/java/com/example/UserDtoProjection.java new file mode 100644 index 000000000..2605f553f --- /dev/null +++ b/spring-data-jpa/src/test/java/com/example/UserDtoProjection.java @@ -0,0 +1,39 @@ +/* + * 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 com.example; + +/** + * @author Christoph Strobl + * @since 2025/01 + */ +public class UserDtoProjection { + + private final String firstname; + private final String emailAddress; + + public UserDtoProjection(String firstname, String emailAddress) { + this.firstname = firstname; + this.emailAddress = emailAddress; + } + + public String getFirstname() { + return firstname; + } + + public String getEmailAddress() { + return emailAddress; + } +} diff --git a/spring-data-jpa/src/test/java/com/example/UserRepository.java b/spring-data-jpa/src/test/java/com/example/UserRepository.java new file mode 100644 index 000000000..8c3e9135e --- /dev/null +++ b/spring-data-jpa/src/test/java/com/example/UserRepository.java @@ -0,0 +1,140 @@ +/* + * 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 com.example; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +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 + */ +public interface UserRepository extends CrudRepository { + + List findUserNoArgumentsBy(); + + User findOneByEmailAddress(String emailAddress); + + Optional findOptionalOneByEmailAddress(String emailAddress); + + Long countUsersByLastname(String lastname); + + Boolean existsUserByLastname(String lastname); + + List findByLastnameStartingWith(String lastname); + + List findTop2ByLastnameStartingWith(String lastname); + + List findByLastnameStartingWithOrderByEmailAddress(String lastname); + + List findByLastnameStartingWith(String lastname, Limit limit); + + List findByLastnameStartingWith(String lastname, Sort sort); + + List findByLastnameStartingWith(String lastname, Sort sort, Limit limit); + + List findByLastnameStartingWith(String lastname, Pageable page); + + Page findPageOfUsersByLastnameStartingWith(String lastname, Pageable page); + + Slice findSliceOfUserByLastnameStartingWith(String lastname, Pageable page); + + /* Annotated Queries */ + + @Query("select u from User u where u.emailAddress = ?1") + User findAnnotatedQueryByEmailAddress(String username); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname); + + @Query("select u from User u where u.lastname like :lastname%") + List findAnnotatedQueryByLastnameParamter(String lastname); + + @Query(""" + select u + from User u + where u.lastname LIKE ?1%""") + List findAnnotatedMultilineQueryByLastname(String username); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname, Limit limit); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname, Sort sort); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname, Limit limit, Sort sort); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname, Pageable pageable); + + @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%") + Slice findAnnotatedQuerySliceOfUsersByLastname(String lastname, Pageable pageable); + + // modifying + + User deleteByEmailAddress(String username); + + Long deleteReturningDeleteCountByEmailAddress(String username); + + @Modifying + @Query("delete from User u where u.emailAddress = ?1") + User deleteAnnotatedQueryByEmailAddress(String username); + + // native queries + + @Query(value = "SELECT firstname FROM SD_User ORDER BY UCASE(firstname)", countQuery = "SELECT count(*) FROM SD_User", + nativeQuery = true) + Page findByNativeQueryWithPageable(Pageable pageable); + + // projections + + + + List findUserProjectionByLastnameStartingWith(String lastname); + + Page findUserProjectionByLastnameStartingWith(String lastname, Pageable page); + + // old ones + + @Query("select u from User u where u.firstname = ?1") + List findAllUsingAnnotatedJpqlQuery(String firstname); + + List findByLastname(String lastname); + + List findByLastnameStartingWithOrderByFirstname(String lastname, Limit limit); + + List findByLastname(String lastname, Sort sort); + + List findByLastname(String lastname, Pageable page); + + List findByLastnameOrderByFirstname(String lastname); + + User findByEmailAddress(String emailAddress); +} 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 new file mode 100644 index 000000000..b6471ea1a --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/JpaRepositoryContributorUnitTests.java @@ -0,0 +1,614 @@ +/* + * 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 new file mode 100644 index 000000000..ad1273b8c --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/StubRepositoryInformation.java @@ -0,0 +1,126 @@ +/* + * 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 java.lang.reflect.Method; +import java.util.Set; + +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; +import org.springframework.data.repository.core.CrudMethods; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; +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 + */ +class StubRepositoryInformation implements RepositoryInformation { + + private final RepositoryMetadata metadata; + private final RepositoryComposition baseComposition; + + public StubRepositoryInformation(Class repositoryInterface, @Nullable RepositoryComposition composition) { + + this.metadata = AbstractRepositoryMetadata.getMetadata(repositoryInterface); + this.baseComposition = composition != null ? composition + : RepositoryComposition.of(RepositoryFragment.structural(SimpleJpaRepository.class)); + } + + @Override + public TypeInformation getIdTypeInformation() { + return metadata.getIdTypeInformation(); + } + + @Override + public TypeInformation getDomainTypeInformation() { + return metadata.getDomainTypeInformation(); + } + + @Override + public Class getRepositoryInterface() { + return metadata.getRepositoryInterface(); + } + + @Override + public TypeInformation getReturnType(Method method) { + return metadata.getReturnType(method); + } + + @Override + public Class getReturnedDomainClass(Method method) { + return metadata.getReturnedDomainClass(method); + } + + @Override + public CrudMethods getCrudMethods() { + return metadata.getCrudMethods(); + } + + @Override + public boolean isPagingRepository() { + return false; + } + + @Override + public Set> getAlternativeDomainTypes() { + return null; + } + + @Override + public boolean isReactiveRepository() { + return false; + } + + @Override + public Set> getFragments() { + return null; + } + + @Override + public boolean isBaseClassMethod(Method method) { + return baseComposition.findMethod(method).isPresent(); + } + + @Override + public boolean isCustomMethod(Method method) { + return false; + } + + @Override + public boolean isQueryMethod(Method method) { + return false; + } + + @Override + public Streamable getQueryMethods() { + return null; + } + + @Override + public Class getRepositoryBaseClass() { + return SimpleJpaRepository.class; + } + + @Override + public Method getTargetClassMethod(Method method) { + return null; + } +} 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/TestJpaAotRepsitoryContext.java new file mode 100644 index 000000000..433a6e602 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/generated/TestJpaAotRepsitoryContext.java @@ -0,0 +1,108 @@ +/* + * 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.Entity; +import jakarta.persistence.MappedSuperclass; + +import java.io.IOException; +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.test.tools.ClassFile; +import org.springframework.data.jpa.domain.sample.Role; +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.lang.Nullable; + +/** + * @author Christoph Strobl + */ +class TestJpaAotRepsitoryContext implements AotRepositoryContext { + + private final StubRepositoryInformation repositoryInformation; + + public TestJpaAotRepsitoryContext(Class repositoryInterface, @Nullable RepositoryComposition composition) { + this.repositoryInformation = new StubRepositoryInformation(repositoryInterface, composition); + } + + @Override + public ConfigurableListableBeanFactory getBeanFactory() { + return null; + } + + @Override + public TypeIntrospector introspectType(String typeName) { + return null; + } + + @Override + public IntrospectedBeanDefinition introspectBeanDefinition(String beanName) { + return null; + } + + @Override + public String getBeanName() { + return "dummyRepository"; + } + + @Override + public Set getBasePackages() { + return Set.of("org.springframework.data.dummy.repository.aot"); + } + + @Override + public Set> getIdentifyingAnnotations() { + return Set.of(Entity.class, MappedSuperclass.class); + } + + @Override + public RepositoryInformation getRepositoryInformation() { + return repositoryInformation; + } + + @Override + public Set> getResolvedAnnotations() { + return Set.of(); + } + + @Override + public Set> getResolvedTypes() { + return Set.of(User.class, Role.class); + } + + public List getRequiredContextFiles() { + return List.of(classFileForType(repositoryInformation.getRepositoryBaseClass())); + } + + static ClassFile classFileForType(Class type) { + + String name = type.getName(); + ClassPathResource cpr = new ClassPathResource(name.replaceAll("\\.", "/") + ".class"); + + try { + return ClassFile.of(name, cpr.getContentAsByteArray()); + } catch (IOException e) { + throw new IllegalArgumentException("Cannot open [%s].".formatted(cpr.getPath())); + } + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java index 3077ded6b..a88c2912f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java @@ -28,6 +28,7 @@ import org.springframework.data.jpa.repository.query.ParameterBinding.InParamete import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding; import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument; import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; +import org.springframework.data.jpa.repository.query.ParameterBindingParser.Metadata; import org.springframework.data.repository.query.parser.Part.Type; /** diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java index 13ee35f49..b64c4de2f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java @@ -16,7 +16,7 @@ package org.springframework.data.jpa.repository.sample; // DATAJPA-1334 -class NameOnlyDto { +public class NameOnlyDto { private String firstname; private String lastname; diff --git a/spring-data-jpa/src/test/resources/logback.xml b/spring-data-jpa/src/test/resources/logback.xml index 19bb933f9..780ba5e8f 100644 --- a/spring-data-jpa/src/test/resources/logback.xml +++ b/spring-data-jpa/src/test/resources/logback.xml @@ -19,6 +19,8 @@ + +