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 &gt; method &gt; 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;
+    }
+}

Reply via email to