From 4cde81dfd72268b00775b04d9c3658d8b4fb8b56 Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Thu, 16 Jul 2026 14:17:34 +0900 Subject: [PATCH] Resolve repository method return types against the repository interface. Query execution resolved the return type of a repository method from the method declaration alone. A return type declared as a type variable on a base interface, such as `T findById(ID id)` on a generic `@NoRepositoryBean` base, therefore resolved to the type variable's bound rather than the concrete domain type. Result post-processing then saw a target type of `Object`, concluded that no processing was required and returned the store's `Optional` unchanged, so callers observed an `Optional` where the method declared `T`. We now register the repository interface as containing class of the return type's `MethodParameter` so that the type variable resolves against the concrete repository interface, following what `MethodLookups` already does for parameter types. The regression test added for #3125 passed because it constructed the `MethodParameter` with the containing class itself, which the production call site never did. The test added here covers the proxy instead. Closes #3507 Related tickets #3125 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Develop-KIM --- .../QueryExecutorMethodInterceptor.java | 13 +++++++++++- .../RepositoryFactorySupportUnitTests.java | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/springframework/data/repository/core/support/QueryExecutorMethodInterceptor.java b/src/main/java/org/springframework/data/repository/core/support/QueryExecutorMethodInterceptor.java index 6af1d119c2..a49cc07afe 100644 --- a/src/main/java/org/springframework/data/repository/core/support/QueryExecutorMethodInterceptor.java +++ b/src/main/java/org/springframework/data/repository/core/support/QueryExecutorMethodInterceptor.java @@ -51,6 +51,7 @@ * @author Christoph Strobl * @author John Blum * @author Johannes Englmeier + * @author Donghwan Kim */ class QueryExecutorMethodInterceptor implements MethodInterceptor { @@ -137,7 +138,7 @@ private void invokeListeners(RepositoryQuery query) { public @Nullable Object invoke(MethodInvocation invocation) throws Throwable { Method method = invocation.getMethod(); - MethodParameter returnType = returnTypeMap.computeIfAbsent(method, it -> new MethodParameter(it, -1)); + MethodParameter returnType = returnTypeMap.computeIfAbsent(method, this::createReturnTypeParameter); QueryExecutionConverters.ExecutionAdapter executionAdapter = QueryExecutionConverters // .getExecutionAdapter(returnType.getParameterType()); @@ -150,6 +151,16 @@ private void invokeListeners(RepositoryQuery query) { .apply(() -> resultHandler.postProcessInvocationResult(doInvoke(invocation), returnType)); } + /** + * Creates the {@link MethodParameter} describing the return type of the given {@link Method}. The repository + * interface is registered as containing class so that a return type declared as type variable on a base interface + * (e.g. {@code T findById(ID id)}) resolves against the concrete repository interface instead of the type variable's + * bound. + */ + private MethodParameter createReturnTypeParameter(Method method) { + return new MethodParameter(method, -1).withContainingClass(repositoryInformation.getRepositoryInterface()); + } + @SuppressWarnings("NullAway") private @Nullable Object doInvoke(MethodInvocation invocation) throws Throwable { diff --git a/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java b/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java index f8f296690e..97a2329c7c 100755 --- a/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java +++ b/src/test/java/org/springframework/data/repository/core/support/RepositoryFactorySupportUnitTests.java @@ -87,6 +87,7 @@ * @author Mark Paluch * @author Ariel Carrera * @author Johannes Englmeier + * @author Donghwan Kim */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -458,6 +459,17 @@ void shouldAllowVoidMethods() { verify(backingRepo).deleteAll(); } + @Test // GH-3507 + void unwrapsOptionalForTypeVariableReturnTypeDeclaredOnBaseInterface() { + + var user = new User(); + when(backingRepo.findById(any())).thenReturn(Optional.of(user)); + + var repository = factory.getRepository(TypeVariableReturningRepository.class); + + assertThat(repository.findById(1L)).isSameAs(user); + } + @Test // DATACMNS-1154 void considersRequiredKotlinParameter() { @@ -635,6 +647,15 @@ interface CustomRepository extends ReadOnlyRepository { } + interface TypeVariableReturningBaseRepository extends Repository { + + T findById(ID id); + } + + interface TypeVariableReturningRepository extends TypeVariableReturningBaseRepository { + + } + @RepositoryDefinition(domainClass = Object.class, idClass = Long.class) interface AnnotatedRepository {