This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/main by this push:
     new 839bfc9df WW-5740 fix(core): resolve visitor-nested constraint 
messages like validation does (#1937)
839bfc9df is described below

commit 839bfc9dfdb41f0f4b5f5779834f619db8fd974c
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Sep 14 12:19:21 2026 +0200

    WW-5740 fix(core): resolve visitor-nested constraint messages like 
validation does (#1937)
    
    * WW-5740 fix(core): resolve visitor-nested constraint messages like 
validation does
    
    A field reached through a visitor validator had its data-msg-* text
    resolved through DelegatingValidatorContext(action) — the action's
    bundles only — while VisitorFieldValidator.validateObject validates it
    with a composite provider over the visited class and the action, and
    pushes the visited instance for ${...}. A <message key=.../> living in
    the visited class's own .properties rendered as the raw key.
    
    Form now gives each unwrapped visitor validator the same composite
    context at unwrap time (class-based, so a form rendered before the
    visited object exists still finds the bundle) and exposes
    getValidatedObject(name); UIBean hands the provider the visited instance
    when it exists, so ${...} and messageParams read it as they do during
    validation. HtmlConstraintProvider keeps its signature; the third
    parameter's javadoc now says what object it is.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 fix(core): carry the whole visited chain for nested visitors
    
    Review of the first cut found two ways rendering still diverged from
    VisitorFieldValidator: a doubly-nested visitor (user.address.street)
    skipped the intermediate level — validation chains Address, User and
    action bundles and has user and address on the stack — and a field
    claimed by two appendPrefix="false" visitors, or by a visitor-nested
    validator next to a direct one, handed the provider whichever object
    was recorded last.
    
    The wrapper now carries every visited class and path outermost first,
    the composite provider folds each level innermost first, UIBean pushes
    the intermediate objects (restoreStackDepth pops them), and a field
    gets a validated object only when exactly one visited path claims it.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 fix(core): read the visited object off the action, not the top of 
the stack
    
    Second review pass: findValue(path) at render time searched the whole
    stack, so an iterator or push frame exposing the same property shadowed
    the action's object that validation uses; the action is now pushed for
    the lookup. And a partial chain (user set, user.address null) handed the
    provider the User for an Address message — the innermost object is
    handed over only when the whole chain exists, the levels that do exist
    are still pushed for ${...}.
    
    Not mirrored, on purpose: the visitor's own <message> that
    AppendingValidatorContext prepends to submitted errors. That is error
    display decoration rather than the field's message; the deprecated JS
    validator never applied it either.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 fix(core): push the visited chain only when it is complete
    
    Third review pass: with user set and user.address null the found level
    was still pushed while the provider got the action, so ${label} in the
    Address message rendered the User's label. The chain is now pushed only
    when the provider is handed its innermost object, and Form resolves it
    once per field instead of once per caller.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 fix(core): mirror the validation-time stack and stop sharing 
validators across visitors
    
    Fourth review pass. ModelDrivenInterceptor pushes the model above the
    action before validation, so a property both declare is validated off
    the model; the visited-object lookup now pushes that same pair rather
    than the action alone. Visitor validators are cached per visited path
    instead of per class, because unwrap() sets a context on the instances
    and two visitors over one class overwrote each other's. The wrapper's
    list accessors are package-private: only Form reads them, and a public
    List<Class<?>> return trips Sonar S1452.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 fix(core): take the model validation saw from the stack, not from 
getModel()
    
    Fifth review pass. Pushing getModel() whenever the action is
    ModelDriven diverged from validation on a stack without the modelDriven
    interceptor, re-invoked getModel() per lookup (a fresh instance per
    call is not the one validation ran against), and ran it outside the
    try. The lookup now reuses the instance directly above the action in
    the root, only when ModelDrivenInterceptor is configured for the
    action, with every push inside the try.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 fix(core): only treat the frame above the action as the model 
when the interceptor pushed one
    
    Sixth review pass, and a real one: modelDriven is in defaultStack, so
    gating on the configured interceptor alone made every non-ModelDriven
    action under <s:iterator> re-push the iterator element as its "model",
    reintroducing the shadowing this branch removes. The frame above the
    action is taken as the model only when the rendering action is
    ModelDriven with a non-null model and ModelDrivenInterceptor is in its
    own stack — read from the current invocation's config, not the form
    target's. Tests cover the interceptor-configured/non-ModelDriven and
    null-model cases the previous negative test could not reach.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 fix(core): identity-check the pushed model and use the visited 
instance's runtime class
    
    Seventh review pass, two one-liners. The frame above the action counts
    as the model only when it is the action's current model, so a model
    created after the interceptor ran cannot promote a page frame. And the
    visited provider is built from the instance's runtime class when the
    instance exists, as VisitorFieldValidator.createTextProvider does, so a
    subclass's own bundle wins over the declared type's.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * WW-5740 refactor(core): address Sonar findings on the visitor message 
context
    
    S1117: two locals named `action` hid the form's own action attribute.
    S3358: the provider choice in unwrap() was a nested ternary; it is a
    method now, named for what VisitorFieldValidator.createTextProvider does.
    S107: findFieldValidators carried the visited classes and paths as two
    parallel lists (8 parameters); they are one list of Visit records.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../java/org/apache/struts2/components/Form.java   | 254 ++++++++++++++++++++-
 .../struts2/components/HtmlConstraintProvider.java |   4 +-
 .../java/org/apache/struts2/components/UIBean.java |  18 +-
 .../apache/struts2/TestConfigurationProvider.java  |  10 +
 ...onstraintUser.java => AdminConstraintUser.java} |  12 +-
 .../struts2/components/ConstraintAction.java       |  10 +
 ...{ConstraintUser.java => ConstraintAddress.java} |  12 +-
 .../components/ConstraintAttributesTest.java       | 224 +++++++++++++++++-
 .../apache/struts2/components/ConstraintUser.java  |  27 +++
 .../components/FormFieldValidatorsTest.java        |  59 ++++-
 ...tUser.java => ModelDrivenConstraintAction.java} |  15 +-
 .../components/AdminConstraintUser.properties      |  19 ++
 .../components/ConstraintAction-validation.xml     |  12 +
 ...dation.xml => ConstraintAddress-validation.xml} |   4 +-
 .../components/ConstraintUser-validation.xml       |  12 +-
 .../struts2/components/ConstraintUser.properties   |  21 ++
 16 files changed, 669 insertions(+), 44 deletions(-)

diff --git a/core/src/main/java/org/apache/struts2/components/Form.java 
b/core/src/main/java/org/apache/struts2/components/Form.java
index d8daf841b..c97755eb5 100644
--- a/core/src/main/java/org/apache/struts2/components/Form.java
+++ b/core/src/main/java/org/apache/struts2/components/Form.java
@@ -18,7 +18,11 @@
  */
 package org.apache.struts2.components;
 
+import org.apache.struts2.ActionInvocation;
+import org.apache.struts2.ModelDriven;
 import org.apache.struts2.ObjectFactory;
+import org.apache.struts2.interceptor.ModelDrivenInterceptor;
+import org.apache.struts2.util.CompoundRoot;
 import org.apache.struts2.config.Configuration;
 import org.apache.struts2.config.RuntimeConfiguration;
 import org.apache.struts2.config.entities.ActionConfig;
@@ -26,7 +30,11 @@ import org.apache.struts2.config.entities.InterceptorMapping;
 import org.apache.struts2.inject.Inject;
 import org.apache.struts2.interceptor.MethodFilterInterceptorUtil;
 import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.text.CompositeTextProvider;
+import org.apache.struts2.text.TextProvider;
+import org.apache.struts2.text.TextProviderFactory;
 import org.apache.struts2.validator.ActionValidatorManager;
+import org.apache.struts2.validator.DelegatingValidatorContext;
 import org.apache.struts2.validator.FieldValidator;
 import org.apache.struts2.validator.ValidationException;
 import org.apache.struts2.validator.ValidationInterceptor;
@@ -44,6 +52,7 @@ import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -129,7 +138,11 @@ public class Form extends ClosingUIBean {
     private List<Validator> cachedActionValidators;
     private String cachedActionName;
     private boolean actionValidatorsResolved;
-    private final Map<Class<?>, List<Validator>> cachedVisitorValidators = new 
HashMap<>();
+    private final Map<String, List<Validator>> cachedVisitorValidators = new 
HashMap<>();
+    private final Map<String, String> visitedPaths = new HashMap<>();
+    private final Map<String, List<Object>> visitedObjects = new HashMap<>();
+    private Boolean modelDrivenConfigured;
+    protected TextProviderFactory textProviderFactory;
 
     public Form(ValueStack stack, HttpServletRequest request, 
HttpServletResponse response) {
         super(stack, request, response);
@@ -170,6 +183,11 @@ public class Form extends ClosingUIBean {
         this.actionValidatorManager = mgr;
     }
 
+    @Inject
+    public void setTextProviderFactory(TextProviderFactory 
textProviderFactory) {
+        this.textProviderFactory = textProviderFactory;
+    }
+
 
     /*
      * Revised for Portlet actionURL as form action, and add wwAction as hidden
@@ -340,13 +358,179 @@ public class Form extends ClosingUIBean {
         Class actionClass = (Class) getAttributes().get(ATTR_ACTION_CLASS);
         List<Validator> validators = new ArrayList<>();
         findFieldValidators(name, actionClass, cachedActionName, 
cachedActionValidators, validators, "");
+        recordVisitedPath(name, validators);
         // the wrapper only exists to prefix the field name for the deprecated 
JS validator; callers of
         // this method dispatch on the concrete validator type
         validators.replaceAll(validator -> validator instanceof 
FieldVisitorValidatorWrapper wrapper
-            ? wrapper.getFieldValidator() : validator);
+            ? unwrap(wrapper) : validator);
         return validators;
     }
 
+    /**
+     * A field has one validated object only when every validator found for it 
came through the same
+     * visitor: two {@code appendPrefix="false"} visitors declaring the same 
field, or a visitor-nested
+     * validator next to a direct one, leave nothing sensible to hand the 
provider.
+     */
+    private void recordVisitedPath(String name, List<Validator> validators) {
+        Set<String> paths = new HashSet<>();
+        for (Validator validator : validators) {
+            paths.add(validator instanceof FieldVisitorValidatorWrapper 
wrapper ? wrapper.getVisitedPath() : null);
+        }
+        visitedObjects.remove(name);
+        if (paths.size() == 1 && !paths.contains(null)) {
+            visitedPaths.put(name, paths.iterator().next());
+        } else {
+            visitedPaths.remove(name);
+        }
+    }
+
+    /**
+     * The object a field's validators run against: the visited object for a 
field reached through a
+     * {@code visitor} validator, otherwise null — the caller then uses the 
action, as validation does.
+     * Resolved by {@link #getFieldValidators(String)}, which must run first.
+     *
+     * @since 7.4.0
+     */
+    public Object getValidatedObject(String name) {
+        String path = visitedPaths.get(name);
+        if (path == null) {
+            return null;
+        }
+        List<Object> chain = getVisitedObjects(name);
+        // a partial chain (user set, user.address still null) has no object 
of the right type
+        return chain.size() == path.split("\\.").length ? 
chain.get(chain.size() - 1) : null;
+    }
+
+    /**
+     * The visited objects on the way to a field, outermost first — {@code 
[user, user.address]} for
+     * {@code user.address.street} — as far as they exist. Validation has 
every one of them on the
+     * value stack when it resolves the innermost message, so rendering pushes 
them too.
+     */
+    List<Object> getVisitedObjects(String name) {
+        String path = visitedPaths.get(name);
+        if (path == null) {
+            return Collections.emptyList();
+        }
+        return visitedObjects.computeIfAbsent(name, key -> resolveChain(path));
+    }
+
+    private List<Object> resolveChain(String path) {
+        List<Object> chain = new ArrayList<>();
+        StringBuilder prefix = new StringBuilder();
+        for (String segment : path.split("\\.")) {
+            prefix.append(prefix.isEmpty() ? "" : ".").append(segment);
+            Object visited = findAsValidation(prefix.toString());
+            if (visited == null) {
+                break;
+            }
+            chain.add(visited);
+        }
+        return chain;
+    }
+
+    private Object currentAction() {
+        ActionInvocation invocation = 
getStack().getActionContext().getActionInvocation();
+        return invocation == null ? null : invocation.getAction();
+    }
+
+    /**
+     * Validation reads the visited object off the stack as the interceptors 
left it: the action, with
+     * the model {@code ModelDrivenInterceptor} pushed directly above it. At 
render time an
+     * {@code <s:iterator>} or {@code <s:push>} frame above those may expose 
the same property, so
+     * that validation-time pair is put back on top for the lookup.
+     */
+    private Object findAsValidation(String path) {
+        Object invoked = currentAction();
+        if (invoked == null) {
+            return getStack().findValue(path);
+        }
+        CompoundRoot root = getStack().getRoot();
+        int depth = root.size();
+        try {
+            Object model = modelPushedAbove(invoked, root);
+            getStack().push(invoked);
+            if (model != null) {
+                getStack().push(model);
+            }
+            return getStack().findValue(path);
+        } finally {
+            while (root.size() > depth) {
+                getStack().pop();
+            }
+        }
+    }
+
+    /**
+     * The model the interceptor pushed: the frame directly above the action, 
and only when it is the
+     * action's current model — anything else there is a page frame. The 
interceptor is in the default
+     * stack, so its being configured alone proves nothing; it is still 
checked because a stack without
+     * it never pushes, however the model looks now.
+     */
+    private Object modelPushedAbove(Object action, CompoundRoot root) {
+        if (!(action instanceof ModelDriven<?> modelDriven) || 
modelDriven.getModel() == null) {
+            return null;
+        }
+        int actionIndex = root.indexOf(action);
+        if (actionIndex <= 0 || !modelDrivenInterceptorConfigured()) {
+            return null;
+        }
+        Object above = root.get(actionIndex - 1);
+        return above == modelDriven.getModel() ? above : null;
+    }
+
+    private boolean modelDrivenInterceptorConfigured() {
+        if (modelDrivenConfigured == null) {
+            modelDrivenConfigured = false;
+            ActionInvocation invocation = 
getStack().getActionContext().getActionInvocation();
+            ActionConfig actionConfig = invocation == null || 
invocation.getProxy() == null
+                ? null : invocation.getProxy().getConfig();
+            if (actionConfig != null) {
+                for (InterceptorMapping interceptorMapping : 
actionConfig.getInterceptors()) {
+                    if (interceptorMapping.getInterceptor() instanceof 
ModelDrivenInterceptor) {
+                        modelDrivenConfigured = true;
+                    }
+                }
+            }
+        }
+        return modelDrivenConfigured;
+    }
+
+    /**
+     * Gives the nested validator the text provider {@code 
VisitorFieldValidator.validateObject} gives
+     * it: each visited level's bundle, innermost first, then the action's. An 
instance is only needed
+     * when it is itself a {@code TextProvider}; the class-based providers 
work for a form rendered
+     * before the visited objects exist.
+     */
+    private FieldValidator unwrap(FieldVisitorValidatorWrapper wrapper) {
+        FieldValidator validator = wrapper.getFieldValidator();
+        Object invoked = currentAction();
+        if (wrapper.getVisitedClasses().isEmpty() || invoked == null || 
textProviderFactory == null) {
+            return validator;
+        }
+        DelegatingValidatorContext parent = new 
DelegatingValidatorContext(invoked, textProviderFactory);
+        List<TextProvider> providers = new ArrayList<>();
+        List<Class<?>> classes = wrapper.getVisitedClasses();
+        List<String> paths = wrapper.getVisitedPaths();
+        for (int level = classes.size() - 1; level >= 0; level--) {
+            
providers.add(visitedTextProvider(findAsValidation(paths.get(level)), 
classes.get(level)));
+        }
+        providers.add(parent);
+        validator.setValidatorContext(new DelegatingValidatorContext(parent, 
new CompositeTextProvider(providers), parent));
+        return validator;
+    }
+
+    /**
+     * What {@code VisitorFieldValidator.createTextProvider} builds: the 
instance itself when it is a
+     * TextProvider, else a provider for its runtime class — or for the 
declared class when the instance
+     * does not exist yet.
+     */
+    private TextProvider visitedTextProvider(Object visited, Class<?> 
declared) {
+        if (visited instanceof TextProvider textProvider) {
+            return textProvider;
+        }
+        return textProviderFactory.createInstance(visited != null ? 
visited.getClass() : declared);
+    }
+
     private void resolveActionValidators() {
         if (actionValidatorsResolved) {
             return;
@@ -389,6 +573,16 @@ public class Form extends ClosingUIBean {
 
     private void findFieldValidators(String name, Class actionClass, String 
actionName,
                                      List<Validator> validatorList, 
List<Validator> resultValidators, String prefix) {
+        findFieldValidators(name, actionClass, actionName, validatorList, 
resultValidators, prefix, Collections.emptyList());
+    }
+
+    /** One level of visitor nesting: the class it validates and the OGNL path 
of that object from the action. */
+    private record Visit(Class<?> clazz, String path) {
+    }
+
+    private void findFieldValidators(String name, Class actionClass, String 
actionName,
+                                     List<Validator> validatorList, 
List<Validator> resultValidators, String prefix,
+                                     List<Visit> visits) {
 
         for (Validator validator : validatorList) {
             if (validator instanceof FieldValidator fieldValidator) {
@@ -400,23 +594,34 @@ public class Form extends ClosingUIBean {
                         continue;
                     }
 
-                    List<Validator> visitorValidators = 
cachedVisitorValidators.computeIfAbsent(clazz,
-                        visited -> 
actionValidatorManager.getValidators(visited, actionName));
                     String vPrefix = prefix + (vfValidator.isAppendPrefix() ? 
vfValidator.getFieldName() + "." : "");
-                    findFieldValidators(name, clazz, actionName, 
visitorValidators, resultValidators, vPrefix);
+                    String vPath = visits.isEmpty() ? 
vfValidator.getFieldName()
+                        : visits.get(visits.size() - 1).path() + "." + 
vfValidator.getFieldName();
+                    // per visitor, not per class: unwrap() sets a context on 
these instances, and two
+                    // visitors over one class must not overwrite each other's
+                    List<Validator> visitorValidators = 
cachedVisitorValidators.computeIfAbsent(vPath,
+                        path -> actionValidatorManager.getValidators(clazz, 
actionName));
+                    findFieldValidators(name, clazz, actionName, 
visitorValidators, resultValidators, vPrefix,
+                        append(visits, new Visit(clazz, vPath)));
                 } else if ((prefix + 
fieldValidator.getFieldName()).equals(name)) {
-                    if (StringUtils.isNotBlank(prefix)) {
-                        //fixing field name for js side
-                        FieldVisitorValidatorWrapper wrap = new 
FieldVisitorValidatorWrapper(fieldValidator, prefix);
-                        resultValidators.add(wrap);
-                    } else {
+                    if (visits.isEmpty()) {
                         resultValidators.add(fieldValidator);
+                    } else {
+                        //fixing field name for js side
+                        resultValidators.add(new 
FieldVisitorValidatorWrapper(fieldValidator, prefix,
+                            visits.stream().map(Visit::clazz).toList(), 
visits.stream().map(Visit::path).toList()));
                     }
                 }
             }
         }
     }
 
+    private static <T> List<T> append(List<T> list, T element) {
+        List<T> appended = new ArrayList<>(list);
+        appended.add(element);
+        return appended;
+    }
+
     /**
      * Wrap field validator, add visitor's field prefix to the field name.
      * Javascript side is not aware of the visitor validators
@@ -428,10 +633,39 @@ public class Form extends ClosingUIBean {
     public static class FieldVisitorValidatorWrapper implements FieldValidator 
{
         private FieldValidator fieldValidator;
         private String namePrefix;
+        private final List<Class<?>> visitedClasses;
+        private final List<String> visitedPaths;
 
         public FieldVisitorValidatorWrapper(FieldValidator fv, String 
namePrefix) {
+            this(fv, namePrefix, Collections.emptyList(), 
Collections.emptyList());
+        }
+
+        /**
+         * @param visitedClasses the classes visited on the way to the field, 
outermost first; their
+         *                       bundles are where the field's messages resolve
+         * @param visitedPaths   the OGNL paths of those objects from the 
action, e.g.
+         *                       {@code [user, user.address]}
+         * @since 7.4.0
+         */
+        public FieldVisitorValidatorWrapper(FieldValidator fv, String 
namePrefix,
+                                            List<Class<?>> visitedClasses, 
List<String> visitedPaths) {
             this.fieldValidator = fv;
             this.namePrefix = namePrefix;
+            this.visitedClasses = List.copyOf(visitedClasses);
+            this.visitedPaths = List.copyOf(visitedPaths);
+        }
+
+        List<Class<?>> getVisitedClasses() {
+            return visitedClasses;
+        }
+
+        List<String> getVisitedPaths() {
+            return visitedPaths;
+        }
+
+        /** The innermost visited path, or null when the wrapper carries none. 
*/
+        public String getVisitedPath() {
+            return visitedPaths.isEmpty() ? null : 
visitedPaths.get(visitedPaths.size() - 1);
         }
 
         public String getValidatorType() {
diff --git 
a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java 
b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
index 926065984..070bf6bec 100644
--- 
a/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
+++ 
b/core/src/main/java/org/apache/struts2/components/HtmlConstraintProvider.java
@@ -42,7 +42,9 @@ public interface HtmlConstraintProvider {
     /**
      * @param validators the field's validators; may be null or empty
      * @param control    the kind of control being rendered
-     * @param action     the action instance, used to resolve i18n validator 
messages; may be null
+     * @param action     the object the field's validators run against, used 
to resolve i18n validator
+     *                   messages: the action, or the visited object for a 
field reached through a
+     *                   {@code visitor} validator; may be null
      * @return attribute name to value; never null, possibly empty
      */
     Map<String, String> constraintsFor(List<Validator> validators, 
HtmlControlType control, Object action);
diff --git a/core/src/main/java/org/apache/struts2/components/UIBean.java 
b/core/src/main/java/org/apache/struts2/components/UIBean.java
index 089950fbe..fb961c8f3 100644
--- a/core/src/main/java/org/apache/struts2/components/UIBean.java
+++ b/core/src/main/java/org/apache/struts2/components/UIBean.java
@@ -22,6 +22,7 @@ import org.apache.struts2.config.ConfigurationException;
 import org.apache.struts2.inject.Inject;
 import org.apache.struts2.util.TextParseUtil;
 import org.apache.struts2.util.ValueStack;
+import org.apache.struts2.validator.Validator;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.servlet.http.HttpSession;
@@ -954,8 +955,20 @@ public abstract class UIBean extends Component {
         }
         int stackDepth = stack.getRoot().size();
         try {
+            List<Validator> validators = form.getFieldValidators(fieldName);
+            // validation resolves a nested message with every visited object 
on the stack; both
+            // happen only when the whole chain exists, so ${...} agrees with 
the object handed over
+            // (restoreStackDepth pops the pushes)
+            Object validated = form.getValidatedObject(fieldName);
+            if (validated == null) {
+                validated = resolveAction();
+            } else {
+                for (Object visited : form.getVisitedObjects(fieldName)) {
+                    stack.push(visited);
+                }
+            }
             Map<String, String> constraints = 
htmlConstraintProvider.constraintsFor(
-                form.getFieldValidators(fieldName), getControlType(), 
resolveAction());
+                validators, getControlType(), validated);
             if (constraints.isEmpty()) {
                 return;
             }
@@ -1007,6 +1020,9 @@ public abstract class UIBean extends Component {
      * messages against a model or a list element while {@code 
ValidationInterceptor} validated the
      * action.
      *
+     * A field reached through a {@code visitor} validator is validated 
against the visited object
+     * instead, so {@link Form#getValidatedObject(String)} takes precedence 
when that object exists.
+     *
      * @return the action, or null when rendering outside action scope, in 
which case the provider
      * simply derives no message attributes
      */
diff --git 
a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java 
b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
index c6fabf742..22f15f1c4 100644
--- a/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
+++ b/core/src/test/java/org/apache/struts2/TestConfigurationProvider.java
@@ -39,6 +39,7 @@ import 
org.apache.struts2.interceptor.TokenSessionStoreInterceptor;
 import org.apache.struts2.interceptor.parameter.ParametersInterceptor;
 import org.apache.struts2.result.ServletDispatcherResult;
 import org.apache.struts2.components.ConstraintAction;
+import org.apache.struts2.interceptor.ModelDrivenInterceptor;
 import org.apache.struts2.views.jsp.ui.DoubleValidationAction;
 
 import java.util.HashMap;
@@ -102,6 +103,14 @@ public class TestConfigurationProvider implements 
ConfigurationProvider {
             .addInterceptor(new InterceptorMapping("validation", 
validationInterceptor))
             .build();
 
+        ActionConfig modelDrivenConstraintActionConfig = new 
ActionConfig.Builder("", "modelDrivenConstraintAction", 
ConstraintAction.class.getName())
+            .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, 
ServletDispatcherResult.class.getName())
+                    .addParam("location", "success.jsp")
+                    .build())
+            .addInterceptor(new InterceptorMapping("modelDriven", new 
ModelDrivenInterceptor()))
+            .addInterceptor(new InterceptorMapping("validation", 
validationInterceptor))
+            .build();
+
         ActionConfig testActionConfig = new ActionConfig.Builder("", "", 
TestAction.class.getName())
             .addResultConfig(new ResultConfig.Builder(Action.SUCCESS, 
ServletDispatcherResult.class.getName())
                     .addParam("location", "success.jsp")
@@ -128,6 +137,7 @@ public class TestConfigurationProvider implements 
ConfigurationProvider {
             .addActionConfig(TEST_ACTION_NAME, testActionConfig)
             .addActionConfig("doubleValidationAction", 
doubleValidationActionConfig)
             .addActionConfig("constraintAction", constraintActionConfig)
+            .addActionConfig("modelDrivenConstraintAction", 
modelDrivenConstraintActionConfig)
             .addActionConfig(TOKEN_ACTION_NAME, tokenActionConfig)
             .addActionConfig(TOKEN_SESSION_ACTION_NAME, 
tokenSessionActionConfig)
             .addActionConfig("testActionTagAction", new 
ActionConfig.Builder("", "", TestAction.class.getName())
diff --git 
a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java 
b/core/src/test/java/org/apache/struts2/components/AdminConstraintUser.java
similarity index 82%
copy from core/src/test/java/org/apache/struts2/components/ConstraintUser.java
copy to 
core/src/test/java/org/apache/struts2/components/AdminConstraintUser.java
index 0acfc461b..0809ba892 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++ b/core/src/test/java/org/apache/struts2/components/AdminConstraintUser.java
@@ -18,15 +18,5 @@
  */
 package org.apache.struts2.components;
 
-public class ConstraintUser {
-
-    private String name;
-
-    public String getName() {
-        return name;
-    }
-
-    public void setName(String name) {
-        this.name = name;
-    }
+public class AdminConstraintUser extends ConstraintUser {
 }
diff --git 
a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java 
b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
index fc75b472a..1d0bed30a 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAction.java
@@ -28,6 +28,8 @@ public class ConstraintAction extends ActionSupport {
     private String bio;
     private String nickname;
     private ConstraintUser user;
+    private ConstraintUser owner = new ConstraintUser();
+    private ConstraintUser contact = new ConstraintUser();
     private String code;
     private String choice;
     private Object attachment;
@@ -94,6 +96,14 @@ public class ConstraintAction extends ActionSupport {
         this.attachment = attachment;
     }
 
+    public ConstraintUser getOwner() {
+        return owner;
+    }
+
+    public ConstraintUser getContact() {
+        return contact;
+    }
+
     @StrutsParameter(depth = 1)
     public ConstraintUser getUser() {
         return user;
diff --git 
a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java 
b/core/src/test/java/org/apache/struts2/components/ConstraintAddress.java
similarity index 82%
copy from core/src/test/java/org/apache/struts2/components/ConstraintUser.java
copy to core/src/test/java/org/apache/struts2/components/ConstraintAddress.java
index 0acfc461b..85d964d64 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintAddress.java
@@ -18,15 +18,15 @@
  */
 package org.apache.struts2.components;
 
-public class ConstraintUser {
+public class ConstraintAddress {
 
-    private String name;
+    private String street;
 
-    public String getName() {
-        return name;
+    public String getStreet() {
+        return street;
     }
 
-    public void setName(String name) {
-        this.name = name;
+    public void setStreet(String street) {
+        this.street = street;
     }
 }
diff --git 
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
 
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
index 6c7a59e73..6b9313bc8 100644
--- 
a/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/ConstraintAttributesTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.struts2.components;
 
+import org.apache.struts2.action.Action;
 import org.apache.struts2.StrutsConstants;
 import org.apache.struts2.TestConfigurationProvider;
 import org.apache.struts2.mock.MockActionProxy;
@@ -34,8 +35,17 @@ import java.util.Map;
 public class ConstraintAttributesTest extends AbstractUITagTest {
 
     private FormTag form;
+
+    private Action actionOverride;
+
+    @Override
+    public Action getAction() {
+        return actionOverride != null ? actionOverride : new 
ConstraintAction();
+    }
+
     private String theme = "html5";
     private String fieldName = "username";
+    private String actionName = "constraintAction";
     private final Map<String, String> dynamicAttributes = new HashMap<>();
     private String declaredMaxlength;
 
@@ -77,6 +87,11 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         assertEquals("3", constraints.get("minlength"));
     }
 
+    /**
+     * The message key lives in the visited class's own bundle ({@code 
ConstraintUser.properties}),
+     * which is where {@code VisitorFieldValidator} resolves it during 
validation — through a
+     * composite text provider over the visited object and the action. 
Rendering must use the same.
+     */
     public void testVisitorValidatedNestedFieldGetsTheConcreteConstraint() 
throws Exception {
         initDispatcherWith("true");
         fieldName = "user.name";
@@ -84,10 +99,28 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
         assertNotNull("expected constraints for a visitor-validated field", 
constraints);
         assertEquals("required", constraints.get("required"));
-        assertEquals("name is required", 
constraints.get("data-msg-requiredstring"));
+        assertEquals("Name is required", 
constraints.get("data-msg-requiredstring"));
         assertFalse(constraints.containsKey("data-msg-field-visitor"));
     }
 
+    /**
+     * Validation pushes the visited instance before resolving the message, so 
{@code ${label}} in a
+     * visited message reads the visited bean, not the action. Same at render 
time when the instance
+     * exists.
+     */
+    public void 
testVisitorValidatedMessageResolvesExpressionsAgainstTheVisitedInstance() 
throws Exception {
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        ConstraintUser user = new ConstraintUser();
+        user.setLabel("Account");
+        ((ConstraintAction) action).setUser(user);
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Account: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
     /**
      * HTML attribute names are ASCII case-insensitive, and the documented 
rule is that the developer's
      * own value always wins — including one typed as {@code MAXLENGTH}.
@@ -126,6 +159,191 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         assertEquals("required", constraints.get("required"));
     }
 
+    /**
+     * Validation chains the providers and the stack through every visited 
level, so a message key in
+     * the intermediate class's bundle, and a {@code ${...}} reading the 
intermediate object, both
+     * resolve for a doubly-nested field.
+     */
+    public void 
testDoublyNestedVisitorMessageResolvesThroughTheIntermediateObject() throws 
Exception {
+        initDispatcherWith("true");
+        fieldName = "user.address.street";
+        ConstraintUser user = new ConstraintUser();
+        user.setLabel("Account");
+        user.setAddress(new ConstraintAddress());
+        ((ConstraintAction) action).setUser(user);
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Account: street is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /**
+     * Validation resolves the visited object from the action. At render time 
an {@code <s:iterator>}
+     * or {@code <s:push>} frame above the action may expose the same 
property, and must not win.
+     */
+    public void testVisitedObjectIsResolvedFromTheActionNotTheTopOfTheStack() 
throws Exception {
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        ConstraintUser actionsUser = new ConstraintUser();
+        actionsUser.setLabel("Account");
+        ((ConstraintAction) action).setUser(actionsUser);
+        ConstraintAction shadow = new ConstraintAction();
+        ConstraintUser shadowsUser = new ConstraintUser();
+        shadowsUser.setLabel("Shadow");
+        shadow.setUser(shadowsUser);
+        stack.push(shadow);
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Account: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /**
+     * ModelDrivenInterceptor pushes the model above the action before 
validation runs, so a property
+     * both declare is validated off the model. Rendering must read it off the 
same instance.
+     */
+    public void 
testVisitedObjectIsResolvedFromTheModelWhenTheModelDrivenInterceptorIsConfigured()
 throws Exception {
+        actionOverride = new ModelDrivenConstraintAction();
+        actionName = "modelDrivenConstraintAction";
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        ConstraintAction model = userLabelled("Action", "Model");
+        ((ModelDrivenConstraintAction) action).setModel(model);
+        stack.push(model);
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Model: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /**
+     * modelDriven is in the default stack, so the interceptor being 
configured says nothing about
+     * whether it pushed: for an action that is not ModelDriven the frame 
above it is a page frame.
+     */
+    public void 
testAPageFrameAboveANonModelDrivenActionIsIgnoredEvenWithTheInterceptorConfigured()
 throws Exception {
+        actionName = "modelDrivenConstraintAction";
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        stack.push(userLabelled("Action", "Shadow"));
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Action: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /**
+     * The interceptor pushes nothing for a null model, so the frame above the 
action is a page frame.
+     */
+    public void testAPageFrameAboveAModelDrivenActionWithANullModelIsIgnored() 
throws Exception {
+        actionOverride = new ModelDrivenConstraintAction();
+        actionName = "modelDrivenConstraintAction";
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        stack.push(userLabelled("Action", "Shadow"));
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Action: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /**
+     * Without the interceptor configured, whatever sits above the action at 
render time is a page
+     * frame, not the model validation saw — so it must not win.
+     */
+    public void 
testAnObjectAboveTheActionIsIgnoredWithoutTheModelDrivenInterceptor() throws 
Exception {
+        actionOverride = new ModelDrivenConstraintAction();
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        ConstraintAction model = userLabelled("Action", "Model");
+        ((ModelDrivenConstraintAction) action).setModel(model);
+        stack.push(model);
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Action: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /**
+     * The interceptor pushes the model it saw; a model that only came into 
existence later was never
+     * pushed, so the frame above the action is still a page frame.
+     */
+    public void 
testAModelCreatedAfterTheInterceptorRanIsNotMistakenForAPushedOne() throws 
Exception {
+        actionOverride = new ModelDrivenConstraintAction();
+        actionName = "modelDrivenConstraintAction";
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        ConstraintAction shadow = userLabelled("Action", "Shadow");
+        ((ModelDrivenConstraintAction) action).setModel(new 
ConstraintAction());
+        stack.push(shadow);
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Action: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /**
+     * Validation builds the visited provider from the instance's runtime 
class, so a subclass's own
+     * bundle wins over the declared type's.
+     */
+    public void testVisitedProviderUsesTheRuntimeClassOfTheVisitedInstance() 
throws Exception {
+        initDispatcherWith("true");
+        fieldName = "user.email";
+        ConstraintUser admin = new AdminConstraintUser();
+        admin.setLabel("Root");
+        ((ConstraintAction) action).setUser(admin);
+
+        Map<String, String> constraints = 
renderFieldAndReturnConstraints(null);
+
+        assertNotNull(constraints);
+        assertEquals("Admin Root: e-mail is required", 
constraints.get("data-msg-requiredstring"));
+    }
+
+    /** Sets the action's user label and returns another action whose user 
carries the other label. */
+    private ConstraintAction userLabelled(String actionsLabel, String 
othersLabel) {
+        ConstraintUser actionsUser = new ConstraintUser();
+        actionsUser.setLabel(actionsLabel);
+        ((ConstraintAction) action).setUser(actionsUser);
+        ConstraintAction other = new ConstraintAction();
+        ConstraintUser othersUser = new ConstraintUser();
+        othersUser.setLabel(othersLabel);
+        other.setUser(othersUser);
+        return other;
+    }
+
+    /**
+     * With {@code user} set but {@code user.address} still null there is no 
Address to validate
+     * against; the provider gets the action, as for any field whose visited 
object does not exist,
+     * rather than the User one level up — and {@code ${...}} agrees with 
that, so the User's label
+     * does not leak into an Address message.
+     */
+    public void testAPartialVisitedChainHandsTheProviderTheAction() throws 
Exception {
+        initDispatcherWith("true");
+        fieldName = "user.address.street";
+        ConstraintUser user = new ConstraintUser();
+        user.setLabel("Account");
+        ((ConstraintAction) action).setUser(user);
+
+        TextFieldTag field = startField(null);
+        List<Object> captured = new ArrayList<>();
+        ((UIBean) field.getComponent()).setHtmlConstraintProvider((validators, 
control, derivedFrom) -> {
+            captured.add(derivedFrom);
+            captured.add(validators.get(0).getMessage(derivedFrom));
+            return Collections.emptyMap();
+        });
+
+        finishField(field);
+
+        assertSame(action, captured.get(0));
+        assertEquals(": street is required", captured.get(1));
+    }
+
     /**
      * Pins the hook to running after {@code evaluateExtraParams()}. A {@code 
stringlength} validator on
      * a control the browser treats as numeric must not emit {@code minlength} 
at all — that attribute
@@ -214,7 +432,7 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
     private TextFieldTag startField(String type) throws Exception {
         form = new FormTag();
         form.setPageContext(pageContext);
-        form.setAction("constraintAction");
+        form.setAction(actionName);
         form.setNamespace("");
         form.setTheme(theme);
         form.doStartTag();
@@ -249,6 +467,6 @@ public class ConstraintAttributesTest extends 
AbstractUITagTest {
         // createMocks() never sets a config on the MockActionProxy it builds; 
without one,
         // AnnotationActionValidatorManager.buildValidatorKey NPEs 
dereferencing proxy.getConfig().
         ((MockActionProxy) actionProxy).setConfig(
-            configuration.getRuntimeConfiguration().getActionConfig("", 
"constraintAction"));
+            configuration.getRuntimeConfiguration().getActionConfig("", 
actionName));
     }
 }
diff --git 
a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java 
b/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
index 0acfc461b..939aa91b4 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++ b/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
@@ -21,6 +21,9 @@ package org.apache.struts2.components;
 public class ConstraintUser {
 
     private String name;
+    private String email;
+    private String label = "Name";
+    private ConstraintAddress address;
 
     public String getName() {
         return name;
@@ -29,4 +32,28 @@ public class ConstraintUser {
     public void setName(String name) {
         this.name = name;
     }
+
+    public String getEmail() {
+        return email;
+    }
+
+    public void setEmail(String email) {
+        this.email = email;
+    }
+
+    public String getLabel() {
+        return label;
+    }
+
+    public void setLabel(String label) {
+        this.label = label;
+    }
+
+    public ConstraintAddress getAddress() {
+        return address;
+    }
+
+    public void setAddress(ConstraintAddress address) {
+        this.address = address;
+    }
 }
diff --git 
a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java 
b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
index 0b09f608e..75590453b 100644
--- 
a/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
+++ 
b/core/src/test/java/org/apache/struts2/components/FormFieldValidatorsTest.java
@@ -18,6 +18,7 @@
  */
 package org.apache.struts2.components;
 
+import org.apache.struts2.action.Action;
 import org.apache.struts2.TestConfigurationProvider;
 import org.apache.struts2.mock.MockActionProxy;
 import org.apache.struts2.validator.ActionValidatorManager;
@@ -42,6 +43,11 @@ import static org.mockito.Mockito.when;
 
 public class FormFieldValidatorsTest extends AbstractUITagTest {
 
+    @Override
+    public Action getAction() {
+        return new ConstraintAction();
+    }
+
     public void testFindsTheFieldsValidators() throws Exception {
         Form form = formForDoubleValidationAction();
 
@@ -108,11 +114,58 @@ public class FormFieldValidatorsTest extends 
AbstractUITagTest {
             validators.get(0) instanceof RequiredStringValidator);
     }
 
+    public void 
testUnwrappedVisitorValidatorResolvesMessagesFromTheVisitedClassBundle() throws 
Exception {
+        currentActionIs("constraintAction");
+        Form form = formFor("constraintAction");
+
+        Validator validator = form.getFieldValidators("user.name").get(0);
+
+        assertEquals("Name is required", validator.getMessage(action));
+    }
+
+    public void 
testValidatedObjectIsTheVisitedInstanceForAVisitorNestedField() throws 
Exception {
+        currentActionIs("constraintAction");
+        ConstraintUser user = new ConstraintUser();
+        ((ConstraintAction) action).setUser(user);
+        Form form = formFor("constraintAction");
+
+        form.getFieldValidators("user.name");
+
+        assertSame(user, form.getValidatedObject("user.name"));
+    }
+
+    /**
+     * Two {@code appendPrefix="false"} visitors over classes that both 
declare {@code name} give the
+     * field two validators from two objects; there is no single object to 
hand the provider, so it
+     * falls back to the action rather than picking one at random.
+     */
+    public void 
testValidatedObjectIsNullWhenTwoVisitorsClaimTheSameUnprefixedField() throws 
Exception {
+        currentActionIs("constraintAction");
+        Form form = formFor("constraintAction");
+
+        List<Validator> validators = form.getFieldValidators("name");
+
+        assertEquals(2, validators.size());
+        assertNotSame("two visitors must not share one validator instance, or 
the second context "
+            + "overwrites the first", validators.get(0), validators.get(1));
+        assertNull(form.getValidatedObject("name"));
+    }
+
+    public void testValidatedObjectIsNullForADirectField() throws Exception {
+        currentActionIs("constraintAction");
+        Form form = formFor("constraintAction");
+
+        form.getFieldValidators("username");
+
+        assertNull(form.getValidatedObject("username"));
+    }
+
     /**
      * The manager caches only validator configs and builds fresh instances on 
every call, so the
-     * visitor branch must be resolved once per form like the top-level list, 
not once per field.
+     * visitor branch must be resolved once per visitor per form, not once per 
field. The fixture
+     * declares three visitors over ConstraintUser (user, owner, contact).
      */
-    public void testResolvesAVisitorsValidatorsOnlyOnceAcrossFields() throws 
Exception {
+    public void 
testResolvesAVisitorsValidatorsOnlyOncePerVisitorAcrossFields() throws 
Exception {
         currentActionIs("constraintAction");
         Form form = formFor("constraintAction");
         ActionValidatorManager manager = 
spy(container.getInstance(ActionValidatorManager.class));
@@ -122,7 +175,7 @@ public class FormFieldValidatorsTest extends 
AbstractUITagTest {
         form.getFieldValidators("username");
         form.getFieldValidators("bio");
 
-        then(manager).should(times(1)).getValidators(eq(ConstraintUser.class), 
anyString());
+        then(manager).should(times(3)).getValidators(eq(ConstraintUser.class), 
anyString());
     }
 
     private void currentActionIs(String actionName) {
diff --git 
a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java 
b/core/src/test/java/org/apache/struts2/components/ModelDrivenConstraintAction.java
similarity index 71%
copy from core/src/test/java/org/apache/struts2/components/ConstraintUser.java
copy to 
core/src/test/java/org/apache/struts2/components/ModelDrivenConstraintAction.java
index 0acfc461b..f26826c53 100644
--- a/core/src/test/java/org/apache/struts2/components/ConstraintUser.java
+++ 
b/core/src/test/java/org/apache/struts2/components/ModelDrivenConstraintAction.java
@@ -18,15 +18,18 @@
  */
 package org.apache.struts2.components;
 
-public class ConstraintUser {
+import org.apache.struts2.ModelDriven;
 
-    private String name;
+public class ModelDrivenConstraintAction extends ConstraintAction implements 
ModelDriven<ConstraintAction> {
 
-    public String getName() {
-        return name;
+    private ConstraintAction model;
+
+    @Override
+    public ConstraintAction getModel() {
+        return model;
     }
 
-    public void setName(String name) {
-        this.name = name;
+    public void setModel(ConstraintAction model) {
+        this.model = model;
     }
 }
diff --git 
a/core/src/test/resources/org/apache/struts2/components/AdminConstraintUser.properties
 
b/core/src/test/resources/org/apache/struts2/components/AdminConstraintUser.properties
new file mode 100644
index 000000000..3554e4d4b
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/components/AdminConstraintUser.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+email.required=Admin ${label}: e-mail is required
diff --git 
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
 
b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
index 6c847a3c3..d91997de3 100644
--- 
a/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
+++ 
b/core/src/test/resources/org/apache/struts2/components/ConstraintAction-validation.xml
@@ -63,4 +63,16 @@
             <message/>
         </field-validator>
     </field>
+    <field name="owner">
+        <field-validator type="visitor">
+            <param name="appendPrefix">false</param>
+            <message/>
+        </field-validator>
+    </field>
+    <field name="contact">
+        <field-validator type="visitor">
+            <param name="appendPrefix">false</param>
+            <message/>
+        </field-validator>
+    </field>
 </validators>
diff --git 
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
 
b/core/src/test/resources/org/apache/struts2/components/ConstraintAddress-validation.xml
similarity index 93%
copy from 
core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
copy to 
core/src/test/resources/org/apache/struts2/components/ConstraintAddress-validation.xml
index b9611d705..0f28f0a7a 100644
--- 
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
+++ 
b/core/src/test/resources/org/apache/struts2/components/ConstraintAddress-validation.xml
@@ -21,9 +21,9 @@
 -->
 <!DOCTYPE validators PUBLIC "-//Apache Struts//XWork Validator 1.0//EN" 
"https://struts.apache.org/dtds/xwork-validator-1.0.dtd";>
 <validators>
-    <field name="name">
+    <field name="street">
         <field-validator type="requiredstring">
-            <message>name is required</message>
+            <message key="street.required"/>
         </field-validator>
     </field>
 </validators>
diff --git 
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
 
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
index b9611d705..12d0529cb 100644
--- 
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
+++ 
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser-validation.xml
@@ -23,7 +23,17 @@
 <validators>
     <field name="name">
         <field-validator type="requiredstring">
-            <message>name is required</message>
+            <message key="name.required"/>
+        </field-validator>
+    </field>
+    <field name="email">
+        <field-validator type="requiredstring">
+            <message key="email.required"/>
+        </field-validator>
+    </field>
+    <field name="address">
+        <field-validator type="visitor">
+            <message/>
         </field-validator>
     </field>
 </validators>
diff --git 
a/core/src/test/resources/org/apache/struts2/components/ConstraintUser.properties
 
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser.properties
new file mode 100644
index 000000000..a64bbaadf
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/components/ConstraintUser.properties
@@ -0,0 +1,21 @@
+#
+# 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.
+#
+name.required=Name is required
+email.required=${label}: e-mail is required
+street.required=${label}: street is required

Reply via email to