This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch WW-3871-typeconversion-key-derivation in repository https://gitbox.apache.org/repos/asf/struts.git
commit 217f74c75f378b8b0fb3d508d0715d8b1bfd5122 Author: Lukasz Lenart <[email protected]> AuthorDate: Sat Jul 25 15:34:28 2026 +0200 WW-3871 feat(core): support @TypeConversion on fields --- .../conversion/annotations/TypeConversion.java | 2 +- .../struts2/conversion/impl/XWorkConverter.java | 38 ++++++++++++++ .../conversion/impl/XWorkConverterTest.java | 15 ++++++ .../apache/struts2/util/FieldConversionAction.java | 58 ++++++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/apache/struts2/conversion/annotations/TypeConversion.java b/core/src/main/java/org/apache/struts2/conversion/annotations/TypeConversion.java index a28d8c987..749d715c9 100644 --- a/core/src/main/java/org/apache/struts2/conversion/annotations/TypeConversion.java +++ b/core/src/main/java/org/apache/struts2/conversion/annotations/TypeConversion.java @@ -150,7 +150,7 @@ import java.lang.annotation.Target; * @author Rainer Hermanns * @version $Id$ */ -@Target({ ElementType.METHOD}) +@Target({ElementType.METHOD, ElementType.FIELD}) @Retention(RetentionPolicy.RUNTIME) public @interface TypeConversion { diff --git a/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java b/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java index b0787735a..0dcd4fcaf 100644 --- a/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java +++ b/core/src/main/java/org/apache/struts2/conversion/impl/XWorkConverter.java @@ -42,8 +42,10 @@ import org.apache.logging.log4j.Logger; import org.apache.struts2.StrutsConstants; import java.lang.annotation.Annotation; +import java.lang.reflect.Field; import java.lang.reflect.Member; import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.net.URL; import java.text.MessageFormat; import java.util.ArrayList; @@ -524,6 +526,7 @@ public class XWorkConverter extends DefaultTypeConverter { processClassLevelAnnotations(mapping, clazz); processMethodAnnotations(mapping, clazz); + processFieldAnnotations(mapping, clazz); } /** @@ -578,6 +581,41 @@ public class XWorkConverter extends DefaultTypeConverter { } } + /** + * Registers {@link TypeConversion} annotations found on the class' own fields. Only declared + * fields are read: {@link #buildConverterMapping(Class)} already walks the class hierarchy and + * calls this method once per class. Static and synthetic fields are skipped, which also makes + * this a no-op for interfaces. + */ + private void processFieldAnnotations(Map<String, Object> mapping, Class clazz) { + for (Field field : clazz.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) { + continue; + } + for (Annotation annotation : field.getAnnotations()) { + if (!(annotation instanceof TypeConversion tc)) { + continue; + } + String name = StringUtils.isEmpty(tc.key()) ? field.getName() : tc.key(); + String key = resolveKey(tc.type(), tc.rule(), name); + if (key == null) { + // defensive: a field always has a name, so this is unreachable in practice + LOG.warn("Ignoring @TypeConversion on field [{}#{}]: the key could not be resolved", + clazz.getName(), field.getName()); + continue; + } + if (mapping.containsKey(key)) { + LOG.debug("Skipping @TypeConversion on field [{}#{}]: key [{}] is already mapped by a higher precedence source", + clazz.getName(), field.getName(), key); + continue; + } + LOG.debug("TypeConversion [{}/{}] on field [{}] resolved to key [{}]", + tc.converter(), tc.converterClass(), field.getName(), key); + annotationProcessor.process(mapping, tc, key); + } + } + } + /** * Looks for converter mappings for the specified class, traversing up its class hierarchy and interfaces and adding diff --git a/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java b/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java index b791fc941..1ca4bc161 100644 --- a/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java +++ b/core/src/test/java/org/apache/struts2/conversion/impl/XWorkConverterTest.java @@ -44,6 +44,7 @@ import org.apache.struts2.conversion.annotations.ConversionType; import org.apache.struts2.util.BareKeyConversionAction; import org.apache.struts2.util.CollidingKeyConversionAction; import org.apache.struts2.util.ExplicitKeyConversionAction; +import org.apache.struts2.util.FieldConversionAction; import org.apache.struts2.util.MyBean; import org.apache.struts2.util.MyBeanAction; @@ -896,6 +897,20 @@ public class XWorkConverterTest extends XWorkTestCase { assertEquals("true", freshConverter.getConverter(CollidingKeyConversionAction.class, "CreateIfNull_afterTheCollision")); } + public void testFieldLevelAnnotationDerivesKeyFromTheFieldName() { + XWorkConverter freshConverter = container.inject(XWorkConverter.class); + freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + assertEquals("true", freshConverter.getConverter(FieldConversionAction.class, "CreateIfNull_fieldOnlyList")); + } + + public void testMethodAnnotationWinsOverFieldAnnotation() { + XWorkConverter freshConverter = container.inject(XWorkConverter.class); + freshConverter.setTypeConverterHolder(new StrutsTypeConverterHolder()); + + assertEquals(Long.class, freshConverter.getConverter(FieldConversionAction.class, "Key_contestedMap")); + } + public static class CountingXWorkConverter extends XWorkConverter { final AtomicInteger builds = new AtomicInteger(); diff --git a/core/src/test/java/org/apache/struts2/util/FieldConversionAction.java b/core/src/test/java/org/apache/struts2/util/FieldConversionAction.java new file mode 100644 index 000000000..920751988 --- /dev/null +++ b/core/src/test/java/org/apache/struts2/util/FieldConversionAction.java @@ -0,0 +1,58 @@ +/* + * 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.struts2.util; + +import org.apache.struts2.conversion.annotations.ConversionRule; +import org.apache.struts2.conversion.annotations.TypeConversion; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Exercises field level {@link TypeConversion}: {@code fieldOnlyList} is annotated on the field + * alone, while {@code contestedMap} is annotated on both the field and its setter so the + * class > method > field precedence can be asserted. + */ +public class FieldConversionAction { + + @TypeConversion(rule = ConversionRule.CREATE_IF_NULL, value = "true") + private List fieldOnlyList = new ArrayList(); + + @TypeConversion(rule = ConversionRule.KEY, converterClass = String.class) + private Map contestedMap = new HashMap(); + + public List getFieldOnlyList() { + return fieldOnlyList; + } + + public void setFieldOnlyList(List fieldOnlyList) { + this.fieldOnlyList = fieldOnlyList; + } + + public Map getContestedMap() { + return contestedMap; + } + + @TypeConversion(rule = ConversionRule.KEY, converterClass = Long.class) + public void setContestedMap(Map contestedMap) { + this.contestedMap = contestedMap; + } +}
