jamesfredley commented on code in PR #16624: URL: https://github.com/apache/grails-core/pull/16624#discussion_r4413762161
########## 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: supports() is still exact class equality, inherited from PersistentEntityValidator. A Hibernate or Javassist proxy of the entity is a subclass, so DataBinder and ValidationUtils throw instead of validating it. The adapter this validator replaces accepted those instances. ########## 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: Cascading is disabled for the whole object, and GORM only walks persistent associations. A transient property annotated @Valid, such as details with a blank @NotBlank name, is accepted. The same split also finishes a group sequence on the child before the root, so a later group on the child fails even when an earlier group on the root already failed. ########## 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: When the owner is validated in Extended, and Extended extends Base, an association annotated @ConvertGroup(from=Base, to=Converted) is validated in Extended and Converted. The provider expands Extended again, so Base is active. A child property @Null(groups=Base) is rejected. The provider's own cascade converts Base to Converted and accepts it. ########## 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: Every composing constraint is applied. An OR composition of @Size(max=3) and @Size(min=10) accepts lengths 2 and 12, but this describes minSize 10 and maxSize 3. The generated schema then rejects a length the provider accepts. ########## 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: ValidatedObject uses Groovy == and the child's hashCode(). Two distinct children with the same business key and different constrained values compare equal, so a valid child causes an invalid child reached with the same groups to be skipped. The provider validates each instance. -- 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]
