Consistent alias processing behind AnnotatedTypeMetadata abstraction (also for ASM)
Issue: SPR-14427
This commit is contained in:
@@ -18,16 +18,11 @@ package org.springframework.core.annotation;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.ExpectedException;
|
||||
|
||||
import org.springframework.core.annotation.AnnotationUtilsTests.ContextConfig;
|
||||
import org.springframework.core.annotation.AnnotationUtilsTests.ImplicitAliasesContextConfig;
|
||||
|
||||
import static org.hamcrest.CoreMatchers.*;
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
@@ -133,21 +128,21 @@ public class AnnotationAttributesTests {
|
||||
@Test
|
||||
public void getEnumWithNullAttributeName() {
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(containsString("attributeName must not be null or empty"));
|
||||
exception.expectMessage("must not be null or empty");
|
||||
attributes.getEnum(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getEnumWithEmptyAttributeName() {
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(containsString("attributeName must not be null or empty"));
|
||||
exception.expectMessage("must not be null or empty");
|
||||
attributes.getEnum("");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getEnumWithUnknownAttributeName() {
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(containsString("Attribute 'bogus' not found"));
|
||||
exception.expectMessage("Attribute 'bogus' not found");
|
||||
attributes.getEnum("bogus");
|
||||
}
|
||||
|
||||
@@ -159,337 +154,6 @@ public class AnnotationAttributesTests {
|
||||
attributes.getEnum("color");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedString() {
|
||||
final String value = "metaverse";
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("name", value);
|
||||
assertEquals(value, getAliasedString("name"));
|
||||
assertEquals(value, getAliasedString("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("value", value);
|
||||
assertEquals(value, getAliasedString("name"));
|
||||
assertEquals(value, getAliasedString("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("name", value);
|
||||
attributes.put("value", value);
|
||||
assertEquals(value, getAliasedString("name"));
|
||||
assertEquals(value, getAliasedString("value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringWithImplicitAliases() {
|
||||
final String value = "metaverse";
|
||||
final List<String> aliases = Arrays.asList("value", "location1", "location2", "location3", "xmlFile", "groovyScript");
|
||||
|
||||
attributes = new AnnotationAttributes(ImplicitAliasesContextConfig.class);
|
||||
attributes.put("value", value);
|
||||
aliases.stream().forEach(alias -> assertEquals(value, getAliasedStringWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", value);
|
||||
aliases.stream().forEach(alias -> assertEquals(value, getAliasedStringWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("value", value);
|
||||
attributes.put("location1", value);
|
||||
attributes.put("xmlFile", value);
|
||||
attributes.put("groovyScript", value);
|
||||
aliases.stream().forEach(alias -> assertEquals(value, getAliasedStringWithImplicitAliases(alias)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringWithImplicitAliasesWithMissingAliasedAttributes() {
|
||||
final List<String> aliases = Arrays.asList("value", "location1", "location2", "location3", "xmlFile", "groovyScript");
|
||||
attributes = new AnnotationAttributes(ImplicitAliasesContextConfig.class);
|
||||
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(startsWith("Neither attribute 'value' nor one of its aliases ["));
|
||||
aliases.stream().forEach(alias -> exception.expectMessage(containsString(alias)));
|
||||
exception.expectMessage(endsWith("] was found in attributes for annotation [" + ImplicitAliasesContextConfig.class.getName() + "]"));
|
||||
getAliasedStringWithImplicitAliases("value");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringFromSynthesizedAnnotationAttributes() {
|
||||
Scope scope = ScopedComponent.class.getAnnotation(Scope.class);
|
||||
AnnotationAttributes scopeAttributes = AnnotationUtils.getAnnotationAttributes(ScopedComponent.class, scope);
|
||||
|
||||
assertEquals("custom", getAliasedString(scopeAttributes, "name"));
|
||||
assertEquals("custom", getAliasedString(scopeAttributes, "value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringWithMissingAliasedAttributes() {
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(equalTo("Neither attribute 'name' nor one of its aliases [value] was found in attributes for annotation [unknown]"));
|
||||
getAliasedString("name");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringWithDifferentAliasedValues() {
|
||||
attributes.put("name", "request");
|
||||
attributes.put("value", "session");
|
||||
|
||||
exception.expect(AnnotationConfigurationException.class);
|
||||
exception.expectMessage(containsString("In annotation [" + Scope.class.getName() + "]"));
|
||||
exception.expectMessage(containsString("attribute [name] and its alias [value]"));
|
||||
exception.expectMessage(containsString("[request] and [session]"));
|
||||
exception.expectMessage(containsString("but only one is permitted"));
|
||||
|
||||
getAliasedString("name");
|
||||
}
|
||||
|
||||
private String getAliasedString(String attributeName) {
|
||||
return getAliasedString(this.attributes, attributeName);
|
||||
}
|
||||
|
||||
private String getAliasedString(AnnotationAttributes attrs, String attributeName) {
|
||||
return attrs.getAliasedString(attributeName, Scope.class, null);
|
||||
}
|
||||
|
||||
private String getAliasedStringWithImplicitAliases(String attributeName) {
|
||||
return this.attributes.getAliasedString(attributeName, ImplicitAliasesContextConfig.class, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringArray() {
|
||||
final String[] INPUT = new String[] {"test.xml"};
|
||||
final String[] EMPTY = new String[0];
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("location"));
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("value", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("location"));
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location", INPUT);
|
||||
attributes.put("value", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("location"));
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location", INPUT);
|
||||
attributes.put("value", EMPTY);
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("location"));
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location", EMPTY);
|
||||
attributes.put("value", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("location"));
|
||||
assertArrayEquals(INPUT, getAliasedStringArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location", EMPTY);
|
||||
attributes.put("value", EMPTY);
|
||||
assertArrayEquals(EMPTY, getAliasedStringArray("location"));
|
||||
assertArrayEquals(EMPTY, getAliasedStringArray("value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringArrayWithImplicitAliases() {
|
||||
final String[] INPUT = new String[] {"test.xml"};
|
||||
final String[] EMPTY = new String[0];
|
||||
final List<String> aliases = Arrays.asList("value", "location1", "location2", "location3", "xmlFile", "groovyScript");
|
||||
|
||||
attributes = new AnnotationAttributes(ImplicitAliasesContextConfig.class);
|
||||
|
||||
attributes.put("location1", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedStringArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("value", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedStringArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", INPUT);
|
||||
attributes.put("value", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedStringArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", INPUT);
|
||||
attributes.put("value", EMPTY);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedStringArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", EMPTY);
|
||||
attributes.put("value", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedStringArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", EMPTY);
|
||||
attributes.put("value", EMPTY);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(EMPTY, getAliasedStringArrayWithImplicitAliases(alias)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringArrayWithImplicitAliasesWithMissingAliasedAttributes() {
|
||||
final List<String> aliases = Arrays.asList("value", "location1", "location2", "location3", "xmlFile", "groovyScript");
|
||||
attributes = new AnnotationAttributes(ImplicitAliasesContextConfig.class);
|
||||
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(startsWith("Neither attribute 'value' nor one of its aliases ["));
|
||||
aliases.stream().forEach(alias -> exception.expectMessage(containsString(alias)));
|
||||
exception.expectMessage(endsWith("] was found in attributes for annotation [" + ImplicitAliasesContextConfig.class.getName() + "]"));
|
||||
getAliasedStringArrayWithImplicitAliases("value");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringArrayWithMissingAliasedAttributes() {
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(equalTo("Neither attribute 'location' nor one of its aliases [value] was found in attributes for annotation [unknown]"));
|
||||
getAliasedStringArray("location");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedStringArrayWithDifferentAliasedValues() {
|
||||
attributes.put("location", new String[] {"1.xml"});
|
||||
attributes.put("value", new String[] {"2.xml"});
|
||||
|
||||
exception.expect(AnnotationConfigurationException.class);
|
||||
exception.expectMessage(containsString("In annotation [" + ContextConfig.class.getName() + "]"));
|
||||
exception.expectMessage(containsString("attribute [location] and its alias [value]"));
|
||||
exception.expectMessage(containsString("[{1.xml}] and [{2.xml}]"));
|
||||
exception.expectMessage(containsString("but only one is permitted"));
|
||||
|
||||
getAliasedStringArray("location");
|
||||
}
|
||||
|
||||
private String[] getAliasedStringArray(String attributeName) {
|
||||
// Note: even though the attributes we test against here are of type
|
||||
// String instead of String[], it doesn't matter... since
|
||||
// AnnotationAttributes does not validate the actual return type of
|
||||
// attributes in the annotation.
|
||||
return attributes.getAliasedStringArray(attributeName, ContextConfig.class, null);
|
||||
}
|
||||
|
||||
private String[] getAliasedStringArrayWithImplicitAliases(String attributeName) {
|
||||
// Note: even though the attributes we test against here are of type
|
||||
// String instead of String[], it doesn't matter... since
|
||||
// AnnotationAttributes does not validate the actual return type of
|
||||
// attributes in the annotation.
|
||||
return this.attributes.getAliasedStringArray(attributeName, ImplicitAliasesContextConfig.class, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedClassArray() {
|
||||
final Class<?>[] INPUT = new Class<?>[] {String.class};
|
||||
final Class<?>[] EMPTY = new Class<?>[0];
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("classes", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("classes"));
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("value", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("classes"));
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("classes", INPUT);
|
||||
attributes.put("value", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("classes"));
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("classes", INPUT);
|
||||
attributes.put("value", EMPTY);
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("classes"));
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("classes", EMPTY);
|
||||
attributes.put("value", INPUT);
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("classes"));
|
||||
assertArrayEquals(INPUT, getAliasedClassArray("value"));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("classes", EMPTY);
|
||||
attributes.put("value", EMPTY);
|
||||
assertArrayEquals(EMPTY, getAliasedClassArray("classes"));
|
||||
assertArrayEquals(EMPTY, getAliasedClassArray("value"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedClassArrayWithImplicitAliases() {
|
||||
final Class<?>[] INPUT = new Class<?>[] {String.class};
|
||||
final Class<?>[] EMPTY = new Class<?>[0];
|
||||
final List<String> aliases = Arrays.asList("value", "location1", "location2", "location3", "xmlFile", "groovyScript");
|
||||
|
||||
attributes = new AnnotationAttributes(ImplicitAliasesContextConfig.class);
|
||||
|
||||
attributes.put("location1", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedClassArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("value", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedClassArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", INPUT);
|
||||
attributes.put("value", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedClassArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", INPUT);
|
||||
attributes.put("value", EMPTY);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedClassArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", EMPTY);
|
||||
attributes.put("value", INPUT);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(INPUT, getAliasedClassArrayWithImplicitAliases(alias)));
|
||||
|
||||
attributes.clear();
|
||||
attributes.put("location1", EMPTY);
|
||||
attributes.put("value", EMPTY);
|
||||
aliases.stream().forEach(alias -> assertArrayEquals(EMPTY, getAliasedClassArrayWithImplicitAliases(alias)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedClassArrayWithMissingAliasedAttributes() {
|
||||
exception.expect(IllegalArgumentException.class);
|
||||
exception.expectMessage(equalTo("Neither attribute 'classes' nor one of its aliases [value] was found in attributes for annotation [unknown]"));
|
||||
getAliasedClassArray("classes");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAliasedClassArrayWithDifferentAliasedValues() {
|
||||
attributes.put("classes", new Class<?>[] {String.class});
|
||||
attributes.put("value", new Class<?>[] {Number.class});
|
||||
|
||||
exception.expect(AnnotationConfigurationException.class);
|
||||
exception.expectMessage(containsString("In annotation [" + Filter.class.getName() + "]"));
|
||||
exception.expectMessage(containsString("attribute [classes] and its alias [value]"));
|
||||
exception.expectMessage(containsString("[{class java.lang.String}] and [{class java.lang.Number}]"));
|
||||
exception.expectMessage(containsString("but only one is permitted"));
|
||||
|
||||
getAliasedClassArray("classes");
|
||||
}
|
||||
|
||||
|
||||
private Class<?>[] getAliasedClassArray(String attributeName) {
|
||||
return attributes.getAliasedClassArray(attributeName, Filter.class, null);
|
||||
}
|
||||
|
||||
private Class<?>[] getAliasedClassArrayWithImplicitAliases(String attributeName) {
|
||||
// Note: even though the attributes we test against here are of type
|
||||
// String instead of Class<?>[], it doesn't matter... since
|
||||
// AnnotationAttributes does not validate the actual return type of
|
||||
// attributes in the annotation.
|
||||
return this.attributes.getAliasedClassArray(attributeName, ImplicitAliasesContextConfig.class, null);
|
||||
}
|
||||
|
||||
|
||||
enum Color {
|
||||
|
||||
@@ -514,23 +178,4 @@ public class AnnotationAttributesTests {
|
||||
static class FilteredClass {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mock of {@code org.springframework.context.annotation.Scope}.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@interface Scope {
|
||||
|
||||
@AliasFor(attribute = "name")
|
||||
String value() default "singleton";
|
||||
|
||||
@AliasFor(attribute = "value")
|
||||
String name() default "singleton";
|
||||
}
|
||||
|
||||
|
||||
@Scope(name = "custom")
|
||||
static class ScopedComponent {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import java.util.Set;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.core.annotation.AnnotationAttributes;
|
||||
import org.springframework.core.type.classreading.MetadataReader;
|
||||
import org.springframework.core.type.classreading.MetadataReaderFactory;
|
||||
@@ -291,6 +292,7 @@ public class AnnotationMetadataTests {
|
||||
Set<MethodMetadata> methods = metadata.getAnnotatedMethods(DirectAnnotation.class.getName());
|
||||
MethodMetadata method = methods.iterator().next();
|
||||
assertEquals("direct", method.getAnnotationAttributes(DirectAnnotation.class.getName()).get("value"));
|
||||
assertEquals("direct", method.getAnnotationAttributes(DirectAnnotation.class.getName()).get("myValue"));
|
||||
List<Object> allMeta = method.getAllAnnotationAttributes(DirectAnnotation.class.getName()).get("value");
|
||||
assertThat(new HashSet<>(allMeta), is(equalTo(new HashSet<Object>(Arrays.asList("direct", "meta")))));
|
||||
allMeta = method.getAllAnnotationAttributes(DirectAnnotation.class.getName()).get("additional");
|
||||
@@ -416,7 +418,11 @@ public class AnnotationMetadataTests {
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface DirectAnnotation {
|
||||
|
||||
String value();
|
||||
@AliasFor("myValue")
|
||||
String value() default "";
|
||||
|
||||
@AliasFor("value")
|
||||
String myValue() default "";
|
||||
|
||||
String additional() default "direct";
|
||||
}
|
||||
@@ -449,7 +455,7 @@ public class AnnotationMetadataTests {
|
||||
}
|
||||
|
||||
// SPR-10914
|
||||
public static enum SubclassEnum {
|
||||
public enum SubclassEnum {
|
||||
FOO {
|
||||
/* Do not delete! This subclassing is intentional. */
|
||||
},
|
||||
@@ -489,14 +495,14 @@ public class AnnotationMetadataTests {
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Component
|
||||
public static @interface TestConfiguration {
|
||||
public @interface TestConfiguration {
|
||||
|
||||
String value() default "";
|
||||
}
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public static @interface TestComponentScan {
|
||||
public @interface TestComponentScan {
|
||||
|
||||
String[] value() default {};
|
||||
|
||||
@@ -509,7 +515,7 @@ public class AnnotationMetadataTests {
|
||||
@TestComponentScan(basePackages = "bogus")
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public static @interface ComposedConfigurationWithAttributeOverrides {
|
||||
public @interface ComposedConfigurationWithAttributeOverrides {
|
||||
|
||||
String[] basePackages() default {};
|
||||
}
|
||||
@@ -520,19 +526,19 @@ public class AnnotationMetadataTests {
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public static @interface NamedAnnotation1 {
|
||||
public @interface NamedAnnotation1 {
|
||||
String name() default "";
|
||||
}
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public static @interface NamedAnnotation2 {
|
||||
public @interface NamedAnnotation2 {
|
||||
String name() default "";
|
||||
}
|
||||
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public static @interface NamedAnnotation3 {
|
||||
public @interface NamedAnnotation3 {
|
||||
String name() default "";
|
||||
}
|
||||
|
||||
@@ -547,7 +553,7 @@ public class AnnotationMetadataTests {
|
||||
@NamedAnnotation3(name = "name 3")
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public static @interface NamedComposedAnnotation {
|
||||
public @interface NamedComposedAnnotation {
|
||||
}
|
||||
|
||||
@NamedComposedAnnotation
|
||||
|
||||
Reference in New Issue
Block a user