jdaugherty commented on code in PR #16624: URL: https://github.com/apache/grails-core/pull/16624#discussion_r4408033059
########## grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaPersistentEntityValidator.groovy: ########## @@ -0,0 +1,98 @@ +/* + * 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 groovy.transform.CompileStatic + +import jakarta.validation.Validator + +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 + +/** + * 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 default group 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}. + * <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 Map<String, ConstrainedProperty> describedConstrainedProperties + + /** + * @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) + JakartaConstraintsTranslator translator = new JakartaConstraintsTranslator(this.beanValidator.getConstraintsForClass(targetClass)) + Map<String, ConstrainedProperty> validated = getValidatedConstrainedProperties() + 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 + } + + /** + * Validates the Bean Validation annotations of the object, without cascading, as the GORM validator cascades. + */ + @Override + protected void validateBeyondConstraints(Object obj, Errors errors) { + beanValidator.validate(obj, errors, false) Review Comment: Fixed in 491fca0fe9. There is still one cascade, GORM's. An association marked `@Valid` now passes its owner's groups to the associated object, converted by its `@ConvertGroup`, and `nullable` is relaxed for the converted groups. Your example reports `child.label` (`NotBlank`). On 8.0.x the provider never followed `@Valid`, because `MappingContextTraversableResolver.isCascadable` returned false at the root, so this is new behavior rather than a restoration. Details and regressions: https://github.com/apache/grails-core/pull/16624#issuecomment-6485914464 ########## grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaConstraintsTranslator.groovy: ########## @@ -0,0 +1,532 @@ +/* + * 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.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 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 {@link BeanDescriptor} of the class, 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 of the class 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 { + + /** + * 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 + + /** + * @param beanDescriptor The descriptor of the class, from {@link jakarta.validation.Validator#getConstraintsForClass} + */ + JakartaConstraintsTranslator(BeanDescriptor beanDescriptor) { + this.beanDescriptor = beanDescriptor + this.constraintDescriptors = Collections.unmodifiableMap(findConstraintDescriptors(beanDescriptor)) + } + + /** + * @return The described class + */ + Class<?> getBeanClass() { + return beanDescriptor.elementClass + } + + private static Map<String, List<ConstraintDescriptor<?>>> findConstraintDescriptors(BeanDescriptor beanDescriptor) { + Set<Class<?>> defaultGroups = defaultGroupsOf(beanDescriptor.elementClass) + Map<String, List<ConstraintDescriptor<?>>> result = new LinkedHashMap<>() + List<PropertyDescriptor> properties = beanDescriptor.constrainedProperties.toSorted { PropertyDescriptor a, PropertyDescriptor b -> + a.propertyName <=> b.propertyName + } + for (PropertyDescriptor property in properties) { + Collection<ConstraintDescriptor<?>> descriptors = property.constraintDescriptors.findAll { ConstraintDescriptor<?> descriptor -> + !Collections.disjoint(descriptor.groups, defaultGroups) + } + if (descriptors) { + // the provider reports the constraints of a property as a set, so they are ordered for the + // description to be the same on every run when two of them describe the same GORM constraint + result.put(property.propertyName, descriptors.toSorted { ConstraintDescriptor<?> a, ConstraintDescriptor<?> b -> + a.annotation.toString() <=> b.annotation.toString() + }) + } + } + return result + } + + /** + * The groups {@code validate()} validates: the {@link Default} group, and the groups of the sequence the class + * redefines it with, if any, in which the class itself stands for the {@link Default} group. As the provider + * validates them, a group of the sequence brings the groups it extends, and a nested sequence its own groups. + */ + private static Set<Class<?>> defaultGroupsOf(Class<?> beanClass) { + Set<Class<?>> groups = new HashSet<>() + groups.add(Default) + GroupSequence sequence = beanClass.getAnnotation(GroupSequence) Review Comment: Fixed in 491fca0fe9, following Hibernate Validator rather than the declaring class. The first class from the validated class up to redefine the default group applies its sequence to its own constraints and to those of its superclasses. The classes below it validate theirs in `Default` only, so a subclass's own `@Null(groups = PublishChecks)` stays outside the superclass's sequence, as Hibernate Validator treats it. Details and regressions: https://github.com/apache/grails-core/pull/16624#issuecomment-6485914464 ########## grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/validation/jakarta/JakartaConstraintsTranslator.groovy: ########## @@ -0,0 +1,532 @@ +/* + * 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.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 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 {@link BeanDescriptor} of the class, 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 of the class 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 { + + /** + * 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 + + /** + * @param beanDescriptor The descriptor of the class, from {@link jakarta.validation.Validator#getConstraintsForClass} + */ + JakartaConstraintsTranslator(BeanDescriptor beanDescriptor) { + this.beanDescriptor = beanDescriptor + this.constraintDescriptors = Collections.unmodifiableMap(findConstraintDescriptors(beanDescriptor)) + } + + /** + * @return The described class + */ + Class<?> getBeanClass() { + return beanDescriptor.elementClass + } + + private static Map<String, List<ConstraintDescriptor<?>>> findConstraintDescriptors(BeanDescriptor beanDescriptor) { + Set<Class<?>> defaultGroups = defaultGroupsOf(beanDescriptor.elementClass) + Map<String, List<ConstraintDescriptor<?>>> result = new LinkedHashMap<>() + List<PropertyDescriptor> properties = beanDescriptor.constrainedProperties.toSorted { PropertyDescriptor a, PropertyDescriptor b -> + a.propertyName <=> b.propertyName + } + for (PropertyDescriptor property in properties) { + Collection<ConstraintDescriptor<?>> descriptors = property.constraintDescriptors.findAll { ConstraintDescriptor<?> descriptor -> + !Collections.disjoint(descriptor.groups, defaultGroups) + } + if (descriptors) { + // the provider reports the constraints of a property as a set, so they are ordered for the + // description to be the same on every run when two of them describe the same GORM constraint + result.put(property.propertyName, descriptors.toSorted { ConstraintDescriptor<?> a, ConstraintDescriptor<?> b -> + a.annotation.toString() <=> b.annotation.toString() + }) + } + } + return result + } + + /** + * The groups {@code validate()} validates: the {@link Default} group, and the groups of the sequence the class + * redefines it with, if any, in which the class itself stands for the {@link Default} group. As the provider + * validates them, a group of the sequence brings the groups it extends, and a nested sequence its own groups. + */ + private static Set<Class<?>> defaultGroupsOf(Class<?> beanClass) { + Set<Class<?>> groups = new HashSet<>() + groups.add(Default) + GroupSequence sequence = beanClass.getAnnotation(GroupSequence) + if (sequence != null) { + Set<Class<?>> visitedSequences = new HashSet<>() + // the class in its own sequence stands for the Default group, which is already added + visitedSequences.add(beanClass) + addSequencedGroups(sequence.value(), groups, visitedSequences) + } + return 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 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() { + Set<String> result = new LinkedHashSet<>() + constraintDescriptors.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 #getNullabilityConstrainedProperties() properties Bean Validation checks + * for null} 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 + */ + void delegateNullabilityToBeanValidation(Map<String, ConstrainedProperty> constraints) { + for (String propertyName in nullabilityConstrainedProperties) { + 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 (decimalMin.inclusive()) { + tightenMin(constrained, min) Review Comment: Fixed in 491fca0fe9. On an integer property an inclusive `@DecimalMin` is rounded up and an inclusive `@DecimalMax` down, so `@DecimalMin('1.5') @DecimalMax('4.5') Integer` is described as `min: 2, max: 4`. The translator spec checks inclusive, exclusive, negative and whole bounds against what Hibernate Validator accepts. Details: https://github.com/apache/grails-core/pull/16624#issuecomment-6485914464 -- 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]
