From 592a3fb56c1241d223f23ae2b15ec0e7255a0d5c Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Wed, 12 Aug 2026 08:57:44 +0200 Subject: [PATCH 01/14] test: reproduce issue 16133 See gh-16133 --- .../functional/tests/Organization.groovy | 24 ++++++++++ .../tests/OrganizationValidationSpec.groovy | 45 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 grails-test-examples/hibernate5/grails-hibernate/grails-app/domain/functional/tests/Organization.groovy create mode 100644 grails-test-examples/hibernate5/grails-hibernate/src/integration-test/groovy/functional/tests/OrganizationValidationSpec.groovy diff --git a/grails-test-examples/hibernate5/grails-hibernate/grails-app/domain/functional/tests/Organization.groovy b/grails-test-examples/hibernate5/grails-hibernate/grails-app/domain/functional/tests/Organization.groovy new file mode 100644 index 00000000000..a7776a4d52e --- /dev/null +++ b/grails-test-examples/hibernate5/grails-hibernate/grails-app/domain/functional/tests/Organization.groovy @@ -0,0 +1,24 @@ +/* + * 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 functional.tests + +class Organization { + String name +} diff --git a/grails-test-examples/hibernate5/grails-hibernate/src/integration-test/groovy/functional/tests/OrganizationValidationSpec.groovy b/grails-test-examples/hibernate5/grails-hibernate/src/integration-test/groovy/functional/tests/OrganizationValidationSpec.groovy new file mode 100644 index 00000000000..59f5368441c --- /dev/null +++ b/grails-test-examples/hibernate5/grails-hibernate/src/integration-test/groovy/functional/tests/OrganizationValidationSpec.groovy @@ -0,0 +1,45 @@ +/* + * 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 functional.tests + +import spock.lang.Specification + +import grails.gorm.transactions.Rollback +import grails.testing.mixin.integration.Integration + +@Integration +class OrganizationValidationSpec extends Specification { + + @Rollback + void "saving an organization without its name succeeds as nullable is true by default"() { + given: + def organization = new Organization() + + expect: + organization.validate() + !organization.hasErrors() + + when: + def savedOrganization = organization.save(flush: true) + + then: + savedOrganization == organization + Organization.count() == 1 + } +} From 274ea82b5d7a5b24882070e59458a5511137253b Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Wed, 12 Aug 2026 12:51:08 +0200 Subject: [PATCH 02/14] fix: issue 16133 Closes gh-16133 --- .../orm/hibernate/cfg/HibernateMappingContext.java | 1 + .../orm/hibernate/cfg/HibernateMappingContext.java | 1 + .../mapping/config/AbstractGormMappingFactory.java | 11 ++++++++++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java index aed3f004d33..3f0ec23c294 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java @@ -74,6 +74,7 @@ public HibernateMappingContext(HibernateConnectionSourceSettings settings, Objec this.mappingFactory.setDefaultMapping(settings.getDefault().getMapping()); this.mappingFactory.setDefaultConstraints(settings.getDefault().getConstraints()); + this.mappingFactory.setDefaultNullable(settings.getDefault().isNullable()); this.mappingFactory.setContextObject(contextObject); this.syntaxStrategy = new JpaMappingConfigurationStrategy(mappingFactory) { @Override diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java index 485332ba194..d7203edb37f 100644 --- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContext.java @@ -53,6 +53,7 @@ public HibernateMappingContext( initialize(settings); this.mappingFactory.setDefaultMapping(settings.getDefault().getMapping()); this.mappingFactory.setDefaultConstraints(settings.getDefault().getConstraints()); + this.mappingFactory.setDefaultNullable(settings.getDefault().isNullable()); this.mappingFactory.setContextObject(contextObject); this.syntaxStrategy = new GrailsJpaMappingConfigurationStrategy(mappingFactory); this.proxyFactory = new HibernateProxyHandler(); diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java index d4029eefa60..d7a33ed1340 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java @@ -54,6 +54,7 @@ public abstract class AbstractGormMappingFactory Date: Wed, 12 Aug 2026 14:39:09 +0200 Subject: [PATCH 03/14] test: adapt other tests to fixing issue 16133 --- .../org/grails/gorm/graphql/SchemaSpec.groovy | 6 ++--- ...ernatePersistentGraphQLPropertySpec.groovy | 18 +++++++++++++-- .../EmbeddedInputObjectTypeBuilderSpec.groovy | 2 +- ...ContextDatabaseMigrationCommandSpec.groovy | 9 ++++++++ ...GrailsHibernatePersistentEntitySpec.groovy | 3 +++ .../domainbinding/EnumTypeBinderSpec.groovy | 8 ++++++- ...ContextDatabaseMigrationCommandSpec.groovy | 4 ++++ .../domain/grails/test/app/User.groovy | 4 ++++ .../domain/grails/test/app/UserRole.groovy | 2 ++ .../test/app/UserIntegrationSpec.groovy | 22 +++++-------------- 10 files changed, 54 insertions(+), 24 deletions(-) diff --git a/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/SchemaSpec.groovy b/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/SchemaSpec.groovy index a637233f7a6..76eb909980f 100644 --- a/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/SchemaSpec.groovy +++ b/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/SchemaSpec.groovy @@ -191,9 +191,9 @@ class SchemaSpec extends Specification implements GraphQLSchemaSpec { GraphQLInputObjectType type = schema.getType('ToOneCreate') expect: - unwrap(null, type.getFieldDefinition('circularOne').type) == schema.getType('CircularOneCreateNested') - unwrap(null, type.getFieldDefinition('one').type) == schema.getType('OneCreateNested') - unwrap(null, type.getFieldDefinition('anEnum').type) == schema.getType('Enum') + type.getFieldDefinition('circularOne').type == schema.getType('CircularOneCreateNested') + type.getFieldDefinition('one').type == schema.getType('OneCreateNested') + type.getFieldDefinition('anEnum').type == schema.getType('Enum') //everything else is a scalar.. not worth testing every property } diff --git a/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/entity/property/impl/HibernatePersistentGraphQLPropertySpec.groovy b/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/entity/property/impl/HibernatePersistentGraphQLPropertySpec.groovy index d987426a136..15617d3452f 100644 --- a/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/entity/property/impl/HibernatePersistentGraphQLPropertySpec.groovy +++ b/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/entity/property/impl/HibernatePersistentGraphQLPropertySpec.groovy @@ -467,8 +467,18 @@ class Book { static hasMany = [authors: Author, tags: Tag, basics: String, otherBookTypes: BookType] static constraints = { - description nullable: true - nullBookType nullable: true + title nullable: false + metadata nullable: false + bookType nullable: false + + otherMetadata nullable: false + otherMetadata2 nullable: false + someOtherMetadata nullable: false + + authors nullable: false + tags nullable: false + basics nullable: false + otherBookTypes nullable: false } static embedded = ['otherMetadata2', 'someOtherMetadata'] @@ -485,6 +495,10 @@ class Book2 implements Serializable { static mapping = { id composite: ['title', 'description'] + title nullable: false + description nullable: false + metadata nullable: false + bookType nullable: false } int hashCode() { diff --git a/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/types/input/EmbeddedInputObjectTypeBuilderSpec.groovy b/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/types/input/EmbeddedInputObjectTypeBuilderSpec.groovy index 03d0ca48d85..73ee7750ec4 100644 --- a/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/types/input/EmbeddedInputObjectTypeBuilderSpec.groovy +++ b/grails-data-graphql/core/src/test/groovy/org/grails/gorm/graphql/types/input/EmbeddedInputObjectTypeBuilderSpec.groovy @@ -84,6 +84,6 @@ class EmbeddedInputObjectTypeBuilderSpec extends HibernateSpec { then: 'one is included because it is the owning side, many is included because it is not bidirectional' props*.name == ['one', 'many'] - props.any { !it.nullable } //some are not nullable + !props.any { !it.nullable } //all are nullable by default } } diff --git a/grails-data-hibernate5/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate5/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy b/grails-data-hibernate5/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate5/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy index 170ecef0b4b..f4ef0aea542 100644 --- a/grails-data-hibernate5/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate5/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy +++ b/grails-data-hibernate5/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate5/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy @@ -118,10 +118,19 @@ abstract class ApplicationContextDatabaseMigrationCommandSpec extends DatabaseMi class Book { String title Author author + + static constraints = { + title nullable: false + author nullable: false + } } @Entity class Author { String name static hasMany = [books: Book] + + static constraints = { + name nullable: false + } } diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernatePersistentEntitySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernatePersistentEntitySpec.groovy index 35aab61a55d..58bb5306406 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernatePersistentEntitySpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/GrailsHibernatePersistentEntitySpec.groovy @@ -476,6 +476,9 @@ class AddressOwner { Long id EntityAddress address static embedded = ['address'] + static constraints = { + address nullable: false + } } class EntityAddress implements Serializable { diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy index 990260ba00e..1848f3df87b 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy @@ -166,7 +166,13 @@ class EnumTypeBinderSpec extends HibernateGormDatastoreSpec { enum Status01 { AVAILABLE, OUT_OF_STOCK } -@Entity class Person01 { Long id; Status01 status } +@Entity class Person01 { + Long id + Status01 status + static constraints = { + status nullable: false + } +} @Entity class Person02 { Long id; Status01 status static mapping = { status enumType: "string", nullable: true } diff --git a/grails-data-hibernate7/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate7/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy b/grails-data-hibernate7/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate7/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy index f8e2755e786..ea279b71ebb 100644 --- a/grails-data-hibernate7/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate7/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy +++ b/grails-data-hibernate7/dbmigration/src/test-cli/groovy/org/apache/grails/data/hibernate7/dbmigration/cli/ApplicationContextDatabaseMigrationCommandSpec.groovy @@ -121,6 +121,7 @@ class Book { Author author static belongsTo = [author: Author] static constraints = { + title nullable: false author nullable: false } } @@ -129,4 +130,7 @@ class Book { class Author { String name static hasMany = [books: Book] + static constraints = { + name nullable: false + } } diff --git a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy index 154fa844faf..4baed680d72 100644 --- a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy +++ b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy @@ -33,6 +33,8 @@ class User { static constraints = { manager nullable: true + profile nullable: false + address nullable: false } static embedded = ['address', 'profile'] @@ -43,6 +45,8 @@ class User { } static graphql = GraphQLMapping.build { + property('profile', nullable: false) + property('address', nullable: false) add('firstNumber', Integer) { //don't include this property in the list of properties to return from operations output(false) diff --git a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/UserRole.groovy b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/UserRole.groovy index a601f10a204..ec4c3799454 100644 --- a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/UserRole.groovy +++ b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/UserRole.groovy @@ -65,6 +65,8 @@ class UserRole implements Serializable { static constraints = { + user nullable: false + role nullable: false role validator: { Role r, UserRole ur -> if (ur.user?.id) { UserRole.withNewSession { diff --git a/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy b/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy index 87f29c71a4e..975d0e9bb80 100644 --- a/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy +++ b/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy @@ -53,10 +53,7 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { Map obj = resp.body then: - obj.data == null - obj.errors.size() == 1 - obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') - obj.errors[0].message.endsWith("is missing required fields '[profile]'") + obj.data.userCreate != null when: 'The profile is provided, but missing a required field' resp = graphQL.graphql(""" @@ -81,10 +78,7 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { obj = resp.body then: - obj.data == null - obj.errors.size() == 1 - obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') - obj.errors[0].message.endsWith("is missing required fields '[lastName]'") + obj.data.userCreate != null } void "test creating a user without an address"() { @@ -104,13 +98,10 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { } } """) - Map obj = resp.body + Map obj = resp.body then: - obj.data == null - obj.errors.size() == 1 - obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') - obj.errors[0].message.endsWith("is missing required fields '[address]'") + obj.data.userCreate != null when: 'The address is provided, but missing a required field' resp = graphQL.graphql(""" @@ -135,10 +126,7 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { obj = resp.body then: - obj.data == null - obj.errors.size() == 1 - obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') - obj.errors[0].message.endsWith("is missing required fields '[zip]'") + obj.data.userCreate != null } void "test creating the top level manager"() { From 6948b393a3daebc3b4ed4c377e7ecd94c3a3b67a Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Thu, 13 Aug 2026 16:49:40 +0200 Subject: [PATCH 04/14] chore: whitespace --- .../groovy/grails/test/app/UserIntegrationSpec.groovy | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy b/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy index 975d0e9bb80..3977e0f8bf5 100644 --- a/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy +++ b/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy @@ -98,7 +98,7 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { } } """) - Map obj = resp.body + Map obj = resp.body then: obj.data.userCreate != null From f07ddd11b72b1d26e61d78725ec65227165ce4b3 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Thu, 13 Aug 2026 17:07:04 +0200 Subject: [PATCH 05/14] test: reset fixtures and UserIntegrationSpec assertions --- .../domain/grails/test/app/Address.groovy | 3 +++ .../domain/grails/test/app/User.groovy | 3 --- .../test/app/UserIntegrationSpec.groovy | 20 +++++++++++++++---- .../grails/test/app/pogo/Profile.groovy | 6 ++++++ 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/Address.groovy b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/Address.groovy index 98c769a5024..b5c9ee73b00 100644 --- a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/Address.groovy +++ b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/Address.groovy @@ -26,5 +26,8 @@ class Address { Integer zip static constraints = { + city nullable: false + state nullable: false + zip nullable: false } } diff --git a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy index 4baed680d72..375049925bd 100644 --- a/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy +++ b/grails-test-examples/graphql/grails-test-app/grails-app/domain/grails/test/app/User.groovy @@ -32,7 +32,6 @@ class User { Address address //embedded domain class static constraints = { - manager nullable: true profile nullable: false address nullable: false } @@ -45,8 +44,6 @@ class User { } static graphql = GraphQLMapping.build { - property('profile', nullable: false) - property('address', nullable: false) add('firstNumber', Integer) { //don't include this property in the list of properties to return from operations output(false) diff --git a/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy b/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy index 3977e0f8bf5..87f29c71a4e 100644 --- a/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy +++ b/grails-test-examples/graphql/grails-test-app/src/integration-test/groovy/grails/test/app/UserIntegrationSpec.groovy @@ -53,7 +53,10 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { Map obj = resp.body then: - obj.data.userCreate != null + obj.data == null + obj.errors.size() == 1 + obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') + obj.errors[0].message.endsWith("is missing required fields '[profile]'") when: 'The profile is provided, but missing a required field' resp = graphQL.graphql(""" @@ -78,7 +81,10 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { obj = resp.body then: - obj.data.userCreate != null + obj.data == null + obj.errors.size() == 1 + obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') + obj.errors[0].message.endsWith("is missing required fields '[lastName]'") } void "test creating a user without an address"() { @@ -101,7 +107,10 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { Map obj = resp.body then: - obj.data.userCreate != null + obj.data == null + obj.errors.size() == 1 + obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') + obj.errors[0].message.endsWith("is missing required fields '[address]'") when: 'The address is provided, but missing a required field' resp = graphQL.graphql(""" @@ -126,7 +135,10 @@ class UserIntegrationSpec extends Specification implements GraphQLSpec { obj = resp.body then: - obj.data.userCreate != null + obj.data == null + obj.errors.size() == 1 + obj.errors[0].message.startsWith('Validation error (WrongType@[userCreate])') + obj.errors[0].message.endsWith("is missing required fields '[zip]'") } void "test creating the top level manager"() { diff --git a/grails-test-examples/graphql/grails-test-app/src/main/groovy/grails/test/app/pogo/Profile.groovy b/grails-test-examples/graphql/grails-test-app/src/main/groovy/grails/test/app/pogo/Profile.groovy index 670472acdb3..5c11b8963fc 100644 --- a/grails-test-examples/graphql/grails-test-app/src/main/groovy/grails/test/app/pogo/Profile.groovy +++ b/grails-test-examples/graphql/grails-test-app/src/main/groovy/grails/test/app/pogo/Profile.groovy @@ -28,4 +28,10 @@ class Profile { String firstName String lastName + static constraints = { + email nullable: false + firstName nullable: false + lastName nullable: false + } + } From 22633948995761dd1ac09e6a7ef72e74fc47ddaf Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Thu, 13 Aug 2026 17:17:59 +0200 Subject: [PATCH 06/14] fix: initialize `defaultNullable` to `true` in `AbstractGormMappingFactory` --- .../datastore/mapping/config/AbstractGormMappingFactory.java | 2 +- .../grails/datastore/mapping/model/AbstractMappingContext.java | 1 + .../mapping/keyvalue/mapping/KeyValueMappingFactoryTests.groovy | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java index d7a33ed1340..9e3f66a649b 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java @@ -54,7 +54,7 @@ public abstract class AbstractGormMappingFactory Date: Fri, 14 Aug 2026 08:32:40 +0200 Subject: [PATCH 07/14] fix: preserve bidirectional binding with nullable associations --- .../datastore/mapping/config/AbstractGormMappingFactory.java | 3 ++- .../groovy/grails/web/databinding/GrailsWebDataBinder.groovy | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java index 9e3f66a649b..494bf0e8ebb 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java @@ -202,7 +202,8 @@ else if (properties != null) { } else { T property = BeanUtils.instantiateClass(getPropertyMappedFormType()); - if (!GormProperties.IDENTITY.equals(mpp.getName()) && !GormProperties.VERSION.equals(mpp.getName())) { + if (!GormProperties.IDENTITY.equals(mpp.getName()) && + !GormProperties.VERSION.equals(mpp.getName())) { property.setNullable(defaultNullable); } return property; diff --git a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy index 80a87452dd7..ec86f0860bf 100644 --- a/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy +++ b/grails-web-databinding/src/main/groovy/grails/web/databinding/GrailsWebDataBinder.groovy @@ -586,7 +586,7 @@ class GrailsWebDataBinder extends SimpleDataBinder { otherSide = ((Association) property).inverseSide } } - if (otherSide != null && List.isAssignableFrom(otherSide.getType()) && !property.isNullable()) { + if (otherSide != null && List.isAssignableFrom(otherSide.getType())) { DeferredBindingActions.addBindingAction(new Runnable() { void run() { if (obj[propName] != null && otherSide instanceof OneToMany) { From 9cf91149ac9c8d8e7b44bf989ae33a2f099cd123 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 17 Aug 2026 09:10:26 +0200 Subject: [PATCH 08/14] test: cover nullable-by-default mapping cases --- .../cfg/HibernateMappingContextSpec.groovy | 59 ++++++++++++ .../cfg/HibernateMappingContextSpec.groovy | 79 +++++++++++++++- .../config/MongoMappingContextSpec.groovy | 89 +++++++++++++++++++ .../gorm/neo4j/Neo4jMappingContextSpec.groovy | 71 +++++++++++++++ .../config/DocumentMappingContextSpec.groovy | 89 +++++++++++++++++++ .../KeyValueMappingFactoryTests.groovy | 51 +++++++++++ 6 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContextSpec.groovy create mode 100644 grails-data-neo4j/grails-datastore-gorm-neo4j/src/test/groovy/org/grails/datastore/gorm/neo4j/Neo4jMappingContextSpec.groovy create mode 100644 grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContextSpec.groovy diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy index 4a1a1940f2c..4c0853f19da 100644 --- a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy @@ -55,6 +55,42 @@ class HibernateMappingContextSpec extends Specification { and:"The type is registered as a custom type with the mapping factory" mappingContext.mappingFactory.isCustomType(MyUUIDGenerator) } + + void "unconstrained properties are nullable in the Hibernate mapping"() { + when: + def entity = new HibernateMappingContext().addPersistentEntity(MappingContextNullableByDefaultEntity) + + then: + entity.getPropertyByName("name").mapping.mappedForm.nullable + } + + void "explicit constraints do not prevent the default nullable mapping"() { + when: + def entity = new HibernateMappingContext().addPersistentEntity(MappingContextConstrainedEntity) + + then: + entity.getPropertyByName("name").mapping.mappedForm.nullable + } + + void "wildcard mappings do not prevent the default nullable mapping"() { + when: + def entity = new HibernateMappingContext().addPersistentEntity(MappingContextWildcardMappedEntity) + + then: + entity.getPropertyByName("name").mapping.mappedForm.nullable + } + + void "default nullable can be disabled"() { + given: + def settings = new HibernateConnectionSourceSettings() + settings.default.nullable = false + + when: + def entity = new HibernateMappingContext(settings).addPersistentEntity(MappingContextNullableByDefaultEntity) + + then: + !entity.getPropertyByName("name").mapping.mappedForm.nullable + } } @Entity @@ -65,6 +101,29 @@ class CustomIdGeneratorEntity { } } +@Entity +class MappingContextNullableByDefaultEntity { + String name +} + +@Entity +class MappingContextConstrainedEntity { + String name + + static constraints = { + name maxSize: 100 + } +} + +@Entity +class MappingContextWildcardMappedEntity { + String name + + static mapping = { + '*' cache: true + } +} + class MyUUIDGenerator { } diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy index 5686060f29c..1994f53fd69 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy @@ -21,6 +21,7 @@ package org.grails.orm.hibernate.cfg import grails.gorm.annotation.Entity import grails.gorm.hibernate.HibernateEntity import grails.gorm.tests.HibernateGormDatastoreSpec +import grails.gorm.transactions.Rollback import org.grails.datastore.mapping.engine.types.AbstractMappingAwareCustomTypeMarshaller import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.model.PersistentProperty @@ -36,7 +37,14 @@ import org.grails.datastore.mapping.core.connections.ConnectionSource class HibernateMappingContextSpec extends HibernateGormDatastoreSpec { def setupSpec() { - manager.registerDomainClasses(MappingContextBook, MappingContextAuthor, MappingContextAddress) + manager.registerDomainClasses( + MappingContextBook, + MappingContextAuthor, + MappingContextAddress, + MappingContextNullableByDefaultEntity, + MappingContextConstrainedEntity, + MappingContextWildcardMappedEntity + ) } // --- unit-style tests (no datastore required) --- @@ -114,6 +122,16 @@ class HibernateMappingContextSpec extends HibernateGormDatastoreSpec { mappingContext instanceof HibernateMappingContext } + @Rollback + void "saving an entity with an unset unconstrained property succeeds"() { + when: + def entity = new MappingContextNullableByDefaultEntity().save(flush: true) + + then: + entity != null + !entity.hasErrors() + } + void "registered domain classes appear as persistent entities"() { expect: mappingContext.getPersistentEntity(MappingContextBook.name) != null @@ -181,6 +199,42 @@ class HibernateMappingContextSpec extends HibernateGormDatastoreSpec { noExceptionThrown() } + void "unconstrained properties are nullable in the Hibernate mapping"() { + when: + def entity = new HibernateMappingContext().addPersistentEntity(MappingContextNullableByDefaultEntity) + + then: + entity.getPropertyByName("name").mapping.mappedForm.nullable + } + + void "explicit non-null constraints do not prevent the default nullable mapping"() { + when: + def entity = new HibernateMappingContext().addPersistentEntity(MappingContextConstrainedEntity) + + then: + entity.getPropertyByName("name").mapping.mappedForm.nullable + } + + void "wildcard mappings do not prevent the default nullable mapping"() { + when: + def entity = new HibernateMappingContext().addPersistentEntity(MappingContextWildcardMappedEntity) + + then: + entity.getPropertyByName("name").mapping.mappedForm.nullable + } + + void "default nullable can be disabled"() { + given: + def settings = new HibernateConnectionSourceSettings() + settings.default.nullable = false + + when: + def entity = new HibernateMappingContext(settings).addPersistentEntity(MappingContextNullableByDefaultEntity) + + then: + !entity.getPropertyByName("name").mapping.mappedForm.nullable + } + void "createPersistentEntity returns null for non-GormEntity class"() { given: def ctx = new HibernateMappingContext() @@ -225,6 +279,29 @@ class MappingContextAddress { String city } +@Entity +class MappingContextNullableByDefaultEntity implements HibernateEntity { + String name +} + +@Entity +class MappingContextConstrainedEntity implements HibernateEntity { + String name + + static constraints = { + name maxSize: 100 + } +} + +@Entity +class MappingContextWildcardMappedEntity implements HibernateEntity { + String name + + static mapping = { + '*' cache: true + } +} + // --- helpers for unit tests --- @Entity diff --git a/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContextSpec.groovy b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContextSpec.groovy new file mode 100644 index 00000000000..a701635a4fb --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/org/grails/datastore/mapping/mongo/config/MongoMappingContextSpec.groovy @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.datastore.mapping.mongo.config + +import grails.gorm.annotation.Entity +import org.grails.datastore.mapping.mongo.connections.MongoConnectionSourceSettings +import spock.lang.Specification + +class MongoMappingContextSpec extends Specification { + + void "properties are nullable by default"() { + when: + def entity = new MongoMappingContext(new MongoConnectionSourceSettings()) + .addPersistentEntity(MongoEntityWithName) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "explicit constraints preserve the default nullable mapping"() { + when: + def entity = new MongoMappingContext(new MongoConnectionSourceSettings()) + .addPersistentEntity(ConstrainedMongoEntity) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "wildcard mappings preserve the default nullable mapping"() { + when: + def entity = new MongoMappingContext(new MongoConnectionSourceSettings()) + .addPersistentEntity(WildcardMongoEntity) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "default nullable can be disabled"() { + given: + def settings = new MongoConnectionSourceSettings() + settings.default.nullable = false + + when: + def entity = new MongoMappingContext(settings) + .addPersistentEntity(MongoEntityWithName) + + then: + !entity.getPropertyByName('name').mapping.mappedForm.nullable + } +} + +@Entity +class MongoEntityWithName { + String name +} + +@Entity +class ConstrainedMongoEntity { + String name + + static constraints = { + name maxSize: 100 + } +} + +@Entity +class WildcardMongoEntity { + String name + + static mapping = { + '*' cache: true + } +} diff --git a/grails-data-neo4j/grails-datastore-gorm-neo4j/src/test/groovy/org/grails/datastore/gorm/neo4j/Neo4jMappingContextSpec.groovy b/grails-data-neo4j/grails-datastore-gorm-neo4j/src/test/groovy/org/grails/datastore/gorm/neo4j/Neo4jMappingContextSpec.groovy new file mode 100644 index 00000000000..435cb048a75 --- /dev/null +++ b/grails-data-neo4j/grails-datastore-gorm-neo4j/src/test/groovy/org/grails/datastore/gorm/neo4j/Neo4jMappingContextSpec.groovy @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.datastore.gorm.neo4j + +import grails.gorm.annotation.Entity +import org.grails.datastore.gorm.neo4j.connections.Neo4jConnectionSourceSettings +import spock.lang.Specification + +class Neo4jMappingContextSpec extends Specification { + + void "properties are nullable by default"() { + when: + def entity = new Neo4jMappingContext(new Neo4jConnectionSourceSettings()) + .addPersistentEntity(Neo4jEntityWithName) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "explicit constraints preserve the default nullable mapping"() { + when: + def entity = new Neo4jMappingContext(new Neo4jConnectionSourceSettings()) + .addPersistentEntity(ConstrainedNeo4jEntity) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "default nullable can be disabled"() { + given: + def settings = new Neo4jConnectionSourceSettings() + settings.default.nullable = false + + when: + def entity = new Neo4jMappingContext(settings) + .addPersistentEntity(Neo4jEntityWithName) + + then: + !entity.getPropertyByName('name').mapping.mappedForm.nullable + } +} + +@Entity +class Neo4jEntityWithName { + String name +} + +@Entity +class ConstrainedNeo4jEntity { + String name + + static constraints = { + name maxSize: 100 + } +} diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContextSpec.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContextSpec.groovy new file mode 100644 index 00000000000..35dda07cafa --- /dev/null +++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/document/config/DocumentMappingContextSpec.groovy @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.grails.datastore.mapping.document.config + +import grails.gorm.annotation.Entity +import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings +import spock.lang.Specification + +class DocumentMappingContextSpec extends Specification { + + void "properties are nullable by default"() { + when: + def entity = new DocumentMappingContext('default', new ConnectionSourceSettings()) + .addPersistentEntity(DocumentEntityWithName) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "explicit constraints preserve the default nullable mapping"() { + when: + def entity = new DocumentMappingContext('default', new ConnectionSourceSettings()) + .addPersistentEntity(ConstrainedDocumentEntity) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "wildcard mappings preserve the default nullable mapping"() { + when: + def entity = new DocumentMappingContext('default', new ConnectionSourceSettings()) + .addPersistentEntity(WildcardDocumentEntity) + + then: + entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + void "default nullable can be disabled"() { + given: + def settings = new ConnectionSourceSettings() + settings.default.nullable = false + + when: + def entity = new DocumentMappingContext('default', settings) + .addPersistentEntity(DocumentEntityWithName) + + then: + !entity.getPropertyByName('name').mapping.mappedForm.nullable + } +} + +@Entity +class DocumentEntityWithName { + String name +} + +@Entity +class ConstrainedDocumentEntity { + String name + + static constraints = { + name maxSize: 100 + } +} + +@Entity +class WildcardDocumentEntity { + String name + + static mapping = { + '*' cache: true + } +} diff --git a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/keyvalue/mapping/KeyValueMappingFactoryTests.groovy b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/keyvalue/mapping/KeyValueMappingFactoryTests.groovy index ad013b9f94d..01908f6c375 100644 --- a/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/keyvalue/mapping/KeyValueMappingFactoryTests.groovy +++ b/grails-datastore-core/src/test/groovy/org/grails/datastore/mapping/keyvalue/mapping/KeyValueMappingFactoryTests.groovy @@ -22,6 +22,7 @@ import org.grails.datastore.mapping.keyvalue.mapping.config.Family import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValue import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValueMappingContext import org.grails.datastore.mapping.keyvalue.mapping.config.KeyValuePersistentEntity +import org.grails.datastore.mapping.core.connections.ConnectionSourceSettings import org.grails.datastore.mapping.model.PersistentProperty import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -74,6 +75,33 @@ class KeyValueMappingFactoryTests { assert prop.mapping.mappedForm.derived } + @Test + void testExplicitConstraintsPreserveDefaultNullable() { + def entity = new KeyValueMappingContext('myspace') + .addPersistentEntity(ConstrainedTestEntity) + + assert entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + @Test + void testWildcardMappingPreservesDefaultNullable() { + def entity = new KeyValueMappingContext('myspace') + .addPersistentEntity(WildcardTestEntity) + + assert entity.getPropertyByName('name').mapping.mappedForm.nullable + } + + @Test + void testDefaultNullableCanBeDisabled() { + def settings = new ConnectionSourceSettings() + settings.default.nullable = false + + def entity = new KeyValueMappingContext('myspace', settings) + .addPersistentEntity(NullableTestEntity) + + assert !entity.getPropertyByName('name').mapping.mappedForm.nullable + } + @Test void testParentEntity() { KeyValuePersistentEntity entity = context.getPersistentEntity(TestEntity.name) @@ -99,4 +127,27 @@ class KeyValueMappingFactoryTests { formulaProperty(formula: 'foo(bar)') } } + + class ConstrainedTestEntity { + Long id + String name + + static constraints = { + name maxSize: 100 + } + } + + class NullableTestEntity { + Long id + String name + } + + class WildcardTestEntity { + Long id + String name + + static mapping = { + '*' cache: true + } + } } From baa3a4c0a35988e9675b642acba393612125a900 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 17 Aug 2026 09:52:30 +0200 Subject: [PATCH 09/14] fix: nullable-by-default mapping cases --- .../cfg/HibernateMappingBuilder.groovy | 6 ++-- .../config/AbstractGormMappingFactory.java | 29 ++++++++++++++----- .../datastore/mapping/config/Property.groovy | 11 ++++++- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingBuilder.groovy b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingBuilder.groovy index 4e9ac40c952..a2613eaea43 100644 --- a/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingBuilder.groovy +++ b/grails-data-hibernate5/core/src/main/groovy/org/grails/orm/hibernate/cfg/HibernateMappingBuilder.groovy @@ -34,7 +34,6 @@ import org.grails.datastore.mapping.reflect.ClassPropertyFetcher * Implements the ORM mapping DSL constructing a model that can be evaluated by the * GrailsDomainBinder class which maps GORM classes onto the database. * - * @author Graeme Rocher * @since 1.0 */ @@ -468,7 +467,9 @@ class HibernateMappingBuilder implements MappingConfigurationBuilder extends MappingFactory { @@ -183,21 +181,33 @@ protected IdentityMapping getIdentityMappedForm(final ClassMapping classMapping, public T createMappedForm(PersistentProperty mpp) { Map properties = entityToPropertyMap.get(mpp.getOwner()); if (properties != null && properties.containsKey(mpp.getName())) { - return properties.get(mpp.getName()); + T property = properties.get(mpp.getName()); + if (!isIdentityOrVersion(mpp) && !property.isNullableConfigured()) { + property.setNullable(defaultNullable); + } + return property; } else if (properties != null) { - Property property = properties.get(IDENTITY_PROPERTY); + T property = properties.get(IDENTITY_PROPERTY); if (property != null && mpp.getName().equals(property.getName())) { - return (T) property; + return property; } } T defaultMapping = properties != null ? properties.get("*") : null; if (defaultMapping != null) { try { - return (T) defaultMapping.clone(); + T property = (T) defaultMapping.clone(); + if (!isIdentityOrVersion(mpp) && !property.isNullableConfigured()) { + property.setNullable(defaultNullable); + } + return property; } catch (CloneNotSupportedException e) { - return BeanUtils.instantiateClass(getPropertyMappedFormType()); + T property = BeanUtils.instantiateClass(getPropertyMappedFormType()); + if (!isIdentityOrVersion(mpp)) { + property.setNullable(defaultNullable); + } + return property; } } else { @@ -209,4 +219,9 @@ else if (properties != null) { return property; } } + + private boolean isIdentityOrVersion(PersistentProperty property) { + return GormProperties.IDENTITY.equals(property.getName()) || + GormProperties.VERSION.equals(property.getName()); + } } diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy index 3f5bbde9dec..2b3383e6bda 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/Property.groovy @@ -34,7 +34,6 @@ import org.springframework.validation.DataBinder /** * Base class for classes returned from {@link org.grails.datastore.mapping.model.PropertyMapping#getMappedForm()} * - * @author Graeme Rocher * @since 1.0 */ @CompileStatic @@ -49,6 +48,7 @@ class Property implements Cloneable { * @return Whether the property is nullable */ boolean nullable = false + private boolean nullableConfigured = false /** * @return Whether this property is a database reference such as a foreign key @@ -169,6 +169,15 @@ class Property implements Cloneable { return lazy == Boolean.TRUE } + void setNullable(boolean nullable) { + this.nullable = nullable + this.nullableConfigured = true + } + + boolean isNullableConfigured() { + return nullableConfigured + } + void setLazy(Boolean lazy) { this.lazy = lazy } From cb0db006e0fcdf81c53022f4c529906ac3d799ee Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 17 Aug 2026 09:59:22 +0200 Subject: [PATCH 10/14] test: update EnumTypeBinderSpec to handle changed nullable default --- .../domainbinding/EnumTypeBinderSpec.groovy | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy index 1848f3df87b..147d5310fda 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/domainbinding/EnumTypeBinderSpec.groovy @@ -18,35 +18,33 @@ */ package org.grails.orm.hibernate.cfg.domainbinding -import org.hibernate.type.descriptor.WrapperOptions +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.SQLException -import grails.gorm.tests.HibernateGormDatastoreSpec -import grails.persistence.Entity -import org.grails.datastore.mapping.model.PersistentProperty -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty -import org.grails.orm.hibernate.cfg.IdentityEnumType import jakarta.persistence.EnumType -import org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateToManyProperty -import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover -import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher -import org.grails.orm.hibernate.cfg.domainbinding.util.DefaultColumnNameFetcher -import org.hibernate.engine.spi.SharedSessionContractImplementor -import org.hibernate.mapping.Table + import org.hibernate.mapping.RootClass +import org.hibernate.mapping.Table +import org.hibernate.type.descriptor.WrapperOptions import org.hibernate.usertype.UserType import spock.lang.Subject import spock.lang.Unroll -import java.sql.PreparedStatement -import java.sql.ResultSet -import java.sql.SQLException - +import grails.gorm.tests.HibernateGormDatastoreSpec +import grails.persistence.Entity +import org.grails.datastore.mapping.model.PersistentProperty +import org.grails.orm.hibernate.cfg.IdentityEnumType import org.grails.orm.hibernate.cfg.domainbinding.binder.ColumnConfigToColumnBinder import org.grails.orm.hibernate.cfg.domainbinding.binder.EnumTypeBinder +import org.grails.orm.hibernate.cfg.domainbinding.binder.GrailsDomainBinder import org.grails.orm.hibernate.cfg.domainbinding.binder.IndexBinder import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity +import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateBasicProperty +import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernateEnumProperty +import org.grails.orm.hibernate.cfg.domainbinding.util.BackticksRemover +import org.grails.orm.hibernate.cfg.domainbinding.util.ColumnNameForPropertyAndPathFetcher +import org.grails.orm.hibernate.cfg.domainbinding.util.DefaultColumnNameFetcher class EnumTypeBinderSpec extends HibernateGormDatastoreSpec { @@ -167,27 +165,24 @@ class EnumTypeBinderSpec extends HibernateGormDatastoreSpec { enum Status01 { AVAILABLE, OUT_OF_STOCK } @Entity class Person01 { - Long id - Status01 status - static constraints = { - status nullable: false - } + Long id; Status01 status + static constraints = { status nullable: false } } @Entity class Person02 { Long id; Status01 status - static mapping = { status enumType: "string", nullable: true } + static mapping = { status enumType: "string" } } @Entity class Person03 { Long id; Status01 status - static mapping = { status enumType: "ordinal", nullable: true } + static mapping = { status enumType: "ordinal" } } @Entity class Person04 { Long id; Status01 status - static mapping = { status enumType: "identity" } + static mapping = { status enumType: "identity", nullable: false } } @Entity class Person05 { Long id; Status01 status - static mapping = { status type: UserTypeEnumType } + static mapping = { status type: UserTypeEnumType, nullable: false } } @Entity class PersonWithCollection { Long id From d76700ae30971235a4d6919a0b4697556383b814 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 17 Aug 2026 10:45:50 +0200 Subject: [PATCH 11/14] docs: update documentation to clarify changes in default nullability for persistence mapping --- grails-doc/src/en/guide/upgrading/upgrading80x.adoc | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 2d777e80ccb..80f747464b2 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1234,9 +1234,13 @@ class Book { } ---- -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. +This change also affects persistence mapping. Columns for unconstrained properties are +nullable by default, so schema creation and schema migration can produce nullable columns +where Grails 7 produced `NOT NULL` columns. Review existing schemas and declare +`nullable: false` explicitly for properties that must remain required before running +schema migration or diff tools. + +Command objects are unaffected: Validateable command-object properties remain non-nullable by default, except for collections and maps, which remain nullable by default. To restore the legacy required-by-default behaviour for an entire application, set the new `grails.gorm.default.nullable` property to `false`: From 47cb138493fb7ee0ef0e1ea81c7028302f5b7e5a Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 17 Aug 2026 11:22:55 +0200 Subject: [PATCH 12/14] test: more default nullable test coverage --- .../cfg/HibernateMappingContextSpec.groovy | 25 +++++-- .../cfg/HibernateMappingContextSpec.groovy | 29 +++++++-- .../GrailsWebDataBinderSpec.groovy | 65 +++++++++++++++++-- 3 files changed, 106 insertions(+), 13 deletions(-) diff --git a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy index 4c0853f19da..cb6fd3ed811 100644 --- a/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy +++ b/grails-data-hibernate5/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy @@ -18,17 +18,15 @@ */ package org.grails.orm.hibernate.cfg +import spock.lang.Specification + import grails.gorm.annotation.Entity import org.grails.datastore.mapping.engine.types.AbstractMappingAwareCustomTypeMarshaller import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.model.PersistentProperty import org.grails.datastore.mapping.model.ValueGenerator import org.grails.orm.hibernate.connections.HibernateConnectionSourceSettings -import spock.lang.Specification -/** - * Created by graemerocher on 07/10/2016. - */ class HibernateMappingContextSpec extends Specification { void "test entity with custom id generator"() { @@ -80,6 +78,15 @@ class HibernateMappingContextSpec extends Specification { entity.getPropertyByName("name").mapping.mappedForm.nullable } + void "composite id components are nullable by default"() { + when: + def entity = new HibernateMappingContext().addPersistentEntity(MappingContextCompositeIdEntity) + + then: + entity.getPropertyByName("tenantId").mapping.mappedForm.nullable + entity.getPropertyByName("code").mapping.mappedForm.nullable + } + void "default nullable can be disabled"() { given: def settings = new HibernateConnectionSourceSettings() @@ -124,6 +131,16 @@ class MappingContextWildcardMappedEntity { } } +@Entity +class MappingContextCompositeIdEntity { + String tenantId + String code + + static mapping = { + id composite: ['tenantId', 'code'] + } +} + class MyUUIDGenerator { } diff --git a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy index 1994f53fd69..c70a83285e9 100644 --- a/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/org/grails/orm/hibernate/cfg/HibernateMappingContextSpec.groovy @@ -18,21 +18,22 @@ */ package org.grails.orm.hibernate.cfg +import org.springframework.validation.Errors + import grails.gorm.annotation.Entity import grails.gorm.hibernate.HibernateEntity import grails.gorm.tests.HibernateGormDatastoreSpec import grails.gorm.transactions.Rollback +import org.grails.datastore.mapping.core.connections.ConnectionSource import org.grails.datastore.mapping.engine.types.AbstractMappingAwareCustomTypeMarshaller import org.grails.datastore.mapping.model.PersistentEntity import org.grails.datastore.mapping.model.PersistentProperty import org.grails.datastore.mapping.model.ValueGenerator import org.grails.datastore.mapping.model.config.JpaMappingConfigurationStrategy import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsHibernatePersistentEntity -import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty import org.grails.orm.hibernate.cfg.domainbinding.hibernate.GrailsJpaMappingConfigurationStrategy +import org.grails.orm.hibernate.cfg.domainbinding.hibernate.HibernatePersistentProperty import org.grails.orm.hibernate.connections.HibernateConnectionSourceSettings -import org.springframework.validation.Errors -import org.grails.datastore.mapping.core.connections.ConnectionSource class HibernateMappingContextSpec extends HibernateGormDatastoreSpec { @@ -43,7 +44,8 @@ class HibernateMappingContextSpec extends HibernateGormDatastoreSpec { MappingContextAddress, MappingContextNullableByDefaultEntity, MappingContextConstrainedEntity, - MappingContextWildcardMappedEntity + MappingContextWildcardMappedEntity, + MappingContextCompositeIdEntity ) } @@ -122,6 +124,15 @@ class HibernateMappingContextSpec extends HibernateGormDatastoreSpec { mappingContext instanceof HibernateMappingContext } + void "composite id components are nullable by default"() { + given: + PersistentEntity entity = mappingContext.getPersistentEntity(MappingContextCompositeIdEntity.name) + + expect: + entity.getPropertyByName('tenantId').nullable + entity.getPropertyByName('code').nullable + } + @Rollback void "saving an entity with an unset unconstrained property succeeds"() { when: @@ -302,6 +313,16 @@ class MappingContextWildcardMappedEntity implements HibernateEntity { + String tenantId + String code + + static mapping = { + id composite: ['tenantId', 'code'] + } +} + // --- helpers for unit tests --- @Entity diff --git a/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy b/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy index 17001a0afb9..36de6f313c4 100644 --- a/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy +++ b/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy @@ -20,20 +20,23 @@ package grails.web.databinding import groovy.transform.Sortable +import spock.lang.Issue +import spock.lang.Specification +import spock.lang.Unroll + +import org.springframework.context.support.StaticMessageSource + import grails.databinding.BindUsing import grails.databinding.BindingFormat import grails.databinding.DataBindingSource import grails.databinding.SimpleMapDataBindingSource +import grails.databinding.converters.ValueConverter import grails.databinding.errors.BindingError import grails.databinding.events.DataBindingListenerAdapter import grails.persistence.Entity import grails.testing.gorm.DataTest import grails.validation.DeferredBindingActions import grails.validation.Validateable -import org.springframework.context.support.StaticMessageSource -import spock.lang.Issue -import spock.lang.Specification -import spock.lang.Unroll class GrailsWebDataBinderSpec extends Specification implements DataTest { @@ -44,7 +47,8 @@ class GrailsWebDataBinderSpec extends Specification implements DataTest { void setupSpec() { mockDomains( AssociationBindingAuthor, AssociationBindingBook, AssociationBindingPage, Author, Child, - CollectionContainer, DataBindingBook, Fidget, Foo, Parent, Publication, Publisher, Team, Widget + BinderNullabilityEntity, CollectionContainer, DataBindingBook, Fidget, Foo, Parent, Publication, + Publisher, Team, Widget ) } @@ -54,6 +58,7 @@ class GrailsWebDataBinderSpec extends Specification implements DataTest { void cleanup() { Locale.setDefault(defaultLocale) + binder.convertEmptyStringsToNull = true } void 'Test binding an invalid String to an object reference does not result in an empty instance being bound'() { @@ -1129,6 +1134,38 @@ class GrailsWebDataBinderSpec extends Specification implements DataTest { afterBindingArgs[0]['propertyName'] == 'someNumber' } + void 'blank binding errors respect domain property nullability'() { + + given: + binder.convertEmptyStringsToNull = false + binder.registerConverter(new ValueConverter() { + boolean canConvert(Object value) { + value instanceof String + } + + Object convert(Object value) { + throw new IllegalArgumentException('Blank status') + } + + Class getTargetType() { + BinderNullabilityStatus + } + }) + def entity = new BinderNullabilityEntity() + + when: + binder.bind(entity, new SimpleMapDataBindingSource( + optionalStatus: '', + requiredStatus: '' + )) + + then: + entity.hasErrors() + entity.errors.errorCount == 1 + entity.errors.getFieldError('requiredStatus').code == 'typeMismatch' + entity.errors.getFieldError('optionalStatus') == null + } + void 'Test binding a List'() { given: @@ -1754,6 +1791,10 @@ class Publication { String title Author author + static constraints = { + publisher nullable: true + } + @SuppressWarnings('unused') static belongsTo = [publisher: Publisher] } @@ -1917,6 +1958,20 @@ class SomeValidateableClass implements Validateable { Integer someNumber } +@Entity +class BinderNullabilityEntity { + BinderNullabilityStatus optionalStatus + BinderNullabilityStatus requiredStatus + + static constraints = { + requiredStatus nullable: false + } +} + +enum BinderNullabilityStatus { + ACTIVE +} + @Entity class AssociationBindingPage { Integer number From f8e4485cbe7f5b7554fe3ca3caf0fe8f0248ffd1 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Mon, 17 Aug 2026 15:10:10 +0200 Subject: [PATCH 13/14] chore: add back import after bad merge --- .../groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy | 1 + 1 file changed, 1 insertion(+) diff --git a/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy b/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy index 0b69ff6b50a..43a1abcad97 100644 --- a/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy +++ b/grails-test-suite-persistence/src/test/groovy/grails/web/databinding/GrailsWebDataBinderSpec.groovy @@ -32,6 +32,7 @@ import grails.databinding.BindUsing import grails.databinding.BindingFormat import grails.databinding.DataBindingSource import grails.databinding.SimpleMapDataBindingSource +import grails.databinding.converters.ValueConverter import grails.databinding.errors.BindingError import grails.databinding.events.DataBindingListenerAdapter import grails.persistence.Entity From 3650fdf15a909ae8c9136dd04524dfe599b75d58 Mon Sep 17 00:00:00 2001 From: Mattias Reichel Date: Tue, 18 Aug 2026 10:21:27 +0200 Subject: [PATCH 14/14] refactor: rename isIdentityOrVersion to notIdentityOrVersion --- .../config/AbstractGormMappingFactory.java | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java index 5c1108a5a10..b34e4dac3a7 100644 --- a/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java +++ b/grails-datastore-core/src/main/groovy/org/grails/datastore/mapping/config/AbstractGormMappingFactory.java @@ -182,7 +182,7 @@ public T createMappedForm(PersistentProperty mpp) { Map properties = entityToPropertyMap.get(mpp.getOwner()); if (properties != null && properties.containsKey(mpp.getName())) { T property = properties.get(mpp.getName()); - if (!isIdentityOrVersion(mpp) && !property.isNullableConfigured()) { + if (notIdentityOrVersion(mpp) && !property.isNullableConfigured()) { property.setNullable(defaultNullable); } return property; @@ -198,13 +198,13 @@ else if (properties != null) { if (defaultMapping != null) { try { T property = (T) defaultMapping.clone(); - if (!isIdentityOrVersion(mpp) && !property.isNullableConfigured()) { + if (notIdentityOrVersion(mpp) && !property.isNullableConfigured()) { property.setNullable(defaultNullable); } return property; } catch (CloneNotSupportedException e) { T property = BeanUtils.instantiateClass(getPropertyMappedFormType()); - if (!isIdentityOrVersion(mpp)) { + if (notIdentityOrVersion(mpp)) { property.setNullable(defaultNullable); } return property; @@ -212,16 +212,15 @@ else if (properties != null) { } else { T property = BeanUtils.instantiateClass(getPropertyMappedFormType()); - if (!GormProperties.IDENTITY.equals(mpp.getName()) && - !GormProperties.VERSION.equals(mpp.getName())) { + if (notIdentityOrVersion(mpp)) { property.setNullable(defaultNullable); } return property; } } - private boolean isIdentityOrVersion(PersistentProperty property) { - return GormProperties.IDENTITY.equals(property.getName()) || - GormProperties.VERSION.equals(property.getName()); + private static boolean notIdentityOrVersion(PersistentProperty property) { + return !GormProperties.IDENTITY.equals(property.getName()) && + !GormProperties.VERSION.equals(property.getName()); } }