From b1df95468f3ff235755b695717c317f19ea772f4 Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Tue, 1 Sep 2026 20:15:14 +0800 Subject: [PATCH 01/12] feat: add composable-annotation parsing and access utility api support - Introduce two meta-annotations, @FesodMarked and @FesodMarked.AliasFor, to expand the Target - ElementType#ANNOTATION_TYPE restriction scope for other internal annotations except @ExcelIgnore and @ExcelIgnoreUnannotated, making it convenient for users to define custom combined annotations. - Define AnnotatedElementUtils to refactor the internal annotation parsing and access. - Added unit tests for AnnotatedElementUtils, and covering: - Merge priority - AliasFor semantics - Synthesized proxies - Fail-fast validation --- .../fesod/sheet/annotation/AliasFor.java | 52 +++ .../annotation/AnnotatedElementUtils.java | 82 +++++ .../annotation/AnnotationAttributes.java | 181 ++++++++++ .../fesod/sheet/annotation/AnnotationMap.java | 114 +++++++ .../sheet/annotation/AnnotationMetadata.java | 48 +++ .../annotation/AnnotationMetadataReader.java | 51 +++ .../AnnotationMetadataResolver.java | 134 ++++++++ .../sheet/annotation/AttributeMethods.java | 101 ++++++ .../fesod/sheet/annotation/ExcelProperty.java | 2 +- .../fesod/sheet/annotation/FesodMarked.java | 61 ++++ .../HierarchicalAnnotationScanner.java | 137 ++++++++ ...ynthesizedAnnotationInvocationHandler.java | 93 +++++ .../annotation/format/DateTimeFormat.java | 2 +- .../sheet/annotation/format/NumberFormat.java | 2 +- .../annotation/write/style/ColumnWidth.java | 2 +- .../write/style/ContentFontStyle.java | 2 +- .../write/style/ContentLoopMerge.java | 2 +- .../write/style/ContentRowHeight.java | 2 +- .../annotation/write/style/ContentStyle.java | 2 +- .../annotation/write/style/FreezePane.java | 2 +- .../annotation/write/style/HeadFontStyle.java | 2 +- .../annotation/write/style/HeadRowHeight.java | 2 +- .../annotation/write/style/HeadStyle.java | 2 +- .../write/style/OnceAbsoluteMerge.java | 2 +- .../annotation/AnnotatedElementUtilsTest.java | 319 ++++++++++++++++++ 25 files changed, 1386 insertions(+), 13 deletions(-) create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AliasFor.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtils.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationAttributes.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMap.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadata.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReader.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolver.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AttributeMethods.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/FesodMarked.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/HierarchicalAnnotationScanner.java create mode 100644 fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/SynthesizedAnnotationInvocationHandler.java create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtilsTest.java 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..ad182d5bb --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AliasFor.java @@ -0,0 +1,52 @@ +/* + * 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.AllArgsConstructor; +import lombok.Getter; + +/** + * A value object representing a declarative attribute aliasing instruction. + */ +@AllArgsConstructor +@Getter +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; +} 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..1660b2285 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationAttributes.java @@ -0,0 +1,181 @@ +/* + * 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.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.ClassUtils; +import org.apache.commons.lang3.Validate; + +/** + * Implement key-value pairs of annotation attributes based on {@link LinkedHashMap}. + */ +@Getter +@EqualsAndHashCode(callSuper = true) +public class AnnotationAttributes extends LinkedHashMap { + + private final Class annotationType; + private final String annotationName; + private final Set defaultValueAttrNames; + + @Setter + private int distance; + + public AnnotationAttributes( + Class annotationType, Map attrs, Set defaultValueAttrNames) { + super(attrs); + this.annotationType = annotationType; + this.annotationName = annotationType.getName(); + this.defaultValueAttrNames = CollectionUtils.isNotEmpty(defaultValueAttrNames) + ? new HashSet<>(defaultValueAttrNames) + : Collections.emptySet(); + this.distance = 0; + } + + public boolean isAnnotationTypeEqual(Class annotationType) { + return this.annotationType.equals(annotationType); + } + + public boolean isDefaultValue(String attributeName) { + return defaultValueAttrNames.contains(attributeName); + } + + public void markAsNonDefault(String attributeName) { + if (CollectionUtils.isNotEmpty(defaultValueAttrNames)) { + defaultValueAttrNames.remove(attributeName); + } + } + + public void merge(AnnotationAttributes other) { + if (other == null) { + return; + } + + if (distance < other.getDistance()) { + for (Map.Entry entry : other.entrySet()) { + String attrName = entry.getKey(); + + if (isDefaultValue(attrName) && !other.isDefaultValue(attrName)) { + put(attrName, entry.getValue()); + markAsNonDefault(attrName); + } + } + } else if (distance > other.getDistance()) { + distance = other.getDistance(); + for (Map.Entry entry : other.entrySet()) { + String attrName = entry.getKey(); + + if (!other.isDefaultValue(attrName)) { + put(attrName, entry.getValue()); + markAsNonDefault(attrName); + } + } + } + } + + @SuppressWarnings("unchecked") + public T getAttribute(String attrName, Class type) { + Object result = get(attrName); + 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]", + attrName, result.getClass().getSimpleName(), type.getSimpleName(), annotationName)); + } + + return (T) result; + } + + public T getRequiredAttribute(String attrName, Class type) { + Validate.notBlank(attrName, "attributeName must not be null or blank"); + T result = getAttribute(attrName, type); + if (Objects.isNull(result)) { + throw new IllegalArgumentException( + String.format("Attribute '%s' not found for annotation '%s'", attrName, annotationName)); + } + return result; + } + + @Override + public String toString() { + Iterator> i = entrySet().iterator(); + if (!i.hasNext()) return "@" + annotationName + "()"; + + StringBuilder sb = + new StringBuilder().append('@').append(annotationName).append('('); + + for (; ; ) { + Map.Entry e = i.next(); + String key = e.getKey(); + Object value = e.getValue(); + sb.append(key); + sb.append('='); + sb.append(toString(value)); + if (!i.hasNext()) return sb.append(')').toString(); + sb.append(',').append(' '); + } + } + + private String toString(Object value) { + Class type = value.getClass(); + if (type.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 (type == 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/AnnotationMap.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMap.java new file mode 100644 index 000000000..f9bea1de5 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMap.java @@ -0,0 +1,114 @@ +/* + * 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.Map; +import java.util.concurrent.ConcurrentHashMap; +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; + + public AnnotationMap(Map, AnnotationAttributes> annotations) { + this.annotations = annotations; + } + + public boolean isEmpty() { + return MapUtils.isEmpty(annotations); + } + + public int size() { + return annotations.size(); + } + + public boolean hasAnnotation(Class annotationType) { + return !isEmpty() && annotations.containsKey(annotationType); + } + + public AnnotationAttributes getAttributes(Class annotationType) { + if (isEmpty()) { + return null; + } + return annotations.get(annotationType); + } + + @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)); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private final Map, AnnotationAttributes> ann; + + public Builder() { + this.ann = new ConcurrentHashMap<>(8); + } + + public Builder put(Class annotationType, AnnotationAttributes attributes) { + Validate.notNull(annotationType, "annotationType must not be null"); + Validate.notNull(attributes, "attributes must not be null"); + + ann.put(annotationType, attributes); + return this; + } + + public Builder merge(Class annotationType, AnnotationAttributes attributes) { + Validate.notNull(annotationType, "annotationType must not be null"); + Validate.notNull(attributes, "attributes must not be null"); + + AnnotationAttributes oldAttrs = ann.get(annotationType); + if (oldAttrs == null) { + ann.put(annotationType, attributes); + } else { + oldAttrs.merge(attributes); + } + return this; + } + + public 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..2b2b6de9a --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadata.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.util.List; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * A wrapper class for resolved annotation instance. + */ +@EqualsAndHashCode +@Getter +class AnnotationMetadata { + + private final AnnotationAttributes attributes; + private final List aliases; + + public AnnotationMetadata(AnnotationAttributes attributes, List aliases) { + this.attributes = attributes; + this.aliases = aliases; + } + + public void addTo(List aliases) { + aliases.addAll(this.aliases); + } + + public void setDistance(int distance) { + attributes.setDistance(distance); + } +} 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..26645b4b2 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolver.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.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +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<>(); + Set defaultAttrNames = new HashSet<>(); + 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); + Object defaultValue = method.getDefaultValue(); + if (defaultValue != null && Objects.deepEquals(result, defaultValue)) { + defaultAttrNames.add(attrName); + } + + // Handle @FesodMarked.AliasFor + if (isMetaAlias(method)) { + FesodMarked.AliasFor aliasFor = method.getAnnotation(FesodMarked.AliasFor.class); + + AttributeMethods targetAttrMethods = markedAnnMap.get(aliasFor.annotation()); + if (targetAttrMethods == null) { + 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)); + } + 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, defaultAttrNames), 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..25eb81020 --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/HierarchicalAnnotationScanner.java @@ -0,0 +1,137 @@ +/* + * 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.ArrayList; +import java.util.Arrays; +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<>(Arrays.asList(annotations)); + // Record visited annotation, to avoid circular dependencies (like: @A -> @B, @B -> @A) + Set> visited = new HashSet<>(); + List aliases = new ArrayList<>(); + int distance = 0; + + while (!queue.isEmpty()) { + int currLevelSize = queue.size(); + + for (int i = 0; i < currLevelSize; i++) { + Annotation ann = queue.poll(); + Class type = ann.annotationType(); + + if (metadataResolver.shouldIgnore(type)) { + continue; + } + + AnnotationMetadata metadata = metadataResolver.resolve(ann); + metadata.setDistance(distance); + + // Handle composable-annotations (low-level attribute value) + if (metadataResolver.isMetaMarked(ann)) { + metadata.addTo(aliases); + + if (visited.add(type)) { + for (Annotation metaAnn : type.getAnnotations()) { + if (metadataResolver.shouldIgnore(metaAnn.annotationType())) { + continue; + } + queue.add(metaAnn); + } + } + } + + builder.merge(type, metadata.getAttributes()); + } + + distance++; + } + + AnnotationMap annotationMap = builder.build(); + + // Handle alias + handleAliasesIfNecessary(annotationMap, aliases); + + return annotationMap; + } + + /** + * Handle the mapping and overriding logic of annotation attribute aliases (AliasFor). + *

+ * Attribute Override Policy: Annotations closer to the annotated target (with a smaller distance) have higher attribute priority + * and can override the properties aliased in their meta-annotations (with a larger distance). + *

+ * The judgment logic for distance is as follows: + *

    + *
  • marked distance == target distance: + * Both are at the same level (for example, peer declarations are made on the same target). In this case, + * there is no hierarchical override relationship between them.
  • + *
  • marked distance < target distance: + * The annotation that declares an alias (marked) is closer to the target (i.e., at the child annotation level) and + * has higher priority. In this case, the attribute values in the child annotation (marked) will override/sync to + * the corresponding attributes in the target meta-annotation (target).
  • + *
+ * + * @param annotationMap A collection of annotation attributes + * @param aliases Alias mapping list + */ + private void handleAliasesIfNecessary(AnnotationMap annotationMap, List aliases) { + if (CollectionUtils.isEmpty(aliases)) { + return; + } + + for (AliasFor alias : aliases) { + AnnotationAttributes marked = annotationMap.getAttributes(alias.getMarked()); + AnnotationAttributes target = annotationMap.getAttributes(alias.getTarget()); + + if (marked == null || target == null) { + continue; + } + if ((marked.getDistance() + 1) <= target.getDistance()) { + target.put(alias.getAttribute(), marked.get(alias.getCustomAttribute())); + target.markAsNonDefault(alias.getAttribute()); + } + } + } +} 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..a52f75c8c --- /dev/null +++ b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/SynthesizedAnnotationInvocationHandler.java @@ -0,0 +1,93 @@ +/* + * 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.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Map; +import java.util.Objects; + +/** + * {@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 attributes.hashCode(); + case "toString": + return attributes.toString(); + } + } + if ("equals".equals(method.getName()) && method.getParameterCount() == 1) { + Object other = args[0]; + 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.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; + } + + throw new UnsupportedOperationException( + String.format("Method [%s] is unsupported for synthesized annotation type [%s]", method, this.type)); + } +} 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/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..cd219c6fa --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtilsTest.java @@ -0,0 +1,319 @@ +/* + * 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"; + } + + @MetaProperty + private String composedOnly; + + @ExcelProperty(index = 5) + @MetaProperty + 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 = 2, value = "proxy") + private String proxySource; + + 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 shouldPreferDirectExplicitAttributesOverMetaDeclaredOnes() throws Exception { + Field field = field("directAndComposed"); + + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class); + Assertions.assertEquals(5, merged.index()); + // attributes left at default on the direct usage are still filled by the meta-declaration + Assertions.assertArrayEquals(new String[] {"meta-head"}, merged.value()); + + AnnotationAttributes attributes = + AnnotatedElementUtils.getMergedAnnotationAttributes(field, ExcelProperty.class); + Assertions.assertNotNull(attributes); + Assertions.assertEquals(Integer.valueOf(5), attributes.getRequiredAttribute("index", Integer.class)); + } + + @Test + void shouldMergeMarkedAnnotationAcrossDirectAndMetaOccurrences() throws Exception { + Field field = field("layered"); + + // column is at its default (42) on the direct usage, so the meta-declared column=8 + // must fill it, and the alias must propagate the merged value (not 42) to ExcelProperty + Assertions.assertEquals( + 8, + AnnotatedElementUtils.getMergedAnnotation(field, LayeredProperty.class) + .column()); + Assertions.assertEquals( + 8, + 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 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)); + Assertions.assertEquals(merged.hashCode(), merged.hashCode()); + Assertions.assertFalse(merged.equals(null)); + Assertions.assertTrue(merged.toString().contains("proxy")); + } + + @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); + } +} From 00362d5cd201673632ffb2d5101211fda647770f Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Wed, 2 Sep 2026 16:48:00 +0800 Subject: [PATCH 02/12] refactor: encapsulate AnnotationAttributes and restrict AnnotationMap mutation access --- .../annotation/AnnotationAttributes.java | 66 ++++++++++++------- .../fesod/sheet/annotation/AnnotationMap.java | 6 +- .../HierarchicalAnnotationScanner.java | 2 +- ...ynthesizedAnnotationInvocationHandler.java | 2 +- 4 files changed, 48 insertions(+), 28 deletions(-) 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 index 1660b2285..9ab96ffdb 100644 --- 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 @@ -28,6 +28,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; @@ -36,27 +37,34 @@ import org.apache.commons.lang3.Validate; /** - * Implement key-value pairs of annotation attributes based on {@link LinkedHashMap}. + * Resolved key-value pairs attributes for an annotation. + * Provides type-safe lookup of annotation attributes. */ -@Getter -@EqualsAndHashCode(callSuper = true) -public class AnnotationAttributes extends LinkedHashMap { +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class AnnotationAttributes { + @Getter + @EqualsAndHashCode.Include private final Class annotationType; + + @Getter private final String annotationName; + + @EqualsAndHashCode.Include + private final Map attributes; + private final Set defaultValueAttrNames; - @Setter + @Getter(AccessLevel.PACKAGE) + @Setter(AccessLevel.PACKAGE) private int distance; - public AnnotationAttributes( + AnnotationAttributes( Class annotationType, Map attrs, Set defaultValueAttrNames) { - super(attrs); this.annotationType = annotationType; this.annotationName = annotationType.getName(); - this.defaultValueAttrNames = CollectionUtils.isNotEmpty(defaultValueAttrNames) - ? new HashSet<>(defaultValueAttrNames) - : Collections.emptySet(); + this.attributes = new LinkedHashMap<>(attrs); + this.defaultValueAttrNames = new HashSet<>(defaultValueAttrNames); this.distance = 0; } @@ -64,23 +72,23 @@ public boolean isAnnotationTypeEqual(Class annotationType) return this.annotationType.equals(annotationType); } - public boolean isDefaultValue(String attributeName) { + boolean isDefaultValue(String attributeName) { return defaultValueAttrNames.contains(attributeName); } - public void markAsNonDefault(String attributeName) { + void markAsNonDefault(String attributeName) { if (CollectionUtils.isNotEmpty(defaultValueAttrNames)) { defaultValueAttrNames.remove(attributeName); } } - public void merge(AnnotationAttributes other) { + void merge(AnnotationAttributes other) { if (other == null) { return; } if (distance < other.getDistance()) { - for (Map.Entry entry : other.entrySet()) { + for (Map.Entry entry : other.attributes.entrySet()) { String attrName = entry.getKey(); if (isDefaultValue(attrName) && !other.isDefaultValue(attrName)) { @@ -90,7 +98,7 @@ public void merge(AnnotationAttributes other) { } } else if (distance > other.getDistance()) { distance = other.getDistance(); - for (Map.Entry entry : other.entrySet()) { + for (Map.Entry entry : other.attributes.entrySet()) { String attrName = entry.getKey(); if (!other.isDefaultValue(attrName)) { @@ -101,9 +109,17 @@ public void merge(AnnotationAttributes other) { } } + void put(String attrName, Object value) { + attributes.put(attrName, value); + } + + public Object getAttribute(String attributeName) { + return attributes.get(attributeName); + } + @SuppressWarnings("unchecked") - public T getAttribute(String attrName, Class type) { - Object result = get(attrName); + public T getAttribute(String attributeName, Class type) { + Object result = getAttribute(attributeName); if (Objects.isNull(result)) { return null; } @@ -120,25 +136,29 @@ public T getAttribute(String attrName, Class type) { if (!wrapped.isInstance(result)) { throw new IllegalArgumentException(String.format( "Attribute '%s' is of type %s, but %s was expected for annotation [%s]", - attrName, result.getClass().getSimpleName(), type.getSimpleName(), annotationName)); + attributeName, result.getClass().getSimpleName(), type.getSimpleName(), annotationName)); } return (T) result; } - public T getRequiredAttribute(String attrName, Class type) { - Validate.notBlank(attrName, "attributeName must not be null or blank"); - T result = getAttribute(attrName, type); + 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'", attrName, annotationName)); + String.format("Attribute '%s' not found for annotation '%s'", attributeName, annotationName)); } return result; } + public Map asImmutableMap() { + return Collections.unmodifiableMap(attributes); + } + @Override public String toString() { - Iterator> i = entrySet().iterator(); + Iterator> i = attributes.entrySet().iterator(); if (!i.hasNext()) return "@" + annotationName + "()"; StringBuilder sb = 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 index f9bea1de5..cfba82479 100644 --- 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 @@ -40,7 +40,7 @@ public class AnnotationMap { private final Map, AnnotationAttributes> annotations; - public AnnotationMap(Map, AnnotationAttributes> annotations) { + AnnotationMap(Map, AnnotationAttributes> annotations) { this.annotations = annotations; } @@ -75,11 +75,11 @@ public T synthesize(Class annotationType) { new SynthesizedAnnotationInvocationHandler(annotationType, attributes)); } - public static Builder builder() { + static Builder builder() { return new Builder(); } - public static class Builder { + static class Builder { private final Map, AnnotationAttributes> ann; public Builder() { 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 index 25eb81020..531dc6f8e 100644 --- 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 @@ -129,7 +129,7 @@ private void handleAliasesIfNecessary(AnnotationMap annotationMap, List entry : attributes.entrySet()) { + for (Map.Entry entry : attributes.asImmutableMap().entrySet()) { try { Method m = attributeMethods.getMethod(entry.getKey()); if (!Objects.deepEquals(entry.getValue(), m.invoke(other))) { From 9b374341f6f38866b86ef961b8e8455d2179f474 Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Wed, 2 Sep 2026 17:07:13 +0800 Subject: [PATCH 03/12] feat: add conditional judgment --- .../fesod/sheet/annotation/AnnotationMetadataResolver.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 26645b4b2..ee0c13084 100644 --- 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 @@ -104,6 +104,12 @@ public AnnotationMetadata resolve(Annotation ann) { 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(), From b3787298e998647006dff1dee85a2ff2a751a2f6 Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Wed, 2 Sep 2026 19:22:54 +0800 Subject: [PATCH 04/12] fix: equals/hashCode issue for annotation dynamic proxies --- .../annotation/AnnotationAttributes.java | 44 ------ ...ynthesizedAnnotationInvocationHandler.java | 149 +++++++++++++++--- .../annotation/AnnotatedElementUtilsTest.java | 4 +- 3 files changed, 127 insertions(+), 70 deletions(-) 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 index 9ab96ffdb..38df8d585 100644 --- 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 @@ -23,7 +23,6 @@ import java.lang.reflect.Array; import java.util.Collections; import java.util.HashSet; -import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -155,47 +154,4 @@ public T getRequiredAttribute(String attributeName, Class type) { public Map asImmutableMap() { return Collections.unmodifiableMap(attributes); } - - @Override - public String toString() { - Iterator> i = attributes.entrySet().iterator(); - if (!i.hasNext()) return "@" + annotationName + "()"; - - StringBuilder sb = - new StringBuilder().append('@').append(annotationName).append('('); - - for (; ; ) { - Map.Entry e = i.next(); - String key = e.getKey(); - Object value = e.getValue(); - sb.append(key); - sb.append('='); - sb.append(toString(value)); - if (!i.hasNext()) return sb.append(')').toString(); - sb.append(',').append(' '); - } - } - - private String toString(Object value) { - Class type = value.getClass(); - if (type.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 (type == 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/SynthesizedAnnotationInvocationHandler.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/SynthesizedAnnotationInvocationHandler.java index 3d5693350..527f4add4 100644 --- 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 @@ -20,11 +20,16 @@ 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 @@ -53,41 +58,135 @@ public Object invoke(Object proxy, Method method, Object[] args) throws Throwabl case "annotationType": return this.type; case "hashCode": - return attributes.hashCode(); + return handleHashCode(); case "toString": - return attributes.toString(); + return handleToString(); } } - if ("equals".equals(method.getName()) && method.getParameterCount() == 1) { - Object other = args[0]; - if (proxy == other) { - return true; + 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); } - if (!this.type.isInstance(other)) { + } + + 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; } - if (Proxy.isProxyClass(other.getClass())) { - InvocationHandler handler = Proxy.getInvocationHandler(other); - if (handler instanceof SynthesizedAnnotationInvocationHandler) { - return this.attributes.equals(((SynthesizedAnnotationInvocationHandler) handler).attributes); - } + } + 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(' '); + } + } - 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; + 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))); } - return true; + builder.append('}'); + return builder.toString(); } - - throw new UnsupportedOperationException( - String.format("Method [%s] is unsupported for synthesized annotation type [%s]", method, this.type)); + 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/test/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtilsTest.java b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotatedElementUtilsTest.java index cd219c6fa..bf8af6fb3 100644 --- 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 @@ -293,7 +293,9 @@ void shouldServeObjectMethodsOnSynthesizedAnnotation() throws Exception { Assertions.assertEquals( merged, AnnotatedElementUtils.getMergedAnnotation(field("proxySource"), ExcelProperty.class)); Assertions.assertEquals(merged, field("proxySource").getAnnotation(ExcelProperty.class)); - Assertions.assertEquals(merged.hashCode(), merged.hashCode()); + // 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")); } From 9644e50a571f0f875e0b9682586734faa35c416f Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Wed, 2 Sep 2026 19:29:51 +0800 Subject: [PATCH 05/12] refactor: replace direct getAnnotation calls with AnnotatedElementUtils --- .../apache/fesod/sheet/util/ClassUtils.java | 31 ++++++++++--------- .../property/ExcelWriteHeadProperty.java | 29 ++++++++++------- .../AnnotationAttributesTestSupport.java | 30 ++++++++++++++++++ .../fesod/sheet/readwrite/CacheDataTest.java | 14 ++++----- 4 files changed, 70 insertions(+), 34 deletions(-) create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTestSupport.java 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/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/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 From bbd66a98c23d199d78f20d0635b1099310c5583c Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Thu, 3 Sep 2026 11:25:57 +0800 Subject: [PATCH 06/12] feat: add integration tests for composable-annotations --- .../ComposableAnnotationDataTest.java | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/readwrite/ComposableAnnotationDataTest.java 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"); + } + } +} From 9acc9f965f4ca6b630972005b6aa8bcb24716aa2 Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Thu, 3 Sep 2026 14:01:48 +0800 Subject: [PATCH 07/12] docs(javadoc): add some Javadoc --- .../annotation/AnnotationAttributes.java | 42 +++++++++++++++++++ .../fesod/sheet/annotation/AnnotationMap.java | 21 ++++++++++ 2 files changed, 63 insertions(+) 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 index 38df8d585..035e881b5 100644 --- 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 @@ -42,18 +42,34 @@ @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; + /** + * Attribute names whose values match their declared defaults. + */ private final Set defaultValueAttrNames; + /** + * The distance of this annotation from the root annotated element. + *

A value of {@code 0} indicates that the annotation is directly declared on the element. + */ @Getter(AccessLevel.PACKAGE) @Setter(AccessLevel.PACKAGE) private int distance; @@ -112,10 +128,24 @@ 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); @@ -141,6 +171,15 @@ public T getAttribute(String attributeName, Class type) { 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); @@ -151,6 +190,9 @@ public T getRequiredAttribute(String attributeName, Class type) { 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 index cfba82479..aa30c8372 100644 --- 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 @@ -44,18 +44,33 @@ public class AnnotationMap { 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; @@ -63,6 +78,12 @@ public AnnotationAttributes getAttributes(Class annotation 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); From 8ac3ce667fb8c4d0af2bcfa2bb478ad247741af3 Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Fri, 4 Sep 2026 12:35:43 +0800 Subject: [PATCH 08/12] test: add more coverage test --- .../annotation/AnnotationAttributesTest.java | 88 +++++++++++++++++++ .../sheet/annotation/AnnotationMapTest.java | 73 +++++++++++++++ .../AnnotationMetadataReaderTest.java | 48 ++++++++++ .../AnnotationMetadataResolverTest.java | 42 +++++++++ 4 files changed, 251 insertions(+) create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTest.java create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMapTest.java create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataReaderTest.java create mode 100644 fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolverTest.java 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..5ade5a7ef --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationAttributesTest.java @@ -0,0 +1,88 @@ +/* + * 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.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +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); + Set defaults = new HashSet<>(Collections.singleton("order")); + + AnnotationAttributes attributes = new AnnotationAttributes(ExcelProperty.class, attrs, defaults); + attrs.put("index", 99); + defaults.add("value"); + + Assertions.assertEquals(Integer.valueOf(2), attributes.getRequiredAttribute("index", Integer.class)); + Assertions.assertFalse(attributes.isDefaultValue("value")); + } + + @Test + void shouldIgnoreDistanceAndDefaultTrackingInEquality() { + AnnotationAttributes near = newAnnotationAttributes(2); + AnnotationAttributes far = newAnnotationAttributes(2); + far.setDistance(3); + far.markAsNonDefault("index"); + + 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, Collections.singleton("index")); + } +} 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..278c995fc --- /dev/null +++ b/fesod-sheet/src/test/java/org/apache/fesod/sheet/annotation/AnnotationMapTest.java @@ -0,0 +1,73 @@ +/* + * 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.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +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 shouldMergeDuplicateTypeEntriesAcrossDistances() { + AnnotationAttributes near = attributes(2, new String[] {"near"}, Collections.singleton("value")); + near.setDistance(0); + AnnotationAttributes far = attributes(3, new String[] {"meta-head"}, Collections.emptySet()); + far.setDistance(1); + + AnnotationMap map = AnnotationMap.builder() + .merge(ExcelProperty.class, near) + .merge(ExcelProperty.class, far) + .build(); + + AnnotationAttributes merged = map.getAttributes(ExcelProperty.class); + Assertions.assertNotNull(merged); + // the closer entry's explicit value wins, and its defaulted attribute is + // filled by the farther declaration + Assertions.assertEquals(2, merged.getRequiredAttribute("index", Integer.class)); + Assertions.assertArrayEquals(new String[] {"meta-head"}, merged.getRequiredAttribute("value", String[].class)); + Assertions.assertNotNull(map.synthesize(ExcelProperty.class)); + } + + private AnnotationAttributes attributes(int index, String[] value, Set defaults) { + Map attrs = new LinkedHashMap<>(); + attrs.put("index", index); + attrs.put("value", value); + return new AnnotationAttributes(ExcelProperty.class, attrs, defaults); + } +} 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)); + } +} From 01453ade363d6842112bdf02fcf39906d7554752 Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Fri, 4 Sep 2026 15:30:11 +0800 Subject: [PATCH 09/12] fix: correct overriding policy handling in AliasFor --- .../HierarchicalAnnotationScanner.java | 15 +++++------ .../annotation/AnnotatedElementUtilsTest.java | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) 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 index 531dc6f8e..5c2f09ebb 100644 --- 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 @@ -104,13 +104,12 @@ protected AnnotationMap scan(AnnotatedElement element) { *

* The judgment logic for distance is as follows: *

    - *
  • marked distance == target distance: - * Both are at the same level (for example, peer declarations are made on the same target). In this case, - * there is no hierarchical override relationship between them.
  • - *
  • marked distance < target distance: - * The annotation that declares an alias (marked) is closer to the target (i.e., at the child annotation level) and - * has higher priority. In this case, the attribute values in the child annotation (marked) will override/sync to - * the corresponding attributes in the target meta-annotation (target).
  • + *
  • target distance ≥ marked distance + 1: the target's closest occurrence is the marked + * annotation's own meta-declaration (or deeper), so the alias value applies unconditionally, + * whether explicitly set or at its default.
  • + *
  • target distance < marked distance + 1: the target is also annotated closer to the + * element (for example directly on the field). The alias only fills attributes that the closer + * target usage left at default; explicitly set attributes keep their value.
  • *
* * @param annotationMap A collection of annotation attributes @@ -128,7 +127,7 @@ private void handleAliasesIfNecessary(AnnotationMap annotationMap, List Date: Fri, 4 Sep 2026 17:18:11 +0800 Subject: [PATCH 10/12] docs(website): add documentation for composable-annotations --- website/docs/sheet/help/annotation.md | 193 +++++++++++++++++- .../current/sheet/help/annotation.md | 193 +++++++++++++++++- 2 files changed, 384 insertions(+), 2 deletions(-) diff --git a/website/docs/sheet/help/annotation.md b/website/docs/sheet/help/annotation.md index 19287de69..14409690d 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,194 @@ 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 be composed within other composed annotations (nested composition) and are merged hierarchically based on declaration levels. _(NOT RECOMMENDED)_ + +### Precedence and Override Rules + +When multiple annotation layers or duplicate attributes coexist on an entity field, FesodSheet resolves conflicts at the **attribute level** following this precedence order: + +```text +[Direct target annotation on field] > [Parameter passed via @FesodMarked.AliasFor (explicit value or default)] > [Static preset inside composed annotation] +``` + +:::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. +::: + +#### Direct Target Annotation on Field + +```java +// Header is {"NAME"} +@ExcelProperty(value = {"NAME"}) +private String name; +``` + +#### Parameter passed via `@FesodMarked.AliasFor` (Explicit Value or Default) + +```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. + +#### Static Preset Inside Composed Annotation + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = {"Preset NAME"}) +public @interface CustomHeader { +} +``` + +```java +// Header is {"Preset NAME"} +@CustomHeader +private String name; +``` + +#### Mixing on the Same Field: Direct Annotation + Composed Annotation _(NOT RECOMMENDED)_ + +Precedence resolution does not shadow the entire annotation; instead, attributes are merged attribute-by-attribute: + +- Attributes **explicitly assigned** in higher-precedence annotations remain unchanged; +- Attributes **not explicitly assigned** are filled by lower-precedence sources. Both **aliased values** (whether explicit or default) and static presets (requiring explicit definition) from composed annotations participate in the fallback population. + +**Static Presets** + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty(value = "Preset NAME", index = 0) +public @interface CustomHeader { +} +``` + +```java +// The explicitly assigned 'index' takes effect; the unassigned 'value' is populated by the preset from the composed annotation. +// Result: index = 2, value = {"Preset NAME"} +@ExcelProperty(index = 2) +@CustomHeader +private String name; +``` + +**Aliased Values (Explicit or Default)** + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty +public @interface CustomHeader { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title() default "Aliased NAME"; +} +``` + +```java +// The explicitly assigned 'index' takes effect; the unassigned 'value' is populated by the alias value from the composed annotation. +// Result: index = 2, value = {"Aliased NAME"} +@ExcelProperty(index = 2) +@CustomHeader +private String name; +``` 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..938e8cf88 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,194 @@ 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 按**属性粒度**遵循以下优先级: + +```text +字段直接标注目标注解 > 组合注解通过 @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 字段显式赋值直接生效;未显式赋值的 value 被组合注解的预设补齐 +// 结果:index = 2,value = {"Preset NAME"} +@ExcelProperty(index = 2) +@CustomHeader +private String name; +``` + +**别名值(无论显式赋值还是默认值)** + +```java +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@FesodMarked +@ExcelProperty +public @interface CustomHeader { + + @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") + String title() default "Aliased NAME"; +} +``` + +```java +// index 字段显式赋值直接生效;未显式赋值的 value 被组合注解的别名值补齐 +// 结果:index = 2,value = {"Aliased NAME"} +@ExcelProperty(index = 2) +@CustomHeader +private String name; +``` From 826ec18af430856156a856ab7ee9e82581c73e0a Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Sat, 5 Sep 2026 14:58:35 +0800 Subject: [PATCH 11/12] refactor: redesign meta-annotation scanning and alias propagation pipeline --- .../fesod/sheet/annotation/AliasFor.java | 10 +- .../sheet/annotation/AnnotationMetadata.java | 35 ++++- .../AnnotationMetadataResolver.java | 3 +- .../HierarchicalAnnotationScanner.java | 125 +++++++++--------- .../annotation/AnnotatedElementUtilsTest.java | 31 ++++- 5 files changed, 130 insertions(+), 74 deletions(-) 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 index ad182d5bb..09dabfa42 100644 --- 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 @@ -20,14 +20,16 @@ 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 +@Getter(AccessLevel.PACKAGE) class AliasFor { /** @@ -49,4 +51,10 @@ class AliasFor { * 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/AnnotationMetadata.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadata.java index 2b2b6de9a..e918c5f77 100644 --- 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 @@ -20,6 +20,8 @@ package org.apache.fesod.sheet.annotation; import java.util.List; +import java.util.Objects; +import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -27,22 +29,43 @@ * A wrapper class for resolved annotation instance. */ @EqualsAndHashCode -@Getter +@Getter(AccessLevel.PACKAGE) class AnnotationMetadata { private final AnnotationAttributes attributes; private final List aliases; - public AnnotationMetadata(AnnotationAttributes attributes, List aliases) { + AnnotationMetadata(AnnotationAttributes attributes, List aliases) { this.attributes = attributes; this.aliases = aliases; } - public void addTo(List aliases) { - aliases.addAll(this.aliases); + void setDistance(int distance) { + attributes.setDistance(distance); } - public void setDistance(int distance) { - attributes.setDistance(distance); + void applyAliasFor(AliasFor aliasFor) { + if (!attributes.isAnnotationTypeEqual(aliasFor.getTarget())) { + return; + } + + attributes.put(aliasFor.getAttribute(), aliasFor.getValue()); + attributes.markAsNonDefault(aliasFor.getAttribute()); + + 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/AnnotationMetadataResolver.java b/fesod-sheet/src/main/java/org/apache/fesod/sheet/annotation/AnnotationMetadataResolver.java index ee0c13084..00c39d9d9 100644 --- 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 @@ -120,7 +120,8 @@ public AnnotationMetadata resolve(Annotation ann) { StringUtils.isNotBlank(aliasFor.attribute()) ? aliasFor.attribute() : attrName; targetAttrMethods.validateAliasFor(method, targetAttrName); - aliases.add(new AliasFor(ann.annotationType(), aliasFor.annotation(), attrName, targetAttrName)); + aliases.add(new AliasFor( + ann.annotationType(), aliasFor.annotation(), attrName, targetAttrName, result)); } attr.put(attrName, result); } catch (IllegalAccessException | InvocationTargetException ex) { 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 index 5c2f09ebb..f2d750f9d 100644 --- 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 @@ -21,8 +21,7 @@ import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; -import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; @@ -47,90 +46,90 @@ protected AnnotationMap scan(AnnotatedElement element) { } AnnotationMap.Builder builder = AnnotationMap.builder(); + Queue queue = new LinkedList<>(); - Queue queue = new LinkedList<>(Arrays.asList(annotations)); - // Record visited annotation, to avoid circular dependencies (like: @A -> @B, @B -> @A) - Set> visited = new HashSet<>(); - List aliases = new ArrayList<>(); - int distance = 0; + for (Annotation root : annotations) { + queue.add(new AnnotationNode(root, 0, Collections.emptyList())); + } while (!queue.isEmpty()) { - int currLevelSize = queue.size(); - - for (int i = 0; i < currLevelSize; i++) { - Annotation ann = queue.poll(); - Class type = ann.annotationType(); + AnnotationNode current = queue.poll(); + Class type = current.annotationType(); - if (metadataResolver.shouldIgnore(type)) { - continue; - } + if (metadataResolver.shouldIgnore(type)) { + continue; + } - AnnotationMetadata metadata = metadataResolver.resolve(ann); - metadata.setDistance(distance); + AnnotationMetadata metadata = metadataResolver.resolve(current.annotation); + metadata.setDistance(current.distance); - // Handle composable-annotations (low-level attribute value) - if (metadataResolver.isMetaMarked(ann)) { - metadata.addTo(aliases); + // Apply aliases + applyAliasesIfNecessary(metadata, current.aliases); - if (visited.add(type)) { - for (Annotation metaAnn : type.getAnnotations()) { - if (metadataResolver.shouldIgnore(metaAnn.annotationType())) { - continue; - } - queue.add(metaAnn); - } + // Handle composable-annotations (low-level attribute value) + if (metadataResolver.isMetaMarked(current.annotation)) { + for (Annotation metaAnn : type.getAnnotations()) { + if (metadataResolver.shouldIgnore(metaAnn.annotationType()) + || current.isVisited(metaAnn.annotationType())) { + continue; } + queue.add(current.next(metaAnn, metadata.getAliases())); } - - builder.merge(type, metadata.getAttributes()); } - distance++; + builder.merge(type, metadata.getAttributes()); } - AnnotationMap annotationMap = builder.build(); - - // Handle alias - handleAliasesIfNecessary(annotationMap, aliases); - - return annotationMap; + return builder.build(); } /** - * Handle the mapping and overriding logic of annotation attribute aliases (AliasFor). - *

- * Attribute Override Policy: Annotations closer to the annotated target (with a smaller distance) have higher attribute priority - * and can override the properties aliased in their meta-annotations (with a larger distance). - *

- * The judgment logic for distance is as follows: - *

    - *
  • target distance ≥ marked distance + 1: the target's closest occurrence is the marked - * annotation's own meta-declaration (or deeper), so the alias value applies unconditionally, - * whether explicitly set or at its default.
  • - *
  • target distance < marked distance + 1: the target is also annotated closer to the - * element (for example directly on the field). The alias only fills attributes that the closer - * target usage left at default; explicitly set attributes keep their value.
  • - *
+ * Apply alias mapping rules ({@link AliasFor}) to the target annotation metadata. * - * @param annotationMap A collection of annotation attributes - * @param aliases Alias mapping list + * @param metadata the target annotation metadata + * @param aliases the aliases inherited from the declaring annotation */ - private void handleAliasesIfNecessary(AnnotationMap annotationMap, List aliases) { + private void applyAliasesIfNecessary(AnnotationMetadata metadata, List aliases) { if (CollectionUtils.isEmpty(aliases)) { return; } - for (AliasFor alias : aliases) { - AnnotationAttributes marked = annotationMap.getAttributes(alias.getMarked()); - AnnotationAttributes target = annotationMap.getAttributes(alias.getTarget()); + for (AliasFor aliasFor : aliases) { + metadata.applyAliasFor(aliasFor); + } + } - if (marked == null || target == null) { - continue; - } - if ((marked.getDistance() + 1) <= target.getDistance() || target.isDefaultValue(alias.getAttribute())) { - target.put(alias.getAttribute(), marked.getAttribute(alias.getCustomAttribute())); - target.markAsNonDefault(alias.getAttribute()); - } + private static class AnnotationNode { + final Annotation annotation; + final int distance; + final List aliases; + // Record visited annotation, to avoid circular dependencies (like: @A -> @B, @B -> @A) + final Set> path; + + AnnotationNode( + Annotation annotation, int distance, List aliases, Set> path) { + this.annotation = annotation; + this.distance = distance; + this.aliases = aliases; + this.path = path; + } + + AnnotationNode(Annotation annotation, int distance, List aliases) { + this(annotation, distance, 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, distance + 1, aliases, fullPath); } } } 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 index 278e7c6c8..d5b9f3956 100644 --- 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 @@ -148,6 +148,24 @@ class AnnotatedElementUtilsTest { int column() default 42; } + @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; @@ -190,6 +208,9 @@ class AnnotatedElementUtilsTest { @ExcelProperty(index = 2, value = "proxy") private String proxySource; + @Outer(title = "chained") + private String chained; + private String unannotated; @Test @@ -221,14 +242,12 @@ void shouldPreferDirectExplicitAttributesOverMetaDeclaredOnes() throws Exception void shouldMergeMarkedAnnotationAcrossDirectAndMetaOccurrences() throws Exception { Field field = field("layered"); - // column is at its default (42) on the direct usage, so the meta-declared column=8 - // must fill it, and the alias must propagate the merged value (not 42) to ExcelProperty Assertions.assertEquals( 8, AnnotatedElementUtils.getMergedAnnotation(field, LayeredProperty.class) .column()); Assertions.assertEquals( - 8, + 42, AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class) .index()); } @@ -327,6 +346,12 @@ void shouldServeObjectMethodsOnSynthesizedAnnotation() throws Exception { 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"); From 1d1eac57996f2a97a0e72869ab70613e8bccb43c Mon Sep 17 00:00:00 2001 From: Bengbengbalabalabeng Date: Sat, 5 Sep 2026 22:13:43 +0800 Subject: [PATCH 12/12] refactor: unify duplicate annotations on first-occurrence-wins Simplify the handling of annotation types declared more than once during scanning into a single rule: the first occurrence wins wholesale. --- .../annotation/AnnotationAttributes.java | 60 +------------------ .../fesod/sheet/annotation/AnnotationMap.java | 25 ++------ .../sheet/annotation/AnnotationMetadata.java | 6 -- .../AnnotationMetadataResolver.java | 10 +--- .../HierarchicalAnnotationScanner.java | 19 +++--- .../annotation/AnnotatedElementUtilsTest.java | 51 ++++++++++++---- .../annotation/AnnotationAttributesTest.java | 14 +---- .../sheet/annotation/AnnotationMapTest.java | 27 +++------ website/docs/sheet/help/annotation.md | 52 ++++------------ .../current/sheet/help/annotation.md | 48 ++++----------- 10 files changed, 93 insertions(+), 219 deletions(-) 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 index 035e881b5..8ca3bae07 100644 --- 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 @@ -22,16 +22,11 @@ import java.lang.annotation.Annotation; import java.lang.reflect.Array; import java.util.Collections; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; -import java.util.Set; -import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; -import lombok.Setter; -import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.ClassUtils; import org.apache.commons.lang3.Validate; @@ -61,69 +56,16 @@ public class AnnotationAttributes { @EqualsAndHashCode.Include private final Map attributes; - /** - * Attribute names whose values match their declared defaults. - */ - private final Set defaultValueAttrNames; - - /** - * The distance of this annotation from the root annotated element. - *

A value of {@code 0} indicates that the annotation is directly declared on the element. - */ - @Getter(AccessLevel.PACKAGE) - @Setter(AccessLevel.PACKAGE) - private int distance; - - AnnotationAttributes( - Class annotationType, Map attrs, Set defaultValueAttrNames) { + AnnotationAttributes(Class annotationType, Map attrs) { this.annotationType = annotationType; this.annotationName = annotationType.getName(); this.attributes = new LinkedHashMap<>(attrs); - this.defaultValueAttrNames = new HashSet<>(defaultValueAttrNames); - this.distance = 0; } public boolean isAnnotationTypeEqual(Class annotationType) { return this.annotationType.equals(annotationType); } - boolean isDefaultValue(String attributeName) { - return defaultValueAttrNames.contains(attributeName); - } - - void markAsNonDefault(String attributeName) { - if (CollectionUtils.isNotEmpty(defaultValueAttrNames)) { - defaultValueAttrNames.remove(attributeName); - } - } - - void merge(AnnotationAttributes other) { - if (other == null) { - return; - } - - if (distance < other.getDistance()) { - for (Map.Entry entry : other.attributes.entrySet()) { - String attrName = entry.getKey(); - - if (isDefaultValue(attrName) && !other.isDefaultValue(attrName)) { - put(attrName, entry.getValue()); - markAsNonDefault(attrName); - } - } - } else if (distance > other.getDistance()) { - distance = other.getDistance(); - for (Map.Entry entry : other.attributes.entrySet()) { - String attrName = entry.getKey(); - - if (!other.isDefaultValue(attrName)) { - put(attrName, entry.getValue()); - markAsNonDefault(attrName); - } - } - } - } - void put(String attrName, Object value) { attributes.put(attrName, value); } 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 index aa30c8372..468c1a8e7 100644 --- 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 @@ -23,8 +23,8 @@ import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Proxy; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import lombok.EqualsAndHashCode; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.Validate; @@ -103,32 +103,19 @@ static Builder builder() { static class Builder { private final Map, AnnotationAttributes> ann; - public Builder() { - this.ann = new ConcurrentHashMap<>(8); + Builder() { + this.ann = new LinkedHashMap<>(8); } - public Builder put(Class annotationType, AnnotationAttributes attributes) { + Builder putIfAbsent(Class annotationType, AnnotationAttributes attributes) { Validate.notNull(annotationType, "annotationType must not be null"); Validate.notNull(attributes, "attributes must not be null"); - ann.put(annotationType, attributes); + ann.putIfAbsent(annotationType, attributes); return this; } - public Builder merge(Class annotationType, AnnotationAttributes attributes) { - Validate.notNull(annotationType, "annotationType must not be null"); - Validate.notNull(attributes, "attributes must not be null"); - - AnnotationAttributes oldAttrs = ann.get(annotationType); - if (oldAttrs == null) { - ann.put(annotationType, attributes); - } else { - oldAttrs.merge(attributes); - } - return this; - } - - public AnnotationMap build() { + 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 index e918c5f77..b36473c34 100644 --- 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 @@ -40,18 +40,12 @@ class AnnotationMetadata { this.aliases = aliases; } - void setDistance(int distance) { - attributes.setDistance(distance); - } - void applyAliasFor(AliasFor aliasFor) { if (!attributes.isAnnotationTypeEqual(aliasFor.getTarget())) { return; } attributes.put(aliasFor.getAttribute(), aliasFor.getValue()); - attributes.markAsNonDefault(aliasFor.getAttribute()); - propagateAliasValue(aliasFor); } 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 index 00c39d9d9..4fab4f99f 100644 --- 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 @@ -25,12 +25,9 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import org.apache.fesod.common.util.StringUtils; @@ -85,7 +82,6 @@ public AnnotationMetadata resolve(Annotation ann) { } List aliases = new ArrayList<>(); - Set defaultAttrNames = new HashSet<>(); Map attr = new LinkedHashMap<>(); AttributeMethods attributeMethods = AttributeMethods.from(ann.annotationType()); @@ -93,10 +89,6 @@ public AnnotationMetadata resolve(Annotation ann) { String attrName = method.getName(); try { Object result = method.invoke(ann); - Object defaultValue = method.getDefaultValue(); - if (defaultValue != null && Objects.deepEquals(result, defaultValue)) { - defaultAttrNames.add(attrName); - } // Handle @FesodMarked.AliasFor if (isMetaAlias(method)) { @@ -132,7 +124,7 @@ public AnnotationMetadata resolve(Annotation ann) { ex); } } - return new AnnotationMetadata(new AnnotationAttributes(ann.annotationType(), attr, defaultAttrNames), aliases); + return new AnnotationMetadata(new AnnotationAttributes(ann.annotationType(), attr), aliases); } private boolean isMetaAlias(AnnotatedElement element) { 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 index f2d750f9d..76d7404c3 100644 --- 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 @@ -47,9 +47,11 @@ protected AnnotationMap scan(AnnotatedElement element) { AnnotationMap.Builder builder = AnnotationMap.builder(); Queue queue = new LinkedList<>(); + Set> rootAnnTypes = new HashSet<>(annotations.length); for (Annotation root : annotations) { - queue.add(new AnnotationNode(root, 0, Collections.emptyList())); + rootAnnTypes.add(root.annotationType()); + queue.add(new AnnotationNode(root, Collections.emptyList())); } while (!queue.isEmpty()) { @@ -61,7 +63,6 @@ protected AnnotationMap scan(AnnotatedElement element) { } AnnotationMetadata metadata = metadataResolver.resolve(current.annotation); - metadata.setDistance(current.distance); // Apply aliases applyAliasesIfNecessary(metadata, current.aliases); @@ -70,6 +71,7 @@ protected AnnotationMap scan(AnnotatedElement element) { if (metadataResolver.isMetaMarked(current.annotation)) { for (Annotation metaAnn : type.getAnnotations()) { if (metadataResolver.shouldIgnore(metaAnn.annotationType()) + || rootAnnTypes.contains(metaAnn.annotationType()) || current.isVisited(metaAnn.annotationType())) { continue; } @@ -77,7 +79,7 @@ protected AnnotationMap scan(AnnotatedElement element) { } } - builder.merge(type, metadata.getAttributes()); + builder.putIfAbsent(type, metadata.getAttributes()); } return builder.build(); @@ -101,21 +103,18 @@ private void applyAliasesIfNecessary(AnnotationMetadata metadata, List private static class AnnotationNode { final Annotation annotation; - final int distance; final List aliases; // Record visited annotation, to avoid circular dependencies (like: @A -> @B, @B -> @A) final Set> path; - AnnotationNode( - Annotation annotation, int distance, List aliases, Set> path) { + AnnotationNode(Annotation annotation, List aliases, Set> path) { this.annotation = annotation; - this.distance = distance; this.aliases = aliases; this.path = path; } - AnnotationNode(Annotation annotation, int distance, List aliases) { - this(annotation, distance, aliases, new HashSet<>()); + AnnotationNode(Annotation annotation, List aliases) { + this(annotation, aliases, new HashSet<>()); } Class annotationType() { @@ -129,7 +128,7 @@ boolean isVisited(Class type) { AnnotationNode next(Annotation annotation, List aliases) { Set> fullPath = new HashSet<>(path); fullPath.add(annotationType()); - return new AnnotationNode(annotation, distance + 1, aliases, fullPath); + return new AnnotationNode(annotation, aliases, fullPath); } } } 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 index d5b9f3956..85e5049f8 100644 --- 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 @@ -148,6 +148,19 @@ class AnnotatedElementUtilsTest { 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 @@ -169,8 +182,8 @@ class AnnotatedElementUtilsTest { @MetaProperty private String composedOnly; - @ExcelProperty(index = 5) @MetaProperty + @ExcelProperty(index = 5) private String directAndComposed; @LayeredProperty @@ -205,6 +218,10 @@ class AnnotatedElementUtilsTest { @SuppressedAliasProperty private String suppressedAlias; + @FirstDeclaredProperty + @SecondDeclaredProperty + private String duplicateTarget; + @ExcelProperty(index = 2, value = "proxy") private String proxySource; @@ -224,13 +241,12 @@ void shouldSurfaceMetaDeclaredAttributesThroughComposedAnnotation() throws Excep } @Test - void shouldPreferDirectExplicitAttributesOverMetaDeclaredOnes() throws Exception { + void shouldPreferDirectAnnotationOverComposedDeclaration() throws Exception { Field field = field("directAndComposed"); ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class); Assertions.assertEquals(5, merged.index()); - // attributes left at default on the direct usage are still filled by the meta-declaration - Assertions.assertArrayEquals(new String[] {"meta-head"}, merged.value()); + Assertions.assertArrayEquals(new String[] {""}, merged.value()); AnnotationAttributes attributes = AnnotatedElementUtils.getMergedAnnotationAttributes(field, ExcelProperty.class); @@ -239,11 +255,11 @@ void shouldPreferDirectExplicitAttributesOverMetaDeclaredOnes() throws Exception } @Test - void shouldMergeMarkedAnnotationAcrossDirectAndMetaOccurrences() throws Exception { + void shouldPreferDirectMarkedOccurrenceOverComposedPreset() throws Exception { Field field = field("layered"); Assertions.assertEquals( - 8, + 42, AnnotatedElementUtils.getMergedAnnotation(field, LayeredProperty.class) .column()); Assertions.assertEquals( @@ -266,13 +282,26 @@ void shouldApplyExplicitAliasOverride() throws Exception { } @Test - void shouldApplyAliasOnlyToDefaultedAttributesOfDirectlyAnnotatedTarget() throws Exception { - ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field("suppressedAlias"), ExcelProperty.class); + void shouldIgnoreComposedAliasesWhenTargetDirectlyAnnotated() throws Exception { + Field field = field("suppressedAlias"); - // the direct usage's explicit index wins over the aliased column=42, while its defaulted - // value is filled by the aliased head="Preset" — aliases merge per attribute + ExcelProperty merged = AnnotatedElementUtils.getMergedAnnotation(field, ExcelProperty.class); Assertions.assertEquals(5, merged.index()); - Assertions.assertArrayEquals(new String[] {"Preset"}, merged.value()); + 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 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 index 5ade5a7ef..4590b1e20 100644 --- 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 @@ -19,11 +19,8 @@ package org.apache.fesod.sheet.annotation; -import java.util.Collections; -import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; -import java.util.Set; import org.apache.fesod.sheet.testkit.Tags; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Tag; @@ -39,22 +36,17 @@ class AnnotationAttributesTest { void shouldIsolateFromCallerOwnedMaps() { Map attrs = new LinkedHashMap<>(); attrs.put("index", 2); - Set defaults = new HashSet<>(Collections.singleton("order")); - AnnotationAttributes attributes = new AnnotationAttributes(ExcelProperty.class, attrs, defaults); + AnnotationAttributes attributes = new AnnotationAttributes(ExcelProperty.class, attrs); attrs.put("index", 99); - defaults.add("value"); Assertions.assertEquals(Integer.valueOf(2), attributes.getRequiredAttribute("index", Integer.class)); - Assertions.assertFalse(attributes.isDefaultValue("value")); } @Test - void shouldIgnoreDistanceAndDefaultTrackingInEquality() { + void shouldEqualByAnnotationTypeAndAttributeValues() { AnnotationAttributes near = newAnnotationAttributes(2); AnnotationAttributes far = newAnnotationAttributes(2); - far.setDistance(3); - far.markAsNonDefault("index"); Assertions.assertEquals(near, far); Assertions.assertEquals(near.hashCode(), far.hashCode()); @@ -83,6 +75,6 @@ void shouldExposeReadOnlyAttributeMapView() { private AnnotationAttributes newAnnotationAttributes(int index) { Map attrs = new LinkedHashMap<>(); attrs.put("index", index); - return new AnnotationAttributes(ExcelProperty.class, attrs, Collections.singleton("index")); + return new AnnotationAttributes(ExcelProperty.class, attrs); } } 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 index 278c995fc..9cf301197 100644 --- 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 @@ -19,10 +19,8 @@ package org.apache.fesod.sheet.annotation; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; -import java.util.Set; import org.apache.fesod.sheet.testkit.Tags; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Tag; @@ -44,30 +42,23 @@ void shouldExposeAbsentSemanticsOnEmptyMap() { } @Test - void shouldMergeDuplicateTypeEntriesAcrossDistances() { - AnnotationAttributes near = attributes(2, new String[] {"near"}, Collections.singleton("value")); - near.setDistance(0); - AnnotationAttributes far = attributes(3, new String[] {"meta-head"}, Collections.emptySet()); - far.setDistance(1); - + void shouldKeepFirstOccurrenceForDuplicateTypes() { AnnotationMap map = AnnotationMap.builder() - .merge(ExcelProperty.class, near) - .merge(ExcelProperty.class, far) + .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); - // the closer entry's explicit value wins, and its defaulted attribute is - // filled by the farther declaration - Assertions.assertEquals(2, merged.getRequiredAttribute("index", Integer.class)); - Assertions.assertArrayEquals(new String[] {"meta-head"}, merged.getRequiredAttribute("value", String[].class)); - Assertions.assertNotNull(map.synthesize(ExcelProperty.class)); + 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, Set defaults) { + private AnnotationAttributes attributes(int index, String value) { Map attrs = new LinkedHashMap<>(); attrs.put("index", index); - attrs.put("value", value); - return new AnnotationAttributes(ExcelProperty.class, attrs, defaults); + attrs.put("value", new String[] {value}); + return new AnnotationAttributes(ExcelProperty.class, attrs); } } diff --git a/website/docs/sheet/help/annotation.md b/website/docs/sheet/help/annotation.md index 14409690d..e4957a90f 100644 --- a/website/docs/sheet/help/annotation.md +++ b/website/docs/sheet/help/annotation.md @@ -220,15 +220,14 @@ public @interface CustomHeader { - 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 be composed within other composed annotations (nested composition) and are merged hierarchically based on declaration levels. _(NOT RECOMMENDED)_ +- _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 annotation layers or duplicate attributes coexist on an entity field, FesodSheet resolves conflicts at the **attribute level** following this precedence order: +When multiple layers of annotations or attributes with the same name coexist on an entity class field, FesodSheet follows the parsing principles below: -```text -[Direct target annotation on field] > [Parameter passed via @FesodMarked.AliasFor (explicit value or default)] > [Static preset inside composed annotation] -``` +- **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. @@ -254,7 +253,7 @@ 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. ::: -#### Direct Target Annotation on Field +#### Directly Declared Target Annotation ```java // Header is {"NAME"} @@ -262,7 +261,7 @@ Therefore, in practice, alias attributes should either: Have no default value (f private String name; ``` -#### Parameter passed via `@FesodMarked.AliasFor` (Explicit Value or Default) +#### Composed Annotation via @FesodMarked.AliasFor (Explicit or Default Values) ```java @Target(ElementType.FIELD) @@ -284,7 +283,7 @@ 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. -#### Static Preset Inside Composed Annotation +#### Composed Annotation with Statically Preset Values ```java @Target(ElementType.FIELD) @@ -301,50 +300,25 @@ public @interface CustomHeader { private String name; ``` -#### Mixing on the Same Field: Direct Annotation + Composed Annotation _(NOT RECOMMENDED)_ - -Precedence resolution does not shadow the entire annotation; instead, attributes are merged attribute-by-attribute: - -- Attributes **explicitly assigned** in higher-precedence annotations remain unchanged; -- Attributes **not explicitly assigned** are filled by lower-precedence sources. Both **aliased values** (whether explicit or default) and static presets (requiring explicit definition) from composed annotations participate in the fallback population. +#### Mixed Usage: Direct and Composed Annotations _(NOT RECOMMENDED)_ -**Static Presets** +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) +@ExcelProperty(value = {"Preset NAME"}, index = 0) public @interface CustomHeader { } ``` ```java -// The explicitly assigned 'index' takes effect; the unassigned 'value' is populated by the preset from the composed annotation. -// Result: index = 2, value = {"Preset NAME"} +// 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; ``` -**Aliased Values (Explicit or Default)** - -```java -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -@FesodMarked -@ExcelProperty -public @interface CustomHeader { - - @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") - String title() default "Aliased NAME"; -} -``` - -```java -// The explicitly assigned 'index' takes effect; the unassigned 'value' is populated by the alias value from the composed annotation. -// Result: index = 2, value = {"Aliased NAME"} -@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 938e8cf88..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 @@ -214,15 +214,14 @@ public @interface CustomHeader { - 别名的目标注解必须声明在组合注解上(如上例中的 `@ExcelProperty`、`@ColumnWidth`),否则扫描时抛出 `IllegalStateException`; - `attribute` 必须是目标注解真实存在的属性,且类型一致(或为"标量对应目标数组分量类型"的适配场景); - `attribute` 留空时按**同名映射**处理(如上例 `index()` 即别名 `ExcelProperty#index()`); -- 组合注解可以再组合其他组合注解(嵌套组合),按声明层级逐级合并。_(不推荐)_ +- _组合注解可以再组合其他组合注解(嵌套组合)。同一注解类型被重复声明时,以最先声明的一次为准,其余整体忽略。(不推荐)_ ### 优先级与覆盖规则 -当实体类字段上同时存在多层注解或同名属性时,FesodSheet 按**属性粒度**遵循以下优先级: +当实体类字段上同时存在多层注解或同名属性时,FesodSheet 遵循以下解析原则: -```text -字段直接标注目标注解 > 组合注解通过 @FesodMarked.AliasFor(显式赋值或默认值)传参 > 组合注解内部静态预设值 -``` +- **注解级别:** 直接标注目标注解 **>** 标注组合注解。直接标注的目标注解整体胜出,组合注解内部对应的目标注解会被整体忽略。 +- **组合注解属性级别:** 在生效的组合注解内部,`@FesodMarked.AliasFor` 赋值(含默认值) **>** 静态预设值。 :::warning `@FesodMarked.AliasFor` 的别名覆盖是**无条件**的:即使别名属性未显式赋值(处于默认值),其默认值也会覆盖组合注解内部的静态预设值。 @@ -256,7 +255,7 @@ private String name; private String name; ``` -#### 组合注解通过 `@FesodMarked.AliasFor`(显式赋值或默认值)传参 +#### 字段标注组合注解(通过 `@FesodMarked.AliasFor` 显式赋值或默认值传递) ```java @Target(ElementType.FIELD) @@ -278,7 +277,7 @@ private String name; > 上例中 `title()` 未声明默认值,可强制使用处显式传参,天然规避别名默认值覆盖静态预设值的问题。 -#### 组合注解内部静态预设值 +#### 字段标注组合注解(通过内部静态预设值传递) ```java @Target(ElementType.FIELD) @@ -297,48 +296,23 @@ private String name; #### 同字段混用:直接标注 + 组合注解 _(不推荐)_ -优先级并非整注解遮蔽,而是逐属性合并: - -- 高优先级注解中**已显式赋值**的属性保持不变; -- **未显式赋值**的属性由低优先级取值补齐,组合注解的**别名值**(无论显式赋值还是默认值)与**静态预设**(需显式赋值)均参与补齐。 - -**静态预设** +字段直接标注目标注解时,**直接标注整体获胜**:组合注解对该注解的所有声明(静态预设、别名取值)均不再参与,包括直接标注中未显式赋值的属性,也不会被组合注解的取值补齐。 ```java @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @FesodMarked -@ExcelProperty(value = "Preset NAME", index = 0) +@ExcelProperty(value = {"Preset NAME"}, index = 0) public @interface CustomHeader { } ``` ```java -// index 字段显式赋值直接生效;未显式赋值的 value 被组合注解的预设补齐 -// 结果:index = 2,value = {"Preset NAME"} +// 直接标注整体生效:index 采用字段显式赋值的 2;未显式赋值的 value 保持默认; +// 结果:index = 2,value = {""} @ExcelProperty(index = 2) @CustomHeader private String name; ``` -**别名值(无论显式赋值还是默认值)** - -```java -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -@FesodMarked -@ExcelProperty -public @interface CustomHeader { - - @FesodMarked.AliasFor(annotation = ExcelProperty.class, attribute = "value") - String title() default "Aliased NAME"; -} -``` - -```java -// index 字段显式赋值直接生效;未显式赋值的 value 被组合注解的别名值补齐 -// 结果:index = 2,value = {"Aliased NAME"} -@ExcelProperty(index = 2) -@CustomHeader -private String name; -``` +> 同理,多个组合注解重复声明同一目标注解时,以**最先声明的一个**为准,其余整体忽略。