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-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-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/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/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index dbc5efdf986..628b92cac7d 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -964,3 +964,47 @@ Previously, rendering an enum value would produce a JSON string with the type an Rendering an enum value as JSON will now instead throw a `ConverterException`. See https://github.com/apache/grails-core/pull/15212[PR 15212] for more details on this change. + +==== 26. 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) +} +---- 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. 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..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 @@ -50,7 +50,9 @@ 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