jamesfredley commented on code in PR #16624:
URL: https://github.com/apache/grails-core/pull/16624#discussion_r4425162554


##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/GormValidatorAdapter.groovy:
##########
@@ -52,12 +72,212 @@ class GormValidatorAdapter extends SpringValidatorAdapter 
implements CascadingVa
 
     @Override
     void validate(Object obj, Errors errors, boolean cascade) {
+        Boolean enclosing = CASCADE_VALIDATION.get()
         CASCADE_VALIDATION.set(cascade)
         try {
             validate(obj, errors)
         } finally {
+            restoreCascade(enclosing)
+        }
+    }
+
+    /**
+     * Validates the object in the given groups, or in the default group when 
there are none
+     *
+     * @param obj The object to validate
+     * @param errors The errors to report the violations to
+     * @param cascade Whether validation cascades to the persistent 
associations of the entity through the
+     * properties marked {@code @Valid}; it cascades through the other 
properties marked {@code @Valid}, and from the
+     * objects they hold through every property marked {@code @Valid}, either 
way
+     * @param groups The groups to validate
+     * @since 9.0.0
+     */
+    void validate(Object obj, Errors errors, boolean cascade, Class<?>[] 
groups) {
+        Boolean enclosing = CASCADE_VALIDATION.get()
+        CASCADE_VALIDATION.set(cascade)
+        try {
+            validate(obj, errors, (Object[]) groups)
+        } finally {
+            restoreCascade(enclosing)
+        }
+    }
+
+    /**
+     * Validates the object in the given groups as {@link #validate(Object, 
Errors, boolean, Class[])} does,
+     * reporting only the violations of the constraints declared in one of the 
reported groups. The provider
+     * validates a group together with the groups it extends; when validation 
cascades from an object validated in
+     * a group whose extended groups a {@code @ConvertGroup} converts, the 
associated object is validated in the
+     * group alone, so the violations of the converted groups are left out. 
The reported groups are the ones of the
+     * object; for an object the provider cascades to through the properties 
marked {@code @Valid}, they are converted
+     * along the path of the violation as the {@code @ConvertGroup} 
annotations of those properties declare, the way
+     * the provider converts the groups it validates in, see {@link 
#isReported}.
+     *
+     * @param obj The object to validate
+     * @param errors The errors to report the violations to
+     * @param cascade Whether validation cascades to the persistent 
associations of the entity
+     * @param groups The groups to validate
+     * @param reportedGroups The groups whose violations to report, null to 
report every violation
+     * @since 9.0.0
+     */
+    void validate(Object obj, Errors errors, boolean cascade, Class<?>[] 
groups, Set<Class<?>> reportedGroups) {
+        if (reportedGroups == null) {
+            validate(obj, errors, cascade, groups)
+            return
+        }
+        Boolean enclosing = CASCADE_VALIDATION.get()
+        CASCADE_VALIDATION.set(cascade)
+        try {
+            Set<ConstraintViolation<Object>> reported = new LinkedHashSet<>()
+            for (ConstraintViolation<Object> violation : 
thisValidator.validate(obj, groups)) {
+                if (isReported(violation, reportedGroups)) {
+                    reported.add(violation)
+                }
+            }
+            processConstraintViolations(reported, errors)
+        } finally {
+            restoreCascade(enclosing)
+        }
+    }
+
+    /**
+     * Puts the cascade flag of the enclosing validation back, if any, once a 
validation ends: a validation started
+     * while another one is in progress must not change how the enclosing one 
cascades.
+     */
+    private static void restoreCascade(Boolean enclosing) {
+        if (enclosing == null) {
             CASCADE_VALIDATION.remove()
         }
+        else {
+            CASCADE_VALIDATION.set(enclosing)
+        }
+    }
+
+    /**
+     * Whether the violation is of a constraint declared in one of the 
reported groups, as they apply to the bean
+     * holding the constraint. The provider cascades to a bean through a 
property marked {@code @Valid} once for each
+     * group it validates the owner in, converting the group as the {@code 
@ConvertGroup} annotations of the property
+     * declare: a converted group is validated as the group it converts to, 
with the groups that one extends or
+     * sequences, and an unconverted group alone. The reported groups follow 
the same conversions along the path of
+     * the violation, from the validated object to the bean holding the 
constraint, so a violation the provider found
+     * through a conversion of a reported group is reported, and one it found 
through a group that is not reported is
+     * not. The beans along the path are the ones the provider cascaded to, 
read from the validated object, as the
+     * conversions and the constraints are the ones of their classes rather 
than of the declared types of the
+     * properties. The default group of a bean stands for the groups of the 
sequence redefining it, if any, the one a
+     * group sequence provider chooses for the bean.
+     */
+    private boolean isReported(ConstraintViolation<?> violation, Set<Class<?>> 
reportedGroups) {
+        List<Path.Node> nodes = violation.propertyPath.toList()
+        Object bean = violation.rootBean
+        Class<?> beanClass = violation.rootBeanClass
+        Set<Class<?>> groups = reportedGroups
+        // the last node is the constrained element, the nodes before it the 
properties the provider cascaded through
+        for (int i = 0; i < nodes.size() - 1; i++) {
+            Path.Node node = nodes.get(i)
+            if (node.kind != ElementKind.PROPERTY) {
+                continue
+            }
+            PropertyDescriptor property = 
thisValidator.getConstraintsForClass(beanClass).getConstraintsForProperty(node.name)
+            CascadableDescriptor cascaded = 
JakartaConstraintsTranslator.cascadedDescriptorOf(property)
+            if (cascaded != null) {
+                groups = convertedGroupsOf(withDefaultGroupsOf(groups, 
beanClass, bean), cascaded.groupConversions)
+            }
+            Path.Node next = nodes.get(i + 1)
+            bean = i == nodes.size() - 2 ? violation.leafBean : 
cascadedBeanOf(bean, node, next)
+            beanClass = bean?.getClass() ?: cascadedClassOf(property, next)
+        }
+        return !Collections.disjoint(violation.constraintDescriptor.groups, 
withDefaultGroupsOf(groups, beanClass, bean))
+    }
+
+    /**
+     * The groups the provider cascades to a bean with when it validates the 
owner in the given groups, converted as
+     * the given conversions declare: each converted group as the group it 
converts to, with the groups that one
+     * extends or sequences, and each other group alone.
+     */
+    private static Set<Class<?>> convertedGroupsOf(Set<Class<?>> groups, 
Set<GroupConversionDescriptor> conversions) {
+        if (conversions.isEmpty()) {
+            return groups
+        }
+        Map<Class<?>, Class<?>> targets = new HashMap<>()
+        for (GroupConversionDescriptor conversion in conversions) {
+            targets.put(conversion.from, conversion.to)
+        }
+        Set<Class<?>> converted = new LinkedHashSet<>()
+        for (Class<?> group in groups) {
+            Class<?> target = targets.get(group)
+            if (target == null) {
+                converted.add(group)
+            }
+            else {
+                
converted.addAll(JakartaConstraintsTranslator.validatedGroupsOf(target))
+            }
+        }
+        return converted
+    }
+
+    /**
+     * The groups with, when the default group is among them, the groups the 
class redefines it with, for the bean
+     * when it is known and a group sequence provider chooses them per instance
+     */
+    private static Set<Class<?>> withDefaultGroupsOf(Set<Class<?>> groups, 
Class<?> beanClass, Object bean) {
+        if (!groups.contains(Default)) {
+            return groups
+        }
+        Set<Class<?>> expanded = new LinkedHashSet<>(groups)
+        
expanded.addAll(JakartaConstraintsTranslator.defaultGroupsOf(beanClass, bean))
+        return expanded
+    }
+
+    /**
+     * The bean a cascaded property of the given bean leads to: the value of 
the property, or, when the next node of
+     * the path is an element of a container, the element at its index or key. 
Null when the bean is unknown, the
+     * property cannot be read, or the element cannot be told, as one of a 
set, which the declared type of the
+     * property then stands for, see {@link #cascadedClassOf}.
+     */
+    private static Object cascadedBeanOf(Object bean, Path.Node node, 
Path.Node next) {
+        if (bean == null) {
+            return null
+        }
+        Object value
+        try {
+            value = InvokerHelper.getProperty(bean, node.name)
+        }
+        catch (MissingPropertyException ignored) {
+            return null
+        }
+        if (!next.isInIterable()) {
+            return value
+        }
+        if (next.key != null) {
+            return value instanceof Map ? ((Map) value).get(next.key) : null
+        }
+        Integer index = next.index
+        if (index == null) {

Review Comment:
   A transient `@Valid Set` has no index on the violation path, so this returns 
null and the element is treated as its declared type. An owner validated in 
`Extended`, whose association converts `Base` to `Converted`, then drops a 
nested conversion from `Extended` to `Details` on a set element. Direct 
validation reports `child.records[].details.name`. Validation through the 
entity reports nothing. Keep the runtime element when the path has no index.



##########
grails-datamapping-validation/src/main/groovy/grails/gorm/validation/PersistentEntityValidator.groovy:
##########
@@ -198,97 +413,97 @@ class PersistentEntityValidator implements 
CascadingValidator, ConstrainedEntity
     }
 
     /**
-     * Cascades validation to a one-to-one or many-to-one property.
+     * Cascades validation to a one-to-one or many-to-one property. A proxy is 
validated as the instance it stands
+     * for, as the object validation starts with is. A cycle of associations 
ends here: an instance validation is
+     * cascading from, the object validation started with or one on the path 
to the associated object, is not
+     * validated again, whatever hints it is reached with. Any other 
associated object is validated with the hints
+     * the validator of the owning entity gives for the association: its 
constraints once, with the hints given for
+     * all the hints of the owner, and, once for each distinct hints it is 
reached with, its associations and its
+     * rules beyond the constraints, through {@link 
#validateAssociationsAndBeyond} of its own validator.
      *
      * @param errors The Errors instance
      * @param bean The original BeanWrapper
-     * @param associatedObject The associated object's current value
+     * @param associatedObject The associated object's current value, a proxy 
or the instance itself
      * @param association The GrailsDomainClassProperty instance
      * @param propertyName The name of the property
      * @param indexOrKey
      */
     @SuppressWarnings('rawtypes')
     protected void cascadeValidationToOne(Object parentObject, String 
propertyName, Association association, Errors errors, EntityReflector 
reflector, Object associatedObject, Object indexOrKey, Set validatedObjects) {
-        if (associatedObject == null) {
+        // the instance holds the state a validator reading fields, such as 
Bean Validation, looks for, which a proxy
+        // does not carry, and it is the one a cycle of associations has to 
end at however it is reached
+        Object instance = unwrap(associatedObject)
+        if (instance == null) {
             return
         }
 
-        if (validatedObjects.contains(associatedObject)) {
+        // a cycle of associations ends at an instance validation is cascading 
from, whatever hints it is reached with
+        Cascade cascade = CASCADE.get()
+        if (cascade != null && cascade.leadsBackTo(instance)) {
             return
         }
 
-        validatedObjects.add(associatedObject)
+        // the hints decide what the object is validated with, so it is 
validated once with each distinct set of them
+        Object[] ownerHints = cascade == null ? NO_VALIDATION_HINTS : 
cascade.hints
+        Object[] ownerAllHints = cascade == null ? NO_VALIDATION_HINTS : 
cascade.allHints
+        Object[] associatedHints = NO_VALIDATION_HINTS
+        Object[] associatedAllHints = NO_VALIDATION_HINTS
+        PersistentEntity owner = association.owner
+        def ownerValidator = owner.is(entity) ? this : 
owner.mappingContext.getEntityValidator(owner)
+        if (ownerValidator instanceof PersistentEntityValidator) {
+            PersistentEntityValidator hinting = (PersistentEntityValidator) 
ownerValidator
+            associatedHints = 
hinting.getAssociationValidationHints(association, ownerHints)
+            associatedAllHints = ownerAllHints.is(ownerHints) ? 
associatedHints : hinting.getAssociationValidationHints(association, 
ownerAllHints)
+        }
+
+        if (!validatedObjects.add(new ValidatedObject(instance, 
associatedHints))) {
+            return
+        }
 
         PersistentEntity associatedEntity = association.getAssociatedEntity()
         if (associatedEntity == null) {
             return
         }
 
         // Make sure this object is eligible to cascade validation at all.
-        if (!association.doesCascadeValidate(associatedObject)) {
+        if (!association.doesCascadeValidate(instance)) {
             return
         }
 
         MappingContext mappingContext = associatedEntity.getMappingContext()
         EntityReflector associatedReflector = 
mappingContext.getEntityReflector(associatedEntity)
 
-        Association otherSide = null
-        if (association.isBidirectional()) {
-            otherSide = association.getInverseSide()
-        }
-
-        Map associatedConstrainedProperties
+        PersistentEntityValidator associatedValidator = null
+        Map<String, ConstrainedProperty> associatedConstrainedProperties = 
Collections.<String, ConstrainedProperty>emptyMap()
 
         def validator = mappingContext.getEntityValidator(associatedEntity)
         if (validator instanceof PersistentEntityValidator) {
-            associatedConstrainedProperties = ((PersistentEntityValidator) 
validator).getConstrainedProperties()
-        }
-        else {
-            associatedConstrainedProperties = Collections.<String, 
ConstrainedProperty>emptyMap()
+            associatedValidator = (PersistentEntityValidator) validator

Review Comment:
   The association is validated with the entity declared on the association. A 
persistent subclass held in a superclass association is checked with the 
superclass validator. A subclass that relaxes `optionalCode` with `@Null` 
validates on its own and fails through the association as 
`item.optionalCode:nullable`. Resolve the runtime entity before choosing the 
validator.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaPersistentEntityValidator.groovy:
##########
@@ -0,0 +1,346 @@
+/*
+ *  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
+ *
+ *    https://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.grails.datastore.gorm.validation.jakarta
+
+import java.util.concurrent.ConcurrentHashMap
+import java.util.function.Function
+
+import groovy.transform.CompileStatic
+
+import jakarta.validation.Validator
+import jakarta.validation.groups.Default
+import jakarta.validation.metadata.CascadableDescriptor
+import jakarta.validation.metadata.GroupConversionDescriptor
+
+import org.springframework.context.MessageSource
+import org.springframework.validation.Errors
+
+import grails.gorm.validation.ConstrainedProperty
+import grails.gorm.validation.PersistentEntityValidator
+import 
org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluator
+import 
org.grails.datastore.gorm.validation.constraints.registry.ConstraintRegistry
+import org.grails.datastore.mapping.model.PersistentEntity
+import org.grails.datastore.mapping.model.types.Association
+
+/**
+ * Validates a persistent entity constrained with Jakarta Bean Validation 
annotations: its GORM constraints (the
+ * {@code constraints} block and the defaults) as a {@link 
PersistentEntityValidator}, then its annotations with the
+ * Bean Validation provider, whose violations are reported as field errors 
with Spring's Bean Validation message
+ * codes (such as {@code NotNull.member.name}, {@code NotNull.name} and {@code 
NotNull}).
+ * <p>
+ * The GORM {@code nullable} constraint is left to Bean Validation for a 
property whose nullability an annotation
+ * of the validated groups declares ({@code @NotNull}, {@code @NotBlank}, 
{@code @NotEmpty} or {@code @Null}), so a
+ * null value is reported once. The GORM validator cascades to the persistent 
associations as it does for any
+ * entity, and validates an annotated associated entity both ways through 
{@link #validateBeyondConstraints};
+ * Bean Validation cascades only through the other properties marked {@code 
@Valid}, such as a transient one, and
+ * from the objects it reaches that way through every property marked {@code 
@Valid}. An object is validated in the
+ * default group, or in the groups given as validation hints to
+ * {@link #validate(Object, Errors, Object...)}, and an object associated 
through a property marked {@code @Valid}
+ * in the groups of the object owning it, converted as the {@code 
@ConvertGroup} annotations of the property
+ * declare, as Bean Validation cascades, see {@link 
#getAssociationValidationHints}. Any other associated object is
+ * validated in the default group, as it is on its own. An associated object 
reached through two associations is
+ * validated once in each distinct list of groups. A group sequence is 
validated group by group, each group over
+ * the object and the objects it cascades to, and stops at the first group 
with a violation among them, see
+ * {@link #validateAssociationsAndBeyond}. The default group of an entity 
redefining it with Hibernate Validator's
+ * {@code @GroupSequenceProvider} stands for the sequence the provider chooses 
for the instance, so the nullability
+ * constraints of the block that give way to its annotations are decided per 
instance, see
+ * {@link #getValidatedConstrainedProperties}.
+ * <p>
+ * {@link #getConstrainedProperties()} describes the annotations as GORM 
constraints next to the ones of the block,
+ * see {@link JakartaConstraintsTranslator}.
+ *
+ * @since 9.0.0
+ */
+@CompileStatic
+class JakartaPersistentEntityValidator extends PersistentEntityValidator {
+
+    private static final List<Class<?>> DEFAULT_GROUP_LIST = 
Collections.<Class<?>> singletonList(Default)
+
+    /**
+     * The validator for the Bean Validation annotations of the entity
+     */
+    final GormValidatorAdapter beanValidator
+
+    private final JakartaConstraintsTranslator translator
+    private final ConstraintsEvaluator constraintsEvaluator
+    private final Map<String, ConstrainedProperty> 
describedConstrainedProperties
+    private final Map<List<Object>, Map<String, ConstrainedProperty>> 
validatedConstrainedPropertiesByGroups = new ConcurrentHashMap<>()
+
+    /**
+     * @param entity The entity
+     * @param messageSource The message source
+     * @param constraintsEvaluator The evaluator of the GORM constraints
+     * @param constraintRegistry The registry the constraints describing the 
annotations are built with
+     * @param beanValidator The Bean Validation validator
+     */
+    JakartaPersistentEntityValidator(PersistentEntity entity, MessageSource 
messageSource, ConstraintsEvaluator constraintsEvaluator,
+            ConstraintRegistry constraintRegistry, Validator beanValidator) {
+        super(entity, messageSource, constraintsEvaluator)
+        this.beanValidator = beanValidator instanceof GormValidatorAdapter
+                ? (GormValidatorAdapter) beanValidator
+                : new GormValidatorAdapter(beanValidator)
+        this.constraintsEvaluator = constraintsEvaluator
+        this.translator = new JakartaConstraintsTranslator(this.beanValidator, 
targetClass)
+        Map<String, ConstrainedProperty> validated = 
super.constrainedProperties
+        translator.delegateNullabilityToBeanValidation(validated)
+        this.describedConstrainedProperties = 
Collections.unmodifiableMap(translator.translate(validated, constraintRegistry))
+    }
+
+    /**
+     * @return The constraints of the entity, the Bean Validation annotations 
described as GORM constraints next to
+     * the ones of the {@code constraints} block. The annotations themselves 
are validated by Bean Validation, see
+     * {@link #getValidatedConstrainedProperties} for the constraints this 
validator validates.
+     */
+    @Override
+    Map<String, ConstrainedProperty> getConstrainedProperties() {
+        return describedConstrainedProperties
+    }
+
+    /**
+     * The constraints of the block, with the nullability of the properties an 
annotation of the groups the object
+     * is validated in checks for null left to Bean Validation. The default 
group among them stands for the groups
+     * the class redefines it with, for the object when a group sequence 
provider chooses them per instance, see
+     * {@link JakartaConstraintsTranslator#findDefaultGroups}.
+     */
+    @Override
+    protected Map<String, ConstrainedProperty> 
getValidatedConstrainedProperties(Object obj, Object[] hints) {
+        List<Class<?>> groups = groupsOf(hints)
+        Set<Class<?>> excluded = excludedGroupsOf(hints)
+        Set<Class<?>> defaultGroups = translator.findDefaultGroups(obj)
+        if (groups.isEmpty() && excluded.isEmpty() && defaultGroups == 
translator.defaultGroups) {
+            return super.getValidatedConstrainedProperties(obj, hints)
+        }
+        List<Object> key = [groups, excluded, defaultGroups] as List<Object>
+        return validatedConstrainedPropertiesByGroups.computeIfAbsent(key, { 
List<Object> k ->
+            Map<String, ConstrainedProperty> evaluated = 
constraintsEvaluator.evaluate(targetClass)
+            translator.delegateNullabilityToBeanValidation(evaluated, groups 
?: DEFAULT_GROUP_LIST, excluded, defaultGroups)
+            return Collections.unmodifiableMap(evaluated)
+        } as Function<List<Object>, Map<String, ConstrainedProperty>>)
+    }
+
+    /**
+     * Validates the object in the groups among its hints as Bean Validation 
validates an object in them: in the
+     * groups that are not group sequences together, cascading to the 
associations with them, then in each group
+     * sequence group by group, cascading with each group, and stopping at the 
first group with a violation of the
+     * object or of an object it cascades to. The constraints of the block of 
the object are validated before, once,
+     * with all the groups, and the ones of an associated object when it is 
first reached, so a violation of them
+     * stops a sequence as a violation of the group it is reached in does.
+     */
+    @Override
+    protected void validateAssociationsAndBeyond(Object obj, Errors errors, 
boolean cascade, Object[] hints, Set validatedObjects) {
+        List<Class<?>> groups = groupsOf(hints) ?: DEFAULT_GROUP_LIST
+        Set<Class<?>> excluded = excludedGroupsOf(hints)
+        List<Class<?>> singleGroups = groups.findAll { Class<?> group -> 
!JakartaConstraintsTranslator.isGroupSequence(group) }
+        if (singleGroups) {
+            validateInGroups(obj, errors, cascade, singleGroups, excluded, 
validatedObjects)
+        }
+        for (Class<?> sequence in groups) {
+            if (!JakartaConstraintsTranslator.isGroupSequence(sequence)) {
+                continue
+            }
+            for (Class<?> group in 
JakartaConstraintsTranslator.sequenceGroupsOf(sequence)) {

Review Comment:
   Hints that contain both group `A` and a sequence `Steps = [A, B]` validate 
`A` in `singleGroups` and again here. One failing annotation in `A` becomes two 
field errors. Bean Validation reports it once. Skip a sequence member that this 
pass already validated. The sequence also stops only when this call adds an 
error. A constraints-block failure already on the root is inside `errorCount` 
before the sequence starts, so a later group still runs.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to