Always find interface annotations in findAllLocalMergedAnnotations()

Prior to this commit, AnnotationDescriptor's
findAllLocalMergedAnnotations() filtered results using
MergedAnnotationPredicates.firstRunOf(
MergedAnnotation::getAggregateIndex), which retains only annotations
that share the same aggregate index as the first annotation
encountered. Since annotations on the root declaring class have
aggregate index 0 and annotations on interfaces have higher indices,
interface annotations were silently excluded whenever the root
declaring class itself declared the annotation.

This commit fixes the issue by replacing the aggregate-index-based
filter with a source-based filter that explicitly includes annotations
from the root declaring class and from each directly implemented
interface. The Javadoc for findAllLocalMergedAnnotations() has also
been updated to document the ordering guarantee: annotations from the
root declaring class appear first, followed by annotations from
implemented interfaces in declaration order.

Closes gh-36975
This commit is contained in:
Sam Brannen
2026-06-28 14:35:18 +02:00
parent efaa53b488
commit ce718cf699
2 changed files with 123 additions and 9 deletions
@@ -29,7 +29,6 @@ import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotationCollectors;
import org.springframework.core.annotation.MergedAnnotationPredicates;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
import org.springframework.core.annotation.RepeatableContainers;
@@ -571,14 +570,21 @@ public abstract class TestContextAnnotationUtils {
* that are present or meta-present on the {@linkplain #getRootDeclaringClass()
* root declaring class} of this descriptor or on any interfaces that the
* root declaring class implements.
* <p>Annotations are returned in the order they are discovered: annotations
* on the root declaring class appear first, followed by annotations on
* implemented interfaces in the order they are declared in the
* {@code implements} clause.
* @return the set of all merged, synthesized {@code Annotations} found,
* or an empty set if none were found
*/
public Set<T> findAllLocalMergedAnnotations() {
SearchStrategy searchStrategy = SearchStrategy.TYPE_HIERARCHY;
return MergedAnnotations.from(getRootDeclaringClass(), searchStrategy, RepeatableContainers.none())
Class<?> rootDeclaringClass = getRootDeclaringClass();
Set<Class<?>> localSources = new HashSet<>();
localSources.add(rootDeclaringClass);
Collections.addAll(localSources, rootDeclaringClass.getInterfaces());
return MergedAnnotations.from(rootDeclaringClass, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none())
.stream(getAnnotationType())
.filter(MergedAnnotationPredicates.firstRunOf(MergedAnnotation::getAggregateIndex))
.filter(mergedAnnotation -> localSources.contains(mergedAnnotation.getSource()))
.collect(MergedAnnotationCollectors.toAnnotationSet());
}
@@ -274,6 +274,73 @@ class TestContextAnnotationUtilsTests {
}
@Nested
@DisplayName("findAllLocalMergedAnnotations() tests")
class FindAllLocalMergedAnnotationsTests {
@Test
void annotationOnClassOnly() {
var descriptor = findAnnotationDescriptor(AnnotatedClass.class, ContextConfiguration.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.findAllLocalMergedAnnotations())
.singleElement()
.satisfies(config -> assertThat(config.classes()).containsExactly(Config1.class));
}
@Test
void annotationOnInterfaceOnly() {
var descriptor = findAnnotationDescriptor(ClassImplementingAnnotatedInterface.class, ContextConfiguration.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.findAllLocalMergedAnnotations())
.singleElement()
.satisfies(config -> assertThat(config.classes()).containsExactly(Config2.class));
}
@Test
void annotationOnClassAndInterface() {
var descriptor = findAnnotationDescriptor(AnnotatedClassImplementingAnnotatedInterface.class, ContextConfiguration.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.findAllLocalMergedAnnotations())
.satisfiesExactly(
config1 -> assertThat(config1.classes()).containsExactly(Config1.class),
config2 -> assertThat(config2.classes()).containsExactly(Config2.class)
);
}
@Test
void annotationOnTwoInterfaces() {
var descriptor = findAnnotationDescriptor(ClassImplementingTwoAnnotatedInterfaces.class, ContextConfiguration.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.findAllLocalMergedAnnotations())
.satisfiesExactly(
config1 -> assertThat(config1.classes()).containsExactly(Config2.class),
config2 -> assertThat(config2.classes()).containsExactly(Config3.class)
);
}
@Test
void annotationOnSuperclassIsExcluded() {
var descriptor = findAnnotationDescriptor(SubAnnotatedClass.class, ContextConfiguration.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.getRootDeclaringClass()).isEqualTo(AnnotatedClass.class);
assertThat(descriptor.findAllLocalMergedAnnotations())
.singleElement()
.satisfies(config -> assertThat(config.classes()).containsExactly(Config1.class));
}
@Test
void metaAnnotationOnClassAndAnnotationOnInterface() {
var descriptor = findAnnotationDescriptor(MetaAnnotatedClassImplementingAnnotatedInterface.class, ContextConfiguration.class);
assertThat(descriptor).isNotNull();
assertThat(descriptor.findAllLocalMergedAnnotations())
.satisfiesExactly(
config1 -> assertThat(config1.classes()).containsExactly(DevConfig.class, ProductionConfig.class),
config2 -> assertThat(config2.classes()).containsExactly(Config2.class)
);
}
}
@Nested
@DisplayName("findAnnotationDescriptorForTypes() tests")
class FindAnnotationDescriptorForTypesTests {
@@ -377,7 +444,7 @@ class TestContextAnnotationUtilsTests {
assertThat(descriptor.getAnnotationType()).isEqualTo(annotationType);
assertThat(((ContextConfiguration) descriptor.getAnnotation()).value()).isEmpty();
assertThat(((ContextConfiguration) descriptor.getAnnotation()).classes())
.containsExactly(MetaConfig.DevConfig.class, MetaConfig.ProductionConfig.class);
.containsExactly(DevConfig.class, ProductionConfig.class);
}
@Test
@@ -559,12 +626,12 @@ class TestContextAnnotationUtilsTests {
@AliasFor(annotation = ContextConfiguration.class)
Class<?>[] classes() default { DevConfig.class, ProductionConfig.class };
class DevConfig {
}
}
class ProductionConfig {
}
static class DevConfig {
}
static class ProductionConfig {
}
// -------------------------------------------------------------------------
@@ -668,4 +735,45 @@ class TestContextAnnotationUtilsTests {
}
}
// -------------------------------------------------------------------------
// Fixtures for FindAllLocalMergedAnnotationsTests
static class Config1 {
}
static class Config2 {
}
static class Config3 {
}
@ContextConfiguration(classes = Config2.class)
interface AnnotatedInterface {
}
@ContextConfiguration(classes = Config3.class)
interface AnotherAnnotatedInterface {
}
@ContextConfiguration(classes = Config1.class)
static class AnnotatedClass {
}
static class SubAnnotatedClass extends AnnotatedClass {
}
static class ClassImplementingAnnotatedInterface implements AnnotatedInterface {
}
@ContextConfiguration(classes = Config1.class)
static class AnnotatedClassImplementingAnnotatedInterface implements AnnotatedInterface {
}
static class ClassImplementingTwoAnnotatedInterfaces implements AnnotatedInterface, AnotherAnnotatedInterface {
}
@MetaConfig
static class MetaAnnotatedClassImplementingAnnotatedInterface implements AnnotatedInterface {
}
}