From c27b930c005f7025aec625228460b0aed1377d5e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Sun, 7 Jun 2026 17:01:29 -0700 Subject: [PATCH 1/3] Make domain properties nullable by default (Grails 8) Flip GORM's validation default so an unconstrained persistent (domain) property is nullable unless explicitly constrained, aligning Grails with the rest of the JVM persistence/validation ecosystem (JPA/Hibernate, Spring Data JPA & MongoDB, Micronaut Data, Jakarta Bean Validation), all of which treat an unconstrained property as valid-when-null. The default is controlled by a new YAML-friendly boolean, defaulting to nullable-by-default: grails.gorm.default.nullable = false # restore legacy required-by-default This is wired through DefaultConstraintEvaluator (new defaultNullable, default true), surfaced as ConnectionSourceSettings.DefaultSettings.nullable, and read on both validator-construction paths (DefaultValidatorRegistry for the GORM datastore and DefaultConstraintEvaluatorFactoryBean for Grails domain classes). The existing closure form also still works: grails.gorm.default.constraints = { '*'(nullable: false) } Scope notes: - Validation layer only. Command-object validation (Validateable.defaultNullable()) is intentionally left required-by-default and unchanged. - Column/DDL nullability is governed separately by the mapping layer (Property.nullable / GrailsDomainBinder) and is not changed here; aligning the DDL default is a documented follow-up. Tests: existing specs that assert required-by-default semantics are updated to declare the field(s) they depend on explicitly (nullable: false), reproducing the prior baseline for just those fields rather than disabling the new default wholesale. Notes: - grails-test-suite-uber DomainConstraintGettersSpec restores required-by-default for its own context via a per-spec doWithConfig override, since it exists to verify default-constraint enumeration via the nullable error. - FindOrCreateWhereSpec (mongodb) was using the wrong Person class (a same-package collision); it now imports grails.gorm.tests.Person to match Pet.owner. - New NullableByDefaultSpec demonstrates the new default without any opt-out, and verifies that grails.gorm.default.nullable = false restores required-by-default through DefaultValidatorRegistry. - The example-app functional/integration suites assert required-by-default app-wide, so each opts out with grails.gorm.default.nullable = false in its application.yml (the flag's intended use). Docs: the nullable constraint reference now documents the new default and the grails.gorm.default.nullable flag (YAML and groovy forms). --- ...SaveWithExistingValidationErrorSpec.groovy | 1 + .../gorm/tests/entities/Contract.groovy | 4 + .../validation/CascadeValidationSpec.groovy | 3 + .../mixin/hibernate/HibernateSpecSpec.groovy | 2 +- ...SaveWithExistingValidationErrorSpec.groovy | 1 + .../gorm/tests/entities/Contract.groovy | 4 + .../validation/CascadeValidationSpec.groovy | 3 + .../mixin/hibernate/HibernateSpecSpec.groovy | 2 +- .../groovy/grails/gorm/tests/Plant.groovy | 4 + ...ngoDbDataStoreSpringInitializerSpec.groovy | 2 +- .../gorm/mongo/FindOrCreateWhereSpec.groovy | 1 + .../gorm/tests/CircularCascadeSpec.groovy | 8 ++ .../ClassWithListArgBeforeValidate.groovy | 2 +- .../ClassWithNoArgBeforeValidate.groovy | 2 +- .../ClassWithOverloadedBeforeValidate.groovy | 2 +- .../eval/DefaultConstraintEvaluator.java | 15 ++- .../registry/DefaultValidatorRegistry.groovy | 3 +- .../validation/NullableByDefaultSpec.groovy | 121 ++++++++++++++++++ .../PersistentEntityValidatorSpec.groovy | 2 + .../datastore/mapping/config/Settings.java | 4 + .../ConnectionSourceSettings.groovy | 6 + .../src/en/ref/Constraints/nullable.adoc | 21 ++- ...faultConstraintEvaluatorFactoryBean.groovy | 6 +- .../plugin/formfields/mock/Author.groovy | 2 +- .../plugin/formfields/mock/Person.groovy | 5 +- .../EntityValidatorDomainPropertySpec.groovy | 2 + .../web/taglib/ValidationTagLibSpec.groovy | 7 + .../app1/grails-app/conf/application.yml | 7 + .../domain/functionaltests/Book.groovy | 1 + .../demo33/grails-app/domain/demo/Car.groovy | 1 + .../gorm/grails-app/conf/application.yml | 3 + .../domain/functional/tests/Book.groovy | 2 +- .../domain/functional/tests/Business.groovy | 3 + .../grails-app/conf/application.yml | 9 +- .../domain/functional/tests/Book.groovy | 2 +- .../domain/functional/tests/Business.groovy | 3 + .../grails-app/conf/application.yml | 9 +- .../grails-app/conf/application.yml | 7 + .../base/grails-app/conf/application.yml | 7 + .../grails-app/conf/application.yml | 7 + .../grails-app/conf/application.yml | 7 + .../grails-app/conf/application.yml | 7 + .../CascadeValidationForEmbeddedSpec.groovy | 2 +- .../DomainClassDeepValidationSpec.groovy | 4 +- .../RestfulControllerSubclassSpec.groovy | 4 + .../mixin/cascade/CascadeCircularSpec.groovy | 1 + .../DomainConstraintGettersSpec.groovy | 11 ++ .../web/metaclass/ChainMethodTests.groovy | 4 + .../servlet/FlashScopeWithErrorsTests.groovy | 4 + .../grails/rest/web/RespondMethodSpec.groovy | 2 +- .../web/converters/JSONConverterTests.groovy | 5 + .../json/view/JsonViewHelperSpec.groovy | 4 + 52 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 grails-datamapping-validation/src/test/groovy/grails/gorm/validation/NullableByDefaultSpec.groovy diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy index a7bf77b4806..54d1f3653c3 100644 --- a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy @@ -66,5 +66,6 @@ class ObjectB { String name static constraints = { + name nullable: false } } \ No newline at end of file diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy index ade88992c88..4d0d32b3820 100644 --- a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy @@ -28,4 +28,8 @@ import grails.gorm.annotation.Entity class Contract { BigDecimal salary static belongsTo = [player: Player] + + static constraints = { + player nullable: false + } } diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy index 81ca43aa60f..cba9e6254a7 100644 --- a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy @@ -61,6 +61,9 @@ class Business { people: Person ] + static constraints = { + name nullable: false + } } @Entity abstract class Person { diff --git a/grails-data-hibernate5/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy b/grails-data-hibernate5/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy index a04aa2de427..ce9e7a52fd5 100644 --- a/grails-data-hibernate5/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy +++ b/grails-data-hibernate5/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy @@ -73,7 +73,7 @@ class Person { Integer age String phone static constraints = { - age min: 18, max: 65 + age min: 18, max: 65, nullable: false name blank: false phone nullable: true } diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy index a7bf77b4806..54d1f3653c3 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/SaveWithExistingValidationErrorSpec.groovy @@ -66,5 +66,6 @@ class ObjectB { String name static constraints = { + name nullable: false } } \ No newline at end of file diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy index ade88992c88..4d0d32b3820 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/entities/Contract.groovy @@ -28,4 +28,8 @@ import grails.gorm.annotation.Entity class Contract { BigDecimal salary static belongsTo = [player: Player] + + static constraints = { + player nullable: false + } } diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy index 81ca43aa60f..cba9e6254a7 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/validation/CascadeValidationSpec.groovy @@ -61,6 +61,9 @@ class Business { people: Person ] + static constraints = { + name nullable: false + } } @Entity abstract class Person { diff --git a/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy b/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy index a04aa2de427..ce9e7a52fd5 100644 --- a/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy +++ b/grails-data-hibernate7/grails-plugin/src/test/groovy/grails/test/mixin/hibernate/HibernateSpecSpec.groovy @@ -73,7 +73,7 @@ class Person { Integer age String phone static constraints = { - age min: 18, max: 65 + age min: 18, max: 65, nullable: false name blank: false phone nullable: true } diff --git a/grails-data-mongodb/core/src/test/groovy/grails/gorm/tests/Plant.groovy b/grails-data-mongodb/core/src/test/groovy/grails/gorm/tests/Plant.groovy index 3aa01b1bf41..e93b1ed628e 100644 --- a/grails-data-mongodb/core/src/test/groovy/grails/gorm/tests/Plant.groovy +++ b/grails-data-mongodb/core/src/test/groovy/grails/gorm/tests/Plant.groovy @@ -28,6 +28,10 @@ class Plant implements Serializable, MongoEntity { boolean goesInPatch String name + static constraints = { + name nullable: false + } + static mapping = { name index:true goesInPatch index:true diff --git a/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerSpec.groovy b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerSpec.groovy index 37cb3742c1d..e2666d95675 100644 --- a/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializerSpec.groovy @@ -226,7 +226,7 @@ class Person implements MongoEntity { Birthday birthday static constraints = { - name blank: false + name blank: false, nullable: false birthday nullable: true } } diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FindOrCreateWhereSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FindOrCreateWhereSpec.groovy index cbbb4d71e29..fef267c99f2 100644 --- a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FindOrCreateWhereSpec.groovy +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/gorm/mongo/FindOrCreateWhereSpec.groovy @@ -18,6 +18,7 @@ */ package org.grails.datastore.gorm.mongo +import grails.gorm.tests.Person import grails.gorm.tests.Pet import org.apache.grails.data.mongo.core.GrailsDataMongoTckManager import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec diff --git a/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/CircularCascadeSpec.groovy b/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/CircularCascadeSpec.groovy index dfb20998ef9..3da490659fe 100644 --- a/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/CircularCascadeSpec.groovy +++ b/grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/CircularCascadeSpec.groovy @@ -133,10 +133,18 @@ class SportValidate { class TeamValidate { String name + + static constraints = { + name nullable: false + } } @Entity class ArenaValidate { String name + + static constraints = { + name nullable: false + } } \ No newline at end of file diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithListArgBeforeValidate.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithListArgBeforeValidate.groovy index 9e368ebd9b4..e79dc83f35c 100644 --- a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithListArgBeforeValidate.groovy +++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithListArgBeforeValidate.groovy @@ -36,6 +36,6 @@ class ClassWithListArgBeforeValidate implements Serializable { } static constraints = { - name(blank: false) + name(blank: false, nullable: false) } } diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithNoArgBeforeValidate.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithNoArgBeforeValidate.groovy index d2036610724..736b8c16b63 100644 --- a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithNoArgBeforeValidate.groovy +++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithNoArgBeforeValidate.groovy @@ -34,6 +34,6 @@ class ClassWithNoArgBeforeValidate implements Serializable { } static constraints = { - name(blank: false) + name(blank: false, nullable: false) } } diff --git a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithOverloadedBeforeValidate.groovy b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithOverloadedBeforeValidate.groovy index 99a74cbe845..492f3bc2313 100644 --- a/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithOverloadedBeforeValidate.groovy +++ b/grails-datamapping-tck/src/main/groovy/org/apache/grails/data/testing/tck/domains/ClassWithOverloadedBeforeValidate.groovy @@ -41,6 +41,6 @@ class ClassWithOverloadedBeforeValidate implements Serializable { } static constraints = { - name(blank: false) + name(blank: false, nullable: false) } } diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java index a6fac32fef6..025eddeba68 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/eval/DefaultConstraintEvaluator.java @@ -72,6 +72,7 @@ public class DefaultConstraintEvaluator implements ConstraintsEvaluator { protected final MappingContext mappingContext; protected final Map defaultConstraints; protected final boolean cacheAutoTimestampAnnotations; + protected final boolean defaultNullable; public DefaultConstraintEvaluator() { this(new DefaultConstraintRegistry(new StaticMessageSource()), new KeyValueMappingContext("default"), Collections.emptyMap(), true); @@ -98,10 +99,15 @@ public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, Mapping } public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, MappingContext mappingContext, Map defaultConstraints, boolean cacheAutoTimestampAnnotations) { + this(constraintRegistry, mappingContext, defaultConstraints, cacheAutoTimestampAnnotations, true); + } + + public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, MappingContext mappingContext, Map defaultConstraints, boolean cacheAutoTimestampAnnotations, boolean defaultNullable) { this.constraintRegistry = constraintRegistry; this.mappingContext = mappingContext; this.defaultConstraints = defaultConstraints; this.cacheAutoTimestampAnnotations = cacheAutoTimestampAnnotations; + this.defaultNullable = defaultNullable; } @Override @@ -270,7 +276,14 @@ protected void applyDefaultConstraints(String propertyName, PersistentProperty p } protected void applyDefaultNullableConstraint(PersistentProperty p, ConstrainedProperty cp) { - applyDefaultNullableConstraint(cp, false); + // Grails 8: persistent properties are nullable by default, aligning Grails with the rest of + // the JVM persistence/validation ecosystem (JPA/Hibernate, Spring Data, Micronaut Data and + // Jakarta Bean Validation are all "nullable unless you say otherwise"). To restore the + // legacy required-by-default behavior, either set the boolean flag: + // grails.gorm.default.nullable = false + // or apply a wildcard default constraint: + // grails.gorm.default.constraints = { '*'(nullable: false) } + applyDefaultNullableConstraint(cp, defaultNullable); } protected void applyDefaultNullableConstraint(ConstrainedProperty cp, boolean defaultNullable) { diff --git a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/registry/DefaultValidatorRegistry.groovy b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/registry/DefaultValidatorRegistry.groovy index 38705047301..555e5adbc5a 100644 --- a/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/registry/DefaultValidatorRegistry.groovy +++ b/grails-datamapping-validation/src/main/groovy/org/grails/datastore/gorm/validation/constraints/registry/DefaultValidatorRegistry.groovy @@ -60,7 +60,8 @@ class DefaultValidatorRegistry implements ValidatorRegistry, ConstraintRegistry, // Default to true if not explicitly configured boolean cacheAnnotations = connectionSourceSettings.cacheAutoTimestampAnnotations != null ? connectionSourceSettings.cacheAutoTimestampAnnotations : true - this.constraintsEvaluator = new DefaultConstraintEvaluator(constraintRegistry, mappingContext, defaultConstraintsMap, cacheAnnotations) + boolean defaultNullable = connectionSourceSettings.default.nullable + this.constraintsEvaluator = new DefaultConstraintEvaluator(constraintRegistry, mappingContext, defaultConstraintsMap, cacheAnnotations, defaultNullable) this.mappingContext = mappingContext } diff --git a/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/NullableByDefaultSpec.groovy b/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/NullableByDefaultSpec.groovy new file mode 100644 index 00000000000..1067d8f8cbb --- /dev/null +++ b/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/NullableByDefaultSpec.groovy @@ -0,0 +1,121 @@ +/* + * 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 grails.gorm.validation + +import org.grails.datastore.gorm.validation.constraints.registry.DefaultValidatorRegistry +import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings +import org.grails.datastore.mapping.keyvalue.mapping.config.GormKeyValueMappingFactory +import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValueMappingContext +import org.grails.datastore.mapping.model.MappingContext +import org.grails.datastore.mapping.model.PersistentEntity +import org.grails.datastore.mapping.model.config.GormMappingConfigurationStrategy +import org.grails.datastore.mapping.validation.ValidationErrors +import org.grails.datastore.mapping.validation.ValidatorRegistry +import org.springframework.validation.Errors +import org.springframework.validation.Validator +import spock.lang.Shared +import spock.lang.Specification + +import jakarta.persistence.Entity + +/** + * Demonstrates the Grails 8 default: an unconstrained persistent property is nullable by default, + * aligning Grails with the rest of the JVM persistence/validation ecosystem. This spec deliberately + * does NOT opt out (no {@code grails.gorm.default.constraints} override), so it exercises the real + * framework default applied by {@code DefaultConstraintEvaluator}. + */ +class NullableByDefaultSpec extends Specification { + + @Shared Validator widgetValidator + + void setupSpec() { + MappingContext mappingContext = new KeyValueMappingContext("test") + mappingContext.mappingFactory = new GormKeyValueMappingFactory("test") + mappingContext.syntaxStrategy = new GormMappingConfigurationStrategy(mappingContext.mappingFactory) + + PersistentEntity widgetEntity = mappingContext.addPersistentEntity(Widget) + + ValidatorRegistry registry = new DefaultValidatorRegistry(mappingContext, new ConnectionSourceSettings()) + widgetValidator = registry.getValidator(widgetEntity) + } + + void "an unconstrained property is nullable by default"() { + given: "a widget whose only populated property is the explicitly-required one" + Widget widget = new Widget(requiredName: 'gizmo') + Errors errors = new ValidationErrors(widget) + + when: + widgetValidator.validate(widget, errors) + + then: "the unconstrained properties are valid while null - they are nullable by default" + !errors.hasErrors() + !errors.getFieldError('description') + !errors.getFieldError('quantity') + } + + void "a property with an explicit nullable: false constraint is still required"() { + given: "a widget missing the explicitly-required property" + Widget widget = new Widget() + Errors errors = new ValidationErrors(widget) + + when: + widgetValidator.validate(widget, errors) + + then: "only the explicitly-required property is rejected; the unconstrained ones are not" + errors.hasErrors() + errors.getFieldError('requiredName')?.code == 'nullable' + !errors.getFieldError('description') + !errors.getFieldError('quantity') + } + + void "grails.gorm.default.nullable = false restores required-by-default"() { + given: "a validator built with the YAML-friendly flag disabled" + MappingContext mappingContext = new KeyValueMappingContext("test") + mappingContext.mappingFactory = new GormKeyValueMappingFactory("test") + mappingContext.syntaxStrategy = new GormMappingConfigurationStrategy(mappingContext.mappingFactory) + PersistentEntity widgetEntity = mappingContext.addPersistentEntity(Widget) + + ConnectionSourceSettings settings = new ConnectionSourceSettings() + settings.default.nullable = false + Validator validator = new DefaultValidatorRegistry(mappingContext, settings).getValidator(widgetEntity) + + and: "a widget whose unconstrained properties are null" + Widget widget = new Widget(requiredName: 'gizmo') + Errors errors = new ValidationErrors(widget) + + when: + validator.validate(widget, errors) + + then: "the unconstrained properties are required again - the flag restored legacy behaviour" + errors.hasErrors() + errors.getFieldError('description')?.code == 'nullable' + errors.getFieldError('quantity')?.code == 'nullable' + } +} + +@Entity +class Widget { + String description + Integer quantity + String requiredName + + static constraints = { + requiredName nullable: false + } +} diff --git a/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/PersistentEntityValidatorSpec.groovy b/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/PersistentEntityValidatorSpec.groovy index e8d9aeefa5b..07af5ddc121 100644 --- a/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/PersistentEntityValidatorSpec.groovy +++ b/grails-datamapping-validation/src/test/groovy/grails/gorm/validation/PersistentEntityValidatorSpec.groovy @@ -200,6 +200,8 @@ class Author { ] static constraints = { + name(nullable: false) // explicit now that properties are nullable by default — this spec + // uses the null-name error to detect that root beforeValidate didn't run publisher(nullable: true) ownedPublisher(nullable: true) defaultPublisher(nullable: true) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java index 401f78034e0..1558419e0f0 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java @@ -54,6 +54,10 @@ public interface Settings { * The default constraints */ String SETTING_DEFAULT_CONSTRAINTS = PREFIX + '.' + "default.constraints"; + /** + * Whether an unconstrained persistent property is nullable by default (Grails 8 default: true) + */ + String SETTING_DEFAULT_NULLABLE = PREFIX + '.' + "default.nullable"; /** * The custom types */ diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettings.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettings.groovy index 34b790c42fe..040878cfa0e 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettings.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/core/connections/ConnectionSourceSettings.groovy @@ -124,6 +124,12 @@ class ConnectionSourceSettings implements Settings { * The default constraints */ Closure constraints + + /** + * Whether an unconstrained persistent property is nullable by default (Grails 8 default: true). + * Set {@code grails.gorm.default.nullable = false} to restore the legacy required-by-default behaviour. + */ + boolean nullable = true } /** diff --git a/grails-doc/src/en/ref/Constraints/nullable.adoc b/grails-doc/src/en/ref/Constraints/nullable.adoc index 878e4c806e1..9da6b81fead 100644 --- a/grails-doc/src/en/ref/Constraints/nullable.adoc +++ b/grails-doc/src/en/ref/Constraints/nullable.adoc @@ -25,7 +25,26 @@ under the License. === Purpose -Allows a property to be set to `null`. By default Grails does not allow `null` values for properties. +Allows a property to be set to `null`. + +NOTE: As of Grails 8, an unconstrained persistent property is *nullable by default*, aligning Grails with the rest of the JVM persistence/validation ecosystem (JPA/Hibernate, Spring Data, Micronaut Data and Jakarta Bean Validation). Declare `nullable: false` to make a property required. To restore the legacy required-by-default behaviour for an application, set `grails.gorm.default.nullable` to `false`: + +[source,yaml] +.grails-app/conf/application.yml +---- +grails: + gorm: + default: + nullable: false +---- + +The equivalent in `grails-app/conf/application.groovy`: + +[source,groovy] +.grails-app/conf/application.groovy +---- +grails.gorm.default.nullable = false +---- === Examples diff --git a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy index d954f5b6935..1f3030e980f 100644 --- a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy +++ b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy @@ -29,6 +29,7 @@ import org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluato import org.grails.datastore.gorm.validation.constraints.eval.DefaultConstraintEvaluator import org.grails.datastore.gorm.validation.constraints.registry.ConstraintRegistry import org.grails.datastore.gorm.validation.constraints.registry.DefaultConstraintRegistry +import org.grails.datastore.mapping.config.Settings import org.grails.datastore.mapping.model.MappingContext import org.grails.validation.ConstraintEvalUtils @@ -50,7 +51,10 @@ class DefaultConstraintEvaluatorFactoryBean implements FactoryBean { class Album { String title String artist + + static constraints = { + title nullable: false + } } @Artefact('Controller') diff --git a/grails-test-suite-uber/src/test/groovy/grails/test/mixin/cascade/CascadeCircularSpec.groovy b/grails-test-suite-uber/src/test/groovy/grails/test/mixin/cascade/CascadeCircularSpec.groovy index 422721b62f7..552218e7472 100644 --- a/grails-test-suite-uber/src/test/groovy/grails/test/mixin/cascade/CascadeCircularSpec.groovy +++ b/grails-test-suite-uber/src/test/groovy/grails/test/mixin/cascade/CascadeCircularSpec.groovy @@ -72,6 +72,7 @@ class Person { static mappedBy = [students: 'none', peers: 'none'] static constraints = { + master nullable: false peers cascade: false } diff --git a/grails-test-suite-uber/src/test/groovy/grails/validation/DomainConstraintGettersSpec.groovy b/grails-test-suite-uber/src/test/groovy/grails/validation/DomainConstraintGettersSpec.groovy index 2f76fee46e0..156c9387bab 100644 --- a/grails-test-suite-uber/src/test/groovy/grails/validation/DomainConstraintGettersSpec.groovy +++ b/grails-test-suite-uber/src/test/groovy/grails/validation/DomainConstraintGettersSpec.groovy @@ -41,6 +41,17 @@ class DomainConstraintGettersSpec extends Specification implements DataTest { ] } + // This spec verifies which properties become constraint properties, using the default + // nullable constraint (and its 'nullable' validation error) as the detection mechanism. + // Grails 8 makes properties nullable by default, so restore required-by-default for this + // spec's own context to keep exercising that detection. + @Override + Closure doWithConfig() { + { config -> + config.grails.gorm.default.constraints = { '*'(nullable: false) } + } + } + // STANDARD DOMAIN void 'ensure all public properties are by default constraint properties'() { diff --git a/grails-test-suite-uber/src/test/groovy/org/grails/web/metaclass/ChainMethodTests.groovy b/grails-test-suite-uber/src/test/groovy/org/grails/web/metaclass/ChainMethodTests.groovy index 6de9ebbd3fe..c9977f19445 100644 --- a/grails-test-suite-uber/src/test/groovy/org/grails/web/metaclass/ChainMethodTests.groovy +++ b/grails-test-suite-uber/src/test/groovy/org/grails/web/metaclass/ChainMethodTests.groovy @@ -79,5 +79,9 @@ class TestChainController { @Entity class TestChainBook { String title + + static constraints = { + title nullable: false + } } diff --git a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/FlashScopeWithErrorsTests.groovy b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/FlashScopeWithErrorsTests.groovy index e3caab0aed9..fa14b3a6725 100644 --- a/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/FlashScopeWithErrorsTests.groovy +++ b/grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/FlashScopeWithErrorsTests.groovy @@ -67,4 +67,8 @@ class FlashScopeWithErrorsTests extends Specification implements DomainUnitTest< class Book { String title URL site + + static constraints = { + title nullable: false + } } diff --git a/grails-test-suite-web/src/test/groovy/grails/rest/web/RespondMethodSpec.groovy b/grails-test-suite-web/src/test/groovy/grails/rest/web/RespondMethodSpec.groovy index 3a51e20b4aa..4cdfaea9ce8 100644 --- a/grails-test-suite-web/src/test/groovy/grails/rest/web/RespondMethodSpec.groovy +++ b/grails-test-suite-web/src/test/groovy/grails/rest/web/RespondMethodSpec.groovy @@ -295,7 +295,7 @@ class Book { String title static constraints = { - title blank:false + title blank:false, nullable: false } } diff --git a/grails-test-suite-web/src/test/groovy/org/grails/web/converters/JSONConverterTests.groovy b/grails-test-suite-web/src/test/groovy/org/grails/web/converters/JSONConverterTests.groovy index 37e3a106bfc..883f3e8351c 100644 --- a/grails-test-suite-web/src/test/groovy/org/grails/web/converters/JSONConverterTests.groovy +++ b/grails-test-suite-web/src/test/groovy/org/grails/web/converters/JSONConverterTests.groovy @@ -228,6 +228,11 @@ class Book { Long version String title String author + + static constraints = { + title nullable: false + author nullable: false + } } class CustomCharSequence implements CharSequence { diff --git a/grails-views-gson/src/test/groovy/grails/plugin/json/view/JsonViewHelperSpec.groovy b/grails-views-gson/src/test/groovy/grails/plugin/json/view/JsonViewHelperSpec.groovy index 8b16be336e7..785215b03f6 100644 --- a/grails-views-gson/src/test/groovy/grails/plugin/json/view/JsonViewHelperSpec.groovy +++ b/grails-views-gson/src/test/groovy/grails/plugin/json/view/JsonViewHelperSpec.groovy @@ -674,6 +674,10 @@ class Player { String name @SuppressWarnings('unused') static belongsTo = [team: Team] + + static constraints = { + name nullable: false + } } @Entity From d92c416190417e3055e1f926df0e155596ce9d23 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 10 Jun 2026 18:58:18 -0600 Subject: [PATCH 2/3] Document nullable-by-default: upgrade guide, constraints guide, IDE metadata Follow-up docs/metadata for the nullable-by-default change: - Upgrade guide (upgrading80x.adoc): new "GORM Properties Are Nullable by Default" section describing the breaking default and the grails.gorm.default.nullable opt-out (YAML and groovy). - Validation constraints guide (constraints.adoc): corrected the now-stale note that claimed all domain properties are not-nullable by default. - grails-core spring-configuration-metadata.json: register grails.gorm.default.nullable so IDEs (IntelliJ) recognise, document and autocomplete it in application.yml. --- .../spring-configuration-metadata.json | 6 +++ .../src/en/guide/upgrading/upgrading80x.adoc | 50 +++++++++++++++++-- .../src/en/guide/validation/constraints.adoc | 2 +- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json index 2db82bc455d..50ba004db15 100644 --- a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json @@ -222,6 +222,12 @@ "description": "A closure applied as the default constraints for all domain classes.", "defaultValue": {} }, + { + "name": "grails.gorm.default.nullable", + "type": "java.lang.Boolean", + "description": "Whether an unconstrained persistent property is nullable by default (Grails 8). Set to false to restore the legacy required-by-default behaviour.", + "defaultValue": true + }, { "name": "grails.gorm.custom.types", "type": "java.util.Map", diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 4e72931f015..db9ef210838 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -680,7 +680,51 @@ If your application contributed `HttpMessageConverter` beans through this class, Spring Framework 7 deprecated `org.springframework.lang.Nullable` and `org.springframework.lang.NonNull` in favor of the JSpecify annotations (`org.jspecify.annotations.Nullable`, `org.jspecify.annotations.NonNull`). The Spring annotations still work, so this is non-blocking, but new code should prefer the JSpecify equivalents. -==== 20. Tag Library Test Cleanup Changes +==== 20. GORM Properties Are Nullable by Default + +Grails 8 changes the validation default so that an *unconstrained persistent (domain) property is nullable by default* rather than required. +This aligns Grails with the rest of the JVM persistence/validation ecosystem (JPA/Hibernate, Spring Data, Micronaut Data and Jakarta Bean Validation), all of which treat an unconstrained property as valid when `null`. + +Previously every domain property without an explicit constraint had an implicit `nullable: false` applied, so `save()` failed validation when the property was `null`. +From Grails 8 such a property is optional unless you declare otherwise: + +[source,groovy] +---- +class Book { + String title // Grails 7: required, Grails 8: nullable + + static constraints = { + title nullable: false // declare explicitly to keep it required + } +} +---- + +This is a *validation-layer* change only. +Column/DDL nullability is governed separately by the mapping layer and is unaffected. +Command objects are also unaffected: `Validateable` command-object fields remain required by default. + +To restore the legacy required-by-default behaviour for an entire application, set the new `grails.gorm.default.nullable` property to `false`: + +[source,yaml] +.application.yml +---- +grails: + gorm: + default: + nullable: false +---- + +The existing `grails.gorm.default.constraints` wildcard form also works: + +[source,groovy] +.application.groovy +---- +grails.gorm.default.constraints = { + '*'(nullable: false) +} +---- + +==== 21. Tag Library Test Cleanup Changes Grails 8 removes the `purgeTagLibMetaClass` test hook used by some web and TagLib unit tests. TagLib metaclass cleanup now happens automatically as part of the web test infrastructure, so test specs should delete any custom `purgeTagLibMetaClass` property or getter. @@ -706,7 +750,7 @@ class MyTagLibSpec extends Specification implements TagLibUnitTest { } ---- -==== 21. Method-based TagLib Handlers +==== 22. Method-based TagLib Handlers Grails 8 introduces method-based tag handler syntax as the recommended way to define tags. Closure-based handlers are still supported for backward compatibility. @@ -843,7 +887,7 @@ tasks.withType(GroovyCompile).configureEach { Without these flags, `Parameter.getName()` returns synthetic names such as `arg0`, the attribute lookup misses, and the call surfaces as `MissingMethodException` at runtime. The `@Tag`-annotated method itself produces no compile-time warning, because preservation is a property of the build, not of the source file. -==== 22. Known Plugin Incompatibilities +==== 23. Known Plugin Incompatibilities Some third-party plugins have not yet been updated for Spring Boot 4 / Spring Framework 7 compatibility. The following are known blockers at this time: diff --git a/grails-doc/src/en/guide/validation/constraints.adoc b/grails-doc/src/en/guide/validation/constraints.adoc index de033e234a2..98f9a0348b3 100644 --- a/grails-doc/src/en/guide/validation/constraints.adoc +++ b/grails-doc/src/en/guide/validation/constraints.adoc @@ -51,7 +51,7 @@ class User { In this example we've declared that the `login` property must be between 5 and 15 characters long, it cannot be blank and must be unique. We've also applied other constraints to the `password`, `email` and `age` properties. -NOTE: By default, all domain class properties are not nullable (i.e. they have an implicit `nullable: false` constraint). +NOTE: As of Grails 8, domain class properties are *nullable by default*: an unconstrained property is optional unless you declare `nullable: false` (prior to Grails 8 the default was the opposite — an implicit `nullable: false` was applied to every property). To restore the legacy required-by-default behaviour application-wide, set `grails.gorm.default.nullable = false`. A complete reference for the available constraints can be found in the Quick Reference section under the Constraints heading. From b112a97d435cc4251322317c4d45411dd659e444 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Fri, 12 Jun 2026 08:18:23 -0700 Subject: [PATCH 3/3] Address review feedback on the nullable-by-default config wiring - Encapsulate the config read: add ConstraintEvalUtils.getDefaultNullable(Config) (new grails.config.Settings.GORM_DEFAULT_NULLABLE), mirroring getDefaultConstraints, instead of reading the property inline in the factory bean. - DefaultConstraintEvaluatorFactoryBean: pass cacheAutoTimestampAnnotations=true literally again (drop the unrelated config read that changed behaviour) and use the new helper; remove the now-unused Settings import. - Remove the unused datastore Settings.SETTING_DEFAULT_NULLABLE constant. - Drop the redundant per-app comment from the example-app application.yml opt-outs. --- grails-core/src/main/groovy/grails/config/Settings.groovy | 5 +++++ .../org/grails/validation/ConstraintEvalUtils.groovy | 8 ++++++++ .../org/grails/datastore/mapping/config/Settings.java | 4 ---- .../support/DefaultConstraintEvaluatorFactoryBean.groovy | 6 ++---- grails-test-examples/app1/grails-app/conf/application.yml | 1 - .../grails-app/conf/application.yml | 1 - .../grails-app/conf/application.yml | 1 - .../grails-app/conf/application.yml | 1 - .../mongodb/base/grails-app/conf/application.yml | 1 - .../scaffolding-fields/grails-app/conf/application.yml | 1 - .../scaffolding/grails-app/conf/application.yml | 1 - .../grails-app/conf/application.yml | 1 - 12 files changed, 15 insertions(+), 16 deletions(-) diff --git a/grails-core/src/main/groovy/grails/config/Settings.groovy b/grails-core/src/main/groovy/grails/config/Settings.groovy index b740bbee8a3..feb8a881cd8 100644 --- a/grails-core/src/main/groovy/grails/config/Settings.groovy +++ b/grails-core/src/main/groovy/grails/config/Settings.groovy @@ -151,6 +151,11 @@ interface Settings { */ String GORM_DEFAULT_CONSTRAINTS = 'grails.gorm.default.constraints' + /** + * Whether an unconstrained persistent property is nullable by default + */ + String GORM_DEFAULT_NULLABLE = 'grails.gorm.default.nullable' + /** * Whether to autowire instances */ diff --git a/grails-core/src/main/groovy/org/grails/validation/ConstraintEvalUtils.groovy b/grails-core/src/main/groovy/org/grails/validation/ConstraintEvalUtils.groovy index e1bc73602ec..79426ae43c7 100644 --- a/grails-core/src/main/groovy/org/grails/validation/ConstraintEvalUtils.groovy +++ b/grails-core/src/main/groovy/org/grails/validation/ConstraintEvalUtils.groovy @@ -61,6 +61,14 @@ class ConstraintEvalUtils { return defaultConstraintsMap } + /** + * Whether an unconstrained persistent property is nullable by default (Grails 8). + * Set {@code grails.gorm.default.nullable = false} to restore the legacy required-by-default behaviour. + */ + static boolean getDefaultNullable(Config config) { + config.getProperty(Settings.GORM_DEFAULT_NULLABLE, Boolean, true) + } + static void clearDefaultConstraints() { defaultConstraintsMap = null configId = null diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java index 1558419e0f0..401f78034e0 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Settings.java @@ -54,10 +54,6 @@ public interface Settings { * The default constraints */ String SETTING_DEFAULT_CONSTRAINTS = PREFIX + '.' + "default.constraints"; - /** - * Whether an unconstrained persistent property is nullable by default (Grails 8 default: true) - */ - String SETTING_DEFAULT_NULLABLE = PREFIX + '.' + "default.nullable"; /** * The custom types */ diff --git a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy index 1f3030e980f..d47b1ce1182 100644 --- a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy +++ b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/support/DefaultConstraintEvaluatorFactoryBean.groovy @@ -29,7 +29,6 @@ import org.grails.datastore.gorm.validation.constraints.eval.ConstraintsEvaluato import org.grails.datastore.gorm.validation.constraints.eval.DefaultConstraintEvaluator import org.grails.datastore.gorm.validation.constraints.registry.ConstraintRegistry import org.grails.datastore.gorm.validation.constraints.registry.DefaultConstraintRegistry -import org.grails.datastore.mapping.config.Settings import org.grails.datastore.mapping.model.MappingContext import org.grails.validation.ConstraintEvalUtils @@ -51,10 +50,9 @@ class DefaultConstraintEvaluatorFactoryBean implements FactoryBean