interfaceClass, T implem
Assert.notNull(implementation, "Implementation object must not be null");
- if (interfaceClass != null) {
+ if (interfaceClass != null && !(implementation instanceof Class)) {
Assert
.isTrue(ClassUtils.isAssignableValue(interfaceClass, implementation),
diff --git a/src/main/java/org/springframework/data/repository/core/support/RepositoryFragmentsContributor.java b/src/main/java/org/springframework/data/repository/core/support/RepositoryFragmentsContributor.java
new file mode 100644
index 0000000000..782b8356c5
--- /dev/null
+++ b/src/main/java/org/springframework/data/repository/core/support/RepositoryFragmentsContributor.java
@@ -0,0 +1,56 @@
+/*
+ * 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.repository.core.support;
+
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments;
+
+/**
+ * Strategy interface support allowing to contribute a {@link RepositoryFragments} based on {@link RepositoryMetadata}.
+ *
+ * Fragments contributors enhance repository functionality based on a repository declaration and activate additional
+ * fragments if a repository defines them, such as extending a built-in fragment interface (e.g.
+ * {@code QuerydslPredicateExecutor}, {@code QueryByExampleExecutor}).
+ *
+ * This interface is a base-interface serving as a contract for repository fragment introspection. The actual
+ * implementation and methods to contribute fragments to be used within the repository instance are store-specific and
+ * require typically access to infrastructure such as a database connection hence those methods must be defined within
+ * the particular store module.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public interface RepositoryFragmentsContributor {
+
+ /**
+ * Empty {@code RepositoryFragmentsContributor} that does not contribute any fragments.
+ *
+ * @return empty {@code RepositoryFragmentsContributor} that does not contribute any fragments.
+ */
+ public static RepositoryFragmentsContributor empty() {
+ return metadata -> RepositoryFragments.empty();
+ }
+
+ /**
+ * Describe fragments that are contributed by {@link RepositoryMetadata}. Fragment description reports typically
+ * structural fragments that are not suitable for invocation but can be used to introspect the repository structure.
+ *
+ * @param metadata the repository metadata describing the repository interface.
+ * @return fragments to be (structurally) contributed to the repository.
+ */
+ RepositoryFragments describe(RepositoryMetadata metadata);
+
+}
diff --git a/src/main/java/org/springframework/data/repository/query/Parameter.java b/src/main/java/org/springframework/data/repository/query/Parameter.java
index 0907d0f035..b52cbb3df1 100644
--- a/src/main/java/org/springframework/data/repository/query/Parameter.java
+++ b/src/main/java/org/springframework/data/repository/query/Parameter.java
@@ -125,6 +125,7 @@ public boolean isDynamicProjectionParameter() {
*
* @return
*/
+ @SuppressWarnings("NullAway")
public String getPlaceholder() {
if (isNamedParameter()) {
diff --git a/src/main/java/org/springframework/data/repository/support/Repositories.java b/src/main/java/org/springframework/data/repository/support/Repositories.java
index 430139305f..4b4b4ca38f 100644
--- a/src/main/java/org/springframework/data/repository/support/Repositories.java
+++ b/src/main/java/org/springframework/data/repository/support/Repositories.java
@@ -35,6 +35,7 @@
import org.springframework.data.repository.core.EntityInformation;
import org.springframework.data.repository.core.RepositoryInformation;
import org.springframework.data.repository.core.support.RepositoryFactoryInformation;
+import org.springframework.data.repository.core.support.RepositoryFragmentsContributor;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.util.ProxyUtils;
import org.springframework.util.Assert;
@@ -365,6 +366,11 @@ public RepositoryInformation getRepositoryInformation() {
throw new UnsupportedOperationException();
}
+ @Override
+ public RepositoryFragmentsContributor getRepositoryFragmentsContributor() {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public PersistentEntity, ?> getPersistentEntity() {
throw new UnsupportedOperationException();
diff --git a/src/test/java/example/UserRepository.java b/src/test/java/example/UserRepository.java
index d87b9237ad..d9b35863ef 100644
--- a/src/test/java/example/UserRepository.java
+++ b/src/test/java/example/UserRepository.java
@@ -24,7 +24,7 @@
/**
* @author Christoph Strobl
*/
-public interface UserRepository extends CrudRepository {
+public interface UserRepository extends CrudRepository, UserRepositoryExtension {
User findByFirstname(String firstname);
diff --git a/src/test/java/example/UserRepositoryExtension.java b/src/test/java/example/UserRepositoryExtension.java
new file mode 100644
index 0000000000..6123aed839
--- /dev/null
+++ b/src/test/java/example/UserRepositoryExtension.java
@@ -0,0 +1,25 @@
+/*
+ * 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 example;
+
+import example.UserRepository.User;
+
+/**
+ * @author Christoph Strobl
+ */
+public interface UserRepositoryExtension {
+ User findUserByExtensionMethod();
+}
diff --git a/src/test/java/example/UserRepositoryExtensionImpl.java b/src/test/java/example/UserRepositoryExtensionImpl.java
new file mode 100644
index 0000000000..8e6ccb2419
--- /dev/null
+++ b/src/test/java/example/UserRepositoryExtensionImpl.java
@@ -0,0 +1,29 @@
+/*
+ * 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 example;
+
+import example.UserRepository.User;
+
+/**
+ * @author Christoph Strobl
+ */
+public class UserRepositoryExtensionImpl implements UserRepositoryExtension {
+
+ @Override
+ public User findUserByExtensionMethod() {
+ return null;
+ }
+}
diff --git a/src/test/java/org/springframework/data/aot/sample/ConfigWithCustomRepositoryBaseClass.java b/src/test/java/org/springframework/data/aot/sample/ConfigWithCustomRepositoryBaseClass.java
index 29d7471593..790f660b9b 100644
--- a/src/test/java/org/springframework/data/aot/sample/ConfigWithCustomRepositoryBaseClass.java
+++ b/src/test/java/org/springframework/data/aot/sample/ConfigWithCustomRepositoryBaseClass.java
@@ -22,13 +22,13 @@
import org.springframework.context.annotation.FilterType;
import org.springframework.data.aot.sample.ConfigWithCustomRepositoryBaseClass.RepoBaseClass;
import org.springframework.data.repository.CrudRepository;
-import org.springframework.data.repository.config.EnableRepositories;
+import org.springframework.data.repository.config.EnableRepositoriesWithContributor;
/**
* @author Christoph Strobl
*/
@Configuration
-@EnableRepositories(repositoryBaseClass = RepoBaseClass.class, considerNestedRepositories = true,
+@EnableRepositoriesWithContributor(repositoryBaseClass = RepoBaseClass.class, considerNestedRepositories = true,
includeFilters = { @Filter(type = FilterType.REGEX, pattern = ".*CustomerRepositoryWithCustomBaseRepo$") })
public class ConfigWithCustomRepositoryBaseClass {
diff --git a/src/test/java/org/springframework/data/aot/sample/ConfigWithSimpleCrudRepository.java b/src/test/java/org/springframework/data/aot/sample/ConfigWithSimpleCrudRepository.java
index 09236c4418..25e35d9248 100644
--- a/src/test/java/org/springframework/data/aot/sample/ConfigWithSimpleCrudRepository.java
+++ b/src/test/java/org/springframework/data/aot/sample/ConfigWithSimpleCrudRepository.java
@@ -15,6 +15,8 @@
*/
package org.springframework.data.aot.sample;
+import org.jspecify.annotations.Nullable;
+
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.FilterType;
import org.springframework.data.aot.sample.ConfigWithSimpleCrudRepository.MyRepo;
@@ -34,7 +36,7 @@ public interface MyRepo extends CrudRepository {
public static class Person {
- @javax.annotation.Nullable
+ @Nullable
Address address;
}
diff --git a/src/test/java/org/springframework/data/repository/aot/RepositoryRegistrationAotProcessorIntegrationTests.java b/src/test/java/org/springframework/data/repository/aot/RepositoryRegistrationAotProcessorIntegrationTests.java
index 39bc545541..bb71245359 100644
--- a/src/test/java/org/springframework/data/repository/aot/RepositoryRegistrationAotProcessorIntegrationTests.java
+++ b/src/test/java/org/springframework/data/repository/aot/RepositoryRegistrationAotProcessorIntegrationTests.java
@@ -55,6 +55,7 @@
import org.springframework.data.repository.config.EnableRepositories;
import org.springframework.data.repository.config.RepositoryRegistrationAotContribution;
import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor;
+import org.springframework.data.repository.config.SampleRepositoryFragmentsContributor;
import org.springframework.data.repository.reactive.ReactiveSortingRepository;
import org.springframework.transaction.interceptor.TransactionalProxy;
@@ -237,10 +238,11 @@ void contributesRepositoryBaseClassCorrectly() {
assertThatContribution(repositoryBeanContribution) //
.targetRepositoryTypeIs(ConfigWithCustomRepositoryBaseClass.CustomerRepositoryWithCustomBaseRepo.class) //
- .hasNoFragments() //
+ .hasFragments() //
.codeContributionSatisfies(contribution -> { //
// interface
contribution
+ .contributesReflectionFor(SampleRepositoryFragmentsContributor.class) // repository structural fragment
.contributesReflectionFor(ConfigWithCustomRepositoryBaseClass.CustomerRepositoryWithCustomBaseRepo.class) // repository
.contributesReflectionFor(ConfigWithCustomRepositoryBaseClass.RepoBaseClass.class) // base repo class
.contributesReflectionFor(ConfigWithCustomRepositoryBaseClass.Person.class); // repository domain type
diff --git a/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryBuilderUnitTests.java b/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryBuilderUnitTests.java
new file mode 100644
index 0000000000..1ac8d043b3
--- /dev/null
+++ b/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryBuilderUnitTests.java
@@ -0,0 +1,191 @@
+/*
+ * 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.repository.aot.generate;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import example.UserRepository.User;
+
+import java.util.List;
+import java.util.TimeZone;
+
+import javax.lang.model.element.Modifier;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.data.geo.Metric;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.querydsl.QuerydslPredicateExecutor;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.data.repository.config.AotRepositoryInformation;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.repository.core.support.AnnotationRepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryFragment;
+import org.springframework.data.repository.query.QueryMethod;
+import org.springframework.javapoet.MethodSpec;
+import org.springframework.javapoet.TypeName;
+import org.springframework.stereotype.Repository;
+
+/**
+ * Unit tests for {@link AotRepositoryBuilder}.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ */
+class AotRepositoryBuilderUnitTests {
+
+ RepositoryInformation repositoryInformation;
+
+ @BeforeEach
+ void beforeEach() {
+
+ repositoryInformation = mock(RepositoryInformation.class);
+ doReturn(UserRepository.class).when(repositoryInformation).getRepositoryInterface();
+ }
+
+ @Test // GH-3279
+ void writesClassSkeleton() {
+
+ AotRepositoryBuilder repoBuilder = AotRepositoryBuilder.forRepository(repositoryInformation, "Commons",
+ new SpelAwareProxyProjectionFactory());
+ assertThat(repoBuilder.build().javaFile().toString())
+ .contains("package %s;".formatted(UserRepository.class.getPackageName())) // same package as source repo
+ .contains("@Generated") // marked as generated source
+ .contains("public class %sImpl__Aot".formatted(UserRepository.class.getSimpleName())) // target name
+ .contains("public UserRepositoryImpl__Aot()"); // default constructor if not arguments to wire
+ }
+
+ @Test // GH-3279
+ void appliesCtorArguments() {
+
+ AotRepositoryBuilder repoBuilder = AotRepositoryBuilder.forRepository(repositoryInformation, "Commons",
+ new SpelAwareProxyProjectionFactory());
+ repoBuilder.withConstructorCustomizer(ctor -> {
+ ctor.addParameter("param1", Metric.class);
+ ctor.addParameter("param2", String.class);
+ ctor.addParameter("ctorScoped", TypeName.OBJECT, false);
+ });
+ assertThat(repoBuilder.build().javaFile().toString()) //
+ .contains("private final Metric param1;") //
+ .contains("private final String param2;") //
+ .doesNotContain("private final Object ctorScoped;") //
+ .contains("public UserRepositoryImpl__Aot(Metric param1, String param2, Object ctorScoped)") //
+ .contains("this.param1 = param1") //
+ .contains("this.param2 = param2") //
+ .doesNotContain("this.ctorScoped = ctorScoped");
+ }
+
+ @Test // GH-3279
+ void appliesCtorCodeBlock() {
+
+ AotRepositoryBuilder repoBuilder = AotRepositoryBuilder.forRepository(repositoryInformation, "Commons",
+ new SpelAwareProxyProjectionFactory());
+ repoBuilder.withConstructorCustomizer(ctor -> {
+ ctor.customize((info, code) -> {
+ code.addStatement("throw new $T($S)", IllegalStateException.class, "initialization error");
+ });
+ });
+ assertThat(repoBuilder.build().javaFile().toString()).containsIgnoringWhitespaces(
+ "UserRepositoryImpl__Aot() { throw new IllegalStateException(\"initialization error\"); }");
+ }
+
+ @Test // GH-3279
+ void appliesClassCustomizations() {
+
+ AotRepositoryBuilder repoBuilder = AotRepositoryBuilder.forRepository(repositoryInformation, "Commons",
+ new SpelAwareProxyProjectionFactory());
+
+ repoBuilder.withClassCustomizer((info, metadata, clazz) -> {
+
+ clazz.addField(Float.class, "f", Modifier.PRIVATE, Modifier.STATIC);
+ clazz.addField(Double.class, "d", Modifier.PUBLIC);
+ clazz.addField(TimeZone.class, "t", Modifier.FINAL);
+
+ clazz.addAnnotation(Repository.class);
+
+ clazz.addMethod(MethodSpec.methodBuilder("oops").build());
+ });
+
+ assertThat(repoBuilder.build().javaFile().toString()) //
+ .contains("@Repository") //
+ .contains("private static Float f;") //
+ .contains("public Double d;") //
+ .contains("final TimeZone t;") //
+ .containsIgnoringWhitespaces("void oops() { }");
+ }
+
+ @Test // GH-3279
+ void appliesQueryMethodContributor() {
+
+ AotRepositoryInformation repositoryInformation = new AotRepositoryInformation(
+ AnnotationRepositoryMetadata.getMetadata(UserRepository.class), CrudRepository.class, List.of());
+
+ AotRepositoryBuilder repoBuilder = AotRepositoryBuilder.forRepository(repositoryInformation, "Commons",
+ new SpelAwareProxyProjectionFactory());
+
+ repoBuilder.withQueryMethodContributor((method, info) -> {
+
+ return new MethodContributor<>(mock(QueryMethod.class), null) {
+
+ @Override
+ public MethodSpec contribute(AotQueryMethodGenerationContext context) {
+ return MethodSpec.methodBuilder("oops").build();
+ }
+
+ @Override
+ public boolean contributesMethodSpec() {
+ return true;
+ }
+ };
+ });
+
+ assertThat(repoBuilder.build().javaFile().toString()) //
+ .containsIgnoringWhitespaces("void oops() { }");
+ }
+
+ @Test // GH-3279
+ void shouldContributeFragmentImplementationMetadata() {
+
+ AotRepositoryInformation repositoryInformation = new AotRepositoryInformation(
+ AnnotationRepositoryMetadata.getMetadata(QuerydslUserRepository.class), CrudRepository.class,
+ List.of(RepositoryFragment.structural(QuerydslPredicateExecutor.class, DummyQuerydslPredicateExecutor.class)));
+
+ AotRepositoryBuilder builder = AotRepositoryBuilder.forRepository(repositoryInformation, "Commons",
+ new SpelAwareProxyProjectionFactory());
+ AotRepositoryBuilder.AotBundle bundle = builder.build();
+
+ AotRepositoryMethod method = bundle.metadata().methods().stream().filter(it -> it.name().equals("findBy"))
+ .findFirst().get();
+
+ assertThat(method.fragment()).isNotNull();
+ assertThat(method.fragment().signature()).isEqualTo(QuerydslPredicateExecutor.class.getName());
+ assertThat(method.fragment().implementation()).isEqualTo(DummyQuerydslPredicateExecutor.class.getName());
+ }
+
+ interface UserRepository extends org.springframework.data.repository.Repository {
+
+ String someMethod();
+ }
+
+ interface QuerydslUserRepository
+ extends org.springframework.data.repository.Repository, QuerydslPredicateExecutor {
+
+ }
+
+ interface DummyQuerydslPredicateExecutor extends QuerydslPredicateExecutor {}
+}
diff --git a/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryMethodBuilderUnitTests.java b/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryMethodBuilderUnitTests.java
new file mode 100644
index 0000000000..b0f19b807e
--- /dev/null
+++ b/src/test/java/org/springframework/data/repository/aot/generate/AotRepositoryMethodBuilderUnitTests.java
@@ -0,0 +1,88 @@
+/*
+ * 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.repository.aot.generate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.when;
+
+import example.UserRepository;
+import example.UserRepository.User;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+import org.springframework.core.ResolvableType;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.util.TypeInformation;
+import org.springframework.javapoet.ParameterSpec;
+import org.springframework.javapoet.ParameterizedTypeName;
+
+/**
+ * @author Christoph Strobl
+ */
+class AotRepositoryMethodBuilderUnitTests {
+
+ RepositoryInformation repositoryInformation;
+ AotQueryMethodGenerationContext methodGenerationContext;
+
+ @BeforeEach
+ void beforeEach() {
+ repositoryInformation = Mockito.mock(RepositoryInformation.class);
+ methodGenerationContext = Mockito.mock(AotQueryMethodGenerationContext.class);
+
+ when(methodGenerationContext.getRepositoryInformation()).thenReturn(repositoryInformation);
+ }
+
+ @Test // GH-3279
+ void generatesMethodSkeletonBasedOnGenerationMetadata() throws NoSuchMethodException {
+
+ Method method = UserRepository.class.getMethod("findByFirstname", String.class);
+ when(methodGenerationContext.getMethod()).thenReturn(method);
+ when(methodGenerationContext.getReturnType()).thenReturn(ResolvableType.forClass(User.class));
+ doReturn(TypeInformation.of(User.class)).when(repositoryInformation).getReturnType(any());
+ MethodMetadata methodMetadata = new MethodMetadata(repositoryInformation, method);
+ methodMetadata.addParameter(ParameterSpec.builder(String.class, "firstname").build());
+ when(methodGenerationContext.getTargetMethodMetadata()).thenReturn(methodMetadata);
+
+ AotRepositoryMethodBuilder builder = new AotRepositoryMethodBuilder(methodGenerationContext);
+ assertThat(builder.buildMethod().toString()) //
+ .containsPattern("public .*User findByFirstname\\(.*String firstname\\)");
+ }
+
+ @Test // GH-3279
+ void generatesMethodWithGenerics() throws NoSuchMethodException {
+
+ Method method = UserRepository.class.getMethod("findByFirstnameIn", List.class);
+ when(methodGenerationContext.getMethod()).thenReturn(method);
+ when(methodGenerationContext.getReturnType())
+ .thenReturn(ResolvableType.forClassWithGenerics(List.class, User.class));
+ doReturn(TypeInformation.of(User.class)).when(repositoryInformation).getReturnType(any());
+ MethodMetadata methodMetadata = new MethodMetadata(repositoryInformation, method);
+ methodMetadata
+ .addParameter(ParameterSpec.builder(ParameterizedTypeName.get(List.class, String.class), "firstnames").build());
+ when(methodGenerationContext.getTargetMethodMetadata()).thenReturn(methodMetadata);
+
+ AotRepositoryMethodBuilder builder = new AotRepositoryMethodBuilder(methodGenerationContext);
+ assertThat(builder.buildMethod().toString()) //
+ .containsPattern("public .*List<.*User> findByFirstnameIn\\(") //
+ .containsPattern(".*List<.*String> firstnames\\)");
+ }
+}
diff --git a/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java b/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java
index 05b058f8e5..8c05276a9a 100644
--- a/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java
+++ b/src/test/java/org/springframework/data/repository/aot/generate/DummyModuleAotRepositoryContext.java
@@ -43,6 +43,11 @@ public DummyModuleAotRepositoryContext(Class> repositoryInterface, @Nullable R
this.repositoryInformation = new StubRepositoryInformation(repositoryInterface, composition);
}
+ @Override
+ public String getModuleName() {
+ return "Commons";
+ }
+
@Override
public ConfigurableListableBeanFactory getBeanFactory() {
return null;
diff --git a/src/test/java/org/springframework/data/repository/aot/generate/MethodCapturingRepositoryContributor.java b/src/test/java/org/springframework/data/repository/aot/generate/MethodCapturingRepositoryContributor.java
new file mode 100644
index 0000000000..033c7fbe18
--- /dev/null
+++ b/src/test/java/org/springframework/data/repository/aot/generate/MethodCapturingRepositoryContributor.java
@@ -0,0 +1,57 @@
+/*
+ * 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.repository.aot.generate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Method;
+import java.util.List;
+
+import org.assertj.core.api.MapAssert;
+import org.jspecify.annotations.Nullable;
+import org.springframework.data.repository.config.AotRepositoryContext;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.repository.query.QueryMethod;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * @author Christoph Strobl
+ */
+public class MethodCapturingRepositoryContributor extends RepositoryContributor {
+
+ MultiValueMap capturedInvocations;
+
+ public MethodCapturingRepositoryContributor(AotRepositoryContext repositoryContext) {
+ super(repositoryContext);
+ this.capturedInvocations = new LinkedMultiValueMap<>(3);
+ }
+
+ @Override
+ protected @Nullable MethodContributor extends QueryMethod> contributeQueryMethod(Method method,
+ RepositoryInformation repositoryInformation) {
+ capturedInvocations.add(method.getName(), method);
+ return null;
+ }
+
+ void verifyContributionFor(String methodName) {
+ assertThat(capturedInvocations).containsKey(methodName);
+ }
+
+ MapAssert> verifyContributedMethods() {
+ return assertThat(capturedInvocations);
+ }
+}
diff --git a/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java b/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java
index b77ac6346e..9156704008 100644
--- a/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java
+++ b/src/test/java/org/springframework/data/repository/aot/generate/RepositoryContributorUnitTests.java
@@ -16,11 +16,17 @@
package org.springframework.data.repository.aot.generate;
import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
import example.UserRepository;
+import example.UserRepositoryExtension;
+import example.UserRepositoryExtensionImpl;
import java.lang.reflect.Method;
import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
@@ -28,18 +34,25 @@
import org.springframework.aot.test.generate.TestGenerationContext;
import org.springframework.core.test.tools.TestCompiler;
import org.springframework.data.aot.CodeContributionAssert;
+import org.springframework.data.repository.CrudRepository;
+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.data.repository.core.support.RepositoryComposition.RepositoryFragments;
+import org.springframework.data.repository.core.support.RepositoryFragment;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.javapoet.CodeBlock;
import org.springframework.util.ClassUtils;
/**
+ * Unit tests targeting {@link RepositoryContributor}.
+ *
* @author Christoph Strobl
*/
class RepositoryContributorUnitTests {
- @Test
- void testCompile() {
+ @Test // GH-3279
+ void createsCompilableClassStub() {
DummyModuleAotRepositoryContext aotContext = new DummyModuleAotRepositoryContext(UserRepository.class, null);
RepositoryContributor repositoryContributor = new RepositoryContributor(aotContext) {
@@ -55,8 +68,7 @@ void testCompile() {
public Map serialize() {
return Map.of();
}
- })
- .contribute(context -> {
+ }).contribute(context -> {
CodeBlock.Builder builder = CodeBlock.builder();
if (!ClassUtils.isVoidType(method.getReturnType())) {
@@ -81,4 +93,149 @@ public Map serialize() {
new CodeContributionAssert(generationContext).contributesReflectionFor(expectedTypeName);
}
+ @Test // GH-3279
+ void callsMethodContributionForQueryMethod() {
+
+ AotRepositoryContext repositoryContext = mock(AotRepositoryContext.class);
+ RepositoryInformation repositoryInformation = mock(RepositoryInformation.class);
+
+ when(repositoryContext.getRepositoryInformation()).thenReturn(repositoryInformation);
+ when(repositoryInformation.getRepositoryInterface()).thenReturn((Class) UserRepository.class);
+ when(repositoryInformation.isQueryMethod(argThat(it -> it.getName().equals("findByFirstname")))).thenReturn(true);
+
+ MethodCapturingRepositoryContributor contributor = new MethodCapturingRepositoryContributor(repositoryContext);
+ contributor.contribute(new TestGenerationContext(UserRepository.class));
+
+ contributor.verifyContributionFor("findByFirstname");
+ }
+
+ @Test // GH-3279
+ void doesNotContributeBaseClassMethods() {
+
+ AotRepositoryContext repositoryContext = mock(AotRepositoryContext.class);
+ when(repositoryContext.getModuleName()).thenReturn("Commons");
+ RepositoryInformation repositoryInformation = mock(RepositoryInformation.class);
+
+ when(repositoryContext.getRepositoryInformation()).thenReturn(repositoryInformation);
+ when(repositoryInformation.getRepositoryInterface()).thenReturn((Class) UserRepository.class);
+ when(repositoryInformation.getRepositoryComposition())
+ .thenReturn(RepositoryComposition.of(RepositoryFragment.structural(RepoBaseClass.class)));
+ when(repositoryInformation.isBaseClassMethod(argThat(it -> it.getName().equals("findByFirstname"))))
+ .thenReturn(true);
+ when(repositoryInformation.isQueryMethod(argThat(it -> !it.getName().equals("findByFirstname")))).thenReturn(true);
+
+ MethodCapturingRepositoryContributor contributor = new MethodCapturingRepositoryContributor(repositoryContext);
+ contributor.contribute(new TestGenerationContext(UserRepository.class));
+
+ contributor.verifyContributedMethods().isNotEmpty().doesNotContainKey("findByFirstname");
+ }
+
+ @Test // GH-3279
+ void doesNotContributeFragmentMethod() {
+
+ AotRepositoryContext repositoryContext = mock(AotRepositoryContext.class);
+ when(repositoryContext.getModuleName()).thenReturn("Commons");
+ RepositoryInformation repositoryInformation = mock(RepositoryInformation.class);
+
+ when(repositoryContext.getRepositoryInformation()).thenReturn(repositoryInformation);
+ when(repositoryInformation.getRepositoryInterface()).thenReturn((Class) UserRepository.class);
+ when(repositoryInformation.getRepositoryComposition())
+ .thenReturn(RepositoryComposition.of(RepositoryFragment.structural(UserRepository.class))
+ .append(RepositoryFragments
+ .from(Set.of(new RepositoryFragment.ImplementedRepositoryFragment(UserRepositoryExtension.class,
+ UserRepositoryExtensionImpl.class)))));
+
+ when(repositoryInformation.isCustomMethod(argThat(it -> it.getName().equals("findUserByExtensionMethod"))))
+ .thenReturn(true);
+ when(repositoryInformation.isQueryMethod(argThat(it -> it.getName().equals("findByFirstname")))).thenReturn(true);
+
+ MethodCapturingRepositoryContributor contributor = new MethodCapturingRepositoryContributor(repositoryContext);
+ contributor.contribute(new TestGenerationContext(UserRepository.class));
+
+ contributor.verifyContributedMethods().isNotEmpty().doesNotContainKey("findUserByExtensionMethod");
+ }
+
+ @Test // GH-3279
+ void contributesBaseClassMethodIfQueryMethod() {
+
+ AotRepositoryContext repositoryContext = mock(AotRepositoryContext.class);
+ when(repositoryContext.getModuleName()).thenReturn("Commons");
+ RepositoryInformation repositoryInformation = mock(RepositoryInformation.class);
+
+ when(repositoryContext.getRepositoryInformation()).thenReturn(repositoryInformation);
+ when(repositoryInformation.getRepositoryInterface()).thenReturn((Class) UserRepository.class);
+ when(repositoryInformation.getRepositoryComposition())
+ .thenReturn(RepositoryComposition.of(RepositoryFragment.structural(RepoBaseClass.class)));
+ when(repositoryInformation.isBaseClassMethod(argThat(it -> it.getName().equals("findByFirstname"))))
+ .thenReturn(true);
+ when(repositoryInformation.isQueryMethod(any())).thenReturn(true);
+
+ MethodCapturingRepositoryContributor contributor = new MethodCapturingRepositoryContributor(repositoryContext);
+ contributor.contribute(new TestGenerationContext(UserRepository.class));
+
+ contributor.verifyContributedMethods().containsKey("findByFirstname").hasSizeGreaterThan(1);
+ }
+
+ static class RepoBaseClass implements CrudRepository {
+
+ private CrudRepository delegate;
+
+ public S save(S entity) {
+ return this.delegate.save(entity);
+ }
+
+ @Override
+ public Iterable saveAll(Iterable entities) {
+ return this.delegate.saveAll(entities);
+ }
+
+ public Optional findById(ID id) {
+ return this.delegate.findById(id);
+ }
+
+ @Override
+ public boolean existsById(ID id) {
+ return this.delegate.existsById(id);
+ }
+
+ @Override
+ public Iterable findAll() {
+ return this.delegate.findAll();
+ }
+
+ @Override
+ public Iterable findAllById(Iterable ids) {
+ return this.delegate.findAllById(ids);
+ }
+
+ @Override
+ public long count() {
+ return this.delegate.count();
+ }
+
+ @Override
+ public void deleteById(ID id) {
+ this.delegate.deleteById(id);
+ }
+
+ @Override
+ public void delete(T entity) {
+ this.delegate.delete(entity);
+ }
+
+ @Override
+ public void deleteAllById(Iterable extends ID> ids) {
+ this.delegate.deleteAllById(ids);
+ }
+
+ @Override
+ public void deleteAll(Iterable extends T> entities) {
+ this.delegate.deleteAll(entities);
+ }
+
+ @Override
+ public void deleteAll() {
+ this.delegate.deleteAll();
+ }
+ }
}
diff --git a/src/test/java/org/springframework/data/repository/config/AnnotationRepositoryConfigurationSourceUnitTests.java b/src/test/java/org/springframework/data/repository/config/AnnotationRepositoryConfigurationSourceUnitTests.java
index 8917668d0c..1b1c657cb7 100755
--- a/src/test/java/org/springframework/data/repository/config/AnnotationRepositoryConfigurationSourceUnitTests.java
+++ b/src/test/java/org/springframework/data/repository/config/AnnotationRepositoryConfigurationSourceUnitTests.java
@@ -15,15 +15,15 @@
*/
package org.springframework.data.repository.config;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.mockito.Mockito.mock;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.context.annotation.ComponentScan.Filter;
@@ -184,6 +184,34 @@ void considerBeanNameGenerator() {
assertThat(getConfigSource(DefaultConfiguration.class).generateBeanName(bd)).isEqualTo("personRepository");
}
+ @Test // GH-3279
+ void considersDefaultFragmentsContributor() {
+
+ RootBeanDefinition bd = new RootBeanDefinition(DummyRepositoryFactory.class);
+ bd.getConstructorArgumentValues().addGenericArgumentValue(PersonRepository.class);
+
+ AnnotationMetadata metadata = new StandardAnnotationMetadata(ConfigurationWithFragmentsContributor.class, true);
+ AnnotationRepositoryConfigurationSource configurationSource = new AnnotationRepositoryConfigurationSource(metadata,
+ EnableRepositoriesWithContributor.class, resourceLoader, environment, registry, null);
+
+ assertThat(configurationSource.getRepositoryFragmentsContributorClassName())
+ .contains(SampleRepositoryFragmentsContributor.class.getName());
+ }
+
+ @Test // GH-3279
+ void omitsUnspecifiedFragmentsContributor() {
+
+ RootBeanDefinition bd = new RootBeanDefinition(DummyRepositoryFactory.class);
+ bd.getConstructorArgumentValues().addGenericArgumentValue(PersonRepository.class);
+
+ AnnotationMetadata metadata = new StandardAnnotationMetadata(ReactiveConfigurationWithBeanNameGenerator.class,
+ true);
+ AnnotationRepositoryConfigurationSource configurationSource = new AnnotationRepositoryConfigurationSource(metadata,
+ EnableReactiveRepositories.class, resourceLoader, environment, registry, null);
+
+ assertThat(configurationSource.getRepositoryFragmentsContributorClassName()).isEmpty();
+ }
+
@Test // GH-3082
void considerBeanNameGeneratorForReactiveRepos() {
@@ -219,6 +247,9 @@ static class ConfigurationWithExplicitFilter {}
@EnableRepositories(nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class)
static class ConfigurationWithBeanNameGenerator {}
+ @EnableRepositoriesWithContributor()
+ static class ConfigurationWithFragmentsContributor {}
+
@EnableReactiveRepositories(nameGenerator = FullyQualifiedAnnotationBeanNameGenerator.class)
static class ReactiveConfigurationWithBeanNameGenerator {}
@@ -234,4 +265,5 @@ static class ReactiveConfigurationWithBeanNameGenerator {}
static class ConfigWithSampleAnnotation {}
interface ReactivePersonRepository extends ReactiveCrudRepository {}
+
}
diff --git a/src/test/java/org/springframework/data/repository/config/DummyRegistrarWithContributor.java b/src/test/java/org/springframework/data/repository/config/DummyRegistrarWithContributor.java
new file mode 100644
index 0000000000..85708eb8fa
--- /dev/null
+++ b/src/test/java/org/springframework/data/repository/config/DummyRegistrarWithContributor.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2022-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.repository.config;
+
+import java.lang.annotation.Annotation;
+
+import org.springframework.core.io.DefaultResourceLoader;
+
+/**
+ * @author Mark Paluch
+ */
+class DummyRegistrarWithContributor extends RepositoryBeanDefinitionRegistrarSupport {
+
+ DummyRegistrarWithContributor() {
+ setResourceLoader(new DefaultResourceLoader());
+ }
+
+ @Override
+ protected Class extends Annotation> getAnnotation() {
+ return EnableRepositoriesWithContributor.class;
+ }
+
+ @Override
+ protected RepositoryConfigurationExtension getExtension() {
+ return new DummyConfigurationExtension();
+ }
+}
diff --git a/src/test/java/org/springframework/data/repository/config/EnableRepositoriesWithContributor.java b/src/test/java/org/springframework/data/repository/config/EnableRepositoriesWithContributor.java
new file mode 100644
index 0000000000..2c38047e00
--- /dev/null
+++ b/src/test/java/org/springframework/data/repository/config/EnableRepositoriesWithContributor.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2012-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.repository.config;
+
+import java.lang.annotation.Inherited;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+import org.springframework.beans.factory.support.BeanNameGenerator;
+import org.springframework.context.annotation.ComponentScan.Filter;
+import org.springframework.context.annotation.Import;
+import org.springframework.data.repository.PagingAndSortingRepository;
+import org.springframework.data.repository.core.support.DummyRepositoryFactoryBean;
+import org.springframework.data.repository.core.support.RepositoryFragmentsContributor;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Import(DummyRegistrarWithContributor.class)
+@Inherited
+public @interface EnableRepositoriesWithContributor {
+
+ String[] value() default {};
+
+ String[] basePackages() default {};
+
+ Class>[] basePackageClasses() default {};
+
+ Filter[] includeFilters() default {};
+
+ Filter[] excludeFilters() default {};
+
+ Class> repositoryFactoryBeanClass() default DummyRepositoryFactoryBean.class;
+
+ Class extends RepositoryFragmentsContributor> fragmentsContributor() default SampleRepositoryFragmentsContributor.class;
+
+ Class> repositoryBaseClass() default PagingAndSortingRepository.class;
+
+ Class extends BeanNameGenerator> nameGenerator() default BeanNameGenerator.class;
+
+ String namedQueriesLocation() default "";
+
+ String repositoryImplementationPostfix() default "Impl";
+
+ boolean considerNestedRepositories() default false;
+
+ boolean limitImplementationBasePackages() default true;
+
+ BootstrapMode bootstrapMode() default BootstrapMode.DEFAULT;
+}
diff --git a/src/test/java/org/springframework/data/repository/config/RepositoryBeanDefinitionReaderTests.java b/src/test/java/org/springframework/data/repository/config/RepositoryBeanDefinitionReaderTests.java
new file mode 100644
index 0000000000..13482da3f8
--- /dev/null
+++ b/src/test/java/org/springframework/data/repository/config/RepositoryBeanDefinitionReaderTests.java
@@ -0,0 +1,163 @@
+/*
+ * 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.repository.config;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.beans.factory.support.RegisteredBean;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.data.aot.sample.ConfigWithCustomImplementation;
+import org.springframework.data.aot.sample.ConfigWithCustomRepositoryBaseClass;
+import org.springframework.data.aot.sample.ConfigWithCustomRepositoryBaseClass.CustomerRepositoryWithCustomBaseRepo;
+import org.springframework.data.aot.sample.ConfigWithFragments;
+import org.springframework.data.aot.sample.ConfigWithSimpleCrudRepository;
+import org.springframework.data.aot.sample.ReactiveConfig;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport;
+import org.springframework.data.repository.core.support.RepositoryFragment;
+
+/**
+ * Unit tests for {@link RepositoryBeanDefinitionReader}.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ */
+class RepositoryBeanDefinitionReaderTests {
+
+ @Test // GH-3279
+ void readsSimpleConfigFromBeanFactory() {
+
+ RegisteredBean repoFactoryBean = repositoryFactory(ConfigWithSimpleCrudRepository.class);
+
+ RepositoryConfiguration> repoConfig = mock(RepositoryConfiguration.class);
+ when(repoConfig.getRepositoryInterface()).thenReturn(ConfigWithSimpleCrudRepository.MyRepo.class.getName());
+
+ RepositoryBeanDefinitionReader reader = new RepositoryBeanDefinitionReader(repoFactoryBean);
+ RepositoryInformation repositoryInformation = reader.getRepositoryInformation();
+
+ assertThat(repositoryInformation.getRepositoryInterface()).isEqualTo(ConfigWithSimpleCrudRepository.MyRepo.class);
+ assertThat(repositoryInformation.getDomainType()).isEqualTo(ConfigWithSimpleCrudRepository.Person.class);
+ assertThat(repositoryInformation.getFragments()).isEmpty();
+ }
+
+ @Test // GH-3279
+ void readsCustomRepoBaseClassFromBeanFactory() {
+
+ RegisteredBean repoFactoryBean = repositoryFactory(ConfigWithCustomRepositoryBaseClass.class);
+
+ RepositoryConfiguration> repoConfig = mock(RepositoryConfiguration.class);
+ Class> repositoryInterfaceType = CustomerRepositoryWithCustomBaseRepo.class;
+ when(repoConfig.getRepositoryInterface()).thenReturn(repositoryInterfaceType.getName());
+
+ RepositoryBeanDefinitionReader reader = new RepositoryBeanDefinitionReader(repoFactoryBean);
+ RepositoryInformation repositoryInformation = reader.getRepositoryInformation();
+
+ assertThat(repositoryInformation.getRepositoryBaseClass())
+ .isEqualTo(ConfigWithCustomRepositoryBaseClass.RepoBaseClass.class);
+ }
+
+ @Test // GH-3279
+ void readsFragmentsContributorFromBeanDefinition() {
+
+ RegisteredBean repoFactoryBean = repositoryFactory(ConfigWithCustomRepositoryBaseClass.class);
+
+ RepositoryConfiguration> repoConfig = mock(RepositoryConfiguration.class);
+ Class> repositoryInterfaceType = CustomerRepositoryWithCustomBaseRepo.class;
+ when(repoConfig.getRepositoryInterface()).thenReturn(repositoryInterfaceType.getName());
+
+ RepositoryBeanDefinitionReader reader = new RepositoryBeanDefinitionReader(repoFactoryBean);
+ RepositoryInformation repositoryInformation = reader.getRepositoryInformation();
+
+ assertThat(repositoryInformation.getFragments())
+ .contains(RepositoryFragment.structural(SampleRepositoryFragmentsContributor.class));
+ }
+
+ @Test // GH-3279
+ void readsFragmentsContributorFromBeanFactory() {
+
+ RegisteredBean repoFactoryBean = repositoryFactory(ReactiveConfig.class);
+
+ RepositoryConfiguration> repoConfig = mock(RepositoryConfiguration.class);
+ Class> repositoryInterfaceType = ReactiveConfig.CustomerRepositoryReactive.class;
+ when(repoConfig.getRepositoryInterface()).thenReturn(repositoryInterfaceType.getName());
+
+ RepositoryBeanDefinitionReader reader = new RepositoryBeanDefinitionReader(repoFactoryBean);
+ RepositoryInformation repositoryInformation = reader.getRepositoryInformation();
+
+ assertThat(repositoryInformation.getFragments()).isEmpty();
+ }
+
+ @Test // GH-3279, GH-3282
+ void readsCustomImplementationFromBeanFactory() {
+
+ RegisteredBean repoFactoryBean = repositoryFactory(ConfigWithCustomImplementation.class);
+ RepositoryConfiguration> repoConfig = mock(RepositoryConfiguration.class);
+
+ Class> repositoryInterfaceType = ConfigWithCustomImplementation.RepositoryWithCustomImplementation.class;
+ when(repoConfig.getRepositoryInterface()).thenReturn(repositoryInterfaceType.getName());
+
+ RepositoryBeanDefinitionReader reader = new RepositoryBeanDefinitionReader(repoFactoryBean);
+ RepositoryInformation repositoryInformation = reader.getRepositoryInformation();
+
+ assertThat(repositoryInformation.getFragments()).satisfiesExactly(fragment -> {
+ assertThat(fragment.getImplementationClass())
+ .contains(ConfigWithCustomImplementation.RepositoryWithCustomImplementationImpl.class);
+ });
+ }
+
+ @Test // GH-3279, GH-3282
+ void readsFragmentsFromBeanFactory() {
+
+ RegisteredBean repoFactoryBean = repositoryFactory(ConfigWithFragments.class);
+ RepositoryConfiguration> repoConfig = mock(RepositoryConfiguration.class);
+
+ Class> repositoryInterfaceType = ConfigWithFragments.RepositoryWithFragments.class;
+ when(repoConfig.getRepositoryInterface()).thenReturn(repositoryInterfaceType.getName());
+
+ RepositoryBeanDefinitionReader reader = new RepositoryBeanDefinitionReader(repoFactoryBean);
+ RepositoryInformation repositoryInformation = reader.getRepositoryInformation();
+
+ assertThat(repositoryInformation.getFragments()).hasSize(2);
+
+ for (RepositoryFragment> fragment : repositoryInformation.getFragments()) {
+
+ assertThat(fragment.getSignatureContributor()).isIn(ConfigWithFragments.CustomImplInterface1.class,
+ ConfigWithFragments.CustomImplInterface2.class);
+
+ assertThat(fragment.getImplementationClass().get()).isIn(ConfigWithFragments.CustomImplInterface1Impl.class,
+ ConfigWithFragments.CustomImplInterface2Impl.class);
+ }
+ }
+
+ static RegisteredBean repositoryFactory(Class> configClass) {
+
+ AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext();
+ applicationContext.register(configClass);
+ applicationContext.refreshForAotProcessing(new RuntimeHints());
+
+ String[] beanNamesForType = applicationContext.getBeanNamesForType(RepositoryFactoryBeanSupport.class);
+ if (beanNamesForType.length != 1) {
+ throw new IllegalStateException("Unable to find repository FactoryBean");
+ }
+
+ return RegisteredBean.of(applicationContext.getBeanFactory(), beanNamesForType[0]);
+ }
+
+}
diff --git a/src/test/java/org/springframework/data/repository/config/SampleRepositoryFragmentsContributor.java b/src/test/java/org/springframework/data/repository/config/SampleRepositoryFragmentsContributor.java
new file mode 100644
index 0000000000..a22db03b6a
--- /dev/null
+++ b/src/test/java/org/springframework/data/repository/config/SampleRepositoryFragmentsContributor.java
@@ -0,0 +1,33 @@
+/*
+ * 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.repository.config;
+
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.data.repository.core.support.RepositoryFragment;
+import org.springframework.data.repository.core.support.RepositoryFragmentsContributor;
+
+/**
+ * @author Mark Paluch
+ */
+public class SampleRepositoryFragmentsContributor implements RepositoryFragmentsContributor {
+
+ @Override
+ public RepositoryComposition.RepositoryFragments describe(RepositoryMetadata metadata) {
+ return RepositoryComposition.RepositoryFragments
+ .of(RepositoryFragment.structural(SampleRepositoryFragmentsContributor.class));
+ }
+}
diff --git a/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactoryBean.java b/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactoryBean.java
index 8a5b9b6d6d..4121d6f0c3 100644
--- a/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactoryBean.java
+++ b/src/test/java/org/springframework/data/repository/core/support/DummyRepositoryFactoryBean.java
@@ -29,6 +29,7 @@ public class DummyRepositoryFactoryBean, S, ID exten
extends RepositoryFactoryBeanSupport {
private final T repository;
+ private RepositoryFragmentsContributor repositoryFragmentsContributor = RepositoryFragmentsContributor.empty();
public DummyRepositoryFactoryBean(Class extends T> repositoryInterface) {
@@ -38,6 +39,19 @@ public DummyRepositoryFactoryBean(Class extends T> repositoryInterface) {
setMappingContext(new SampleMappingContext());
}
+ public T getRepository() {
+ return repository;
+ }
+
+ @Override
+ public RepositoryFragmentsContributor getRepositoryFragmentsContributor() {
+ return repositoryFragmentsContributor;
+ }
+
+ public void setRepositoryFragmentsContributor(RepositoryFragmentsContributor repositoryFragmentsContributor) {
+ this.repositoryFragmentsContributor = repositoryFragmentsContributor;
+ }
+
@Override
protected RepositoryFactorySupport createRepositoryFactory() {
return new DummyRepositoryFactory(repository);
diff --git a/src/test/java/org/springframework/data/repository/support/RepositoriesUnitTests.java b/src/test/java/org/springframework/data/repository/support/RepositoriesUnitTests.java
index 22c0959811..cbb08cd940 100755
--- a/src/test/java/org/springframework/data/repository/support/RepositoriesUnitTests.java
+++ b/src/test/java/org/springframework/data/repository/support/RepositoriesUnitTests.java
@@ -46,6 +46,7 @@
import org.springframework.data.repository.core.support.DummyRepositoryFactoryBean;
import org.springframework.data.repository.core.support.DummyRepositoryInformation;
import org.springframework.data.repository.core.support.RepositoryFactoryInformation;
+import org.springframework.data.repository.core.support.RepositoryFragmentsContributor;
import org.springframework.data.repository.query.QueryMethod;
import org.springframework.data.util.TypeInformation;
import org.springframework.util.ClassUtils;
@@ -290,6 +291,11 @@ public RepositoryInformation getRepositoryInformation() {
return new DummyRepositoryInformation(repositoryMetadata);
}
+ @Override
+ public RepositoryFragmentsContributor getRepositoryFragmentsContributor() {
+ return RepositoryFragmentsContributor.empty();
+ }
+
@Override
public PersistentEntity, ?> getPersistentEntity() {
return mappingContext.getRequiredPersistentEntity(repositoryMetadata.getDomainType());