From 994311865b7d4c4d2c50c6d96c49c750b4b600be Mon Sep 17 00:00:00 2001 From: t Date: Sat, 4 Jul 2026 13:45:34 -0400 Subject: [PATCH 1/2] Keep BeanBuilder DSL independent of XML setup Avoid eager Spring XML reader setup when evaluating ordinary BeanBuilder DSLs so resources.groovy remains compatible with Spring Framework 7. Move XML reader and namespace support behind a lazy helper used only by XML import and xmlns paths. Add grails-spring regression coverage for plain DSL, XML import, and namespace support, and wire the module into the shared test configuration so those tests execute. Document the compatibility behavior in the Grails 8 upgrade and Spring DSL guides. Assisted-by: Hephaestus:openai/gpt-5.5 codex-review --- .../src/en/guide/introduction/whatsNew.adoc | 4 + .../en/guide/spring/springdslAdditional.adoc | 3 + .../spring/theBeanBuilderDSLExplained.adoc | 4 + .../src/en/guide/upgrading/upgrading80x.adoc | 8 ++ grails-spring/build.gradle | 3 +- .../groovy/grails/spring/BeanBuilder.java | 46 +++----- .../grails/spring/BeanBuilderXmlSupport.java | 99 +++++++++++++++++ .../grails/spring/BeanBuilderTests.groovy | 105 ++++++++++++++++++ 8 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 grails-spring/src/main/groovy/grails/spring/BeanBuilderXmlSupport.java create mode 100644 grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index b0d4d9b49da..117cc85b343 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -39,6 +39,10 @@ This brings the Spring Boot 4 modular artifact layout, Spring Framework 7 API re The Grails 8 upgrade guide calls out the major application-impacting changes and links to the Spring Boot 4.0 migration guide, Spring Boot 4.1 release notes and Spring Framework 7.0 release notes. +Grails 8 keeps the `resources.groovy` BeanBuilder DSL backward compatible on Spring Framework 7. +The standard BeanBuilder DSL path registers programmatic Spring `BeanDefinition` instances and does not initialize Spring XML infrastructure unless an application explicitly imports XML bean definitions or uses Spring XML namespace DSL support through `xmlns`. +The `resources.xml` file and BeanBuilder namespace support remain available for compatibility, but `resources.groovy` is the recommended path for custom bean definitions. + ==== GSP Tag Library Improvements Grails {grailsMajorVersion} continues the move toward method-based TagLib handlers while preserving compatibility with existing closure-based tags. diff --git a/grails-doc/src/en/guide/spring/springdslAdditional.adoc b/grails-doc/src/en/guide/spring/springdslAdditional.adoc index 7b38630f87d..dd2bede6be0 100644 --- a/grails-doc/src/en/guide/spring/springdslAdditional.adoc +++ b/grails-doc/src/en/guide/spring/springdslAdditional.adoc @@ -113,6 +113,9 @@ NOTE: If you define a bean in `resources.groovy` with the same name as one previ Beans can also be configured using a `grails-app/conf/spring/resources.xml`. In earlier versions of Grails this file was automatically generated for you by the `run-app` script, but the DSL in `resources.groovy` is the preferred approach now so it isn't automatically generated now. But it is still supported - you just need to create it yourself. +In Grails 8, `resources.xml` remains supported for backward compatibility on Spring Framework 7. +For new bean definitions, prefer `resources.groovy`; BeanBuilder registers standard Spring bean definitions programmatically and only initializes Spring XML infrastructure when XML imports or Spring XML namespaces are used. + This file is typical Spring XML file and the Spring documentation has an {springreference}core/beans/introduction.html[excellent reference] on how to configure Spring beans. The `myBean` bean that we configured using the DSL would be configured with this syntax in the XML file: diff --git a/grails-doc/src/en/guide/spring/theBeanBuilderDSLExplained.adoc b/grails-doc/src/en/guide/spring/theBeanBuilderDSLExplained.adoc index e97342e3041..049eb068397 100644 --- a/grails-doc/src/en/guide/spring/theBeanBuilderDSLExplained.adoc +++ b/grails-doc/src/en/guide/spring/theBeanBuilderDSLExplained.adoc @@ -303,6 +303,10 @@ In this example we create an abstract bean of type `KnightOfTheRoundTable` and u Since Spring 2.0, users of Spring have had easier access to key features via XML namespaces. You can use a Spring namespace in BeanBuilder by declaring it with this syntax: +Spring namespace support in BeanBuilder is a compatibility feature backed by Spring's XML namespace handlers. +BeanBuilder only initializes that XML namespace infrastructure when `xmlns` is used. +For ordinary bean definitions in `resources.groovy`, BeanBuilder registers programmatic Spring bean definitions without using Spring XML configuration. + [source,groovy] ---- xmlns context:"https://www.springframework.org/schema/context" diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index c17cb0d63bc..4bcd0074754 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -57,6 +57,14 @@ For full details, consult: 3. https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.1-Release-Notes[Spring Boot 4.1 Release Notes] 4. https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-7.0-Release-Notes[Spring Framework 7.0 Release Notes] +===== BeanBuilder and Spring XML configuration + +No application changes are required for existing `grails-app/conf/spring/resources.groovy` BeanBuilder DSL files. +Grails 8 keeps that DSL backed by programmatic Spring bean definitions and avoids Spring XML infrastructure unless an application explicitly uses `importBeans` for XML resources or Spring XML namespace DSL support through `xmlns`. + +Existing `grails-app/conf/spring/resources.xml` files remain supported for compatibility. +For new custom bean definitions, prefer `resources.groovy` so applications stay on Grails' programmatic bean registration path. + ==== 3. Spring Boot Autoconfigure Modularization Spring Boot 4 split the monolithic `spring-boot-autoconfigure` module into domain-specific modules. diff --git a/grails-spring/build.gradle b/grails-spring/build.gradle index e5318b54cd5..e7f0edd4b6b 100644 --- a/grails-spring/build.gradle +++ b/grails-spring/build.gradle @@ -61,4 +61,5 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') -} \ No newline at end of file + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') +} diff --git a/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java b/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java index 12b0f637875..a495e713cd0 100644 --- a/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java +++ b/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java @@ -43,23 +43,14 @@ import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.RuntimeBeanReference; import org.springframework.beans.factory.parsing.BeanDefinitionParsingException; -import org.springframework.beans.factory.parsing.EmptyReaderEventListener; -import org.springframework.beans.factory.parsing.FailFastProblemReporter; import org.springframework.beans.factory.parsing.Location; -import org.springframework.beans.factory.parsing.NullSourceExtractor; import org.springframework.beans.factory.parsing.Problem; import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.ManagedList; import org.springframework.beans.factory.support.ManagedMap; -import org.springframework.beans.factory.support.SimpleBeanDefinitionRegistry; -import org.springframework.beans.factory.xml.BeanDefinitionParserDelegate; -import org.springframework.beans.factory.xml.DefaultNamespaceHandlerResolver; import org.springframework.beans.factory.xml.NamespaceHandler; import org.springframework.beans.factory.xml.NamespaceHandlerResolver; -import org.springframework.beans.factory.xml.ParserContext; -import org.springframework.beans.factory.xml.XmlBeanDefinitionReader; -import org.springframework.beans.factory.xml.XmlReaderContext; import org.springframework.context.ApplicationContext; import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.io.ByteArrayResource; @@ -121,13 +112,11 @@ public class BeanBuilder extends GroovyObjectSupport { private ApplicationContext parentCtx; private Map binding = Collections.emptyMap(); private ClassLoader classLoader = null; - private NamespaceHandlerResolver namespaceHandlerResolver; private Map namespaceHandlers = new HashMap<>(); - private XmlBeanDefinitionReader xmlBeanDefinitionReader; private Map namespaces = new HashMap<>(); private Resource beanBuildResource = new ByteArrayResource(new byte[0]); - private XmlReaderContext readerContext; private ResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver(); + private BeanBuilderXmlSupport xmlSupport; public BeanBuilder() { this(null, null); @@ -158,24 +147,22 @@ public void setResourcePatternResolver(ResourcePatternResolver resourcePatternRe } protected void initializeSpringConfig() { - xmlBeanDefinitionReader = new XmlBeanDefinitionReader((GenericApplicationContext) springConfig.getUnrefreshedApplicationContext()); - initializeBeanBuilderForClassLoader(classLoader); + xmlSupport = null; } public void setClassLoader(ClassLoader classLoader) { this.classLoader = classLoader == null ? getClass().getClassLoader() : classLoader; - initializeBeanBuilderForClassLoader(classLoader); + if (xmlSupport != null) { + xmlSupport.setClassLoader(this.classLoader); + } } protected void initializeBeanBuilderForClassLoader(ClassLoader classLoader) { - xmlBeanDefinitionReader.setBeanClassLoader(classLoader); - namespaceHandlerResolver = new DefaultNamespaceHandlerResolver(this.classLoader); - readerContext = new XmlReaderContext(beanBuildResource, new FailFastProblemReporter(), new EmptyReaderEventListener(), - new NullSourceExtractor(), xmlBeanDefinitionReader, namespaceHandlerResolver); + getXmlSupport().setClassLoader(classLoader); } public void setNamespaceHandlerResolver(NamespaceHandlerResolver namespaceHandlerResolver) { - this.namespaceHandlerResolver = namespaceHandlerResolver; + getXmlSupport().setNamespaceHandlerResolver(namespaceHandlerResolver); } protected RuntimeSpringConfiguration createRuntimeSpringConfiguration(ApplicationContext parent, ClassLoader cl) { @@ -207,9 +194,7 @@ public void importBeans(Resource resource) { loadBeans(resource); } else if (resource.getFilename().endsWith(".xml")) { - SimpleBeanDefinitionRegistry beanRegistry = new SimpleBeanDefinitionRegistry(); - XmlBeanDefinitionReader beanReader = new XmlBeanDefinitionReader(beanRegistry); - beanReader.loadBeanDefinitions(resource); + BeanDefinitionRegistry beanRegistry = getXmlSupport().loadBeanDefinitions(resource); String[] beanNames = beanRegistry.getBeanDefinitionNames(); for (String beanName : beanNames) { springConfig.addBeanDefinition(beanName, beanRegistry.getBeanDefinition(beanName)); @@ -223,7 +208,6 @@ else if (resource.getFilename().endsWith(".xml")) { * @param definition The definition */ public void xmlns(Map definition) { - Assert.notNull(namespaceHandlerResolver, "You cannot define a Spring namespace without a [namespaceHandlerResolver] set"); if (definition.isEmpty()) { return; } @@ -234,11 +218,11 @@ public void xmlns(Map definition) { Assert.notNull(uri, "Namespace definition cannot supply a null URI"); - final NamespaceHandler namespaceHandler = namespaceHandlerResolver.resolve(uri); + final NamespaceHandler namespaceHandler = getXmlSupport().resolveNamespaceHandler(uri); if (namespaceHandler == null) { throw new BeanDefinitionParsingException( new Problem("No namespace handler found for URI: " + uri, - new Location(readerContext.getResource()))); + new Location(getXmlSupport().getReaderContext(beanBuildResource).getResource()))); } namespaceHandlers.put(namespace, namespaceHandler); namespaces.put(namespace, uri); @@ -952,8 +936,7 @@ public Object getProperty(String name) { protected DynamicElementReader createDynamicElementReader(String namespace, final boolean decorator) { NamespaceHandler handler = namespaceHandlers.get(namespace); - ParserContext parserContext = new ParserContext(readerContext, new BeanDefinitionParserDelegate(readerContext)); - final DynamicElementReader dynamicElementReader = new DynamicElementReader(namespace, namespaces, handler, parserContext) { + final DynamicElementReader dynamicElementReader = new DynamicElementReader(namespace, namespaces, handler, getXmlSupport().createParserContext(beanBuildResource)) { @Override protected void afterInvocation() { if (!decorator) { @@ -973,6 +956,13 @@ else if (!decorator) { return dynamicElementReader; } + private BeanBuilderXmlSupport getXmlSupport() { + if (xmlSupport == null) { + xmlSupport = new BeanBuilderXmlSupport(springConfig, classLoader); + } + return xmlSupport; + } + /** * Sets the binding (the variables available in the scope of the BeanBuilder). * @param b The Binding instance diff --git a/grails-spring/src/main/groovy/grails/spring/BeanBuilderXmlSupport.java b/grails-spring/src/main/groovy/grails/spring/BeanBuilderXmlSupport.java new file mode 100644 index 00000000000..520102e294e --- /dev/null +++ b/grails-spring/src/main/groovy/grails/spring/BeanBuilderXmlSupport.java @@ -0,0 +1,99 @@ +/* + * 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.spring; + +import org.springframework.beans.factory.parsing.EmptyReaderEventListener; +import org.springframework.beans.factory.parsing.FailFastProblemReporter; +import org.springframework.beans.factory.parsing.NullSourceExtractor; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.SimpleBeanDefinitionRegistry; +import org.springframework.beans.factory.xml.BeanDefinitionParserDelegate; +import org.springframework.beans.factory.xml.DefaultNamespaceHandlerResolver; +import org.springframework.beans.factory.xml.NamespaceHandler; +import org.springframework.beans.factory.xml.NamespaceHandlerResolver; +import org.springframework.beans.factory.xml.ParserContext; +import org.springframework.beans.factory.xml.XmlBeanDefinitionReader; +import org.springframework.beans.factory.xml.XmlReaderContext; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.io.Resource; + +import org.grails.spring.RuntimeSpringConfiguration; + +final class BeanBuilderXmlSupport { + + private final RuntimeSpringConfiguration springConfig; + private ClassLoader classLoader; + private NamespaceHandlerResolver namespaceHandlerResolver; + private XmlBeanDefinitionReader xmlBeanDefinitionReader; + private XmlReaderContext readerContext; + private Resource readerContextResource; + + BeanBuilderXmlSupport(RuntimeSpringConfiguration springConfig, ClassLoader classLoader) { + this.springConfig = springConfig; + setClassLoader(classLoader); + } + + void setClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + namespaceHandlerResolver = new DefaultNamespaceHandlerResolver(classLoader); + xmlBeanDefinitionReader = null; + readerContext = null; + readerContextResource = null; + } + + void setNamespaceHandlerResolver(NamespaceHandlerResolver namespaceHandlerResolver) { + this.namespaceHandlerResolver = namespaceHandlerResolver; + readerContext = null; + readerContextResource = null; + } + + NamespaceHandler resolveNamespaceHandler(String uri) { + return namespaceHandlerResolver.resolve(uri); + } + + ParserContext createParserContext(Resource resource) { + XmlReaderContext currentReaderContext = getReaderContext(resource); + return new ParserContext(currentReaderContext, new BeanDefinitionParserDelegate(currentReaderContext)); + } + + XmlReaderContext getReaderContext(Resource resource) { + if (readerContext == null || !resource.equals(readerContextResource)) { + readerContext = new XmlReaderContext(resource, new FailFastProblemReporter(), new EmptyReaderEventListener(), + new NullSourceExtractor(), getXmlBeanDefinitionReader(), namespaceHandlerResolver); + readerContextResource = resource; + } + return readerContext; + } + + BeanDefinitionRegistry loadBeanDefinitions(Resource resource) { + SimpleBeanDefinitionRegistry beanRegistry = new SimpleBeanDefinitionRegistry(); + XmlBeanDefinitionReader beanReader = new XmlBeanDefinitionReader(beanRegistry); + beanReader.setBeanClassLoader(classLoader); + beanReader.loadBeanDefinitions(resource); + return beanRegistry; + } + + private XmlBeanDefinitionReader getXmlBeanDefinitionReader() { + if (xmlBeanDefinitionReader == null) { + xmlBeanDefinitionReader = new XmlBeanDefinitionReader((GenericApplicationContext) springConfig.getUnrefreshedApplicationContext()); + xmlBeanDefinitionReader.setBeanClassLoader(classLoader); + } + return xmlBeanDefinitionReader; + } +} diff --git a/grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy b/grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy new file mode 100644 index 00000000000..a2ebd7f3f63 --- /dev/null +++ b/grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy @@ -0,0 +1,105 @@ +/* + * 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.spring + +import org.grails.spring.DefaultRuntimeSpringConfiguration +import org.junit.jupiter.api.Test +import org.springframework.context.ApplicationContext +import org.springframework.core.io.ByteArrayResource + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertTrue + +class BeanBuilderTests { + + @Test + void testPlainBeanBuilderDslDoesNotInitializeXmlSupport() { + def springConfig = new DefaultRuntimeSpringConfiguration() { + @Override + ApplicationContext getUnrefreshedApplicationContext() { + throw new AssertionError('XML support should not be initialized for plain BeanBuilder DSL') + } + } + def beanBuilder = new BeanBuilder(null, springConfig, getClass().classLoader) + + beanBuilder.beans { + bean1(Bean1) { + person = 'homer' + } + } + + assertTrue springConfig.containsBean('bean1') + assertEquals Bean1, springConfig.createBeanDefinition('bean1').beanClass + } + + @Test + void testImportBeansXmlInitializesXmlSupportOnDemand() { + def springConfig = new DefaultRuntimeSpringConfiguration() + def beanBuilder = new BeanBuilder(null, springConfig, getClass().classLoader) + + beanBuilder.beans { + importBeans new NamedByteArrayResource(''' + + + + + + '''.bytes, 'test.xml') + } + + assertTrue springConfig.containsBean('xmlBean') + assertEquals String, springConfig.createBeanDefinition('xmlBean').beanClass + } + + @Test + void testXmlnsInitializesNamespaceSupportOnDemand() { + def beanBuilder = new BeanBuilder() + + beanBuilder.beans { + xmlns util: 'http://www.springframework.org/schema/util' + + util.list(id: 'letters') { + value 'one' + value 'two' + } + } + + assertEquals ['one', 'two'], beanBuilder.createApplicationContext().getBean('letters') + } + + static class Bean1 { + String person + } + + private static class NamedByteArrayResource extends ByteArrayResource { + private final String filename + + NamedByteArrayResource(byte[] byteArray, String filename) { + super(byteArray) + this.filename = filename + } + + @Override + String getFilename() { + filename + } + } +} From 833d3e4d8e498041780e9509d509f4f1831b061a Mon Sep 17 00:00:00 2001 From: James Fredley Date: Thu, 9 Jul 2026 14:16:02 -0400 Subject: [PATCH 2/2] Avoid XML reader init for missing namespaces Use the existing BeanBuilder resource when reporting missing XML namespace handlers so the error path does not create a reader context. Add coverage that fails if the XML reader path initializes while building the missing namespace error. Assisted-by: opencode:gpt-5.5 codex-review --- .../groovy/grails/spring/BeanBuilder.java | 2 +- .../grails/spring/BeanBuilderTests.groovy | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java b/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java index a495e713cd0..78b3375c9a9 100644 --- a/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java +++ b/grails-spring/src/main/groovy/grails/spring/BeanBuilder.java @@ -222,7 +222,7 @@ public void xmlns(Map definition) { if (namespaceHandler == null) { throw new BeanDefinitionParsingException( new Problem("No namespace handler found for URI: " + uri, - new Location(getXmlSupport().getReaderContext(beanBuildResource).getResource()))); + new Location(beanBuildResource))); } namespaceHandlers.put(namespace, namespaceHandler); namespaces.put(namespace, uri); diff --git a/grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy b/grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy index a2ebd7f3f63..4ddb56392d3 100644 --- a/grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy +++ b/grails-spring/src/test/groovy/grails/spring/BeanBuilderTests.groovy @@ -20,10 +20,12 @@ package grails.spring import org.grails.spring.DefaultRuntimeSpringConfiguration import org.junit.jupiter.api.Test +import org.springframework.beans.factory.parsing.BeanDefinitionParsingException import org.springframework.context.ApplicationContext import org.springframework.core.io.ByteArrayResource import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertThrows import static org.junit.jupiter.api.Assertions.assertTrue class BeanBuilderTests { @@ -85,6 +87,25 @@ class BeanBuilderTests { assertEquals ['one', 'two'], beanBuilder.createApplicationContext().getBean('letters') } + @Test + void testUnknownNamespaceDoesNotInitializeXmlReaderForErrorLocation() { + def springConfig = new DefaultRuntimeSpringConfiguration() { + @Override + ApplicationContext getUnrefreshedApplicationContext() { + throw new AssertionError('XML reader should not be initialized when namespace handler is missing') + } + } + def beanBuilder = new BeanBuilder(null, springConfig, getClass().classLoader) + + def exception = assertThrows(BeanDefinitionParsingException) { + beanBuilder.beans { + xmlns missing: 'https://example.invalid/schema/missing' + } + } + + assertTrue exception.message.contains('No namespace handler found for URI') + } + static class Bean1 { String person }