This is an automated email from the ASF dual-hosted git repository. davydotcom pushed a commit to branch fix/null-constructor-arg-groovy4 in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit cc919dca2c6127d9df8aa54e2e6599659772355a Author: David Estes <[email protected]> AuthorDate: Tue Mar 3 11:10:42 2026 -0500 fix: handle null argument in GORM domain class constructor (Groovy 4 regression) In Groovy 3, calling new DomainClass(null) was resolved by the runtime to the implicit map-based constructor and treated equivalently to new DomainClass(). Groovy 4 changed how constructor resolution works at runtime and no longer matches a null argument to the implicit map constructor, resulting in: GroovyRuntimeException: Could not find matching constructor for: DomainClass(null) This commit injects an explicit Map constructor into GORM entity classes via GormEntityTransformation. The constructor handles null gracefully by skipping property assignment, making it behave identically to the no-arg constructor. A corresponding no-arg constructor is also ensured since adding any explicit constructor prevents Groovy from auto-generating the default one. Co-Authored-By: Oz <[email protected]> --- .../gorm/tests/NullConstructorArgSpec.groovy | 57 ++++++++++++++++++++++ .../compiler/gorm/GormEntityTransformation.groovy | 47 ++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/NullConstructorArgSpec.groovy b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/NullConstructorArgSpec.groovy new file mode 100644 index 0000000000..9a465a3076 --- /dev/null +++ b/grails-data-hibernate5/core/src/test/groovy/grails/gorm/tests/NullConstructorArgSpec.groovy @@ -0,0 +1,57 @@ +/* + * 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.tests + +import org.apache.grails.data.hibernate5.core.GrailsDataHibernate5TckManager +import org.apache.grails.data.testing.tck.base.GrailsDataTckSpec + +class NullConstructorArgSpec extends GrailsDataTckSpec<GrailsDataHibernate5TckManager> { + void setupSpec() { + manager.domainClasses.addAll([Club]) + } + + void "test creating domain with no-arg constructor works"() { + when: + def club = new Club() + + then: + club != null + club.name == null + } + + void "test creating domain with null constructor argument works the same as no-arg"() { + when: + def club = new Club(null) + + then: + club != null + club.name == null + } + + void "test creating domain with null constructor argument can be saved"() { + when: + def club = new Club(null) + club.name = "Test Club" + club.save(flush: true) + + then: + club.id != null + Club.get(club.id).name == "Test Club" + } +} diff --git a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy index 121cc89575..a8b439c6d3 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/compiler/gorm/GormEntityTransformation.groovy @@ -30,6 +30,7 @@ import org.codehaus.groovy.ast.AnnotatedNode import org.codehaus.groovy.ast.AnnotationNode import org.codehaus.groovy.ast.ClassHelper import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.ConstructorNode import org.codehaus.groovy.ast.GenericsType import org.codehaus.groovy.ast.InnerClassNode import org.codehaus.groovy.ast.MethodNode @@ -69,6 +70,7 @@ import jakarta.persistence.Version import grails.gorm.annotation.Entity import org.apache.grails.common.compiler.GroovyTransformOrder +import org.codehaus.groovy.runtime.InvokerHelper import org.grails.datastore.gorm.GormEnhancer import org.grails.datastore.gorm.GormEntity import org.grails.datastore.gorm.GormEntityDirtyCheckable @@ -83,7 +85,12 @@ import static org.codehaus.groovy.ast.tools.GeneralUtils.args import static org.codehaus.groovy.ast.tools.GeneralUtils.callX import static org.codehaus.groovy.ast.tools.GeneralUtils.classX import static org.codehaus.groovy.ast.tools.GeneralUtils.constX +import static org.codehaus.groovy.ast.tools.GeneralUtils.ifS +import static org.codehaus.groovy.ast.tools.GeneralUtils.neX +import static org.codehaus.groovy.ast.tools.GeneralUtils.nullX import static org.codehaus.groovy.ast.tools.GeneralUtils.returnS +import static org.codehaus.groovy.ast.tools.GeneralUtils.stmt +import static org.codehaus.groovy.ast.tools.GeneralUtils.varX /** * An AST transformation that adds the following features:<br><br> @@ -226,6 +233,10 @@ class GormEntityTransformation extends AbstractASTTransformation implements Comp injectToStringMethod(classNode) } + // inject Map constructor so that new DomainClass(null) works the same as new DomainClass() + // Groovy 4 no longer resolves null to the implicit map constructor at runtime + injectMapConstructor(classNode) + // inject the GORM entity trait unless it is an RX entity MethodNode addToMethodNode = ADD_TO_METHOD_NODE MethodNode removeFromMethodNode = REMOVE_FROM_METHOD_NODE @@ -454,6 +465,42 @@ class GormEntityTransformation extends AbstractASTTransformation implements Comp } } + protected void injectMapConstructor(ClassNode classNode) { + ClassNode mapType = ClassHelper.MAP_TYPE.getPlainNodeReference() + def declaredConstructors = classNode.getDeclaredConstructors() + boolean hasMapConstructor = declaredConstructors.any { ConstructorNode cn -> + cn.parameters.length == 1 && cn.parameters[0].type == mapType + } + if (hasMapConstructor) return + + Parameter mapParam = new Parameter(mapType, 'args') + BlockStatement body = new BlockStatement() + + MethodCallExpression setPropertiesCall = callX( + classX(ClassHelper.make(InvokerHelper)), + 'setProperties', + args(varX('this'), varX('args')) + ) + body.addStatement(ifS(neX(varX('args'), nullX()), stmt(setPropertiesCall))) + + ConstructorNode constructor = classNode.addConstructor( + Modifier.PUBLIC, [mapParam] as Parameter[], null, body + ) + markAsGenerated(classNode, constructor) + + // Adding any explicit constructor prevents Groovy from auto-generating + // the default no-arg constructor, so ensure one exists + boolean hasNoArgConstructor = declaredConstructors.any { ConstructorNode cn -> + cn.parameters.length == 0 + } + if (!hasNoArgConstructor) { + ConstructorNode noArgConstructor = classNode.addConstructor( + Modifier.PUBLIC, AstUtils.ZERO_PARAMETERS, null, new BlockStatement() + ) + markAsGenerated(classNode, noArgConstructor) + } + } + protected void injectVersionProperty(ClassNode classNode) { final boolean hasVersion = AstUtils.hasOrInheritsProperty(classNode, GormProperties.VERSION)
