From c04b50286678170099576c8118a65c64eb11a5c9 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:37:19 +0200 Subject: [PATCH 1/2] Polishing --- .../core/annotation/MergedAnnotation.java | 2 +- .../core/annotation/MergedAnnotations.java | 16 ++++++++-------- .../core/type/classreading/MetadataReader.java | 3 ++- .../MetadataReaderFactoryDelegate.java | 2 +- .../MetadataReaderFactoryDelegate.java | 2 +- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotation.java b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotation.java index 3c0cf910f17..bed8407fc9d 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotation.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotation.java @@ -623,7 +623,7 @@ public interface MergedAnnotation { * @param annotationType the annotation type * @param attributes the annotation attributes or {@code null} if just default * values should be used - * @return a {@link MergedAnnotation} instance for the annotation and attributes + * @return a {@link MergedAnnotation} instance for the annotation type and attributes */ static MergedAnnotation of( @Nullable ClassLoader classLoader, @Nullable Object source, diff --git a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java index 488e3375c06..c5b84c9021a 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java @@ -446,15 +446,15 @@ public interface MergedAnnotations extends Iterable } /** - * Create a new {@link MergedAnnotations} instance from the specified - * collection of directly present annotations. This method allows a - * {@code MergedAnnotations} instance to be created from annotations that - * are not necessarily loaded using reflection. The provided annotations - * must all be {@link MergedAnnotation#isDirectlyPresent() directly present} - * and must have an {@link MergedAnnotation#getAggregateIndex() aggregate - * index} of {@code 0}. + * Create a new {@link MergedAnnotations} instance from the provided + * collection of annotations. + *

This method allows a {@code MergedAnnotations} instance to be created + * from annotations that are not necessarily loaded using reflection. + *

The provided annotations must all be + * {@link MergedAnnotation#isDirectlyPresent() directly present} and must have + * an {@link MergedAnnotation#getAggregateIndex() aggregate index} of {@code 0}. *

The resulting {@code MergedAnnotations} instance will contain both the - * specified annotations and any meta-annotations that can be read using + * provided annotations and any meta-annotations that can be read using * reflection. * @param annotations the annotations to include * @return a {@code MergedAnnotations} instance containing the annotations diff --git a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java index 8ec1dfd573d..33d6250d0f2 100644 --- a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java +++ b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReader.java @@ -22,7 +22,8 @@ import org.springframework.core.type.ClassMetadata; /** * Simple facade for accessing class metadata, - * as read by an ASM {@link org.springframework.asm.ClassReader}. + * as read by an ASM {@link org.springframework.asm.ClassReader} or the + * {@code java.lang.classfile.ClassFile} API on Java 24 and higher. * * @author Juergen Hoeller * @since 2.5 diff --git a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java index 3f62f8e2a2b..39c2ff83f88 100644 --- a/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java +++ b/spring-core/src/main/java/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java @@ -22,7 +22,7 @@ import org.springframework.core.io.ResourceLoader; /** * Internal delegate for instantiating {@link MetadataReaderFactory} implementations. - * For JDK < 24, the {@link SimpleMetadataReaderFactory} is being used. + * For JDK < 24, the {@link SimpleMetadataReaderFactory} is used. * * @author Brian Clozel * @since 7.0 diff --git a/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java b/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java index 875b9f79ffb..77de126692c 100644 --- a/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java +++ b/spring-core/src/main/java24/org/springframework/core/type/classreading/MetadataReaderFactoryDelegate.java @@ -22,7 +22,7 @@ import org.springframework.core.io.ResourceLoader; /** * Internal delegate for instantiating {@link MetadataReaderFactory} implementations. - * For JDK >= 24, the {@link ClassFileMetadataReaderFactory} is being used. + * For JDK >= 24, the {@link ClassFileMetadataReaderFactory} is used. * * @author Brian Clozel * @since 7.0 From 38464a15dc20fdd8cc6e9f139b248983279aa1c4 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:17:27 +0200 Subject: [PATCH 2/2] Retain source declaration order in AnnotatedTypeMetadata on Java 24+ Prior to this commit, ClassFileAnnotationDelegate created MergedAnnotations from a HashSet, which resulted in a non-deterministic iteration order and lost the original source declaration order of the annotations. To address that, this commit revises ClassFileAnnotationDelegate to create MergedAnnotations from a List. In addition, this commit updates all related tests to use the containsExactly() assertion instead of containsExactlyInAnyOrder() to ensure we consistently adhere to the "source declaration order" requirement. Closes gh-36598 --- .../core/annotation/MergedAnnotations.java | 2 ++ .../ClassFileAnnotationDelegate.java | 6 +++--- .../type/AbstractAnnotationMetadataTests.java | 20 +++++++++++++------ .../type/AbstractMethodMetadataTests.java | 10 +++++++--- .../core/type/AnnotationMetadataTests.java | 4 +++- ...tedAnnotationsAnnotationMetadataTests.java | 18 ++++++++++++----- 6 files changed, 42 insertions(+), 18 deletions(-) diff --git a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java index c5b84c9021a..c33e0fd9514 100644 --- a/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java +++ b/spring-core/src/main/java/org/springframework/core/annotation/MergedAnnotations.java @@ -453,6 +453,8 @@ public interface MergedAnnotations extends Iterable *

The provided annotations must all be * {@link MergedAnnotation#isDirectlyPresent() directly present} and must have * an {@link MergedAnnotation#getAggregateIndex() aggregate index} of {@code 0}. + * In addition, the provided collection must retain the source declaration order + * of the annotations — for example, a {@link java.util.List}. *

The resulting {@code MergedAnnotations} instance will contain both the * provided annotations and any meta-annotations that can be read using * reflection. diff --git a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java index 810636236da..d5ce6e21941 100644 --- a/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java +++ b/spring-core/src/main/java24/org/springframework/core/type/classreading/ClassFileAnnotationDelegate.java @@ -26,7 +26,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -43,6 +42,7 @@ import org.springframework.util.ClassUtils; * * @author Brian Clozel * @author Juergen Hoeller + * @author Sam Brannen * @since 7.0 */ abstract class ClassFileAnnotationDelegate { @@ -50,11 +50,11 @@ abstract class ClassFileAnnotationDelegate { static MergedAnnotations createMergedAnnotations( String className, RuntimeVisibleAnnotationsAttribute annotationAttribute, @Nullable ClassLoader classLoader) { - Set> annotations = annotationAttribute.annotations() + List> annotations = annotationAttribute.annotations() .stream() .map(ann -> createMergedAnnotation(className, ann, classLoader)) .filter(Objects::nonNull) - .collect(Collectors.toSet()); + .collect(Collectors.toList()); return MergedAnnotations.of(annotations); } diff --git a/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java index f5d163797e9..48681542e4a 100644 --- a/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java +++ b/spring-core/src/test/java/org/springframework/core/type/AbstractAnnotationMetadataTests.java @@ -261,7 +261,9 @@ public abstract class AbstractAnnotationMetadataTests { assertThat(get(WithDirectAnnotations.class).getAnnotations().stream()) .filteredOn(MergedAnnotation::isDirectlyPresent) .extracting(a -> a.getType().getName()) - .containsExactlyInAnyOrder(DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName()); + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. + .containsExactly(DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName()); } @Test @@ -290,8 +292,10 @@ public abstract class AbstractAnnotationMetadataTests { MultiValueMap attributes = get(WithMetaAnnotationAttributes.class).getAllAnnotationAttributes(AnnotationAttributes.class.getName()); assertThat(attributes).containsOnlyKeys("name", "size"); - assertThat(attributes.get("name")).containsExactlyInAnyOrder("m1", "m2"); - assertThat(attributes.get("size")).containsExactlyInAnyOrder(1, 2); + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. + assertThat(attributes.get("name")).containsExactly("m1", "m2"); + assertThat(attributes.get("size")).containsExactly(1, 2); } @Test @@ -335,15 +339,19 @@ public abstract class AbstractAnnotationMetadataTests { @Test void getAnnotationTypesReturnsDirectAnnotations() { AnnotationMetadata metadata = get(WithDirectAnnotations.class); - assertThat(metadata.getAnnotationTypes()).containsExactlyInAnyOrder( - DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName()); + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. + assertThat(metadata.getAnnotationTypes()) + .containsExactly(DirectAnnotation1.class.getName(), DirectAnnotation2.class.getName()); } @Test void getMetaAnnotationTypesReturnsMetaAnnotations() { AnnotationMetadata metadata = get(WithMetaAnnotations.class); + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. assertThat(metadata.getMetaAnnotationTypes(MetaAnnotationRoot.class.getName())) - .containsExactlyInAnyOrder(MetaAnnotation1.class.getName(), MetaAnnotation2.class.getName()); + .containsExactly(MetaAnnotation1.class.getName(), MetaAnnotation2.class.getName()); } @Test diff --git a/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java index e21dee73006..799626afd1b 100644 --- a/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java +++ b/spring-core/src/test/java/org/springframework/core/type/AbstractMethodMetadataTests.java @@ -224,7 +224,9 @@ public abstract class AbstractMethodMetadataTests { Stream> types = metadata.getAnnotations().stream() .filter(MergedAnnotation::isDirectlyPresent) .map(MergedAnnotation::getType); - assertThat(types).containsExactlyInAnyOrder(Tag.class, DirectAnnotation.class); + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. + assertThat(types).containsExactly(Tag.class, DirectAnnotation.class); } @Test @@ -254,8 +256,10 @@ public abstract class AbstractMethodMetadataTests { MultiValueMap attributes = getTagged(WithMetaAnnotationAttributes.class) .getAllAnnotationAttributes(AnnotationAttributes.class.getName()); assertThat(attributes).containsOnlyKeys("name", "size"); - assertThat(attributes.get("name")).containsExactlyInAnyOrder("m1", "m2"); - assertThat(attributes.get("size")).containsExactlyInAnyOrder(1, 2); + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. + assertThat(attributes.get("name")).containsExactly("m1", "m2"); + assertThat(attributes.get("size")).containsExactly(1, 2); } @Test // gh-24375 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 878734aaeac..1185a34ef38 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 @@ -405,7 +405,9 @@ class AnnotationMetadataTests { assertThat(metadata.hasAnnotation(SpecialAttr.class.getName())).isTrue(); assertThat(metadata.hasAnnotation(NamedComposedAnnotation.class.getName())).isTrue(); - assertThat(metadata.getAnnotationTypes()).containsExactlyInAnyOrder( + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. + assertThat(metadata.getAnnotationTypes()).containsExactly( Component.class.getName(), Scope.class.getName(), SpecialAttr.class.getName(), DirectAnnotation.class.getName(), MetaMetaAnnotation.class.getName(), EnumSubclasses.class.getName(), diff --git a/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java b/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java index 83c819216de..e3c20e314c9 100644 --- a/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java +++ b/spring-core/src/test/java/org/springframework/core/type/InheritedAnnotationsAnnotationMetadataTests.java @@ -57,7 +57,9 @@ class InheritedAnnotationsAnnotationMetadataTests { @Test void getAnnotationTypes() { - assertThat(standardMetadata.getAnnotationTypes()).containsExactlyInAnyOrder( + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order. + assertThat(standardMetadata.getAnnotationTypes()).containsExactly( NamedAnnotation3.class.getName(), InheritedComposedAnnotation.class.getName()); @@ -81,11 +83,14 @@ class InheritedAnnotationsAnnotationMetadataTests { Set metaAnnotationTypes; metaAnnotationTypes = standardMetadata.getMetaAnnotationTypes(InheritedComposedAnnotation.class.getName()); - assertThat(metaAnnotationTypes).containsExactlyInAnyOrder( - MetaAnnotation.class.getName(), + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order, with local annotations + // before meta-annotations. + assertThat(metaAnnotationTypes).containsExactly( NamedAnnotation1.class.getName(), NamedAnnotation2.class.getName(), - NamedAnnotation3.class.getName()); + NamedAnnotation3.class.getName(), + MetaAnnotation.class.getName()); metaAnnotationTypes = asmMetadata.getMetaAnnotationTypes(InheritedComposedAnnotation.class.getName()); assertThat(metaAnnotationTypes).isEmpty(); @@ -138,7 +143,10 @@ class InheritedAnnotationsAnnotationMetadataTests { annotationAttributes = standardMetadata.getAllAnnotationAttributes(NamedAnnotation3.class.getName()); assertThat(annotationAttributes).containsKey("name"); - assertThat(annotationAttributes.get("name")).containsExactlyInAnyOrder("name 3", "local"); + // We do not use containsExactlyInAnyOrder(), because annotations + // must be returned in source declaration order, with local annotations + // before meta-annotations. + assertThat(annotationAttributes.get("name")).containsExactly("local", "name 3"); annotationAttributes = asmMetadata.getAllAnnotationAttributes(NamedAnnotation1.class.getName()); assertThat(annotationAttributes).isNull();