diff --git a/spring-core/src/main/java/org/springframework/core/type/AnnotatedTypeMetadata.java b/spring-core/src/main/java/org/springframework/core/type/AnnotatedTypeMetadata.java
index a9db6d3d599..b0d3844bc6f 100644
--- a/spring-core/src/main/java/org/springframework/core/type/AnnotatedTypeMetadata.java
+++ b/spring-core/src/main/java/org/springframework/core/type/AnnotatedTypeMetadata.java
@@ -17,8 +17,13 @@
package org.springframework.core.type;
import java.lang.annotation.Annotation;
+import java.util.LinkedHashSet;
import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotation.Adapt;
import org.springframework.core.annotation.MergedAnnotationCollectors;
@@ -155,4 +160,41 @@ public interface AnnotatedTypeMetadata {
map -> (map.isEmpty() ? null : map), adaptations));
}
+ /**
+ * Retrieve all repeatable annotations of the given type within the
+ * annotation hierarchy above the underlying element (as direct
+ * annotation or meta-annotation); and for each annotation found, merge that
+ * annotation's attributes with matching attributes from annotations
+ * in lower levels of the annotation hierarchy and store the results in an
+ * instance of {@link AnnotationAttributes}.
+ *
{@link org.springframework.core.annotation.AliasFor @AliasFor} semantics
+ * are fully supported, both within a single annotation and within annotation
+ * hierarchies.
+ * @param annotationType the annotation type to find
+ * @param containerType the type of the container that holds the annotations
+ * @param classValuesAsString whether to convert class references to {@code String}
+ * class names for exposure as values in the returned {@code AnnotationAttributes},
+ * instead of {@code Class} references which might potentially have to be loaded
+ * first
+ * @return the set of all merged repeatable {@code AnnotationAttributes} found,
+ * or an empty set if none were found
+ * @since 6.1
+ */
+ default Set getMergedRepeatableAnnotationAttributes(
+ Class extends Annotation> annotationType, Class extends Annotation> containerType,
+ boolean classValuesAsString) {
+
+ Adapt[] adaptations = Adapt.values(classValuesAsString, true);
+ return getAnnotations().stream()
+ .filter(MergedAnnotationPredicates.typeIn(containerType, annotationType))
+ .map(annotation -> annotation.asAnnotationAttributes(adaptations))
+ .flatMap(attributes -> {
+ if (containerType.equals(attributes.annotationType())) {
+ return Stream.of(attributes.getAnnotationArray(MergedAnnotation.VALUE));
+ }
+ return Stream.of(attributes);
+ })
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
}
diff --git a/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java
index 082b7ecb4da..57cdba7e70c 100644
--- a/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java
+++ b/spring-core/src/test/java/org/springframework/core/type/AnnotationMetadataTests.java
@@ -21,6 +21,7 @@ import java.lang.annotation.Annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
+import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@@ -32,6 +33,7 @@ import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.core.annotation.AliasFor;
+import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationAttributes;
import org.springframework.core.testfixture.stereotype.Component;
import org.springframework.core.type.classreading.MetadataReader;
@@ -247,6 +249,82 @@ class AnnotationMetadataTests {
assertMultipleAnnotationsWithIdenticalAttributeNames(metadata);
}
+ @Test // gh-31041
+ void multipleComposedRepeatableAnnotationsUsingStandardAnnotationMetadata() {
+ AnnotationMetadata metadata = AnnotationMetadata.introspect(MultipleComposedRepeatableAnnotationsClass.class);
+ assertRepeatableAnnotations(metadata);
+ }
+
+ @Test // gh-31041
+ void multipleComposedRepeatableAnnotationsUsingSimpleAnnotationMetadata() throws Exception {
+ MetadataReaderFactory metadataReaderFactory = new SimpleMetadataReaderFactory();
+ MetadataReader metadataReader = metadataReaderFactory.getMetadataReader(MultipleComposedRepeatableAnnotationsClass.class.getName());
+ AnnotationMetadata metadata = metadataReader.getAnnotationMetadata();
+ assertRepeatableAnnotations(metadata);
+ }
+
+ @Test // gh-31041
+ void multipleRepeatableAnnotationsInContainersUsingStandardAnnotationMetadata() {
+ AnnotationMetadata metadata = AnnotationMetadata.introspect(MultipleRepeatableAnnotationsInContainersClass.class);
+ assertRepeatableAnnotations(metadata);
+ }
+
+ @Test // gh-31041
+ void multipleRepeatableAnnotationsInContainersUsingSimpleAnnotationMetadata() throws Exception {
+ MetadataReaderFactory metadataReaderFactory = new SimpleMetadataReaderFactory();
+ MetadataReader metadataReader = metadataReaderFactory.getMetadataReader(MultipleRepeatableAnnotationsInContainersClass.class.getName());
+ AnnotationMetadata metadata = metadataReader.getAnnotationMetadata();
+ assertRepeatableAnnotations(metadata);
+ }
+
+ /**
+ * Tests {@code AnnotatedElementUtils#getMergedRepeatableAnnotations()} variants to ensure that
+ * {@link AnnotationMetadata#getMergedRepeatableAnnotationAttributes(Class, Class, boolean)}
+ * behaves the same.
+ */
+ @Test // gh-31041
+ void multipleComposedRepeatableAnnotationsUsingAnnotatedElementUtils() throws Exception {
+ Class> element = MultipleComposedRepeatableAnnotationsClass.class;
+
+ Set annotations = AnnotatedElementUtils.getMergedRepeatableAnnotations(element, TestComponentScan.class);
+ assertRepeatableAnnotations(annotations);
+
+ annotations = AnnotatedElementUtils.getMergedRepeatableAnnotations(element, TestComponentScan.class, TestComponentScans.class);
+ assertRepeatableAnnotations(annotations);
+ }
+
+ /**
+ * Tests {@code AnnotatedElementUtils#getMergedRepeatableAnnotations()} variants to ensure that
+ * {@link AnnotationMetadata#getMergedRepeatableAnnotationAttributes(Class, Class, boolean)}
+ * behaves the same.
+ */
+ @Test // gh-31041
+ void multipleRepeatableAnnotationsInContainersUsingAnnotatedElementUtils() throws Exception {
+ Class> element = MultipleRepeatableAnnotationsInContainersClass.class;
+
+ Set annotations = AnnotatedElementUtils.getMergedRepeatableAnnotations(element, TestComponentScan.class);
+ assertRepeatableAnnotations(annotations);
+
+ annotations = AnnotatedElementUtils.getMergedRepeatableAnnotations(element, TestComponentScan.class, TestComponentScans.class);
+ assertRepeatableAnnotations(annotations);
+ }
+
+ private static void assertRepeatableAnnotations(AnnotationMetadata metadata) {
+ Set attributesSet =
+ metadata.getMergedRepeatableAnnotationAttributes(TestComponentScan.class, TestComponentScans.class, false);
+ assertThat(attributesSet.stream().map(attributes -> attributes.getStringArray("value")).flatMap(Arrays::stream))
+ .containsExactly("A", "B", "C", "D");
+ assertThat(attributesSet.stream().map(attributes -> attributes.getStringArray("basePackages")).flatMap(Arrays::stream))
+ .containsExactly("A", "B", "C", "D");
+ }
+
+ private static void assertRepeatableAnnotations(Set annotations) {
+ assertThat(annotations.stream().map(TestComponentScan::value).flatMap(Arrays::stream))
+ .containsExactly("A", "B", "C", "D");
+ assertThat(annotations.stream().map(TestComponentScan::basePackages).flatMap(Arrays::stream))
+ .containsExactly("A", "B", "C", "D");
+ }
+
@Test
void inheritedAnnotationWithMetaAnnotationsWithIdenticalAttributeNamesUsingStandardAnnotationMetadata() {
AnnotationMetadata metadata = AnnotationMetadata.introspect(NamedComposedAnnotationExtended.class);
@@ -534,6 +612,14 @@ class AnnotationMetadataTests {
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
+ public @interface TestComponentScans {
+
+ TestComponentScan[] value();
+ }
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE)
+ @Repeatable(TestComponentScans.class)
public @interface TestComponentScan {
@AliasFor("basePackages")
@@ -560,6 +646,40 @@ class AnnotationMetadataTests {
public static class ComposedConfigurationWithAttributeOverridesClass {
}
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE)
+ @TestComponentScan("C")
+ public @interface ScanPackageC {
+ }
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE)
+ @TestComponentScan("D")
+ public @interface ScanPackageD {
+ }
+
+ @Retention(RetentionPolicy.RUNTIME)
+ @Target(ElementType.TYPE)
+ @TestComponentScans({
+ @TestComponentScan("C"),
+ @TestComponentScan("D")
+ })
+ public @interface ScanPackagesCandD {
+ }
+
+ @TestComponentScan("A")
+ @ScanPackageC
+ @ScanPackageD
+ @TestComponentScan("B")
+ static class MultipleComposedRepeatableAnnotationsClass {
+ }
+
+ @TestComponentScan("A")
+ @ScanPackagesCandD
+ @TestComponentScans(@TestComponentScan("B"))
+ static class MultipleRepeatableAnnotationsInContainersClass {
+ }
+
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface NamedAnnotation1 {