diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AliasFor.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AliasFor.java new file mode 100644 index 000000000..09dabfa42 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AliasFor.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +/** + * A value object representing a declarative attribute aliasing instruction. + */ +@AllArgsConstructor +@Getter(AccessLevel.PACKAGE) +class AliasFor { + + /** + * The source annotation that declares the alias. + */ + private final Class marked; + + /** + * The target meta-annotation being aliased. + */ + private final Class target; + + /** + * The name of the attribute in the source annotation. + */ + private final String customAttribute; + + /** + * The name of the attribute in the target annotation to be overridden. + */ + private final String attribute; + + /** + * The value of the attribute in the target annotation to be overridden. + */ + @Setter(AccessLevel.PACKAGE) + private Object value; +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtils.java new file mode 100644 index 000000000..0b68c2d7a --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtils.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * Utility methods for finding and resolving composable annotations. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AnnotatedElementUtils { + + private static final AnnotationMetadataReader reader = new AnnotationMetadataReader(); + + /** + * Get the merged annotation metadata for the given element. + * + * @param element the annotated element + */ + public static AnnotationMap getAnnotationMap(AnnotatedElement element) { + if (element == null) { + return AnnotationMap.EMPTY; + } + AnnotationMap map = reader.read(element); + return map != null ? map : AnnotationMap.EMPTY; + } + + /** + * Get the merged annotation of the specified {@code annotationType} on the supplied element. + * + * @param element the annotated element + * @param annotationType the target annotation type + * @param the annotation type + * @return the synthesized annotation instance or null if not found + */ + public static T getMergedAnnotation(AnnotatedElement element, Class annotationType) { + return getAnnotationMap(element).synthesize(annotationType); + } + + /** + * Retrieve the merged {@link AnnotationAttributes} for the specified {@code annotationType} on the supplied element. + * + * @param element the annotated element + * @param annotationType the target annotation type + * @return the merged {@link AnnotationAttributes} or {@code null} if the annotation is not present + */ + public static AnnotationAttributes getMergedAnnotationAttributes( + AnnotatedElement element, Class annotationType) { + return getAnnotationMap(element).getAttributes(annotationType); + } + + /** + * Determine whether the given annotation type is present either directly declared or as a + * meta-annotation on the supplied element. + * + * @param element the annotated element + * @param annotationType the target annotation type + */ + public static boolean isAnnotated(AnnotatedElement element, Class annotationType) { + return getAnnotationMap(element).hasAnnotation(annotationType); + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationAttributes.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationAttributes.java new file mode 100644 index 000000000..8ca3bae07 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationAttributes.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Array; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.Validate; + +/** + * Resolved key-value pairs attributes for an annotation. + * Provides type-safe lookup of annotation attributes. + */ +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class AnnotationAttributes { + + /** + * The type of the annotation, represented by this {@code AnnotationAttributes}. + */ + @Getter + @EqualsAndHashCode.Include + private final Class annotationType; + + /** + * The class name of the annotation type. + */ + @Getter + private final String annotationName; + + /** + * The key-value attribute pairs declared on the annotation. + */ + @EqualsAndHashCode.Include + private final Map attributes; + + AnnotationAttributes(Class annotationType, Map attrs) { + this.annotationType = annotationType; + this.annotationName = annotationType.getName(); + this.attributes = new LinkedHashMap<>(attrs); + } + + public boolean isAnnotationTypeEqual(Class annotationType) { + return this.annotationType.equals(annotationType); + } + + void put(String attrName, Object value) { + attributes.put(attrName, value); + } + + /** + * Get an attribute value from the annotation. + * + * @param attributeName the attribute name + * @return the attribute value or {@code null} if not found + */ + public Object getAttribute(String attributeName) { + return attributes.get(attributeName); + } + + /** + * Get an attribute value from the annotation. + * + * @param attributeName the attribute name + * @param type the attribute type + * @return the attribute value or {@code null} if not found + * @throws IllegalArgumentException if the value cannot be converted/cast to the target type + */ + @SuppressWarnings("unchecked") + public T getAttribute(String attributeName, Class type) { + Object result = getAttribute(attributeName); + if (Objects.isNull(result)) { + return null; + } + + Class wrapped = ClassUtils.primitiveToWrapper(type); + + if (!wrapped.isInstance(result) + && type.isArray() + && ClassUtils.primitiveToWrapper(type.getComponentType()).isInstance(result)) { + Object array = Array.newInstance(type.getComponentType(), 1); + Array.set(array, 0, result); + result = array; + } + if (!wrapped.isInstance(result)) { + throw new IllegalArgumentException(String.format( + "Attribute '%s' is of type %s, but %s was expected for annotation [%s]", + attributeName, result.getClass().getSimpleName(), type.getSimpleName(), annotationName)); + } + + return (T) result; + } + + /** + * Get a required attribute value from the annotation. + * + * @param attributeName the attribute name + * @param type the attribute type + * @return the attribute value + * @throws NullPointerException if the {@code attributeName} is {@code null} + * @throws IllegalArgumentException if the {@code attributeName} is blank or attribute does not exist + */ + public T getRequiredAttribute(String attributeName, Class type) { + Validate.notBlank(attributeName, "attributeName must not be null or blank"); + T result = getAttribute(attributeName, type); + if (Objects.isNull(result)) { + throw new IllegalArgumentException( + String.format("Attribute '%s' not found for annotation '%s'", attributeName, annotationName)); + } + return result; + } + + /** + * Returns an unmodifiable view of the attributes map. + */ + public Map asImmutableMap() { + return Collections.unmodifiableMap(attributes); + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMap.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMap.java new file mode 100644 index 000000000..468c1a8e7 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMap.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Proxy; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.EqualsAndHashCode; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.Validate; + +/** + * A wrapper class for all annotation (include composable annotation) attribute key-value pairs + * associated with {@link AnnotatedElement}. + */ +@EqualsAndHashCode +public class AnnotationMap { + + public static final AnnotationMap EMPTY = new AnnotationMap(Collections.emptyMap()); + + private final Map, AnnotationAttributes> annotations; + + AnnotationMap(Map, AnnotationAttributes> annotations) { + this.annotations = annotations; + } + + /** + * Check if the annotations map is empty. + */ + public boolean isEmpty() { + return MapUtils.isEmpty(annotations); + } + + /** + * Returns the number of annotations. + */ + public int size() { + return annotations.size(); + } + + /** + * Returns whether the specified annotation type is present. + */ + public boolean hasAnnotation(Class annotationType) { + return !isEmpty() && annotations.containsKey(annotationType); + } + + /** + * Get the {@link AnnotationAttributes} for the specified annotation type. + * + * @param annotationType the annotation type + * @return the {@link AnnotationAttributes} or {@code null} if not found + */ + public AnnotationAttributes getAttributes(Class annotationType) { + if (isEmpty()) { + return null; + } + return annotations.get(annotationType); + } + + /** + * Create a synthesized annotation proxy of the specified annotation type. + * + * @param annotationType the annotation type + * @return the synthesized annotation proxy, or {@code null} if annotation is not present + */ + @SuppressWarnings("unchecked") + public T synthesize(Class annotationType) { + AnnotationAttributes attributes = getAttributes(annotationType); + if (attributes == null) { + return null; + } + return (T) Proxy.newProxyInstance( + annotationType.getClassLoader(), + new Class[] {annotationType}, + new SynthesizedAnnotationInvocationHandler(annotationType, attributes)); + } + + static Builder builder() { + return new Builder(); + } + + static class Builder { + private final Map, AnnotationAttributes> ann; + + Builder() { + this.ann = new LinkedHashMap<>(8); + } + + Builder putIfAbsent(Class annotationType, AnnotationAttributes attributes) { + Validate.notNull(annotationType, "annotationType must not be null"); + Validate.notNull(attributes, "attributes must not be null"); + + ann.putIfAbsent(annotationType, attributes); + return this; + } + + AnnotationMap build() { + return new AnnotationMap(ann); + } + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadata.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadata.java new file mode 100644 index 000000000..b36473c34 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadata.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.util.List; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * A wrapper class for resolved annotation instance. + */ +@EqualsAndHashCode +@Getter(AccessLevel.PACKAGE) +class AnnotationMetadata { + + private final AnnotationAttributes attributes; + private final List aliases; + + AnnotationMetadata(AnnotationAttributes attributes, List aliases) { + this.attributes = attributes; + this.aliases = aliases; + } + + void applyAliasFor(AliasFor aliasFor) { + if (!attributes.isAnnotationTypeEqual(aliasFor.getTarget())) { + return; + } + + attributes.put(aliasFor.getAttribute(), aliasFor.getValue()); + propagateAliasValue(aliasFor); + } + + /** + * Propagates the applied alias value to downstream chained aliases. + *

+ * If any alias declared on this annotation originates from the parent's target attribute, + * its value is updated so that the newly assigned value cascades to the next nesting level. + */ + private void propagateAliasValue(AliasFor parentAliasFor) { + for (AliasFor current : aliases) { + if (Objects.equals(current.getCustomAttribute(), parentAliasFor.getAttribute())) { + current.setValue(parentAliasFor.getValue()); + } + } + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReader.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReader.java new file mode 100644 index 000000000..7256c5313 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReader.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A coordinator for discovering and reading annotation metadata from {@link AnnotatedElement}s. + */ +class AnnotationMetadataReader extends HierarchicalAnnotationScanner { + + private final Map elementAnnotation; + + public AnnotationMetadataReader() { + this(new ConcurrentHashMap<>()); + } + + public AnnotationMetadataReader(Map elementAnnotation) { + this.elementAnnotation = elementAnnotation; + } + + /** + * Read the merged annotation metadata for the given element. + * + * @param element the {@link Class} or {@link Field} + * @return the resolved {@link AnnotationMap} + */ + AnnotationMap read(AnnotatedElement element) { + return elementAnnotation.computeIfAbsent(element, super::scan); + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolver.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolver.java new file mode 100644 index 000000000..4fab4f99f --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolver.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.fesod.common.util.StringUtils; + +/** + * Providing introspection and resolution of annotation metadata. + */ +class AnnotationMetadataResolver { + + private static final String JAVA_LANG_ANNOTATION_PACKAGE_PREFIX = "java.lang.annotation"; + + private final Map, Boolean> metaMarkedMap = new ConcurrentHashMap<>(); + private final Map metaAliasMap = new ConcurrentHashMap<>(); + + /** + * Determine if the given annotation type should be ignored by the scanner: + * JDK-standard meta-annotations such as {@code @Target} or {@code @Retention}, + * and the {@code @FesodMarked} protocol marker itself. + * + * @param type the type to check + * @return {@code true} if the annotation should be skipped + */ + public boolean shouldIgnore(Class type) { + return type == FesodMarked.class || type.getName().startsWith(JAVA_LANG_ANNOTATION_PACKAGE_PREFIX); + } + + /** + * Determine if the annotation is marked ({@code @FesodMarked}) with the core meta-protocol. + * + * @param ann the annotation instance to check + * @return {@code true} if it is a composable meta-annotation + */ + public boolean isMetaMarked(Annotation ann) { + Class type = ann.annotationType(); + return metaMarkedMap.computeIfAbsent(type, k -> type.getAnnotation(FesodMarked.class) != null); + } + + /** + * Resolve a raw {@link Annotation} into a {@link AnnotationMetadata} object. + * + * @param ann the annotation instance to resolve + * @return the resolved metadata + */ + public AnnotationMetadata resolve(Annotation ann) { + Map, AttributeMethods> markedAnnMap = new HashMap<>(); + if (isMetaMarked(ann)) { + Annotation[] annotations = ann.annotationType().getAnnotations(); + for (Annotation markedAnn : annotations) { + if (!shouldIgnore(markedAnn.annotationType())) { + markedAnnMap.put(markedAnn.annotationType(), AttributeMethods.from(markedAnn.annotationType())); + } + } + } + + List aliases = new ArrayList<>(); + Map attr = new LinkedHashMap<>(); + + AttributeMethods attributeMethods = AttributeMethods.from(ann.annotationType()); + for (Method method : attributeMethods.getAttributeMethods()) { + String attrName = method.getName(); + try { + Object result = method.invoke(ann); + + // Handle @FesodMarked.AliasFor + if (isMetaAlias(method)) { + FesodMarked.AliasFor aliasFor = method.getAnnotation(FesodMarked.AliasFor.class); + + AttributeMethods targetAttrMethods = markedAnnMap.get(aliasFor.annotation()); + if (targetAttrMethods == null) { + if (!isMetaMarked(ann)) { + throw new IllegalStateException(String.format( + "The custom-annotation '%s' declares @FesodMarked.AliasFor but is not annotated with @FesodMarked", + ann.annotationType().getName())); + } + + throw new IllegalStateException(String.format( + "The alias annotation '%s' is not marked on the custom-annotation '%s'", + aliasFor.annotation().getName(), + ann.annotationType().getName())); + } + + String targetAttrName = + StringUtils.isNotBlank(aliasFor.attribute()) ? aliasFor.attribute() : attrName; + targetAttrMethods.validateAliasFor(method, targetAttrName); + + aliases.add(new AliasFor( + ann.annotationType(), aliasFor.annotation(), attrName, targetAttrName, result)); + } + attr.put(attrName, result); + } catch (IllegalAccessException | InvocationTargetException ex) { + throw new IllegalStateException( + String.format( + "Failed to invoke annotation [%s] method [%s]", + ann.annotationType().getName(), attrName), + ex); + } + } + return new AnnotationMetadata(new AnnotationAttributes(ann.annotationType(), attr), aliases); + } + + private boolean isMetaAlias(AnnotatedElement element) { + return metaAliasMap.computeIfAbsent(element, k -> element.getAnnotation(FesodMarked.AliasFor.class) != null); + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AttributeMethods.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AttributeMethods.java new file mode 100644 index 000000000..51dd22697 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AttributeMethods.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A collection of attribute methods for a specific annotation type. + * provides indexed access and alias compatibility validation. + */ +class AttributeMethods { + + private static final Map, AttributeMethods> attributeMethodsCache = + new ConcurrentHashMap<>(); + + private final Class type; + + private final List methods; + + private final Map methodMap; + + private AttributeMethods(Class annotationType) { + this.type = annotationType; + Method[] declaredMethods = annotationType.getDeclaredMethods(); + List tmpMethods = new ArrayList<>(declaredMethods.length); + this.methodMap = new HashMap<>(declaredMethods.length); + + for (Method method : declaredMethods) { + if (isAttributeMethod(method)) { + if (!method.isAccessible()) { + method.setAccessible(true); + } + + tmpMethods.add(method); + this.methodMap.put(method.getName(), method); + } + } + this.methods = Collections.unmodifiableList(tmpMethods); + } + + public static AttributeMethods from(Class annotationType) { + return attributeMethodsCache.computeIfAbsent(annotationType, AttributeMethods::new); + } + + static boolean isAttributeMethod(Method method) { + return method.getParameterCount() == 0 && method.getReturnType() != void.class; + } + + public Method getMethod(String attributeName) { + return methodMap.get(attributeName); + } + + public List getAttributeMethods() { + return methods; + } + + public void validateAliasFor(Method attribute, String attributeName) { + Method target = getMethod(attributeName); + if (target == null) { + throw new IllegalStateException(String.format( + "Annotation [%s] does not declare attribute [%s] referenced by @AliasFor", + type.getName(), attributeName)); + } + if (!isCompatibleReturnType(attribute.getReturnType(), target.getReturnType())) { + throw new IllegalStateException(String.format( + "Return type of attribute [%s#%s()] must match return type of target attribute [%s#%s()]", + attribute.getDeclaringClass().getName(), + attribute.getName(), + target.getDeclaringClass().getName(), + attributeName)); + } + } + + private boolean isCompatibleReturnType(Class attributeType, Class targetType) { + return (attributeType == targetType || attributeType == targetType.getComponentType()); + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/ExcelProperty.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/ExcelProperty.java index 1cb75c9db..c43ed3f7d 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/ExcelProperty.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/ExcelProperty.java @@ -36,7 +36,7 @@ /** * */ -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface ExcelProperty { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/FesodMarked.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/FesodMarked.java new file mode 100644 index 000000000..aad9e12c8 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/FesodMarked.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * {@code FesodMarked} is a meta-annotation (annotations used on other annotations) + * used for indicating that instead of using target annotation + * (annotation annotated with this annotation), + * Fesod should use meta-annotations it has. + * This can be useful in creating "Composable Annotations" by having + * a container annotation, which needs to be annotated with this + * annotation as well as all annotations it 'contains'. + */ +@Target(ElementType.ANNOTATION_TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface FesodMarked { + + /** + * {@code @AliasFor} is an annotation that is used to declare aliases for + * annotation attributes. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface AliasFor { + + /** + * The type of annotation in which the aliased attribute() is declared. + */ + Class annotation(); + + /** + * The name of the attribute that this attribute is an alias for. + *

Defaults to {@code ""}, meaning that the aliased attribute has the same + * name as the attribute that declares this {@code @AliasFor} annotation. + */ + String attribute() default ""; + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/HierarchicalAnnotationScanner.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/HierarchicalAnnotationScanner.java new file mode 100644 index 000000000..76d7404c3 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/HierarchicalAnnotationScanner.java @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.Set; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ArrayUtils; + +/** + * Abstract base class for scanning and storing composable annotations. + */ +abstract class HierarchicalAnnotationScanner { + + protected final AnnotationMetadataResolver metadataResolver = new AnnotationMetadataResolver(); + + protected HierarchicalAnnotationScanner() {} + + protected AnnotationMap scan(AnnotatedElement element) { + Annotation[] annotations = element.getAnnotations(); + if (ArrayUtils.isEmpty(annotations)) { + return AnnotationMap.EMPTY; + } + + AnnotationMap.Builder builder = AnnotationMap.builder(); + Queue queue = new LinkedList<>(); + Set> rootAnnTypes = new HashSet<>(annotations.length); + + for (Annotation root : annotations) { + rootAnnTypes.add(root.annotationType()); + queue.add(new AnnotationNode(root, Collections.emptyList())); + } + + while (!queue.isEmpty()) { + AnnotationNode current = queue.poll(); + Class type = current.annotationType(); + + if (metadataResolver.shouldIgnore(type)) { + continue; + } + + AnnotationMetadata metadata = metadataResolver.resolve(current.annotation); + + // Apply aliases + applyAliasesIfNecessary(metadata, current.aliases); + + // Handle composable-annotations (low-level attribute value) + if (metadataResolver.isMetaMarked(current.annotation)) { + for (Annotation metaAnn : type.getAnnotations()) { + if (metadataResolver.shouldIgnore(metaAnn.annotationType()) + || rootAnnTypes.contains(metaAnn.annotationType()) + || current.isVisited(metaAnn.annotationType())) { + continue; + } + queue.add(current.next(metaAnn, metadata.getAliases())); + } + } + + builder.putIfAbsent(type, metadata.getAttributes()); + } + + return builder.build(); + } + + /** + * Apply alias mapping rules ({@link AliasFor}) to the target annotation metadata. + * + * @param metadata the target annotation metadata + * @param aliases the aliases inherited from the declaring annotation + */ + private void applyAliasesIfNecessary(AnnotationMetadata metadata, List aliases) { + if (CollectionUtils.isEmpty(aliases)) { + return; + } + + for (AliasFor aliasFor : aliases) { + metadata.applyAliasFor(aliasFor); + } + } + + private static class AnnotationNode { + final Annotation annotation; + final List aliases; + // Record visited annotation, to avoid circular dependencies (like: @A -> @B, @B -> @A) + final Set> path; + + AnnotationNode(Annotation annotation, List aliases, Set> path) { + this.annotation = annotation; + this.aliases = aliases; + this.path = path; + } + + AnnotationNode(Annotation annotation, List aliases) { + this(annotation, aliases, new HashSet<>()); + } + + Class annotationType() { + return annotation.annotationType(); + } + + boolean isVisited(Class type) { + return path.contains(type); + } + + AnnotationNode next(Annotation annotation, List aliases) { + Set> fullPath = new HashSet<>(path); + fullPath.add(annotationType()); + return new AnnotationNode(annotation, aliases, fullPath); + } + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/SynthesizedAnnotationInvocationHandler.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/SynthesizedAnnotationInvocationHandler.java new file mode 100644 index 000000000..527f4add4 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/SynthesizedAnnotationInvocationHandler.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Array; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import org.apache.commons.lang3.ClassUtils; + +/** + * {@link InvocationHandler} implementation used to support synthesized annotation proxy + * instances created from {@link AnnotationAttributes}. + */ +class SynthesizedAnnotationInvocationHandler implements InvocationHandler { + + private final Class type; + private final AnnotationAttributes attributes; + + public SynthesizedAnnotationInvocationHandler( + Class annotationType, AnnotationAttributes attributes) { + this.type = annotationType; + this.attributes = attributes; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + Object result = attributes.getAttribute(method.getName(), method.getReturnType()); + if (result != null) { + return result; + } + + if (method.getParameterCount() == 0) { + switch (method.getName()) { + case "annotationType": + return this.type; + case "hashCode": + return handleHashCode(); + case "toString": + return handleToString(); + } + } + if (method.getParameterCount() == 1 + && "equals".equals(method.getName()) + && method.getParameterTypes()[0] == Object.class) { + return handleEquals(proxy, args[0]); + } + + throw new UnsupportedOperationException( + String.format("Method [%s] is unsupported for synthesized annotation type [%s]", method, this.type)); + } + + private boolean handleEquals(Object proxy, Object other) { + if (proxy == other) { + return true; + } + if (!this.type.isInstance(other)) { + return false; + } + if (Proxy.isProxyClass(other.getClass())) { + InvocationHandler handler = Proxy.getInvocationHandler(other); + if (handler instanceof SynthesizedAnnotationInvocationHandler) { + return this.attributes.equals(((SynthesizedAnnotationInvocationHandler) handler).attributes); + } + } + + AttributeMethods attributeMethods = AttributeMethods.from(this.type); + for (Map.Entry entry : attributes.asImmutableMap().entrySet()) { + try { + Method m = attributeMethods.getMethod(entry.getKey()); + if (!Objects.deepEquals(entry.getValue(), m.invoke(other))) { + return false; + } + } catch (Exception ex) { + return false; + } + } + return true; + } + + private int handleHashCode() { + int hashCode = 0; + for (Map.Entry entry : attributes.asImmutableMap().entrySet()) { + hashCode += (127 * entry.getKey().hashCode()) ^ calcAttributeValueHashCode(entry.getValue()); + } + return hashCode; + } + + private int calcAttributeValueHashCode(Object value) { + if (!value.getClass().isArray()) { + return Objects.hashCode(value); + } + + if (value instanceof boolean[]) { + return Arrays.hashCode((boolean[]) value); + } + if (value instanceof byte[]) { + return Arrays.hashCode((byte[]) value); + } + if (value instanceof short[]) { + return Arrays.hashCode((short[]) value); + } + if (value instanceof int[]) { + return Arrays.hashCode((int[]) value); + } + if (value instanceof long[]) { + return Arrays.hashCode((long[]) value); + } + if (value instanceof float[]) { + return Arrays.hashCode((float[]) value); + } + if (value instanceof double[]) { + return Arrays.hashCode((double[]) value); + } + if (value instanceof char[]) { + return Arrays.hashCode((char[]) value); + } + return Arrays.hashCode((Object[]) value); + } + + private String handleToString() { + Iterator> item = + attributes.asImmutableMap().entrySet().iterator(); + StringBuilder sb = new StringBuilder() + .append('@') + .append(ClassUtils.getCanonicalName(type)) + .append('('); + + if (!item.hasNext()) { + return sb.append(')').toString(); + } + + for (; ; ) { + Map.Entry e = item.next(); + String key = e.getKey(); + Object value = e.getValue(); + sb.append(key); + sb.append('='); + sb.append(toString(value)); + if (!item.hasNext()) { + return sb.append(')').toString(); + } + sb.append(',').append(' '); + } + } + + private String toString(Object value) { + Class valueType = value.getClass(); + if (valueType.isArray()) { + StringBuilder builder = new StringBuilder("{"); + int arrayLength = Array.getLength(value); + for (int i = 0; i < arrayLength; i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(toString(Array.get(value, i))); + } + builder.append('}'); + return builder.toString(); + } + if (value instanceof Enum) { + return ((Enum) value).name(); + } + if (valueType == Class.class) { + return ClassUtils.getCanonicalName((Class) value) + ".class"; + } + return String.valueOf(value); + } +} diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/DateTimeFormat.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/DateTimeFormat.java index e579f451a..f5308880c 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/DateTimeFormat.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/DateTimeFormat.java @@ -42,7 +42,7 @@ * * */ -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface DateTimeFormat { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/NumberFormat.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/NumberFormat.java index f6178b085..ac8956de3 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/NumberFormat.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/format/NumberFormat.java @@ -42,7 +42,7 @@ * * */ -@Target(ElementType.FIELD) +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface NumberFormat { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ColumnWidth.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ColumnWidth.java index e929b0412..377abeb42 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ColumnWidth.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ColumnWidth.java @@ -36,7 +36,7 @@ * * */ -@Target({ElementType.FIELD, ElementType.TYPE}) +@Target({ElementType.FIELD, ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface ColumnWidth { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentFontStyle.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentFontStyle.java index bf129bc92..15600bbe0 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentFontStyle.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentFontStyle.java @@ -41,7 +41,7 @@ * * */ -@Target({ElementType.FIELD, ElementType.TYPE}) +@Target({ElementType.FIELD, ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface ContentFontStyle { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentLoopMerge.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentLoopMerge.java index ac037130a..9b9a1fc05 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentLoopMerge.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentLoopMerge.java @@ -36,7 +36,7 @@ * * */ -@Target({ElementType.FIELD}) +@Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface ContentLoopMerge { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentRowHeight.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentRowHeight.java index f19e39191..dd5a72a5a 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentRowHeight.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentRowHeight.java @@ -36,7 +36,7 @@ * * */ -@Target({ElementType.TYPE}) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface ContentRowHeight { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentStyle.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentStyle.java index 722ac5661..73a41b88e 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentStyle.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/ContentStyle.java @@ -45,7 +45,7 @@ * * */ -@Target({ElementType.FIELD, ElementType.TYPE}) +@Target({ElementType.FIELD, ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface ContentStyle { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/FreezePane.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/FreezePane.java index 225f8d566..9d42e3ded 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/FreezePane.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/FreezePane.java @@ -28,7 +28,7 @@ /** * An annotation used to define a freeze pane for an Excel sheet. */ -@Target(ElementType.TYPE) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface FreezePane { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadFontStyle.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadFontStyle.java index a37659b8f..10ce79056 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadFontStyle.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadFontStyle.java @@ -41,7 +41,7 @@ * * */ -@Target({ElementType.FIELD, ElementType.TYPE}) +@Target({ElementType.FIELD, ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface HeadFontStyle { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadRowHeight.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadRowHeight.java index b20ef5775..7c95abe0a 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadRowHeight.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadRowHeight.java @@ -36,7 +36,7 @@ * * */ -@Target({ElementType.TYPE}) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface HeadRowHeight { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadStyle.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadStyle.java index 4a66f9ad2..7f446d396 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadStyle.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/HeadStyle.java @@ -45,7 +45,7 @@ * * */ -@Target({ElementType.FIELD, ElementType.TYPE}) +@Target({ElementType.FIELD, ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface HeadStyle { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/OnceAbsoluteMerge.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/OnceAbsoluteMerge.java index c1525d001..905c74e24 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/OnceAbsoluteMerge.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/write/style/OnceAbsoluteMerge.java @@ -36,7 +36,7 @@ * * */ -@Target({ElementType.TYPE}) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Retention(RetentionPolicy.RUNTIME) @Inherited public @interface OnceAbsoluteMerge { diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/ClassUtils.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/ClassUtils.java index 3116e9bf9..1d994b6a4 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/ClassUtils.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/util/ClassUtils.java @@ -47,6 +47,7 @@ import org.apache.fesod.common.util.ListUtils; import org.apache.fesod.common.util.MapUtils; import org.apache.fesod.shaded.cglib.beans.BeanMap; +import org.apache.fesod.sheet.annotation.AnnotatedElementUtils; import org.apache.fesod.sheet.annotation.ExcelIgnore; import org.apache.fesod.sheet.annotation.ExcelIgnoreUnannotated; import org.apache.fesod.sheet.annotation.ExcelProperty; @@ -226,14 +227,15 @@ private static Map doDeclaredFieldContentMap(Class } List tempFieldList = FieldUtils.resolveAllFields(clazz); - ContentStyle parentContentStyle = clazz.getAnnotation(ContentStyle.class); - ContentFontStyle parentContentFontStyle = clazz.getAnnotation(ContentFontStyle.class); + ContentStyle parentContentStyle = AnnotatedElementUtils.getMergedAnnotation(clazz, ContentStyle.class); + ContentFontStyle parentContentFontStyle = + AnnotatedElementUtils.getMergedAnnotation(clazz, ContentFontStyle.class); Map fieldContentMap = MapUtils.newHashMapWithExpectedSize(tempFieldList.size()); for (Field field : tempFieldList) { ExcelContentProperty excelContentProperty = new ExcelContentProperty(); excelContentProperty.setField(field); - ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + ExcelProperty excelProperty = AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class); if (excelProperty != null) { Class> convertClazz = excelProperty.converter(); if (convertClazz != AutoConverter.class) { @@ -247,22 +249,23 @@ private static Map doDeclaredFieldContentMap(Class } } - ContentStyle contentStyle = field.getAnnotation(ContentStyle.class); + ContentStyle contentStyle = AnnotatedElementUtils.getMergedAnnotation(field, ContentStyle.class); if (contentStyle == null) { contentStyle = parentContentStyle; } excelContentProperty.setContentStyleProperty(StyleProperty.build(contentStyle)); - ContentFontStyle contentFontStyle = field.getAnnotation(ContentFontStyle.class); + ContentFontStyle contentFontStyle = + AnnotatedElementUtils.getMergedAnnotation(field, ContentFontStyle.class); if (contentFontStyle == null) { contentFontStyle = parentContentFontStyle; } excelContentProperty.setContentFontProperty(FontProperty.build(contentFontStyle)); - excelContentProperty.setDateTimeFormatProperty( - DateTimeFormatProperty.build(field.getAnnotation(DateTimeFormat.class))); + excelContentProperty.setDateTimeFormatProperty(DateTimeFormatProperty.build( + AnnotatedElementUtils.getMergedAnnotation(field, DateTimeFormat.class))); excelContentProperty.setNumberFormatProperty( - NumberFormatProperty.build(field.getAnnotation(NumberFormat.class))); + NumberFormatProperty.build(AnnotatedElementUtils.getMergedAnnotation(field, NumberFormat.class))); fieldContentMap.put(field.getName(), excelContentProperty); } @@ -300,11 +303,11 @@ public static FieldCache declaredFields(Class clazz, ConfigurationHolder conf private static FieldCache doDeclaredFields(Class clazz, ConfigurationHolder configurationHolder) { List tempFieldList = FieldUtils.resolveAllFields(clazz); - ExcelIgnoreUnannotated excelIgnoreUnannotated = clazz.getAnnotation(ExcelIgnoreUnannotated.class); + boolean isIgnoreUnannotated = AnnotatedElementUtils.isAnnotated(clazz, ExcelIgnoreUnannotated.class); Set ignoreSet = new HashSet<>(); // First collect all field names annotated with ExcelIgnore (including subclass overrides) for (Field field : tempFieldList) { - if (field.getAnnotation(ExcelIgnore.class) != null) { + if (AnnotatedElementUtils.isAnnotated(field, ExcelIgnore.class)) { ignoreSet.add(FieldUtils.resolveCglibFieldName(field)); } } @@ -316,7 +319,7 @@ private static FieldCache doDeclaredFields(Class clazz, ConfigurationHolder c if (ignoreSet.contains(fieldName)) { continue; } - declaredOneField(field, orderFieldMap, indexFieldMap, ignoreSet, excelIgnoreUnannotated); + declaredOneField(field, orderFieldMap, indexFieldMap, ignoreSet, isIgnoreUnannotated); } Map sortedFieldMap = buildSortedAllFieldMap(orderFieldMap, indexFieldMap); FieldCache fieldCache = new FieldCache(sortedFieldMap, indexFieldMap); @@ -459,7 +462,7 @@ private static void declaredOneField( Map> orderFieldMap, Map indexFieldMap, Set ignoreSet, - ExcelIgnoreUnannotated excelIgnoreUnannotated) { + boolean isIgnoreUnannotated) { String fieldName = FieldUtils.resolveCglibFieldName(field); // skip if the field is in ignoreSet if (ignoreSet.contains(fieldName)) { @@ -469,8 +472,8 @@ private static void declaredOneField( fieldWrapper.setField(field); fieldWrapper.setFieldName(fieldName); - ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); - boolean noExcelProperty = excelProperty == null && excelIgnoreUnannotated != null; + ExcelProperty excelProperty = AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class); + boolean noExcelProperty = excelProperty == null && isIgnoreUnannotated; boolean isStaticFinalOrTransient = (Modifier.isStatic(field.getModifiers()) && Modifier.isFinal(field.getModifiers())) || Modifier.isTransient(field.getModifiers()); diff --git a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/property/ExcelWriteHeadProperty.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/property/ExcelWriteHeadProperty.java index fb97d78c0..5323ba6e4 100644 --- a/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/property/ExcelWriteHeadProperty.java +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/write/property/ExcelWriteHeadProperty.java @@ -35,6 +35,7 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; +import org.apache.fesod.sheet.annotation.AnnotatedElementUtils; import org.apache.fesod.sheet.annotation.write.style.ColumnWidth; import org.apache.fesod.sheet.annotation.write.style.ContentLoopMerge; import org.apache.fesod.sheet.annotation.write.style.ContentRowHeight; @@ -77,15 +78,18 @@ public ExcelWriteHeadProperty( if (getHeadKind() != HeadKindEnum.CLASS) { return; } - this.headRowHeightProperty = RowHeightProperty.build(headClazz.getAnnotation(HeadRowHeight.class)); - this.contentRowHeightProperty = RowHeightProperty.build(headClazz.getAnnotation(ContentRowHeight.class)); - this.onceAbsoluteMergeProperty = - OnceAbsoluteMergeProperty.build(headClazz.getAnnotation(OnceAbsoluteMerge.class)); - this.freezePaneProperty = SheetFreezePaneProperty.build(headClazz.getAnnotation(FreezePane.class)); + this.headRowHeightProperty = + RowHeightProperty.build(AnnotatedElementUtils.getMergedAnnotation(headClazz, HeadRowHeight.class)); + this.contentRowHeightProperty = + RowHeightProperty.build(AnnotatedElementUtils.getMergedAnnotation(headClazz, ContentRowHeight.class)); + this.onceAbsoluteMergeProperty = OnceAbsoluteMergeProperty.build( + AnnotatedElementUtils.getMergedAnnotation(headClazz, OnceAbsoluteMerge.class)); + this.freezePaneProperty = + SheetFreezePaneProperty.build(AnnotatedElementUtils.getMergedAnnotation(headClazz, FreezePane.class)); - ColumnWidth parentColumnWidth = headClazz.getAnnotation(ColumnWidth.class); - HeadStyle parentHeadStyle = headClazz.getAnnotation(HeadStyle.class); - HeadFontStyle parentHeadFontStyle = headClazz.getAnnotation(HeadFontStyle.class); + ColumnWidth parentColumnWidth = AnnotatedElementUtils.getMergedAnnotation(headClazz, ColumnWidth.class); + HeadStyle parentHeadStyle = AnnotatedElementUtils.getMergedAnnotation(headClazz, HeadStyle.class); + HeadFontStyle parentHeadFontStyle = AnnotatedElementUtils.getMergedAnnotation(headClazz, HeadFontStyle.class); for (Map.Entry entry : getHeadMap().entrySet()) { Head headData = entry.getValue(); @@ -95,25 +99,26 @@ public ExcelWriteHeadProperty( } Field field = headData.getField(); - ColumnWidth columnWidth = field.getAnnotation(ColumnWidth.class); + ColumnWidth columnWidth = AnnotatedElementUtils.getMergedAnnotation(field, ColumnWidth.class); if (columnWidth == null) { columnWidth = parentColumnWidth; } headData.setColumnWidthProperty(ColumnWidthProperty.build(columnWidth)); - HeadStyle headStyle = field.getAnnotation(HeadStyle.class); + HeadStyle headStyle = AnnotatedElementUtils.getMergedAnnotation(field, HeadStyle.class); if (headStyle == null) { headStyle = parentHeadStyle; } headData.setHeadStyleProperty(StyleProperty.build(headStyle)); - HeadFontStyle headFontStyle = field.getAnnotation(HeadFontStyle.class); + HeadFontStyle headFontStyle = AnnotatedElementUtils.getMergedAnnotation(field, HeadFontStyle.class); if (headFontStyle == null) { headFontStyle = parentHeadFontStyle; } headData.setHeadFontProperty(FontProperty.build(headFontStyle)); - headData.setLoopMergeProperty(LoopMergeProperty.build(field.getAnnotation(ContentLoopMerge.class))); + headData.setLoopMergeProperty( + LoopMergeProperty.build(AnnotatedElementUtils.getMergedAnnotation(field, ContentLoopMerge.class))); } } diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtilsTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtilsTest.java new file mode 100644 index 000000000..85e5049f8 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtilsTest.java @@ -0,0 +1,402 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Field; +import org.apache.fesod.sheet.testkit.Tags; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AnnotatedElementUtils}: composable-annotation merging, {@code @FesodMarked.AliasFor} overrides and + * synthesized annotation proxies. + */ +@Tag(Tags.UNIT) +class AnnotatedElementUtilsTest { + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty(index = 3, value = "meta-head") + @interface MetaProperty {} + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty(index = 7) + @interface AliasedProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "index") + int column() default 42; + } + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty + @interface SingleHeadProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String head() default "single"; + } + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty + @interface SameNameProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class) + int index() default 11; + } + + @Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @CycleB + @interface CycleA {} + + @Target(ElementType.ANNOTATION_TYPE) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @CycleA + @interface CycleB {} + + /** + * Declares an alias for a meta-annotation that is not meta-present, which the + * resolver must reject instead of silently ignoring. + */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @interface BrokenAliasProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "index") + int column() default 1; + } + + @Target({ElementType.FIELD, ElementType.ANNOTATION_TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty + @interface LayeredProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "index") + int column() default 42; + } + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @LayeredProperty(column = 8) + @interface ComposedLayeredProperty {} + + /** Aliases an attribute name that the target annotation does not declare. */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty + @interface TypoAliasProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "indx") + int column() default 1; + } + + /** Declares an alias whose return type is incompatible with the target attribute. */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty + @interface BadTypeAliasProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "index") + String column() default "x"; + } + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty + @interface SuppressedAliasProperty { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String head() default "Preset"; + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "index") + int column() default 42; + } + + /** Two composed annotations declaring the same target type with different presets. */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty(index = 1, value = "first-declared") + @interface FirstDeclaredProperty {} + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty(index = 2, value = "second-declared") + @interface SecondDeclaredProperty {} + + @Target({ElementType.FIELD, ElementType.TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty + public @interface Middle { + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String name() default "Preset"; + } + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @Middle + public @interface Outer { + @FesodMarked.AliasFor(annotation = Middle.class, attribute = "name") + String title() default ""; + } + + @MetaProperty + private String composedOnly; + + @MetaProperty + @ExcelProperty(index = 5) + private String directAndComposed; + + @LayeredProperty + @ComposedLayeredProperty + private String layered; + + @AliasedProperty + private String aliasedDefault; + + @AliasedProperty(column = 9) + private String aliasedExplicit; + + @SingleHeadProperty + private String scalarAlias; + + @SameNameProperty + private String sameNameAlias; + + @CycleA + private String cyclic; + + @BrokenAliasProperty + private String brokenAlias; + + @TypoAliasProperty + private String typoAlias; + + @BadTypeAliasProperty + private String badTypeAlias; + + @ExcelProperty(index = 5) + @SuppressedAliasProperty + private String suppressedAlias; + + @FirstDeclaredProperty + @SecondDeclaredProperty + private String duplicateTarget; + + @ExcelProperty(index = 2, value = "proxy") + private String proxySource; + + @Outer(title = "chained") + private String chained; + + private String unannotated; + + @Test + void shouldSurfaceMetaDeclaredAttributesThroughComposedAnnotation() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("composedOnly"), ExcelProperty.class); + + Assertions.assertNotNull(merged); + Assertions.assertEquals(3, merged.index()); + Assertions.assertArrayEquals(new String[] {"meta-head"}, merged.value()); + Assertions.assertTrue(AnnotatedElementUtils.isAnnotated(field("composedOnly"), ExcelProperty.class)); + } + + @Test + void shouldPreferDirectAnnotationOverComposedDeclaration() throws Exception { + Field field = field("directAndComposed"); + + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class); + Assertions.assertEquals(5, merged.index()); + Assertions.assertArrayEquals(new String[] {""}, merged.value()); + + AnnotationAttributes attributes = + AnnotatedElementUtils.getMergedAnnotationAttributes(field, ExcelProperty.class); + Assertions.assertNotNull(attributes); + Assertions.assertEquals(Integer.valueOf(5), attributes.getRequiredAttribute("index", Integer.class)); + } + + @Test + void shouldPreferDirectMarkedOccurrenceOverComposedPreset() throws Exception { + Field field = field("layered"); + + Assertions.assertEquals( + 42, + AnnotatedElementUtils.getMergedAnnotation(field, LayeredProperty.class) + .column()); + Assertions.assertEquals( + 42, + AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class) + .index()); + } + + @Test + void shouldApplyAliasOverrideEvenWhenAliasAttributeIsAtDefault() throws Exception { + // the alias value always overrides the target, so meta-declared index=7 must not win + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("aliasedDefault"), ExcelProperty.class); + Assertions.assertEquals(42, merged.index()); + } + + @Test + void shouldApplyExplicitAliasOverride() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("aliasedExplicit"), ExcelProperty.class); + Assertions.assertEquals(9, merged.index()); + } + + @Test + void shouldIgnoreComposedAliasesWhenTargetDirectlyAnnotated() throws Exception { + Field field = field("suppressedAlias"); + + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class); + Assertions.assertEquals(5, merged.index()); + Assertions.assertArrayEquals(new String[] {""}, merged.value()); + + SuppressedAliasProperty customAnnotation = + AnnotatedElementUtils.getMergedAnnotation(field, SuppressedAliasProperty.class); + Assertions.assertEquals(42, customAnnotation.column()); + Assertions.assertEquals("Preset", customAnnotation.head()); + } + + @Test + void shouldKeepFirstDeclaredOccurrenceForDuplicateTargetType() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("duplicateTarget"), ExcelProperty.class); + + // first-occurrence-wins: the later composed declaration of the same target type is discarded wholesale + Assertions.assertEquals(1, merged.index()); + Assertions.assertArrayEquals(new String[] {"first-declared"}, merged.value()); + } + + @Test + void shouldPromoteScalarAliasValueToArrayAttribute() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("scalarAlias"), ExcelProperty.class); + Assertions.assertArrayEquals(new String[] {"single"}, merged.value()); + } + + @Test + void shouldDefaultBlankAliasAttributeToSameNamedAttribute() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("sameNameAlias"), ExcelProperty.class); + Assertions.assertEquals(11, merged.index()); + } + + @Test + void shouldTerminateOnCyclicMarkedAnnotations() throws Exception { + Field field = field("cyclic"); + + Assertions.assertNotNull(AnnotatedElementUtils.getMergedAnnotation(field, CycleA.class)); + Assertions.assertTrue(AnnotatedElementUtils.isAnnotated(field, CycleB.class)); + } + + @Test + void shouldFailFastWhenAliasTargetIsNotMetaPresent() throws Exception { + Assertions.assertThrows( + IllegalStateException.class, + () -> AnnotatedElementUtils.getMergedAnnotation(field("brokenAlias"), ExcelProperty.class)); + } + + @Test + void shouldFailFastWhenAliasAttributeIsNotDeclaredOnTarget() throws Exception { + Assertions.assertThrows( + IllegalStateException.class, + () -> AnnotatedElementUtils.getMergedAnnotation(field("typoAlias"), ExcelProperty.class)); + } + + @Test + void shouldFailFastWhenAliasReturnTypeIsIncompatible() throws Exception { + Assertions.assertThrows( + IllegalStateException.class, + () -> AnnotatedElementUtils.getMergedAnnotation(field("badTypeAlias"), ExcelProperty.class)); + } + + @Test + void shouldRejectAttributeRequestedWithWrongType() throws Exception { + AnnotationAttributes attributes = + AnnotatedElementUtils.getMergedAnnotationAttributes(field("proxySource"), ExcelProperty.class); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> attributes.getRequiredAttribute("index", String.class)); + } + + @Test + void shouldReadPrimitiveAttributesFromSynthesizedAnnotation() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("proxySource"), ExcelProperty.class); + Assertions.assertEquals(2, merged.index()); + } + + @Test + void shouldServeObjectMethodsOnSynthesizedAnnotation() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("proxySource"), ExcelProperty.class); + + Assertions.assertEquals(ExcelProperty.class, merged.annotationType()); + Assertions.assertEquals( + merged, AnnotatedElementUtils.getMergedAnnotation(field("proxySource"), ExcelProperty.class)); + Assertions.assertEquals(merged, field("proxySource").getAnnotation(ExcelProperty.class)); + // equal objects must hash equally: synthesized hashCode follows the JLS annotation formula + Assertions.assertEquals( + field("proxySource").getAnnotation(ExcelProperty.class).hashCode(), merged.hashCode()); + Assertions.assertFalse(merged.equals(null)); + Assertions.assertTrue(merged.toString().contains("proxy")); + } + + @Test + void shouldPropagateChainedAliasesInNestedComposedAnnotations() throws Exception { + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("chained"), ExcelProperty.class); + Assertions.assertArrayEquals(new String[] {"chained"}, merged.value()); + } + + @Test + void shouldReportMissingAnnotationAsAbsent() throws Exception { + Field field = field("unannotated"); + + Assertions.assertNull(AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class)); + Assertions.assertNull(AnnotatedElementUtils.getMergedAnnotationAttributes(field, ExcelProperty.class)); + Assertions.assertFalse(AnnotatedElementUtils.isAnnotated(field, ExcelProperty.class)); + } + + @Test + void shouldTolerateNullElement() { + Assertions.assertNull(AnnotatedElementUtils.getMergedAnnotation(null, ExcelProperty.class)); + Assertions.assertFalse(AnnotatedElementUtils.isAnnotated(null, ExcelProperty.class)); + } + + private Field field(String name) throws NoSuchFieldException { + return getClass().getDeclaredField(name); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTest.java new file mode 100644 index 000000000..4590b1e20 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTest.java @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.fesod.sheet.testkit.Tags; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AnnotationAttributes}. + */ +@Tag(Tags.UNIT) +class AnnotationAttributesTest { + + @Test + void shouldIsolateFromCallerOwnedMaps() { + Map attrs = new LinkedHashMap<>(); + attrs.put("index", 2); + + AnnotationAttributes attributes = new AnnotationAttributes(ExcelProperty.class, attrs); + attrs.put("index", 99); + + Assertions.assertEquals(Integer.valueOf(2), attributes.getRequiredAttribute("index", Integer.class)); + } + + @Test + void shouldEqualByAnnotationTypeAndAttributeValues() { + AnnotationAttributes near = newAnnotationAttributes(2); + AnnotationAttributes far = newAnnotationAttributes(2); + + Assertions.assertEquals(near, far); + Assertions.assertEquals(near.hashCode(), far.hashCode()); + + far.put("index", 5); + Assertions.assertNotEquals(near, far); + } + + @Test + void shouldRejectRequiredAttributeWhenAbsent() { + AnnotationAttributes attributes = newAnnotationAttributes(2); + + Assertions.assertThrows( + IllegalArgumentException.class, () -> attributes.getRequiredAttribute("order", Integer.class)); + } + + @Test + void shouldExposeReadOnlyAttributeMapView() { + AnnotationAttributes attributes = newAnnotationAttributes(2); + Map view = attributes.asImmutableMap(); + + Assertions.assertEquals(2, view.get("index")); + Assertions.assertThrows(UnsupportedOperationException.class, () -> view.put("order", 99)); + } + + private AnnotationAttributes newAnnotationAttributes(int index) { + Map attrs = new LinkedHashMap<>(); + attrs.put("index", index); + return new AnnotationAttributes(ExcelProperty.class, attrs); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTestSupport.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTestSupport.java new file mode 100644 index 000000000..70a3d8e06 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTestSupport.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +/** + * For testing purposes only. + */ +public class AnnotationAttributesTestSupport { + + public static void put(AnnotationAttributes attrs, String name, Object value) { + attrs.put(name, value); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMapTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMapTest.java new file mode 100644 index 000000000..9cf301197 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMapTest.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.apache.fesod.sheet.testkit.Tags; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AnnotationMap}. + */ +@Tag(Tags.UNIT) +class AnnotationMapTest { + + @Test + void shouldExposeAbsentSemanticsOnEmptyMap() { + Assertions.assertTrue(AnnotationMap.EMPTY.isEmpty()); + Assertions.assertEquals(0, AnnotationMap.EMPTY.size()); + Assertions.assertFalse(AnnotationMap.EMPTY.hasAnnotation(ExcelProperty.class)); + Assertions.assertNull(AnnotationMap.EMPTY.getAttributes(ExcelProperty.class)); + Assertions.assertNull(AnnotationMap.EMPTY.synthesize(ExcelProperty.class)); + } + + @Test + void shouldKeepFirstOccurrenceForDuplicateTypes() { + AnnotationMap map = AnnotationMap.builder() + .putIfAbsent(ExcelProperty.class, attributes(1, "first")) + .putIfAbsent(ExcelProperty.class, attributes(2, "second")) + .build(); + + // duplicate types are first-occurrence-wins: the second declaration is discarded wholesale + AnnotationAttributes merged = map.getAttributes(ExcelProperty.class); + Assertions.assertNotNull(merged); + Assertions.assertEquals(Integer.valueOf(1), merged.getRequiredAttribute("index", Integer.class)); + Assertions.assertArrayEquals(new String[] {"first"}, merged.getRequiredAttribute("value", String[].class)); + } + + private AnnotationAttributes attributes(int index, String value) { + Map attrs = new LinkedHashMap<>(); + attrs.put("index", index); + attrs.put("value", new String[] {value}); + return new AnnotationAttributes(ExcelProperty.class, attrs); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReaderTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReaderTest.java new file mode 100644 index 000000000..75a168e30 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReaderTest.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.reflect.Field; +import org.apache.fesod.sheet.testkit.Tags; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AnnotationMetadataReader}. + */ +@Tag(Tags.UNIT) +class AnnotationMetadataReaderTest { + + private final AnnotationMetadataReader reader = new AnnotationMetadataReader(); + + @ExcelProperty(index = 1) + private String readerField; + + @Test + void shouldReturnCachedAnnotationMapPerElement() throws Exception { + Field field = getClass().getDeclaredField("readerField"); + AnnotationMap first = reader.read(field); + + Assertions.assertNotNull(first); + Assertions.assertTrue(first.hasAnnotation(ExcelProperty.class)); + Assertions.assertSame(first, reader.read(field)); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolverTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolverTest.java new file mode 100644 index 000000000..8d22dfab2 --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolverTest.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.annotation; + +import java.lang.annotation.Target; +import org.apache.fesod.sheet.testkit.Tags; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AnnotationMetadataResolver}. + */ +@Tag(Tags.UNIT) +class AnnotationMetadataResolverTest { + + private final AnnotationMetadataResolver resolver = new AnnotationMetadataResolver(); + + @Test + void shouldIgnoreProtocolAndJdkMetaAnnotationsOnly() { + Assertions.assertTrue(resolver.shouldIgnore(FesodMarked.class)); + Assertions.assertTrue(resolver.shouldIgnore(Target.class)); + Assertions.assertFalse(resolver.shouldIgnore(ExcelProperty.class)); + } +} diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/CacheDataTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/CacheDataTest.java index c50ad4ff1..0a403f3f5 100644 --- a/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/CacheDataTest.java +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/CacheDataTest.java @@ -27,11 +27,12 @@ import java.io.File; import java.lang.reflect.Field; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Proxy; import java.util.Map; import lombok.Getter; import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.annotation.AnnotatedElementUtils; +import org.apache.fesod.sheet.annotation.AnnotationAttributes; +import org.apache.fesod.sheet.annotation.AnnotationAttributesTestSupport; import org.apache.fesod.sheet.annotation.ExcelProperty; import org.apache.fesod.sheet.context.AnalysisContext; import org.apache.fesod.sheet.enums.CacheLocationEnum; @@ -126,12 +127,9 @@ private void assertHeadMap(File file, String expectedFirstHead, CacheLocationEnu private void setNameHeader(String nameHeader) throws Exception { Field name = FieldUtils.getField(CacheData.class, "name", true); - ExcelProperty annotation = name.getAnnotation(ExcelProperty.class); - InvocationHandler invocationHandler = Proxy.getInvocationHandler(annotation); - Field memberValues = invocationHandler.getClass().getDeclaredField("memberValues"); - memberValues.setAccessible(true); - Map map = (Map) memberValues.get(invocationHandler); - map.put("value", new String[] {nameHeader}); + AnnotationAttributes attributes = + AnnotatedElementUtils.getMergedAnnotationAttributes(name, ExcelProperty.class); + AnnotationAttributesTestSupport.put(attributes, "value", new String[] {nameHeader}); } @Getter diff --git a/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/ComposableAnnotationDataTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/ComposableAnnotationDataTest.java new file mode 100644 index 000000000..05f84e89a --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/ComposableAnnotationDataTest.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.fesod.sheet.readwrite; + +import java.io.File; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.Collections; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import org.apache.fesod.sheet.FesodSheet; +import org.apache.fesod.sheet.annotation.ExcelProperty; +import org.apache.fesod.sheet.annotation.FesodMarked; +import org.apache.fesod.sheet.annotation.write.style.ColumnWidth; +import org.apache.fesod.sheet.annotation.write.style.HeadFontStyle; +import org.apache.fesod.sheet.enums.BooleanEnum; +import org.apache.fesod.sheet.testkit.Tags; +import org.apache.fesod.sheet.testkit.assertions.ExcelAssertions; +import org.apache.fesod.sheet.testkit.base.AbstractExcelTest; +import org.apache.fesod.sheet.testkit.enums.ExcelFormat; +import org.apache.fesod.sheet.testkit.helpers.RoundTripHelper; +import org.apache.fesod.sheet.testkit.params.ExcelFormatSource; +import org.apache.fesod.sheet.testkit.params.FormatScope; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; + +/** + * End-to-end coverage for composable annotations: {@code @FesodMarked} + * annotations carrying meta-declared {@link ExcelProperty}, {@code @ColumnWidth} + * and {@code @HeadFontStyle} values, and {@code @FesodMarked.AliasFor} + * attribute overrides (explicit, defaulted, and same-name blank) flowing + * through to the written header cells. + */ +@Tag(Tags.ROUND_TRIP) +class ComposableAnnotationDataTest extends AbstractExcelTest { + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty("Meta Head") + @interface TitledColumn { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title() default "Alias Head"; + } + + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty("Meta Value Head") + @interface NamedColumn { + + // Blank attribute() aliases the same-named attribute of the meta-annotation + @FesodMarked.AliasFor(annotation = ExcelProperty.class) + String[] value() default {"Same Name Default Head"}; + } + + /** + * Composes several fesod annotations without any alias, relying on meta-declared values. + */ + @Target(ElementType.FIELD) + @Retention(RetentionPolicy.RUNTIME) + @FesodMarked + @ExcelProperty(value = "Composed Name", index = 0) + @ColumnWidth(20) + @HeadFontStyle(bold = BooleanEnum.TRUE, fontHeightInPoints = 20) + @interface MarkedNameColumn {} + + @Getter + @Setter + public static class TitledData { + + @TitledColumn + private String name; + + @NamedColumn("Explicit Head") + private String alias; + + @TitledColumn(title = "Custom Head") + private String customTitle; + } + + @Getter + @Setter + public static class StyledData { + + @MarkedNameColumn + private String name; + } + + private static TitledData titledData() { + TitledData data = new TitledData(); + data.setName("v1"); + data.setAlias("v2"); + data.setCustomTitle("v3"); + return data; + } + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void shouldApplyComposedAnnotationAttributesEndToEnd(ExcelFormat format) throws Exception { + File file = createTempFile(format); + StyledData data = new StyledData(); + data.setName("v1"); + + List result = RoundTripHelper.writeAndRead(file, StyledData.class, Collections.singletonList(data)); + Assertions.assertEquals("v1", result.get(0).getName()); + + try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { + ea.sheet(0) + .hasColumnWidth(0, 20 * 256) + .row(0) + .cell(0) + .hasStringValue("Composed Name") + .hasBoldFont(true) + .hasFontSize((short) 20); + } + } + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void shouldApplyExplicitAliasOverrideEndToEnd(ExcelFormat format) throws Exception { + File file = createTempFile(format); + FesodSheet.write(file, TitledData.class).sheet().doWrite(Collections.singletonList(titledData())); + + try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { + ea.sheet(0).row(0).cell(2).hasStringValue("Custom Head"); + } + } + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void shouldApplyAliasDefaultHeadOverMetaDeclaredHead(ExcelFormat format) throws Exception { + File file = createTempFile(format); + FesodSheet.write(file, TitledData.class).sheet().doWrite(Collections.singletonList(titledData())); + + // aliased attribute must win over the meta-declared "Meta Head" + try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { + ea.sheet(0).row(0).cell(0).hasStringValue("Alias Head"); + } + } + + @ParameterizedTest + @ExcelFormatSource(FormatScope.BINARY) + void shouldResolveBlankAliasAttributeToSameNamedAttribute(ExcelFormat format) throws Exception { + File file = createTempFile(format); + FesodSheet.write(file, TitledData.class).sheet().doWrite(Collections.singletonList(titledData())); + + try (ExcelAssertions ea = ExcelAssertions.assertThat(file)) { + ea.sheet(0).row(0).cell(1).hasStringValue("Explicit Head"); + } + } +} diff --git a/website/docs/sheet/help/annotation.md b/website/docs/sheet/help/annotation.md index 19287de69..e4957a90f 100644 --- a/website/docs/sheet/help/annotation.md +++ b/website/docs/sheet/help/annotation.md @@ -22,7 +22,7 @@ title: 'Annotation' # Annotation -This section describes how to read annotations provided in the project. +This section provides an overview of the core annotations available in FesodSheet, including their configuration options, usage, and support for composed meta-annotations. ## Entity Class Annotations @@ -157,3 +157,168 @@ Define a freeze pane for an Excel sheet. The parameters are as follows: | rowSplit | 0 | Vertical position of freeze pane. | | leftmostColumn | -1 | Left column visible in right pane. By default, it's equal to `colSplit`. | | topRow | -1 | Top row visible in bottom pane. By default, it's equal to `rowSplit`. | + +--- + +## Composing Annotation Configurations + +To improve configuration reusability and semantic clarity, FesodSheet introduces a meta-annotation mechanism: simply annotate a custom annotation with `@FesodMarked` to package multiple annotations¹ into a single, reusable business annotation. + +> Annotation¹: Includes FesodSheet's built-in annotations (except `@ExcelIgnore` and `@ExcelIgnoreUnannotated`), as well as third-party annotations (retrievable only via `AnnotatedElementUtils` APIs; they do not participate in FesodSheet's internal read/write operations). + +### Definition Patterns + +**1. Preset Template Pattern** + +Best suited for fixed configurations that do not require dynamic parameter passing. + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +// highlight-start +@FesodMarked +@ColumnWidth(25) +@NumberFormat("#,##0.00") +@ContentFontStyle(bold = BooleanEnum.TRUE) +// highlight-end +public @interface AmountColumn { +} +``` + +**2. Alias Mapping Pattern** + +When a custom annotation needs to accept parameters dynamically and forward/override them to target annotations, use `@FesodMarked.AliasFor` to establish attribute mappings. + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +// highlight-start +@FesodMarked +@ExcelProperty +@ColumnWidth +// highlight-end +public @interface CustomHeader { + + // highlight-next-line + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title() default ""; + + // highlight-next-line + @FesodMarked.AliasFor(annotation = ExcelProperty.class) + int index() default -1; + + // highlight-next-line + @FesodMarked.AliasFor(annotation = ColumnWidth.class, attribute = "value") + int width() default 20; +} +``` + +> Type Adaptation: If the target annotation attribute expects an array type (e.g., `ExcelProperty#value()` expects `String[]`), declaring a single scalar type (e.g., `String`) in your custom annotation will be automatically wrapped into a one-dimensional array by FesodSheet at runtime. + +**Alias Constraints:** + +- The target annotation of an alias must be declared on the composed annotation (e.g., `@ExcelProperty` and `@ColumnWidth` in the example above); otherwise, an `IllegalStateException` will be thrown during scanning. +- The `attribute` must be an existing attribute on the target annotation with a matching type (or eligible for scalar-to-array adaptation). +- When `attribute` is omitted, it defaults to **same-name mapping** (e.g., `index()` maps to `ExcelProperty#index()`). +- _Composed annotations can further compose other composed annotations (nested composition). When the same annotation type is declared multiple times across nested layers, the first declared instance takes precedence, and the rest are ignored entirely. (NOT RECOMMENDED)_ + +### Precedence and Override Rules + +When multiple layers of annotations or attributes with the same name coexist on an entity class field, FesodSheet follows the parsing principles below: + +- **Annotation Level:** Directly declared target annotation **>** Composed annotation. The directly declared target annotation wins entirely, and any matching target annotation inside composed annotations is completely ignored. +- **Composed Annotation Attribute Level:** Within an active composed annotation, `@FesodMarked.AliasFor` values (including defaults) **>** Static preset values. + +:::warning +Alias overriding with `@FesodMarked.AliasFor` is **unconditional**: even if an alias attribute is not explicitly assigned at the usage site (remaining at its default value), its default value will still override any static presets defined inside the composed annotation. + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = {"Preset NAME"}) +public @interface CustomHeader { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title() default "Aliased NAME"; +} +``` + +```java +// Header will be {"Aliased NAME"} instead of the preset {"Preset NAME"} +@CustomHeader +private String name; +``` + +Therefore, in practice, alias attributes should either: Have no default value (forcing explicit assignment at the usage site), or use a default value identical to the preset value. +::: + +#### Directly Declared Target Annotation + +```java +// Header is {"NAME"} +@ExcelProperty(value = {"NAME"}) +private String name; +``` + +#### Composed Annotation via @FesodMarked.AliasFor (Explicit or Default Values) + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty +public @interface CustomHeader { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title(); +} +``` + +```java +// Header is {"Aliased NAME"} +@CustomHeader(title = "Aliased NAME") +private String name; +``` + +> In the example above, `title()` has no default value, enforcing explicit parameter passing at the usage site and naturally preventing default values from unintentionally overriding static presets. + +#### Composed Annotation with Statically Preset Values + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = {"Preset NAME"}) +public @interface CustomHeader { +} +``` + +```java +// Header is {"Preset NAME"} +@CustomHeader +private String name; +``` + +#### Mixed Usage: Direct and Composed Annotations _(NOT RECOMMENDED)_ + +When a field directly declares the target annotation, the direct declaration wins completely: all declarations on composed annotations (both static presets and alias values) no longer participate. Attributes left unassigned in the direct declaration will not be backfilled by values from the composed annotation. + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = {"Preset NAME"}, index = 0) +public @interface CustomHeader { +} +``` + +```java +// Direct declaration wins completely: index takes the explicitly assigned 2; value remains at its default value. +// Result: index = 2, value = {""} +@ExcelProperty(index = 2) +@CustomHeader +private String name; +``` + +> Similarly, when multiple composed annotations declare the same target annotation, the **first declared one takes precedence**, and the others are ignored entirely. diff --git a/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/help/annotation.md b/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/help/annotation.md index e24e5e9eb..1455e6fa4 100644 --- a/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/help/annotation.md +++ b/website/i18n/zh-cn/docusaurus-plugin-content-docs/current/sheet/help/annotation.md @@ -22,7 +22,7 @@ title: '注解' # 注解 -本章节介绍读取 FesodSheet 中提供的注解。 +本节概述了 FesodSheet 中提供的核心注解,包括其配置项、使用以及组合元注解支持。 ## 实体类注解 @@ -151,3 +151,168 @@ title: '注解' | rowSplit | 0 | 冻结窗格的垂直位置(即需要冻结的行数) | | leftmostColumn | -1 | 右侧窗格中可见的最左侧列。默认情况下,该值等于 `colSplit` | | topRow | -1 | 底部窗格中可见的最顶部行。默认情况下,该值等于 `rowSplit` | + +--- + +## 组合注解配置 + +为了提升配置的复用性与语义化表达,FesodSheet 引入了元注解机制:只需在自定义注解上标注 `@FesodMarked`,即可将多个注解¹打包组合成一个可复用的业务注解。 + +> 注解¹:包括 FesodSheet 提供的内部注解(`@ExcelIgnore` 与 `@ExcelIgnoreUnannotated` 除外);也包括第三方注解(仅可通过 `AnnotatedElementUtils` 的 API 获取,不会参与 FesodSheet 自身的读写行为)。 + +### 定义模式 + +**1. 预设模版模式** + +适合固定格式无需动态传参的场景。 + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +// highlight-start +@FesodMarked +@ColumnWidth(25) +@NumberFormat("#,##0.00") +@ContentFontStyle(bold = BooleanEnum.TRUE) +// highlight-end +public @interface AmountColumn { +} +``` + +**2. 别名映射模式** + +当自定义注解需要动态接收参数并传递覆盖到目标注解时,可使用 `@FesodMarked.AliasFor` 建立属性映射。 + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +// highlight-start +@FesodMarked +@ExcelProperty +@ColumnWidth +// highlight-end +public @interface CustomHeader { + + // highlight-next-line + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title() default ""; + + // highlight-next-line + @FesodMarked.AliasFor(annotation = ExcelProperty.class) + int index() default -1; + + // highlight-next-line + @FesodMarked.AliasFor(annotation = ColumnWidth.class, attribute = "value") + int width() default 20; +} +``` + +> 类型自适应:若目标注解属性要求数组类型(如 `ExcelProperty#value()` 为 `String[]`),在自定义注解中声明单个标量类型(如 `String`)时,FesodSheet 会在运行时自动包装为一维数组。 + +**别名约束**: + +- 别名的目标注解必须声明在组合注解上(如上例中的 `@ExcelProperty`、`@ColumnWidth`),否则扫描时抛出 `IllegalStateException`; +- `attribute` 必须是目标注解真实存在的属性,且类型一致(或为"标量对应目标数组分量类型"的适配场景); +- `attribute` 留空时按**同名映射**处理(如上例 `index()` 即别名 `ExcelProperty#index()`); +- _组合注解可以再组合其他组合注解(嵌套组合)。同一注解类型被重复声明时,以最先声明的一次为准,其余整体忽略。(不推荐)_ + +### 优先级与覆盖规则 + +当实体类字段上同时存在多层注解或同名属性时,FesodSheet 遵循以下解析原则: + +- **注解级别:** 直接标注目标注解 **>** 标注组合注解。直接标注的目标注解整体胜出,组合注解内部对应的目标注解会被整体忽略。 +- **组合注解属性级别:** 在生效的组合注解内部,`@FesodMarked.AliasFor` 赋值(含默认值) **>** 静态预设值。 + +:::warning +`@FesodMarked.AliasFor` 的别名覆盖是**无条件**的:即使别名属性未显式赋值(处于默认值),其默认值也会覆盖组合注解内部的静态预设值。 + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = {"Preset NAME"}) +public @interface CustomHeader { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title() default "Aliased NAME"; +} +``` + +```java +// 表头为 {"Aliased NAME"} 而非预设的 {"Preset NAME"} +@CustomHeader +private String name; +``` + +因此实践中别名属性应当:要么不声明默认值(强制使用处显式赋值),要么让默认值与预设值保持一致。 +::: + +#### 字段直接标注目标注解 + +```java +// 表头为 {"NAME"} +@ExcelProperty(value = {"NAME"}) +private String name; +``` + +#### 字段标注组合注解(通过 `@FesodMarked.AliasFor` 显式赋值或默认值传递) + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty +public @interface CustomHeader { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title(); +} +``` + +```java +// 表头为 {"Aliased NAME"} +@CustomHeader(title = "Aliased NAME") +private String name; +``` + +> 上例中 `title()` 未声明默认值,可强制使用处显式传参,天然规避别名默认值覆盖静态预设值的问题。 + +#### 字段标注组合注解(通过内部静态预设值传递) + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = {"Preset NAME"}) +public @interface CustomHeader { +} +``` + +```java +// 表头为 {"Preset NAME"} +@CustomHeader +private String name; +``` + +#### 同字段混用:直接标注 + 组合注解 _(不推荐)_ + +字段直接标注目标注解时,**直接标注整体获胜**:组合注解对该注解的所有声明(静态预设、别名取值)均不再参与,包括直接标注中未显式赋值的属性,也不会被组合注解的取值补齐。 + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = {"Preset NAME"}, index = 0) +public @interface CustomHeader { +} +``` + +```java +// 直接标注整体生效:index 采用字段显式赋值的 2;未显式赋值的 value 保持默认; +// 结果:index = 2,value = {""} +@ExcelProperty(index = 2) +@CustomHeader +private String name; +``` + +> 同理,多个组合注解重复声明同一目标注解时,以**最先声明的一个**为准,其余整体忽略。