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


##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaPersistentEntityValidator.groovy:
##########
@@ -0,0 +1,199 @@
+/*
+ *  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.ContainerElementTypeDescriptor
+import jakarta.validation.metadata.GroupConversionDescriptor
+import jakarta.validation.metadata.PropertyDescriptor
+
+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 annotated with Jakarta Bean Validation 
constraints: 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. Bean Validation does not cascade: the GORM 
validator cascades to the associations
+ * as it does for any entity, and validates an annotated associated entity 
both ways through
+ * {@link #validateBeyondConstraints}. 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.
+ * <p>
+ * {@link #getConstrainedProperties()} describes the annotations as GORM 
constraints next to the ones of the block,
+ * see {@link JakartaConstraintsTranslator}.
+ *
+ * @since 8.0.1
+ */
+@CompileStatic
+class JakartaPersistentEntityValidator extends PersistentEntityValidator {

Review Comment:
   Fixed in 15689b8f9f: `PersistentEntityValidator.supports` accepts the 
subclasses of the entity, and `validate()` unwraps a proxy to validate the 
instance it stands for, as the provider reads fields a proxy does not carry. 
`PersistentEntityValidatorSpec` validates a Javassist proxy from the mapping 
context's proxy factory through `ValidationUtils.invokeValidator`. Details in 
the review body.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaPersistentEntityValidator.groovy:
##########
@@ -0,0 +1,199 @@
+/*
+ *  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.ContainerElementTypeDescriptor
+import jakarta.validation.metadata.GroupConversionDescriptor
+import jakarta.validation.metadata.PropertyDescriptor
+
+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 annotated with Jakarta Bean Validation 
constraints: 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. Bean Validation does not cascade: the GORM 
validator cascades to the associations
+ * as it does for any entity, and validates an annotated associated entity 
both ways through
+ * {@link #validateBeyondConstraints}. 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.
+ * <p>
+ * {@link #getConstrainedProperties()} describes the annotations as GORM 
constraints next to the ones of the block,
+ * see {@link JakartaConstraintsTranslator}.
+ *
+ * @since 8.0.1
+ */
+@CompileStatic
+class JakartaPersistentEntityValidator extends PersistentEntityValidator {
+
+    /**
+     * 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<Class<?>>, 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.getValidatedConstrainedProperties(NO_VALIDATION_HINTS)
+        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.
+     */
+    @Override
+    protected Map<String, ConstrainedProperty> 
getValidatedConstrainedProperties(Object[] hints) {
+        List<Class<?>> groups = groupsOf(hints)
+        if (groups.isEmpty()) {
+            return super.getValidatedConstrainedProperties(hints)
+        }
+        return validatedConstrainedPropertiesByGroups.computeIfAbsent(groups, 
{ List<Class<?>> key ->
+            Map<String, ConstrainedProperty> evaluated = 
constraintsEvaluator.evaluate(targetClass)
+            translator.delegateNullabilityToBeanValidation(evaluated, 
key.toArray(new Class<?>[0]))
+            return Collections.unmodifiableMap(evaluated)
+        } as Function<List<Class<?>>, Map<String, ConstrainedProperty>>)
+    }
+
+    /**
+     * Validates the Bean Validation annotations of the object in the groups 
it is validated in, without cascading,
+     * as the GORM validator cascades.
+     */
+    @Override
+    protected void validateBeyondConstraints(Object obj, Errors errors, 
Object[] hints) {
+        beanValidator.validate(obj, errors, false, groupsOf(hints).toArray(new 
Class<?>[0]))
+    }
+
+    /**
+     * The groups an object associated through a property marked {@code 
@Valid} is validated in: the groups of the
+     * object owning it, converted as the {@code @ConvertGroup} annotations of 
the property declare. An object
+     * associated through any other property is validated in the default 
group, as it is on its own.
+     * <p>
+     * A conversion applies to the groups the provider validates when a group 
of the owner is validated: the group
+     * and the groups it extends, and, for a group sequence, its groups in 
their order. A group none of whose groups
+     * is converted stays as it is, so a sequence keeps stopping at the first 
of its groups with a violation. The
+     * groups a sequence converts to are validated in its order, each of them, 
as the provider validates the groups
+     * a conversion produces.
+     */
+    @Override
+    protected Object[] getAssociationValidationHints(Association association, 
Object[] hints) {
+        CascadableDescriptor cascaded = 
findCascadedDescriptor(association.name)
+        if (cascaded == null) {
+            return NO_VALIDATION_HINTS
+        }
+        Map<Class<?>, Class<?>> conversions = new HashMap<>()
+        for (GroupConversionDescriptor conversion in 
cascaded.groupConversions) {
+            conversions.put(conversion.from, conversion.to)
+        }
+        List<Class<?>> groups = groupsOf(hints) ?: Collections.<Class<?>> 
singletonList(Default)
+        List<Class<?>> converted = new ArrayList<>()
+        for (Class<?> group in groups) {
+            List<Class<?>> validated = 
JakartaConstraintsTranslator.validatedGroupsOf(group)
+            List<Class<?>> convertedGroups = validated.collect { Class<?> 
validatedGroup -> conversions.get(validatedGroup) ?: validatedGroup }

Review Comment:
   Fixed in 15689b8f9f: the hints follow the owner's validation order group by 
group, converting each by itself and passing an unconverted group alone, as 
`validateCascadedAnnotatedObjectForCurrentGroup` passes 
`getValidationOrder(currentGroup, currentGroup != originalGroup)`. The provider 
cannot validate a group without the groups it extends, so the hints exclude the 
converted ones, whose violations are left out of the report, of the `nullable` 
relaxation and of the further cascade. Your case is in 
`JakartaValidatorRegistrySpec`: validated in `Extended`, the child reports 
`label` and `note`, not `retired`, and in `Base` it reports `label`, as the 
provider does. Details in the review body.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaConstraintsTranslator.groovy:
##########
@@ -0,0 +1,662 @@
+/*
+ *  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.lang.annotation.Annotation
+import java.math.RoundingMode
+
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import jakarta.validation.GroupSequence
+import jakarta.validation.Validator
+import jakarta.validation.constraints.AssertFalse
+import jakarta.validation.constraints.AssertTrue
+import jakarta.validation.constraints.DecimalMax
+import jakarta.validation.constraints.DecimalMin
+import jakarta.validation.constraints.Email
+import jakarta.validation.constraints.Max
+import jakarta.validation.constraints.Min
+import jakarta.validation.constraints.Negative
+import jakarta.validation.constraints.NegativeOrZero
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.NotEmpty
+import jakarta.validation.constraints.NotNull
+import jakarta.validation.constraints.Null
+import jakarta.validation.constraints.Pattern
+import jakarta.validation.constraints.Positive
+import jakarta.validation.constraints.PositiveOrZero
+import jakarta.validation.constraints.Size
+import jakarta.validation.groups.Default
+import jakarta.validation.metadata.BeanDescriptor
+import jakarta.validation.metadata.ConstraintDescriptor
+import jakarta.validation.metadata.PropertyDescriptor
+import jakarta.validation.metadata.Scope
+
+import org.springframework.context.support.StaticMessageSource
+import org.springframework.util.ClassUtils
+import org.springframework.util.ReflectionUtils
+
+import grails.gorm.validation.ConstrainedProperty
+import grails.gorm.validation.DefaultConstrainedProperty
+import grails.gorm.validation.exceptions.ValidationConfigurationException
+import 
org.grails.datastore.gorm.validation.constraints.registry.ConstraintRegistry
+import 
org.grails.datastore.gorm.validation.constraints.registry.DefaultConstraintRegistry
+import org.grails.datastore.mapping.reflect.ClassPropertyFetcher
+
+/**
+ * Describes the Jakarta Bean Validation constraints of a class in terms of 
GORM constraints, so that the constraint
+ * metadata of an entity annotated with them ({@link 
grails.gorm.validation.ConstrainedEntity}) reflects the
+ * annotations as well as the {@code constraints} block. The annotations 
themselves are still validated by Bean
+ * Validation, the translated constraints only describe them: a property 
annotated with {@code @NotNull} reports
+ * {@code nullable == false}, one annotated with {@code @Size(max = 10)} 
reports {@code maxSize == 10}, and so on.
+ * <p>
+ * The constraints are read from the descriptors of the class and of its 
superclasses, as the Bean Validation
+ * provider sees them: a composed constraint is described through the 
constraints composing it, with its attribute
+ * overrides applied, and only the constraints of the default group are 
described, as {@code validate()} validates no
+ * other group. A constraint on a getter without a backing field, such as a 
cross field {@code @AssertTrue}, is
+ * validated but not described, unless the {@code constraints} block 
constrains the property. Annotations without
+ * a GORM equivalent (such as {@code @Past}, {@code @Digits} or custom 
constraints) are left out.
+ *
+ * @since 8.0.1
+ */
+@Slf4j
+@CompileStatic
+class JakartaConstraintsTranslator {
+
+    private static final Set<Class<?>> DEFAULT_GROUP = Collections.<Class<?>> 
singleton(Default)
+
+    /**
+     * The descriptor of the class
+     */
+    final BeanDescriptor beanDescriptor
+
+    /**
+     * The constraints of the default group by property name, the properties 
without any left out
+     */
+    final Map<String, List<ConstraintDescriptor<?>>> constraintDescriptors
+
+    /**
+     * The constraints of every group by property name
+     */
+    private final Map<String, List<DeclaredConstraint>> declaredConstraints
+
+    /**
+     * @param validator The Bean Validation validator to read the descriptors 
of the class and its superclasses from
+     * @param beanClass The class
+     */
+    JakartaConstraintsTranslator(Validator validator, Class<?> beanClass) {
+        this.beanDescriptor = validator.getConstraintsForClass(beanClass)
+        this.declaredConstraints = findDeclaredConstraints(validator, 
beanClass)
+        this.constraintDescriptors = 
Collections.unmodifiableMap(findValidatedConstraints(DEFAULT_GROUP))
+    }
+
+    /**
+     * @return The described class
+     */
+    Class<?> getBeanClass() {
+        return beanDescriptor.elementClass
+    }
+
+    /**
+     * Reads the constraints each class of the hierarchy declares, with the 
groups the default group stands for in
+     * that class. As the provider validates them, the first class from the 
described class up to redefine the
+     * default group with a {@link GroupSequence} applies its sequence to its 
own constraints and to the ones of its
+     * superclasses, whereas the classes below it validate theirs in the 
{@link Default} group only.
+     */
+    private static Map<String, List<DeclaredConstraint>> 
findDeclaredConstraints(Validator validator, Class<?> beanClass) {
+        List<Class<?>> hierarchy = hierarchyOf(beanClass)
+        int redefining = hierarchy.findIndexOf { Class<?> type -> 
!type.isInterface() && type.getAnnotation(GroupSequence) != null }
+        Set<Class<?>> sequenced = redefining < 0 ? DEFAULT_GROUP : 
sequencedDefaultGroupsOf(hierarchy.get(redefining))
+        Map<String, List<DeclaredConstraint>> result = new TreeMap<>()
+        for (int i = 0; i < hierarchy.size(); i++) {
+            Set<Class<?>> defaultGroups = redefining >= 0 && i >= redefining ? 
sequenced : DEFAULT_GROUP
+            for (PropertyDescriptor property in 
validator.getConstraintsForClass(hierarchy.get(i)).constrainedProperties) {
+                Set<ConstraintDescriptor<?>> declared = 
property.findConstraints().lookingAt(Scope.LOCAL_ELEMENT).constraintDescriptors
+                for (ConstraintDescriptor<?> descriptor in declared) {
+                    result.computeIfAbsent(property.propertyName) { String 
name -> new ArrayList<DeclaredConstraint>() }
+                            .add(new DeclaredConstraint(descriptor, 
defaultGroups))
+                }
+            }
+        }
+        return result
+    }
+
+    /**
+     * The class, its interfaces, then its superclass with its interfaces and 
so on, each type once, in the order
+     * the provider visits them.
+     */
+    private static List<Class<?>> hierarchyOf(Class<?> beanClass) {
+        List<Class<?>> hierarchy = new ArrayList<>()
+        addHierarchy(beanClass, hierarchy)
+        return hierarchy
+    }
+
+    private static void addHierarchy(Class<?> type, List<Class<?>> hierarchy) {
+        for (Class<?> current = type; current != null; current = 
current.superclass) {
+            if (hierarchy.contains(current)) {
+                return
+            }
+            hierarchy.add(current)
+            for (Class<?> implemented in current.interfaces) {
+                addHierarchy(implemented, hierarchy)
+            }
+        }
+    }
+
+    /**
+     * The groups the default group of a class stands for when the class 
redefines it: the {@link Default} group, and
+     * the groups of the sequence, in which the class itself stands for the 
{@link Default} group.
+     */
+    private static Set<Class<?>> sequencedDefaultGroupsOf(Class<?> redefining) 
{
+        Set<Class<?>> groups = new HashSet<>()
+        groups.add(Default)
+        Set<Class<?>> visitedSequences = new HashSet<>()
+        visitedSequences.add(redefining)
+        addSequencedGroups(redefining.getAnnotation(GroupSequence).value(), 
groups, visitedSequences)
+        return Collections.unmodifiableSet(groups)
+    }
+
+    /**
+     * The groups the provider validates when the given group is, in the order 
it validates them: the group itself
+     * and the groups it extends, or, for a group sequence, its groups in 
their order, each with the groups it
+     * extends, with a nested sequence expanded in place.
+     *
+     * @param group The group, or group sequence
+     * @return The validated groups, each once
+     */
+    static List<Class<?>> validatedGroupsOf(Class<?> group) {
+        Set<Class<?>> groups = new LinkedHashSet<>()
+        addSequencedGroups([group] as Class<?>[], groups, new 
HashSet<Class<?>>())
+        return Collections.unmodifiableList(new ArrayList<>(groups))
+    }
+
+    /**
+     * Adds the groups validated when the given ones are, as the provider 
validates them: a group brings the groups
+     * it extends, and a sequence its own groups.
+     */
+    private static void addSequencedGroups(Class<?>[] sequence, Set<Class<?>> 
groups, Set<Class<?>> visitedSequences) {
+        for (Class<?> group in sequence) {
+            GroupSequence nested = group.getAnnotation(GroupSequence)
+            if (nested == null) {
+                addWithInheritedGroups(group, groups)
+            }
+            else if (visitedSequences.add(group)) {
+                addSequencedGroups(nested.value(), groups, visitedSequences)
+            }
+        }
+    }
+
+    private static void addWithInheritedGroups(Class<?> group, Set<Class<?>> 
groups) {
+        if (groups.add(group)) {
+            for (Class<?> inherited in group.interfaces) {
+                addWithInheritedGroups(inherited, groups)
+            }
+        }
+    }
+
+    /**
+     * The constraints validated when the given groups are, by property name, 
ordered so that the description is the
+     * same on every run when two of them describe the same GORM constraint, 
as the provider reports them as a set.
+     */
+    private Map<String, List<ConstraintDescriptor<?>>> 
findValidatedConstraints(Collection<Class<?>> groups) {
+        Set<Class<?>> requested = new HashSet<>()
+        addSequencedGroups(groups.toArray(new Class<?>[0]), requested, new 
HashSet<Class<?>>())
+        Map<String, List<ConstraintDescriptor<?>>> result = new 
LinkedHashMap<>()
+        declaredConstraints.each { String propertyName, 
List<DeclaredConstraint> declared ->
+            List<ConstraintDescriptor<?>> validated = new ArrayList<>()
+            for (DeclaredConstraint constraint in declared) {
+                if (constraint.isValidatedIn(requested)) {
+                    validated.add(constraint.descriptor)
+                }
+            }
+            if (validated) {
+                result.put(propertyName, validated.toSorted { 
ConstraintDescriptor<?> a, ConstraintDescriptor<?> b ->
+                    a.annotation.toString() <=> b.annotation.toString()
+                })
+            }
+        }
+        return result
+    }
+
+    /**
+     * The properties whose nullability a constraint of the default group 
declares ({@code @NotNull},
+     * {@code @NotBlank}, {@code @NotEmpty} or {@code @Null}, directly or 
through a composed constraint).
+     *
+     * @return The property names
+     */
+    Set<String> getNullabilityConstrainedProperties() {
+        return nullabilityConstrainedPropertiesOf(constraintDescriptors)
+    }
+
+    /**
+     * The properties whose nullability a constraint validated in the given 
groups declares, as
+     * {@link #getNullabilityConstrainedProperties()} for the default group.
+     *
+     * @param groups The groups, the default group when there are none
+     * @return The property names
+     */
+    Set<String> findNullabilityConstrainedProperties(Class<?>... groups) {
+        if (isDefaultGroup(groups)) {
+            return nullabilityConstrainedProperties
+        }
+        return 
nullabilityConstrainedPropertiesOf(findValidatedConstraints(Arrays.asList(groups)))
+    }
+
+    private static boolean isDefaultGroup(Class<?>[] groups) {
+        return groups.length == 0 || (groups.length == 1 && groups[0] == 
Default)
+    }
+
+    private static Set<String> nullabilityConstrainedPropertiesOf(Map<String, 
List<ConstraintDescriptor<?>>> descriptorsByProperty) {
+        Set<String> result = new LinkedHashSet<>()
+        descriptorsByProperty.each { String propertyName, 
List<ConstraintDescriptor<?>> descriptors ->
+            if (descriptors.any { ConstraintDescriptor<?> descriptor -> 
constrainsNullability(descriptor) }) {
+                result.add(propertyName)
+            }
+        }
+        return result
+    }
+
+    private static boolean constrainsNullability(ConstraintDescriptor<?> 
descriptor) {
+        Annotation annotation = descriptor.annotation
+        if (annotation instanceof NotNull || annotation instanceof NotBlank || 
annotation instanceof NotEmpty || annotation instanceof Null) {
+            return true
+        }
+        return descriptor.composingConstraints.any { ConstraintDescriptor<?> 
composing -> constrainsNullability(composing) }
+    }
+
+    /**
+     * Leaves the nullability of the {@link 
#findNullabilityConstrainedProperties properties Bean Validation checks
+     * for null} in the given groups to it by relaxing their GORM {@code 
nullable} constraint, so that a {@code null}
+     * value is reported once rather than by both validators. The constraints 
are modified in place.
+     *
+     * @param constraints The evaluated GORM constraints of the class
+     * @param groups The groups the class is validated in, the default group 
when there are none
+     */
+    void delegateNullabilityToBeanValidation(Map<String, ConstrainedProperty> 
constraints, Class<?>... groups) {
+        for (String propertyName in 
findNullabilityConstrainedProperties(groups)) {
+            ConstrainedProperty constrained = constraints.get(propertyName)
+            if (constrained != null && 
constrained.hasAppliedConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT)) {
+                
constrained.applyConstraint(ConstrainedProperty.NULLABLE_CONSTRAINT, true)
+            }
+        }
+    }
+
+    /**
+     * Describes the Bean Validation constraints of the class as GORM 
constraints, on top of a copy of the given
+     * constraints. The given constraints are not modified, as they are the 
ones the GORM validator validates with.
+     *
+     * @param constraints The GORM constraints of the class, typically 
evaluated from its {@code constraints} block
+     * @param registry The registry to build the constraints with
+     * @return A new map with the merged constraints, or the given map when 
the class has no constraints to describe
+     */
+    Map<String, ConstrainedProperty> translate(Map<String, 
ConstrainedProperty> constraints, ConstraintRegistry registry) {
+        if (constraintDescriptors.isEmpty()) {
+            return constraints
+        }
+        ConstraintRegistry targetRegistry = registry ?: new 
DefaultConstraintRegistry(new StaticMessageSource())
+        Map<String, ConstrainedProperty> merged = new 
LinkedHashMap<>(constraints ?: Collections.<String, ConstrainedProperty> 
emptyMap())
+        Class<?> beanClass = this.beanClass
+        constraintDescriptors.each { String propertyName, 
List<ConstraintDescriptor<?>> descriptors ->
+            ConstrainedProperty existing = constraints?.get(propertyName)
+            DefaultConstrainedProperty constrained
+            if (existing != null) {
+                constrained = copy(existing, targetRegistry)
+            }
+            else if (ReflectionUtils.findField(beanClass, propertyName) == 
null) {
+                // a getter without a backing field, such as a cross field 
@AssertTrue, is not a property to describe
+                return
+            }
+            else {
+                Class<?> propertyType = 
ClassPropertyFetcher.getPropertyType(beanClass, propertyName)
+                if (propertyType == null) {
+                    return
+                }
+                constrained = new DefaultConstrainedProperty(beanClass, 
propertyName, propertyType, targetRegistry)
+                constrained.order = merged.size() + 1
+            }
+            for (ConstraintDescriptor<?> descriptor in descriptors) {
+                applyConstraint(constrained, descriptor)
+            }
+            merged.put(propertyName, constrained)
+        }
+        return merged
+    }
+
+    /**
+     * A new constrained property with the same constraints and metadata as 
the given one.
+     *
+     * @param source The constrained property to copy
+     * @param registry The registry to build the constraints with
+     * @return The copy
+     */
+    static DefaultConstrainedProperty copy(ConstrainedProperty source, 
ConstraintRegistry registry) {
+        DefaultConstrainedProperty target = new 
DefaultConstrainedProperty(source.owner, source.propertyName, 
source.propertyType, registry)
+        for (grails.gorm.validation.Constraint constraint in 
source.appliedConstraints) {
+            try {
+                target.applyConstraint(constraint.name, constraint.parameter)
+            }
+            catch (Exception e) {
+                log.warn('Unable to copy the [{}] constraint of [{}.{}]: {}', 
constraint.name, source.owner.name, source.propertyName, e.message)
+            }
+        }
+        if (source instanceof DefaultConstrainedProperty) {
+            DefaultConstrainedProperty defaults = (DefaultConstrainedProperty) 
source
+            defaults.metaConstraints.each { String name, Object value -> 
target.addMetaConstraint(name, value) }
+            target.order = defaults.order
+            target.displayType = defaults.displayType
+            target.editable = defaults.editable
+            target.format = defaults.format
+            target.widget = defaults.widget
+            target.password = defaults.password
+            target.attributes = defaults.attributes
+        }
+        return target
+    }
+
+    /**
+     * Applies the GORM constraints describing the constraint. Bounds tighten 
the ones already applied rather than
+     * replace them. A composed constraint without an equivalent of its own is 
described through the constraints
+     * composing it, with the attributes it overrides applied.
+     *
+     * @param constrained The constrained property to describe the constraint 
on
+     * @param descriptor The constraint
+     */
+    void applyConstraint(DefaultConstrainedProperty constrained, 
ConstraintDescriptor<?> descriptor) {
+        Annotation annotation = descriptor.annotation
+        switch (annotation) {
+            case NotNull:
+                applyIfSupported(constrained, 
ConstrainedProperty.NULLABLE_CONSTRAINT, false)
+                break
+            case NotBlank:
+                applyIfSupported(constrained, 
ConstrainedProperty.NULLABLE_CONSTRAINT, false)
+                applyIfSupported(constrained, 
ConstrainedProperty.BLANK_CONSTRAINT, false)
+                break
+            case NotEmpty:
+                applyIfSupported(constrained, 
ConstrainedProperty.NULLABLE_CONSTRAINT, false)
+                tightenMinSize(constrained, 1)
+                break
+            case Null:
+                applyIfSupported(constrained, 
ConstrainedProperty.NULLABLE_CONSTRAINT, true)
+                break
+            case Size:
+                Size size = (Size) annotation
+                if (size.min() > 0) {
+                    tightenMinSize(constrained, size.min())
+                }
+                if (size.max() < Integer.MAX_VALUE) {
+                    tightenMaxSize(constrained, size.max())
+                }
+                break
+            case Min:
+                tightenMin(constrained, ((Min) annotation).value())
+                break
+            case Max:
+                tightenMax(constrained, ((Max) annotation).value())
+                break
+            case DecimalMin:
+                DecimalMin decimalMin = (DecimalMin) annotation
+                BigDecimal min = new BigDecimal(decimalMin.value())
+                if (isIntegral(constrained.propertyType)) {
+                    // the smallest whole number the bound admits
+                    tightenMin(constrained, decimalMin.inclusive()
+                            ? min.setScale(0, RoundingMode.CEILING)
+                            : min.setScale(0, 
RoundingMode.FLOOR).add(BigDecimal.ONE))
+                }
+                else if (decimalMin.inclusive()) {
+                    tightenMin(constrained, min)
+                }
+                break
+            case DecimalMax:
+                DecimalMax decimalMax = (DecimalMax) annotation
+                BigDecimal max = new BigDecimal(decimalMax.value())
+                if (isIntegral(constrained.propertyType)) {
+                    // the largest whole number the bound admits
+                    tightenMax(constrained, decimalMax.inclusive()
+                            ? max.setScale(0, RoundingMode.FLOOR)
+                            : max.setScale(0, 
RoundingMode.CEILING).subtract(BigDecimal.ONE))
+                }
+                else if (decimalMax.inclusive()) {
+                    tightenMax(constrained, max)
+                }
+                break
+            case Positive:
+                if (isIntegral(constrained.propertyType)) {
+                    tightenMin(constrained, 1)
+                }
+                break
+            case PositiveOrZero:
+                tightenMin(constrained, 0)
+                break
+            case Negative:
+                if (isIntegral(constrained.propertyType)) {
+                    tightenMax(constrained, -1)
+                }
+                break
+            case NegativeOrZero:
+                tightenMax(constrained, 0)
+                break
+            case Email:
+                applyIfSupported(constrained, 
ConstrainedProperty.EMAIL_CONSTRAINT, true)
+                Email email = (Email) annotation
+                if (email.regexp() != '.*') {
+                    applyMatches(constrained, withInlineFlags(email.regexp(), 
email.flags()))
+                }
+                break
+            case Pattern:
+                Pattern pattern = (Pattern) annotation
+                applyMatches(constrained, withInlineFlags(pattern.regexp(), 
pattern.flags()))
+                break
+            case AssertTrue:
+                applyIfSupported(constrained, 
ConstrainedProperty.IN_LIST_CONSTRAINT, [Boolean.TRUE])
+                break
+            case AssertFalse:
+                applyIfSupported(constrained, 
ConstrainedProperty.IN_LIST_CONSTRAINT, [Boolean.FALSE])
+                break
+            default:
+                if (!applyHibernateValidatorConstraint(constrained, 
descriptor)) {
+                    for (ConstraintDescriptor<?> composing in 
descriptor.composingConstraints) {

Review Comment:
   Fixed in 15689b8f9f: the translator describes nothing through the 
constraints composing a `@ConstraintComposition` other than `AND`, neither 
bounds nor nullability. `JakartaConstraintsTranslatorSpec` has your `OR` of 
`@Size(max = 3)` and `@Size(min = 10)`, with lengths 2 and 12 accepted by the 
provider and neither bound described, a `@Null` or `@Size(min = 10)` 
composition that does not make the property required, and an `ALL_FALSE` one.



##########
grails-datamapping-validation/src/main/groovy/grails/gorm/validation/PersistentEntityValidator.groovy:
##########
@@ -335,4 +459,34 @@ class PersistentEntityValidator implements 
CascadingValidator, ConstrainedEntity
     boolean supports(Class<?> clazz) {
         return targetClass.is(clazz)
     }
+
+    /**
+     * An object validation has reached, with the validation hints it was 
validated with. Two objects that are
+     * equal reached with the same hints are the same validated object, so a 
cycle of associations ends, while an
+     * object reached again with other hints is validated again with them.
+     */
+    private static final class ValidatedObject {
+
+        private final Object object
+        private final List<Object> hints
+
+        ValidatedObject(Object object, Object[] hints) {
+            this.object = object
+            this.hints = Arrays.asList(hints)
+        }
+
+        @Override
+        boolean equals(Object other) {
+            if (!(other instanceof ValidatedObject)) {
+                return false
+            }
+            ValidatedObject that = (ValidatedObject) other
+            return object == that.object && hints == that.hints

Review Comment:
   Fixed in 15689b8f9f: the instance is compared by identity, so each instance 
is validated whatever it compares equal to, as the provider validates each 
instance (`BeanValueContext.isBeanAlreadyValidated` compares the bean with 
`==`). `PersistentEntityValidatorSpec` has two lines equal by name and as a 
`Comparable`, with the violation of the second reported, and the TCK 
`BeanValidatedBasket` the same on every datastore.



##########
grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaPersistentEntityValidator.groovy:
##########
@@ -0,0 +1,199 @@
+/*
+ *  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.ContainerElementTypeDescriptor
+import jakarta.validation.metadata.GroupConversionDescriptor
+import jakarta.validation.metadata.PropertyDescriptor
+
+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 annotated with Jakarta Bean Validation 
constraints: 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. Bean Validation does not cascade: the GORM 
validator cascades to the associations
+ * as it does for any entity, and validates an annotated associated entity 
both ways through
+ * {@link #validateBeyondConstraints}. 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.
+ * <p>
+ * {@link #getConstrainedProperties()} describes the annotations as GORM 
constraints next to the ones of the block,
+ * see {@link JakartaConstraintsTranslator}.
+ *
+ * @since 8.0.1
+ */
+@CompileStatic
+class JakartaPersistentEntityValidator extends PersistentEntityValidator {
+
+    /**
+     * 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<Class<?>>, 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.getValidatedConstrainedProperties(NO_VALIDATION_HINTS)
+        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.
+     */
+    @Override
+    protected Map<String, ConstrainedProperty> 
getValidatedConstrainedProperties(Object[] hints) {
+        List<Class<?>> groups = groupsOf(hints)
+        if (groups.isEmpty()) {
+            return super.getValidatedConstrainedProperties(hints)
+        }
+        return validatedConstrainedPropertiesByGroups.computeIfAbsent(groups, 
{ List<Class<?>> key ->
+            Map<String, ConstrainedProperty> evaluated = 
constraintsEvaluator.evaluate(targetClass)
+            translator.delegateNullabilityToBeanValidation(evaluated, 
key.toArray(new Class<?>[0]))
+            return Collections.unmodifiableMap(evaluated)
+        } as Function<List<Class<?>>, Map<String, ConstrainedProperty>>)
+    }
+
+    /**
+     * Validates the Bean Validation annotations of the object in the groups 
it is validated in, without cascading,
+     * as the GORM validator cascades.
+     */
+    @Override
+    protected void validateBeyondConstraints(Object obj, Errors errors, 
Object[] hints) {
+        beanValidator.validate(obj, errors, false, groupsOf(hints).toArray(new 
Class<?>[0]))

Review Comment:
   Fixed in 15689b8f9f, both points. While the GORM validator cascades, the 
traversable resolver lets the provider cascade through every property marked 
`@Valid` that is not a persistent association, so the transient `details` are 
validated under their path, with or without `deepValidate`. A group sequence, 
given as a hint or converted to, is validated group by group over the whole 
graph and stops at the first group with a violation anywhere in it, as 
`ValidatorImpl.validateInContext` does: `JakartaValidatorRegistrySpec` has the 
root failing in the first group stopping the child before the second, and the 
grandchild case from the other review, both checked against the provider's own 
result. Details in the review body.



-- 
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