Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions grails-core/src/main/groovy/grails/config/Settings.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@
"description": "A closure applied as the default constraints for all domain classes.",
"defaultValue": {}
},
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You added this to grails-core instead of grails-datastore-core.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamesfredley why are we not adding a specific configuration file for the datastore project? That's where the constraint is actually used.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jdaugherty there is no similar file in grails-datastore-core. I put it where all the other grails.gorm.* keys are. It wouldn't make sense to put it a location different from where the other keys are.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, and we shouldn't let this block this change. But I think we've made a larger mistake here - the config keys were supposed to exist in the projects where they're used/defined. It's my understanding that hasn't happened based on this.

"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<java.lang.String,java.lang.Object>",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@ class ObjectB {
String name

static constraints = {
name nullable: false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ import grails.gorm.annotation.Entity
class Contract {
BigDecimal salary
static belongsTo = [player: Player]

static constraints = {
player nullable: false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class Business {
people: Person
]

static constraints = {
name nullable: false
}
}
@Entity
abstract class Person {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,6 @@ class ObjectB {
String name

static constraints = {
name nullable: false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ import grails.gorm.annotation.Entity
class Contract {
BigDecimal salary
static belongsTo = [player: Player]

static constraints = {
player nullable: false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class Business {
people: Person
]

static constraints = {
name nullable: false
}
}
@Entity
abstract class Person {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ class Plant implements Serializable, MongoEntity<Plant> {
boolean goesInPatch
String name

static constraints = {
name nullable: false
}

static mapping = {
name index:true
goesInPatch index:true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ class Person implements MongoEntity<Person> {
Birthday birthday

static constraints = {
name blank: false
name blank: false, nullable: false
birthday nullable: true
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@ class ClassWithListArgBeforeValidate implements Serializable {
}

static constraints = {
name(blank: false)
name(blank: false, nullable: false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ class ClassWithNoArgBeforeValidate implements Serializable {
}

static constraints = {
name(blank: false)
name(blank: false, nullable: false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ class ClassWithOverloadedBeforeValidate implements Serializable {
}

static constraints = {
name(blank: false)
name(blank: false, nullable: false)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public class DefaultConstraintEvaluator implements ConstraintsEvaluator {
protected final MappingContext mappingContext;
protected final Map<String, Object> defaultConstraints;
protected final boolean cacheAutoTimestampAnnotations;
protected final boolean defaultNullable;

public DefaultConstraintEvaluator() {
this(new DefaultConstraintRegistry(new StaticMessageSource()), new KeyValueMappingContext("default"), Collections.<String, Object>emptyMap(), true);
Expand All @@ -98,10 +99,15 @@ public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, Mapping
}

public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, MappingContext mappingContext, Map<String, Object> defaultConstraints, boolean cacheAutoTimestampAnnotations) {
this(constraintRegistry, mappingContext, defaultConstraints, cacheAutoTimestampAnnotations, true);
}

public DefaultConstraintEvaluator(ConstraintRegistry constraintRegistry, MappingContext mappingContext, Map<String, Object> defaultConstraints, boolean cacheAutoTimestampAnnotations, boolean defaultNullable) {
this.constraintRegistry = constraintRegistry;
this.mappingContext = mappingContext;
this.defaultConstraints = defaultConstraints;
this.cacheAutoTimestampAnnotations = cacheAutoTimestampAnnotations;
this.defaultNullable = defaultNullable;
}

@Override
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down
Loading
Loading