From 2cdc16fa6aaf428c75eef8fe32a30e03c9b6a36b Mon Sep 17 00:00:00 2001 From: hippi345 <13539685+hippi345@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:34:42 +0000 Subject: [PATCH] Add AOT repository support for CustomCollections Register type collector filters and reflection hints for Vavr and Eclipse Collections used in repository query methods. Closes gh-3416 Signed-off-by: hippi345 <13539685+hippi345@users.noreply.github.com> --- .../data/core/CustomCollections.java | 26 ++++++++ .../RepositoryRegistrationAotProcessor.java | 33 ++++++++++ .../data/util/TypeCollector.java | 30 +++++++-- .../resources/META-INF/spring/aot.factories | 3 +- .../data/aot/TypeCollectorUnitTests.java | 28 +++++++++ ...ConfigWithCustomCollectionReturnTypes.java | 49 +++++++++++++++ .../types/EntityWithEclipseCollections.java | 30 +++++++++ .../aot/types/EntityWithVavrCollections.java | 28 +++++++++ .../data/aot/types/EntityWithVavrDomain.java | 30 +++++++++ ...lectionsAotRepositoryIntegrationTests.java | 62 +++++++++++++++++++ .../AotRepositoryCreatorUnitTests.java | 45 ++++++++++++++ 11 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/springframework/data/aot/sample/ConfigWithCustomCollectionReturnTypes.java create mode 100644 src/test/java/org/springframework/data/aot/types/EntityWithEclipseCollections.java create mode 100644 src/test/java/org/springframework/data/aot/types/EntityWithVavrCollections.java create mode 100644 src/test/java/org/springframework/data/aot/types/EntityWithVavrDomain.java create mode 100644 src/test/java/org/springframework/data/repository/aot/CustomCollectionsAotRepositoryIntegrationTests.java diff --git a/src/main/java/org/springframework/data/core/CustomCollections.java b/src/main/java/org/springframework/data/core/CustomCollections.java index a4769ae428..adc9c951fe 100644 --- a/src/main/java/org/springframework/data/core/CustomCollections.java +++ b/src/main/java/org/springframework/data/core/CustomCollections.java @@ -31,6 +31,7 @@ import java.util.Set; import java.util.function.BiPredicate; import java.util.function.Function; +import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -57,6 +58,7 @@ import org.springframework.core.convert.converter.Converter; import org.springframework.core.convert.converter.ConverterRegistry; import org.springframework.core.io.support.SpringFactoriesLoader; +import org.springframework.data.util.TypeCollector.TypeCollectorFilters; import org.springframework.lang.Contract; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -648,4 +650,28 @@ public boolean matches(TypeDescriptor sourceType, TypeDescriptor targetType) { } + /** + * {@link TypeCollectorFilters} to exclude custom collection and map types from AOT type introspection while still + * considering their component types. + * + * @since 4.2 + */ + public static class AotTypeCollectorFilters implements TypeCollectorFilters { + + private static final Predicate> CUSTOM_COLLECTION_TYPE_FILTER = type -> !isCollection(type) && !isMap(type) + && !isCustomCollectionLibraryType(type); + + @Override + public Predicate> classPredicate() { + return CUSTOM_COLLECTION_TYPE_FILTER; + } + + private static boolean isCustomCollectionLibraryType(Class type) { + + String packageName = type.getPackageName(); + + return packageName.startsWith("io.vavr") || packageName.startsWith("org.eclipse.collections"); + } + } + } diff --git a/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java b/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java index 547a38e912..b4c96715d2 100644 --- a/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java +++ b/src/main/java/org/springframework/data/repository/config/RepositoryRegistrationAotProcessor.java @@ -40,12 +40,15 @@ import org.springframework.beans.factory.support.RegisteredBean; import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.EnvironmentAware; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.env.Environment; import org.springframework.core.env.EnvironmentCapable; import org.springframework.core.env.StandardEnvironment; import org.springframework.data.aot.AotContext; import org.springframework.data.aot.AotTypeConfiguration; +import org.springframework.data.core.CustomCollections; import org.springframework.data.projection.EntityProjectionIntrospector; import org.springframework.data.repository.Repository; import org.springframework.data.repository.aot.generate.RepositoryContributor; @@ -234,6 +237,36 @@ protected void configureTypeContributions(AotRepositoryContext repositoryContext repositoryContext.getResolvedAnnotations().stream() .filter(RepositoryRegistrationAotProcessor::isSpringDataManagedAnnotation).map(MergedAnnotation::getType) .forEach(it -> contributeType(it, generationContext)); + + registerCustomCollectionTypeHints(information, generationContext); + } + + private void registerCustomCollectionTypeHints(RepositoryInformation information, + GenerationContext generationContext) { + + Class repositoryInterface = information.getRepositoryInterface(); + + information.getQueryMethods().forEach(method -> { + + contributeCustomCollectionTypeIfNecessary( + ResolvableType.forMethodReturnType(method, repositoryInterface), generationContext); + + for (int i = 0; i < method.getParameterCount(); i++) { + contributeCustomCollectionTypeIfNecessary( + ResolvableType.forMethodParameter(MethodParameter.forParameter(method.getParameters()[i]) + .withContainingClass(repositoryInterface)), + generationContext); + } + }); + } + + private void contributeCustomCollectionTypeIfNecessary(ResolvableType type, GenerationContext generationContext) { + + Class rawType = type.toClass(); + + if (CustomCollections.isCollection(rawType) || CustomCollections.isMap(rawType)) { + contributeType(rawType, generationContext); + } } /** diff --git a/src/main/java/org/springframework/data/util/TypeCollector.java b/src/main/java/org/springframework/data/util/TypeCollector.java index 5b8be8ba5c..a3b03fc44c 100644 --- a/src/main/java/org/springframework/data/util/TypeCollector.java +++ b/src/main/java/org/springframework/data/util/TypeCollector.java @@ -38,6 +38,7 @@ import org.springframework.beans.factory.aot.AotServices; import org.springframework.core.ResolvableType; +import org.springframework.data.core.CustomCollections; import org.springframework.lang.Contract; import org.springframework.util.ObjectUtils; import org.springframework.util.ReflectionUtils; @@ -215,13 +216,26 @@ private void processType(ResolvableType type, InspectionCache cache, Consumer additionalTypes = new LinkedHashSet<>(); - additionalTypes.addAll(TypeUtils.resolveTypesInSignature(type)); - additionalTypes.addAll(visitConstructorsOfType(type)); - additionalTypes.addAll(visitMethodsOfType(type)); - additionalTypes.addAll(visitFieldsOfType(type)); - if (!ObjectUtils.isEmpty(type.toClass().getDeclaredClasses())) { - additionalTypes.addAll(Arrays.asList(type.toClass().getDeclaredClasses())); + if (typeFilter.test(type.toClass())) { + + additionalTypes.addAll(TypeUtils.resolveTypesInSignature(type)); + additionalTypes.addAll(visitConstructorsOfType(type)); + additionalTypes.addAll(visitMethodsOfType(type)); + additionalTypes.addAll(visitFieldsOfType(type)); + + if (!ObjectUtils.isEmpty(type.toClass().getDeclaredClasses())) { + additionalTypes.addAll(Arrays.asList(type.toClass().getDeclaredClasses())); + } + } + else if (isCustomCollectionOrMapType(type.toClass())) { + + for (ResolvableType generic : type.getGenerics()) { + additionalTypes.add(generic.getType()); + } + } + else { + additionalTypes.addAll(TypeUtils.resolveTypesInSignature(type)); } for (Type discoveredType : additionalTypes) { @@ -229,6 +243,10 @@ private void processType(ResolvableType type, InspectionCache cache, Consumer type) { + return CustomCollections.isCollection(type) || CustomCollections.isMap(type); + } + private Set visitConstructorsOfType(ResolvableType type) { if (!typeFilter.test(type.toClass())) { diff --git a/src/main/resources/META-INF/spring/aot.factories b/src/main/resources/META-INF/spring/aot.factories index 977f848a81..6e14f4c1ee 100644 --- a/src/main/resources/META-INF/spring/aot.factories +++ b/src/main/resources/META-INF/spring/aot.factories @@ -10,4 +10,5 @@ org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\ org.springframework.data.aot.AuditingBeanRegistrationAotProcessor org.springframework.data.util.TypeCollector$TypeCollectorFilters=\ - org.springframework.data.util.TypeCollector$DefaultTypeCollectorFilters + org.springframework.data.util.TypeCollector$DefaultTypeCollectorFilters, \ + org.springframework.data.core.CustomCollections$AotTypeCollectorFilters diff --git a/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java b/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java index 315116363a..9bdbf8eb70 100644 --- a/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java +++ b/src/test/java/org/springframework/data/aot/TypeCollectorUnitTests.java @@ -21,6 +21,9 @@ import org.junit.jupiter.api.Test; +import org.springframework.data.aot.types.EntityWithEclipseCollections; +import org.springframework.data.aot.types.EntityWithVavrCollections; +import org.springframework.data.aot.types.EntityWithVavrDomain; import org.springframework.data.aot.types.*; import org.springframework.data.util.TypeCollector; @@ -99,4 +102,29 @@ void typeFilterReflectsAdditionalTypeFilters() { assertThat(TypeCollector.inspect(c -> c.filterTypes(cls -> false), FieldsAndMethods.class).list()).isEmpty(); } + @Test // GH-3416 + void filtersCustomCollectionLibraryTypes() { + assertThat(TypeCollector.create(tc -> {}).getTypeFilter().test(io.vavr.collection.Traversable.class)).isFalse(); + assertThat(TypeCollector.create(tc -> {}).getTypeFilter().test(org.eclipse.collections.api.list.ImmutableList.class)) + .isFalse(); + } + + @Test // GH-3416 + void skipsVavrCollectionIntrospection() { + assertThat(TypeCollector.inspect(EntityWithVavrCollections.class).list()) + .containsExactly(EntityWithVavrCollections.class); + } + + @Test // GH-3416 + void collectsDomainTypesFromVavrCollectionProperties() { + assertThat(TypeCollector.inspect(EntityWithVavrDomain.class).list()).containsExactlyInAnyOrder( + EntityWithVavrDomain.class, EntityWithVavrDomain.SamplePerson.class); + } + + @Test // GH-3416 + void collectsDomainTypesFromEclipseCollectionProperties() { + assertThat(TypeCollector.inspect(EntityWithEclipseCollections.class).list()).containsExactlyInAnyOrder( + EntityWithEclipseCollections.class, EntityWithEclipseCollections.SampleItem.class); + } + } diff --git a/src/test/java/org/springframework/data/aot/sample/ConfigWithCustomCollectionReturnTypes.java b/src/test/java/org/springframework/data/aot/sample/ConfigWithCustomCollectionReturnTypes.java new file mode 100644 index 0000000000..91c22528b8 --- /dev/null +++ b/src/test/java/org/springframework/data/aot/sample/ConfigWithCustomCollectionReturnTypes.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025-present 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.aot.sample; + +import org.eclipse.collections.api.list.ImmutableList; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.data.repository.PagingAndSortingRepository; +import org.springframework.data.repository.config.EnableRepositories; + +import io.vavr.collection.List; +import io.vavr.collection.Seq; + +/** + * @author hippi345 + */ +@Configuration +@EnableRepositories(considerNestedRepositories = true, includeFilters = { + @Filter(type = FilterType.REGEX, pattern = ".*CustomCollectionReturnTypesRepository") }) +public class ConfigWithCustomCollectionReturnTypes { + + public interface CustomCollectionReturnTypesRepository extends PagingAndSortingRepository { + + List findByFirstname(String firstname); + + Seq findAllByLastname(String lastname); + + ImmutableList findAllEclipse(); + } + + public static class Person { + String firstname; + String lastname; + } +} diff --git a/src/test/java/org/springframework/data/aot/types/EntityWithEclipseCollections.java b/src/test/java/org/springframework/data/aot/types/EntityWithEclipseCollections.java new file mode 100644 index 0000000000..6e445e8ab8 --- /dev/null +++ b/src/test/java/org/springframework/data/aot/types/EntityWithEclipseCollections.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022-present 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.aot.types; + +import org.eclipse.collections.api.list.ImmutableList; + +/** + * @author hippi345 + */ +public class EntityWithEclipseCollections { + + ImmutableList items; + + public static class SampleItem { + String label; + } +} diff --git a/src/test/java/org/springframework/data/aot/types/EntityWithVavrCollections.java b/src/test/java/org/springframework/data/aot/types/EntityWithVavrCollections.java new file mode 100644 index 0000000000..2933b08952 --- /dev/null +++ b/src/test/java/org/springframework/data/aot/types/EntityWithVavrCollections.java @@ -0,0 +1,28 @@ +/* + * Copyright 2022-present 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.aot.types; + +import io.vavr.collection.List; +import io.vavr.collection.Map; + +/** + * @author hippi345 + */ +public class EntityWithVavrCollections { + + List names; + Map scores; +} diff --git a/src/test/java/org/springframework/data/aot/types/EntityWithVavrDomain.java b/src/test/java/org/springframework/data/aot/types/EntityWithVavrDomain.java new file mode 100644 index 0000000000..d8ca654d41 --- /dev/null +++ b/src/test/java/org/springframework/data/aot/types/EntityWithVavrDomain.java @@ -0,0 +1,30 @@ +/* + * Copyright 2022-present 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.aot.types; + +import io.vavr.collection.List; + +/** + * @author hippi345 + */ +public class EntityWithVavrDomain { + + List people; + + public static class SamplePerson { + String name; + } +} diff --git a/src/test/java/org/springframework/data/repository/aot/CustomCollectionsAotRepositoryIntegrationTests.java b/src/test/java/org/springframework/data/repository/aot/CustomCollectionsAotRepositoryIntegrationTests.java new file mode 100644 index 0000000000..dd8857e5b4 --- /dev/null +++ b/src/test/java/org/springframework/data/repository/aot/CustomCollectionsAotRepositoryIntegrationTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025-present 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.repository.aot; + +import static org.springframework.data.repository.aot.RepositoryRegistrationAotContributionAssert.assertThatContribution; + +import org.eclipse.collections.api.list.ImmutableList; +import org.junit.jupiter.api.Test; +import org.springframework.data.aot.sample.ConfigWithCustomCollectionReturnTypes; +import org.springframework.data.repository.config.RepositoryRegistrationAotContribution; + +import io.vavr.collection.List; +import io.vavr.collection.Seq; + +/** + * Integration tests for AOT repository support of {@link org.springframework.data.core.CustomCollections}. + * + * @author hippi345 + */ +class CustomCollectionsAotRepositoryIntegrationTests { + + @Test // GH-3416 + void registersReflectionForVavrCollectionReturnTypes() { + + RepositoryRegistrationAotContribution contribution = computeAotConfiguration( + ConfigWithCustomCollectionReturnTypes.class) + .forRepository(ConfigWithCustomCollectionReturnTypes.CustomCollectionReturnTypesRepository.class); + + assertThatContribution(contribution).codeContributionSatisfies(it -> { + it.contributesReflectionFor(List.class, Seq.class); + }); + } + + @Test // GH-3416 + void registersReflectionForEclipseCollectionReturnTypes() { + + RepositoryRegistrationAotContribution contribution = computeAotConfiguration( + ConfigWithCustomCollectionReturnTypes.class) + .forRepository(ConfigWithCustomCollectionReturnTypes.CustomCollectionReturnTypesRepository.class); + + assertThatContribution(contribution).codeContributionSatisfies(it -> { + it.contributesReflectionFor(ImmutableList.class); + }); + } + + AotUtil.RepositoryRegistrationAotContributionBuilder computeAotConfiguration(Class configuration) { + return AotUtil.contributionFor(configuration); + } +} diff --git a/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryCreatorUnitTests.java b/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryCreatorUnitTests.java index 89da401422..275e5ea616 100644 --- a/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryCreatorUnitTests.java +++ b/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryCreatorUnitTests.java @@ -29,6 +29,8 @@ import java.util.TimeZone; import java.util.stream.Stream; +import org.eclipse.collections.api.list.ImmutableList; + import javax.lang.model.element.Modifier; import org.junit.jupiter.api.BeforeEach; @@ -240,6 +242,42 @@ void usesGenericConstructorArguments() { "public %s(List param1, String param2, Object ctorScoped)".formatted(targetType.getSimpleName())); } + @Test // GH-3416 + void supportsVavrAndEclipseCollectionReturnTypes() throws NoSuchMethodException { + + assertThat(ResolvableGenerics.of(CustomCollectionRepository.class.getMethod("findAllVavr"), + CustomCollectionRepository.class).hasUnresolvableGenerics()).isFalse(); + assertThat(ResolvableGenerics.of(CustomCollectionRepository.class.getMethod("findAllEclipse"), + CustomCollectionRepository.class).hasUnresolvableGenerics()).isFalse(); + + SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); + AotRepositoryInformation repositoryInformation = new AotRepositoryInformation( + AnnotationRepositoryMetadata.getMetadata(CustomCollectionRepository.class), CrudRepository.class, + List.of(RepositoryFragment.structural(DummyModuleDefaultRepositoryImplementation.class))); + + AotRepositoryCreator repositoryCreator = AotRepositoryCreator.forRepository(repositoryInformation, "Commons", + projectionFactory); + repositoryCreator.contributeMethods(method -> { + + QueryMethod queryMethod = new QueryMethod(method, repositoryInformation, projectionFactory, + DefaultParameters::new); + + return MethodContributor.forQueryMethod(queryMethod).withMetadata(Map::of).contribute(context -> { + + CodeBlock.Builder builder = CodeBlock.builder(); + if (!ClassUtils.isVoidType(method.getReturnType())) { + builder.addStatement("return null"); + } + + return builder.build(); + }); + }); + + String generated = generate(repositoryCreator); + + assertThat(generated).contains("io.vavr.collection.List<").contains("ImmutableList<"); + } + @Test // GH-3374 void skipsMethodWithUnresolvableGenericReturnType() { @@ -383,6 +421,13 @@ interface BaseRepository extends org.springframework.data.repository.Repo } + interface CustomCollectionRepository extends BaseRepository { + + io.vavr.collection.List findAllVavr(); + + ImmutableList findAllEclipse(); + } + interface UserRepository extends BaseRepository { String someMethod();