From a853ddaf6f2aafe7b8e9dfc5e661dab2c37c88e0 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 10:10:53 -0600 Subject: [PATCH 01/10] Retime plugin doWithSpring to run before Spring Boot auto-configuration Plugin bean definitions previously registered through GrailsApplicationPostProcessor, a registry-discovered BeanDefinitionRegistryPostProcessor that runs after ConfigurationClassPostProcessor has expanded auto-configurations, so plugin beans had to override Boot's auto-configured defaults. Register them ahead of auto-configuration instead so that Boot's @ConditionalOnMissingBean guards back off in favour of plugin beans. GrailsPluginLifecycleInitializer (registered via spring.factories) adds GrailsEarlyPluginRegistrationPostProcessor with addBeanFactoryPostProcessor; manually-added registry post-processors are guaranteed to run before registry-discovered ones, including ConfigurationClassPostProcessor. The early phase builds the one true GrailsApplication and DefaultGrailsPluginManager from the promoted PluginDiscovery singleton, performs artefact discovery (required because core plugins iterate artefacts inside doWithSpring), drains doWithSpring/doWithRuntimeConfiguration into the registry, and promotes both singletons plus a completion marker. Application classes are resolved for early artefact discovery from the source classes GrailsApp now stashes in the context, falling back to GrailsApplicationClass bean definitions already present in the registry when the application is started through a plain SpringApplication. The default scanning logic is extracted from GrailsAutoConfiguration.classes() into ApplicationClassScanner so there is a single implementation. GrailsApplicationPostProcessor reuses the promoted grailsApplication (adopting the application class via the new DefaultGrailsApplication.setApplicationClass) and pluginManager, skips loadPlugins and the initialization sequence when the early phase ran, and no longer re-drains plugin runtime configuration; resources.groovy, resources.xml and the application's own doWithSpring still drain in GrailsApplicationPostProcessor as before. Plugins and the plugin manager are constructed exactly once. DevelopmentModeWatchSpec now supplies its watched plugin through the real PluginDiscovery bootstrap path instead of defining a second plugin manager bean, which would be ambiguous with the promoted singleton. --- .../main/groovy/grails/boot/GrailsApp.groovy | 17 ++ .../config/ApplicationClassScanner.groovy | 85 +++++++ .../GrailsApplicationPostProcessor.groovy | 77 +++++- .../config/GrailsAutoConfiguration.groovy | 21 +- ...sEarlyPluginRegistrationPostProcessor.java | 225 ++++++++++++++++++ .../GrailsPluginLifecycleInitializer.java | 40 ++++ .../grails/core/DefaultGrailsApplication.java | 12 + .../main/resources/META-INF/spring.factories | 3 +- .../boot/DevelopmentModeWatchSpec.groovy | 29 ++- ...EarlyPluginRegistrationOrderingSpec.groovy | 127 ++++++++++ 10 files changed, 595 insertions(+), 41 deletions(-) create mode 100644 grails-core/src/main/groovy/grails/boot/config/ApplicationClassScanner.groovy create mode 100644 grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java create mode 100644 grails-core/src/main/groovy/grails/boot/config/GrailsPluginLifecycleInitializer.java create mode 100644 grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy diff --git a/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy b/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy index 2b7c644247f..7199b9920b3 100644 --- a/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy +++ b/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy @@ -34,6 +34,7 @@ import org.springframework.context.ConfigurableApplicationContext import org.springframework.core.env.ConfigurableEnvironment import org.springframework.core.io.ResourceLoader +import grails.boot.config.GrailsEarlyPluginRegistrationPostProcessor import grails.compiler.ast.ClassInjector import grails.config.Settings import grails.core.GrailsApplication @@ -142,6 +143,22 @@ class GrailsApp extends SpringApplication { } } + /** + * Stashes the application source classes as a well-known singleton so that + * {@code GrailsEarlyPluginRegistrationPostProcessor} can perform artefact discovery before + * Spring Boot auto-configuration is processed. Runs before the context initializers are + * applied, so the singleton is available by the time the early registration phase executes. + */ + @Override + protected void postProcessApplicationContext(ConfigurableApplicationContext applicationContext) { + super.postProcessApplicationContext(applicationContext) + Class[] sourceClasses = getAllSources().findAll { it instanceof Class } as Class[] + if (sourceClasses.length > 0) { + applicationContext.beanFactory.registerSingleton( + GrailsEarlyPluginRegistrationPostProcessor.APPLICATION_SOURCE_CLASSES_BEAN_NAME, sourceClasses) + } + } + @Override protected ConfigurableApplicationContext createApplicationContext() { setAllowBeanDefinitionOverriding(configuredEnvironment.getProperty(Settings.SPRING_MAIN_ALLOW_BEAN_DEFINITION_OVERRIDING, Boolean, Boolean.TRUE)) diff --git a/grails-core/src/main/groovy/grails/boot/config/ApplicationClassScanner.groovy b/grails-core/src/main/groovy/grails/boot/config/ApplicationClassScanner.groovy new file mode 100644 index 00000000000..e392a18bcb2 --- /dev/null +++ b/grails-core/src/main/groovy/grails/boot/config/ApplicationClassScanner.groovy @@ -0,0 +1,85 @@ +/* + * 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.boot.config + +import groovy.transform.CompileStatic + +import grails.boot.config.tools.ClassPathScanner +import org.grails.compiler.injection.AbstractGrailsArtefactTransformer + +/** + * Discovers the classes that constitute a Grails application by scanning the classpath relative + * to an application class. This is the single implementation of the default scanning performed by + * {@link GrailsAutoConfiguration#classes()}, also used by + * {@link GrailsEarlyPluginRegistrationPostProcessor} to perform artefact discovery before Spring + * Boot auto-configuration is processed. + * + * @since 8.0 + */ +@CompileStatic +final class ApplicationClassScanner { + + private ApplicationClassScanner() { + } + + /** + * Scans for application classes in the package of the given application class, including any + * classes registered by Grails artefact transformations. + * + * @param applicationClass The application class to scan relative to + * @return The classes that constitute the Grails application + */ + static Collection scanApplicationClasses(Class applicationClass) { + Package applicationPackage = applicationClass.package + Collection packageNames = applicationPackage != null ? [applicationPackage.name] : new ArrayList() + return scanApplicationClasses(applicationClass, packageNames) + } + + /** + * Scans for application classes in the given packages relative to the given application class, + * including any classes registered by Grails artefact transformations. + * + * @param applicationClass The application class to scan relative to + * @param packageNames The package names to scan + * @return The classes that constitute the Grails application + */ + static Collection scanApplicationClasses(Class applicationClass, Collection packageNames) { + Collection classes = new HashSet<>() + classes.addAll(new ClassPathScanner().scan(applicationClass, packageNames)) + classes.addAll(loadTransformedClasses(applicationClass.classLoader)) + return classes + } + + /** + * Loads the classes registered by Grails artefact transformations at compile time. + * + * @param classLoader The class loader to load the classes with + * @return The transformed classes resolvable by the given class loader + */ + static Collection loadTransformedClasses(ClassLoader classLoader) { + Collection classes = [] + for (String className in AbstractGrailsArtefactTransformer.transformedClassNames) { + try { + classes << classLoader.loadClass(className) + } catch (ClassNotFoundException ignored) { + } + } + return classes + } +} diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy index 2734ce81fcb..2150650a81d 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy @@ -78,6 +78,7 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces final GrailsApplicationClass applicationClass final Class[] classes protected final GrailsPluginManager pluginManager + protected final boolean earlyPluginRegistrationRan protected ApplicationContext applicationContext boolean loadExternalBeans = true boolean reloadingEnabled = RELOADING_ENABLED @@ -91,13 +92,36 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces this.applicationClass = null } this.classes = classes != null ? classes : [] as Class[] - grailsApplication = applicationClass != null ? new DefaultGrailsApplication(applicationClass) : new DefaultGrailsApplication() + this.earlyPluginRegistrationRan = hasEarlyPluginRegistrationRun(applicationContext) + if (earlyPluginRegistrationRan) { + grailsApplication = applicationContext.getBean(GrailsApplication.APPLICATION_ID, GrailsApplication) + if (applicationClass != null && grailsApplication instanceof DefaultGrailsApplication) { + ((DefaultGrailsApplication) grailsApplication).setApplicationClass(applicationClass) + } + } + else { + grailsApplication = applicationClass != null ? new DefaultGrailsApplication(applicationClass) : new DefaultGrailsApplication() + } pluginManager = applicationContext?.getBeanNamesForType(GrailsPluginManager) ? applicationContext.getBean(GrailsPluginManager) : new DefaultGrailsPluginManager(grailsApplication, pluginDiscovery) if (applicationContext != null) { setApplicationContext(applicationContext) } } + /** + * Determines whether {@link GrailsEarlyPluginRegistrationPostProcessor} already built the + * {@code grailsApplication} and {@code pluginManager} singletons and drained the plugin + * runtime configuration for this context. Checked on the local bean factory only, so a + * parent context's early phase never short-circuits a child context's lifecycle. + */ + private static boolean hasEarlyPluginRegistrationRun(ApplicationContext applicationContext) { + if (applicationContext instanceof ConfigurableApplicationContext) { + return ((ConfigurableApplicationContext) applicationContext).beanFactory + .containsSingleton(GrailsEarlyPluginRegistrationPostProcessor.EARLY_REGISTRATION_COMPLETE_BEAN_NAME) + } + return false + } + /** * Resolves the {@link PluginDiscovery} from the application context. * The bootstrap registry promotes the discovery bean before context refresh, @@ -122,11 +146,31 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces Environment.setInitializing(true) grailsApplication.applicationContext = applicationContext grailsApplication.mainContext = applicationContext - pluginManager.loadPlugins() - pluginManager.applicationContext = applicationContext + if (!earlyPluginRegistrationRan) { + pluginManager.loadPlugins() + pluginManager.applicationContext = applicationContext + } loadApplicationConfig() customizeGrailsApplication(grailsApplication) - performGrailsInitializationSequence() + if (earlyPluginRegistrationRan) { + registerRemainingApplicationClasses() + } + else { + performGrailsInitializationSequence() + } + } + + /** + * When the early plugin registration phase already performed artefact discovery, only the + * application classes it could not resolve (e.g. a customized {@code classes()} implementation) + * still need to be registered. + */ + private void registerRemainingApplicationClasses() { + for (cls in classes) { + if (!grailsApplication.isArtefact(cls)) { + grailsApplication.addArtefact(cls) + } + } } protected void customizeGrailsApplication(GrailsApplication grailsApplication) { @@ -194,8 +238,11 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces def application = grailsApplication Holders.setGrailsApplication(application) - // first register plugin beans - pluginManager.doRuntimeConfiguration(springConfig) + if (!earlyPluginRegistrationRan) { + // first register plugin beans; when the early phase ran they were + // already drained into the registry ahead of auto-configuration + pluginManager.doRuntimeConfiguration(springConfig) + } if (loadExternalBeans) { // now allow overriding via application @@ -240,11 +287,21 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces BeanFactory parentBeanFactory = beanFactory.getParentBeanFactory() if (parentBeanFactory instanceof ConfigurableBeanFactory) { ConfigurableBeanFactory configurableBeanFactory = parentBeanFactory - configurableBeanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication) - configurableBeanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager) + registerSingletonIfAbsent(configurableBeanFactory, GrailsApplication.APPLICATION_ID, grailsApplication) + registerSingletonIfAbsent(configurableBeanFactory, GrailsPluginManager.BEAN_NAME, pluginManager) } else { - beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication) - beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager) + registerSingletonIfAbsent(beanFactory, GrailsApplication.APPLICATION_ID, grailsApplication) + registerSingletonIfAbsent(beanFactory, GrailsPluginManager.BEAN_NAME, pluginManager) + } + } + + /** + * The early plugin registration phase may have already promoted these singletons; + * {@code registerSingleton} throws {@code IllegalStateException} on double registration. + */ + private static void registerSingletonIfAbsent(ConfigurableBeanFactory beanFactory, String beanName, Object singleton) { + if (!beanFactory.containsSingleton(beanName)) { + beanFactory.registerSingleton(beanName, singleton) } } diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy index 7025bbcec17..aeb8c724944 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy @@ -33,7 +33,6 @@ import grails.config.Config import grails.core.GrailsApplication import grails.core.GrailsApplicationClass import org.apache.grails.core.plugins.PluginDiscovery -import org.grails.compiler.injection.AbstractGrailsArtefactTransformer import org.grails.spring.aop.autoproxy.GroovyAwareAspectJAwareAdvisorAutoProxyCreator import org.grails.spring.aop.autoproxy.GroovyAwareInfrastructureAdvisorAutoProxyCreator @@ -80,25 +79,13 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont * @return The classes that constitute the Grails application */ Collection classes() { - Collection classes = new HashSet() - - ClassPathScanner scanner = new ClassPathScanner() if (limitScanningToApplication()) { - classes.addAll(scanner.scan(getClass(), packageNames())) - } - else { - classes.addAll(scanner.scan(new PathMatchingResourcePatternResolver(applicationContext), packageNames())) - } - - ClassLoader classLoader = getClass().getClassLoader() - for (cls in AbstractGrailsArtefactTransformer.transformedClassNames) { - try { - classes << classLoader.loadClass(cls) - } catch (ClassNotFoundException cnfe) { - // ignore - } + return ApplicationClassScanner.scanApplicationClasses(getClass(), packageNames()) } + Collection classes = new HashSet() + classes.addAll(new ClassPathScanner().scan(new PathMatchingResourcePatternResolver(applicationContext), packageNames())) + classes.addAll(ApplicationClassScanner.loadTransformedClasses(getClass().classLoader)) return classes } diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java new file mode 100644 index 00000000000..fbb141e454b --- /dev/null +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java @@ -0,0 +1,225 @@ +/* + * 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.boot.config; + +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.context.ApplicationListener; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.env.AbstractEnvironment; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.io.Resource; +import org.springframework.util.ClassUtils; + +import grails.core.DefaultGrailsApplication; +import grails.core.GrailsApplication; +import grails.core.GrailsApplicationClass; +import grails.plugins.DefaultGrailsPluginManager; +import grails.plugins.GrailsPluginManager; +import grails.util.Environment; +import grails.util.Holders; +import org.apache.grails.core.plugins.PluginDiscovery; +import org.grails.config.NavigableMap; +import org.grails.config.PropertySourcesConfig; +import org.grails.spring.DefaultRuntimeSpringConfiguration; +import org.grails.spring.RuntimeSpringConfiguration; + +/** + * Runs the plugin bean-registration phase of the Grails lifecycle before Spring Boot's + * auto-configuration is processed, so that beans contributed by plugins via {@code doWithSpring} + * are already present in the registry when Boot evaluates its {@code @ConditionalOnMissingBean} + * guards — auto-configured defaults then back off in favour of the plugin beans, without any + * override or removal afterwards. + * + *

It is added to the context programmatically (see {@link GrailsPluginLifecycleInitializer}), so its + * {@code postProcessBeanDefinitionRegistry} runs ahead of Boot's {@code ConfigurationClassPostProcessor} + * (which expands the {@code @AutoConfiguration} imports). Manually-added + * {@code BeanDefinitionRegistryPostProcessor}s always run before registry-discovered ones; Spring does + * not sort manually-added post-processors by {@code getOrder()}, so this class deliberately does not + * implement {@code PriorityOrdered}. + * + *

This phase builds the one true {@link GrailsApplication} and {@link GrailsPluginManager}: plugins + * are discovered via the promoted {@link PluginDiscovery} singleton and instantiated exactly once. + * Artefact discovery also happens here, mirroring + * {@code GrailsApplicationPostProcessor.performGrailsInitializationSequence()}, because core plugins + * (controllers, services, interceptors) iterate {@code grailsApplication} artefacts inside their + * {@code doWithSpring} closures. Application classes are resolved from the source classes stashed by + * {@link grails.boot.GrailsApp} (see {@link #APPLICATION_SOURCE_CLASSES_BEAN_NAME}) and scanned with the + * same logic {@link GrailsAutoConfiguration#classes()} uses; when the application was not started + * through {@code GrailsApp} the phase proceeds without application classes. + * + *

Once complete, the {@code grailsApplication} and {@code pluginManager} singletons are promoted to + * the bean factory together with the {@link #EARLY_REGISTRATION_COMPLETE_BEAN_NAME} marker, so + * {@link GrailsApplicationPostProcessor} reuses them instead of rebuilding and skips the already-drained + * plugin runtime configuration. + * + * @since 8.0 + */ +public class GrailsEarlyPluginRegistrationPostProcessor + implements BeanDefinitionRegistryPostProcessor, ApplicationListener { + + /** + * Name of the {@code Class[]} singleton under which {@link grails.boot.GrailsApp} stashes the + * application source classes so this phase can perform early artefact discovery. + */ + public static final String APPLICATION_SOURCE_CLASSES_BEAN_NAME = "grailsApplicationSourceClasses"; + + /** + * Name of the marker singleton registered once this phase has completed, checked by + * {@link GrailsApplicationPostProcessor} to reuse the promoted singletons and skip the + * already-performed lifecycle steps. Always checked on the local bean factory only. + */ + public static final String EARLY_REGISTRATION_COMPLETE_BEAN_NAME = "grailsEarlyPluginRegistrationComplete"; + + private static final Logger LOG = LoggerFactory.getLogger(GrailsEarlyPluginRegistrationPostProcessor.class); + + private final ConfigurableApplicationContext applicationContext; + + public GrailsEarlyPluginRegistrationPostProcessor(ConfigurableApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + // Check the LOCAL singleton only — a parent context's discovery must not cause us to re-run + // the early phase in a child context (containsBean/getBean would delegate to the parent). + Object discovery = applicationContext.getBeanFactory().getSingleton(PluginDiscovery.BEAN_NAME); + if (!(discovery instanceof PluginDiscovery pluginDiscovery)) { + // No plugin discovery promoted to this context (e.g. unit-test slice) — nothing to do. + return; + } + + Environment.setInitializing(true); + + DefaultGrailsApplication grailsApplication = new DefaultGrailsApplication(); + grailsApplication.setConfig(buildConfig()); + grailsApplication.setApplicationContext(applicationContext); + grailsApplication.setMainContext(applicationContext); + + DefaultGrailsPluginManager pluginManager = new DefaultGrailsPluginManager(grailsApplication, pluginDiscovery); + pluginManager.loadPlugins(); + pluginManager.setApplicationContext(applicationContext); + + pluginManager.doArtefactConfiguration(); + grailsApplication.initialise(); + // register plugin provided classes first, this gives the opportunity + // for application classes to override those provided by a plugin + pluginManager.registerProvidedArtefacts(grailsApplication); + registerApplicationArtefacts(grailsApplication, registry); + + RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration(); + pluginManager.doRuntimeConfiguration(springConfig); + springConfig.registerBeansWithRegistry(registry); + + ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); + beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication); + beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager); + beanFactory.registerSingleton(EARLY_REGISTRATION_COMPLETE_BEAN_NAME, Boolean.TRUE); + Holders.setGrailsApplication(grailsApplication); + + // GrailsApplicationPostProcessor resets the initializing flag on refresh, but it is not + // present in every context that runs this phase — reset here as well so the flag (a system + // property) does not leak once the context is up. + applicationContext.addApplicationListener(this); + } + + private void registerApplicationArtefacts(DefaultGrailsApplication grailsApplication, BeanDefinitionRegistry registry) { + Class[] sources = resolveApplicationSourceClasses(registry); + if (sources.length == 0) { + LOG.debug("No application source classes available — proceeding without early application artefact discovery"); + return; + } + for (Class source : sources) { + for (Class applicationClass : ApplicationClassScanner.scanApplicationClasses(source)) { + grailsApplication.addArtefact(applicationClass); + } + } + } + + /** + * Resolves the application source classes to scan for artefacts, preferring the singleton + * stashed by {@link grails.boot.GrailsApp}. When the application was started through a plain + * {@code SpringApplication} no stash exists, but the primary sources are already registered as + * bean definitions by the time this phase runs, so any {@link GrailsApplicationClass} among + * them is recovered from the registry instead. + */ + private Class[] resolveApplicationSourceClasses(BeanDefinitionRegistry registry) { + Object stashedSources = applicationContext.getBeanFactory().getSingleton(APPLICATION_SOURCE_CLASSES_BEAN_NAME); + if (stashedSources instanceof Class[] sources) { + return sources; + } + List> sources = new ArrayList<>(); + for (String beanDefinitionName : registry.getBeanDefinitionNames()) { + String beanClassName = registry.getBeanDefinition(beanDefinitionName).getBeanClassName(); + if (beanClassName == null) { + continue; + } + try { + Class beanClass = ClassUtils.forName(beanClassName, applicationContext.getClassLoader()); + if (GrailsApplicationClass.class.isAssignableFrom(beanClass)) { + sources.add(beanClass); + } + } catch (ClassNotFoundException | LinkageError ignored) { + // not resolvable here — cannot be an application class + } + } + return sources.toArray(new Class[0]); + } + + /** + * Builds the {@link PropertySourcesConfig} that backs {@code grailsApplication.config} in this + * phase, registering the same conversion-service converters that + * {@code GrailsApplicationPostProcessor.loadApplicationConfig} registers for the main lifecycle. + * This gives {@code doWithSpring} closures parity when reading config — null-safe navigation of + * missing paths and {@code String -> Resource} coercion — not just scalar + * {@code getProperty(...)} access. + */ + private PropertySourcesConfig buildConfig() { + ConfigurableEnvironment environment = applicationContext.getEnvironment(); + ConfigurableConversionService conversionService = null; + if (environment instanceof AbstractEnvironment) { + conversionService = ((AbstractEnvironment) environment).getConversionService(); + conversionService.addConverter(String.class, Resource.class, applicationContext::getResource); + conversionService.addConverter(NavigableMap.NullSafeNavigator.class, String.class, source -> null); + conversionService.addConverter(NavigableMap.NullSafeNavigator.class, Object.class, source -> null); + } + PropertySourcesConfig config = new PropertySourcesConfig(environment.getPropertySources()); + if (conversionService != null) { + config.setConversionService(conversionService); + } + return config; + } + + @Override + public void onApplicationEvent(ContextRefreshedEvent event) { + if (event.getApplicationContext() == applicationContext) { + Environment.setInitializing(false); + } + } +} diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsPluginLifecycleInitializer.java b/grails-core/src/main/groovy/grails/boot/config/GrailsPluginLifecycleInitializer.java new file mode 100644 index 00000000000..1d3ca88234d --- /dev/null +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsPluginLifecycleInitializer.java @@ -0,0 +1,40 @@ +/* + * 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.boot.config; + +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; + +/** + * Registers the {@link GrailsEarlyPluginRegistrationPostProcessor} on the context via + * {@code addBeanFactoryPostProcessor}, which guarantees it runs ahead of Spring Boot's + * registry-discovered post-processors (including {@code ConfigurationClassPostProcessor}, + * which expands auto-configuration). That ordering is what lets plugin beans contributed via + * {@code doWithSpring} be present in the registry before Boot's {@code @ConditionalOnMissingBean} + * guards are evaluated, so auto-configured defaults back off in favour of plugin beans. + * + * @since 8.0 + */ +public class GrailsPluginLifecycleInitializer implements ApplicationContextInitializer { + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + applicationContext.addBeanFactoryPostProcessor(new GrailsEarlyPluginRegistrationPostProcessor(applicationContext)); + } +} diff --git a/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java b/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java index 8d5e708de42..fdb754b9dff 100644 --- a/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java +++ b/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java @@ -219,6 +219,18 @@ public GrailsApplicationClass getApplicationClass() { return applicationClass; } + /** + * Sets the application class. Used to adopt the application class when this instance was + * constructed before the {@link GrailsApplicationClass} was available, e.g. during early + * plugin registration ahead of Spring Boot auto-configuration. + * + * @param applicationClass The application class + * @since 8.0 + */ + public void setApplicationClass(GrailsApplicationClass applicationClass) { + this.applicationClass = applicationClass; + } + /** * Initialises the default set of ArtefactHandler instances. * diff --git a/grails-core/src/main/resources/META-INF/spring.factories b/grails-core/src/main/resources/META-INF/spring.factories index 824ecc7a0ea..d8db197a83f 100644 --- a/grails-core/src/main/resources/META-INF/spring.factories +++ b/grails-core/src/main/resources/META-INF/spring.factories @@ -22,4 +22,5 @@ org.springframework.boot.env.PropertySourceLoader=\ org.grails.config.yaml.YamlPropertySourceLoader org.springframework.boot.EnvironmentPostProcessor=grails.boot.config.GrailsEnvironmentPostProcessor org.springframework.boot.SpringApplicationRunListener=grails.config.external.ExternalConfigRunListener -org.springframework.boot.bootstrap.BootstrapRegistryInitializer=org.apache.grails.core.GrailsBootstrapRegistryInitializer \ No newline at end of file +org.springframework.boot.bootstrap.BootstrapRegistryInitializer=org.apache.grails.core.GrailsBootstrapRegistryInitializer +org.springframework.context.ApplicationContextInitializer=grails.boot.config.GrailsPluginLifecycleInitializer \ No newline at end of file diff --git a/grails-core/src/test/groovy/grails/boot/DevelopmentModeWatchSpec.groovy b/grails-core/src/test/groovy/grails/boot/DevelopmentModeWatchSpec.groovy index 9f9e35d6605..26f9589dc0f 100644 --- a/grails-core/src/test/groovy/grails/boot/DevelopmentModeWatchSpec.groovy +++ b/grails-core/src/test/groovy/grails/boot/DevelopmentModeWatchSpec.groovy @@ -18,14 +18,14 @@ */ package grails.boot -import grails.plugins.GrailsPlugin import grails.plugins.GrailsPluginManager import grails.plugins.Plugin import grails.util.Environment -import org.grails.plugins.DefaultGrailsPlugin -import org.grails.plugins.MockGrailsPluginManager +import org.apache.grails.core.plugins.DefaultPluginDiscovery +import org.apache.grails.core.plugins.PluginDiscovery +import org.springframework.boot.bootstrap.BootstrapRegistry +import org.springframework.boot.bootstrap.BootstrapRegistryInitializer import org.springframework.context.ConfigurableApplicationContext -import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import spock.lang.Specification import spock.util.concurrent.PollingConditions @@ -42,8 +42,17 @@ class DevelopmentModeWatchSpec extends Specification { System.setProperty(Environment.KEY, Environment.DEVELOPMENT.getName()) System.setProperty("base.dir", ".") GrailsApp app = new GrailsApp(GrailsTestConfigurationClass.class) + DefaultPluginDiscovery discovery = new DefaultPluginDiscovery([WatchedResourcesGrailsPlugin] as Class[]) + discovery.loadPluginsFromClasspath = false + app.addBootstrapRegistryInitializer(new BootstrapRegistryInitializer() { + @Override + void initialize(BootstrapRegistry registry) { + registry.register(PluginDiscovery, BootstrapRegistry.InstanceSupplier.of(discovery)) + } + }) ConfigurableApplicationContext context = app.run() - WatchedResourcesGrailsPlugin plugin = context.getBean('grailsPluginManager').plugins.values().first().instance + GrailsPluginManager pluginManager = context.getBean(GrailsPluginManager.BEAN_NAME, GrailsPluginManager) + WatchedResourcesGrailsPlugin plugin = (WatchedResourcesGrailsPlugin) pluginManager.getGrailsPlugin('watchedResources').instance when: File watchedFile = new File('testWatchedFile.properties') @@ -56,6 +65,8 @@ class DevelopmentModeWatchSpec extends Specification { } cleanup: + GrailsApp.setDevelopmentModeActive(false) + context?.close() if(watchedFile != null) { watchedFile.delete() } @@ -64,14 +75,6 @@ class DevelopmentModeWatchSpec extends Specification { @Configuration class GrailsTestConfigurationClass { - - @Bean(name = "grailsPluginManager") - GrailsPluginManager getGrailsPluginManager() { - MockGrailsPluginManager mockGrailsPluginManager = new MockGrailsPluginManager() - GrailsPlugin watchedPlugin = new DefaultGrailsPlugin(WatchedResourcesGrailsPlugin.class, mockGrailsPluginManager.application) - mockGrailsPluginManager.registerMockPlugin(watchedPlugin) - return mockGrailsPluginManager - } } class WatchedResourcesGrailsPlugin extends Plugin { diff --git a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy new file mode 100644 index 00000000000..8b0ea5b67eb --- /dev/null +++ b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy @@ -0,0 +1,127 @@ +/* + * 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.boot.config + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.AnnotationConfigApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +import grails.core.GrailsApplication +import grails.plugins.GrailsPluginManager +import grails.util.Environment +import grails.util.Holders +import org.apache.grails.core.plugins.DefaultPluginDiscovery +import org.apache.grails.core.plugins.PluginDiscovery +import spock.lang.Specification + +/** + * Proves the architectural linchpin of {@link GrailsEarlyPluginRegistrationPostProcessor}: a plugin + * bean registered via {@code doWithSpring} lands in the registry before + * {@code ConfigurationClassPostProcessor} evaluates {@code @ConditionalOnMissingBean}, so the + * matching Boot auto-config bean backs off — instead of the plugin having to override or remove + * it afterwards. + */ +class EarlyPluginRegistrationOrderingSpec extends Specification { + + void 'a plugin doWithSpring bean registered early makes a @ConditionalOnMissingBean auto-config bean defer'() { + given: 'a context whose configuration would, by itself, register a conditional myResolver' + def ctx = new AnnotationConfigApplicationContext() + ctx.register(EarlyOrderingAutoConfigLikeConfig) + + and: 'a plugin discovery promoted to the context, exactly as the bootstrap registry does' + def discovery = new DefaultPluginDiscovery([earlyOrderingPluginClass] as Class[]) + discovery.loadPluginsFromClasspath = false + discovery.init(ctx.environment) + ctx.beanFactory.registerSingleton(PluginDiscovery.BEAN_NAME, discovery) + + and: 'the early registration phase installed through its real entry point' + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes' + ctx.refresh() + + then: 'the early (plugin) bean is the one named myResolver...' + ctx.getBean('myResolver') instanceof EarlyOrderingPluginResolver + + and: '...and the conditional default was never created' + ctx.getBeansOfType(EarlyOrderingBootDefaultResolver).isEmpty() + + and: 'the one true grailsApplication and pluginManager singletons were promoted' + ctx.getBean(GrailsApplication.APPLICATION_ID) instanceof GrailsApplication + ctx.getBean(GrailsPluginManager.BEAN_NAME) instanceof GrailsPluginManager + ctx.getBean(GrailsPluginManager.BEAN_NAME, GrailsPluginManager).getGrailsPlugin('earlyOrdering') != null + + and: 'the environment initializing flag was reset on refresh' + !Environment.isInitializing() + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'without a promoted plugin discovery the early phase is a no-op and the conditional bean is created (control)'() { + given: + def ctx = new AnnotationConfigApplicationContext() + ctx.register(EarlyOrderingAutoConfigLikeConfig) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: + ctx.refresh() + + then: 'the conditional default wins when nothing registered the bean first' + ctx.getBean('myResolver') instanceof EarlyOrderingBootDefaultResolver + + and: 'no Grails singletons were promoted' + !ctx.beanFactory.containsSingleton(GrailsApplication.APPLICATION_ID) + !ctx.beanFactory.containsSingleton(GrailsPluginManager.BEAN_NAME) + + cleanup: + ctx.close() + } + + private static Class getEarlyOrderingPluginClass() { + new GroovyClassLoader(EarlyPluginRegistrationOrderingSpec.classLoader).parseClass(''' +class EarlyOrderingGrailsPlugin { + def version = '1.0' + def doWithSpring = { + myResolver(grails.boot.config.EarlyOrderingPluginResolver) + } +} +''') + } + + /** Stands in for a Spring Boot auto-configuration: a name-guarded conditional bean. */ + @Configuration + static class EarlyOrderingAutoConfigLikeConfig { + + @Bean + @ConditionalOnMissingBean(name = 'myResolver') + EarlyOrderingBootDefaultResolver myResolver() { + new EarlyOrderingBootDefaultResolver() + } + } +} + +class EarlyOrderingBootDefaultResolver { +} + +class EarlyOrderingPluginResolver { +} From de3dd940f2df7c133f319afa975b2340ea41028d Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 10:24:34 -0600 Subject: [PATCH 02/10] Add BeanRegistrar plugin hook and deprecate the doWithSpring bean DSL Introduce beanRegistrar() on GrailsApplicationLifeCycle (interface default returning null) and grails.plugins.Plugin as the modern, Spring-native way for plugins and applications to register beans, built on Spring Framework 7's org.springframework.beans.factory.BeanRegistrar (register(BeanRegistry, Environment)). GrailsPlugin gains a default getBeanRegistrar() accessor, implemented by DefaultGrailsPlugin to expose the plugin instance's registrar, mirroring how doWithSpring reaches the instance today. The early plugin registration phase applies each enabled plugin's registrar to the bean definition registry in plugin order, immediately after the doWithSpring drain and still ahead of Spring Boot auto-configuration, using BeanRegistryAdapter exactly as GenericApplicationContext.register does. The application class's registrar drains in GrailsApplicationPostProcessor at the same point as the application doWithSpring closure. doWithSpring() on GrailsApplicationLifeCycle and Plugin is deprecated since 8.0; the bean builder DSL continues to work unchanged. Documents the retimed lifecycle and the new API in the 8.0 upgrade guide and rewrites the plugin runtime-configuration guide around beanRegistrar() with doWithSpring as the deprecated alternative. Extends the ordering spec to prove a registrar bean wins a @ConditionalOnMissingBean race and that both hooks coexist on one plugin. --- .../GrailsApplicationPostProcessor.groovy | 12 +++ ...sEarlyPluginRegistrationPostProcessor.java | 25 +++++ .../core/GrailsApplicationLifeCycle.groovy | 22 ++++- .../groovy/grails/plugins/GrailsPlugin.java | 15 +++ .../main/groovy/grails/plugins/Plugin.groovy | 20 ++++ .../grails/plugins/DefaultGrailsPlugin.java | 10 ++ ...EarlyPluginRegistrationOrderingSpec.groovy | 94 +++++++++++++++++++ .../hookingIntoRuntimeConfiguration.adoc | 49 +++++++++- .../src/en/guide/upgrading/upgrading80x.adoc | 67 +++++++++++++ 9 files changed, 311 insertions(+), 3 deletions(-) diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy index 2150650a81d..3e4b90c0759 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy @@ -24,10 +24,12 @@ import groovy.util.logging.Slf4j import org.springframework.beans.BeansException import org.springframework.beans.factory.BeanFactory +import org.springframework.beans.factory.BeanRegistrar import org.springframework.beans.factory.config.ConfigurableBeanFactory import org.springframework.beans.factory.config.ConfigurableListableBeanFactory import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor +import org.springframework.beans.factory.support.BeanRegistryAdapter import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContextAware import org.springframework.context.ApplicationListener @@ -280,6 +282,16 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces } springConfig.registerBeansWithRegistry(registry) + + if (lifeCycle) { + // the application's BeanRegistrar drains at the same point as its doWithSpring closure, + // after the DSL flush so registrar beans win any name conflicts with the deprecated DSL + BeanRegistrar registrar = lifeCycle.beanRegistrar() + if (registrar != null) { + new BeanRegistryAdapter(registry, applicationContext, applicationContext.environment, registrar.getClass()) + .register(registrar) + } + } } @Override diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java index fbb141e454b..d806c673cb7 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java @@ -25,9 +25,11 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanRegistrar; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.beans.factory.support.BeanRegistryAdapter; import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.event.ContextRefreshedEvent; @@ -41,6 +43,7 @@ import grails.core.GrailsApplication; import grails.core.GrailsApplicationClass; import grails.plugins.DefaultGrailsPluginManager; +import grails.plugins.GrailsPlugin; import grails.plugins.GrailsPluginManager; import grails.util.Environment; import grails.util.Holders; @@ -136,6 +139,7 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration(); pluginManager.doRuntimeConfiguration(springConfig); springConfig.registerBeansWithRegistry(registry); + applyBeanRegistrars(pluginManager, registry); ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication); @@ -149,6 +153,27 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t applicationContext.addApplicationListener(this); } + /** + * Applies the {@link BeanRegistrar} exposed by each enabled plugin through + * {@link grails.core.GrailsApplicationLifeCycle#beanRegistrar()}, in plugin order, using the + * same adapter Spring uses for {@code GenericApplicationContext.register(BeanRegistrar...)}. + * Runs after the {@code doWithSpring} drain so registrar beans win any name conflicts with the + * deprecated DSL. + */ + private void applyBeanRegistrars(DefaultGrailsPluginManager pluginManager, BeanDefinitionRegistry registry) { + String[] activeProfiles = applicationContext.getEnvironment().getActiveProfiles(); + for (GrailsPlugin plugin : pluginManager.getAllPlugins()) { + if (!plugin.supportsCurrentScopeAndEnvironment() || !plugin.isEnabled(activeProfiles)) { + continue; + } + BeanRegistrar registrar = plugin.getBeanRegistrar(); + if (registrar != null) { + new BeanRegistryAdapter(registry, applicationContext.getBeanFactory(), + applicationContext.getEnvironment(), registrar.getClass()).register(registrar); + } + } + } + private void registerApplicationArtefacts(DefaultGrailsApplication grailsApplication, BeanDefinitionRegistry registry) { Class[] sources = resolveApplicationSourceClasses(registry); if (sources.length == 0) { diff --git a/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy b/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy index 7f3ca551ec3..3dee78dd009 100644 --- a/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy +++ b/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy @@ -18,10 +18,12 @@ */ package grails.core +import org.springframework.beans.factory.BeanRegistrar + /** * API which plugins implement to provide behavior in defined application lifecycle hooks. * - * The {@link GrailsApplicationLifeCycle#doWithSpring()} method can be used register Spring beans. + * The {@link GrailsApplicationLifeCycle#beanRegistrar()} method can be used to register Spring beans. * * @since 3.0 * @see {@link grails.plugins.Plugin} @@ -32,9 +34,27 @@ interface GrailsApplicationLifeCycle { * Sub classes should override to provide implementations * * @return A closure that defines beans to be registered by Spring + * @deprecated since 8.0 in favour of {@link #beanRegistrar()}. The bean builder DSL continues + * to work, but {@link #beanRegistrar()} is the modern, Spring-native replacement. */ + @Deprecated(since = '8.0') Closure doWithSpring() + /** + * Sub classes should override to register beans with the Spring Framework + * {@link org.springframework.beans.factory.BeanRegistry} using a {@link BeanRegistrar}. + * This is the modern, Spring-native replacement for the {@link #doWithSpring()} bean builder DSL. + * + *

The returned registrar is applied before Spring Boot auto-configuration is processed, so + * beans registered here take precedence over Boot's {@code @ConditionalOnMissingBean} defaults.

+ * + * @return A {@link BeanRegistrar} that registers beans, or {@code null} if none (the default) + * @since 8.0 + */ + default BeanRegistrar beanRegistrar() { + return null + } + /** * Invoked once the {@link org.springframework.context.ApplicationContext} has been refreshed in a phase where plugins can add dynamic methods. Subclasses should override */ diff --git a/grails-core/src/main/groovy/grails/plugins/GrailsPlugin.java b/grails-core/src/main/groovy/grails/plugins/GrailsPlugin.java index 3dcac050977..877e6835cbb 100644 --- a/grails-core/src/main/groovy/grails/plugins/GrailsPlugin.java +++ b/grails-core/src/main/groovy/grails/plugins/GrailsPlugin.java @@ -24,6 +24,7 @@ import groovy.lang.GroovyObject; +import org.springframework.beans.factory.BeanRegistrar; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.env.PropertySource; @@ -193,6 +194,20 @@ public interface GrailsPlugin extends ApplicationContextAware, Comparable, Grail */ void doWithRuntimeConfiguration(RuntimeSpringConfiguration springConfig); + /** + * Returns the {@link BeanRegistrar} exposed by this plugin's + * {@link grails.core.GrailsApplicationLifeCycle#beanRegistrar()} hook, if any. The registrar + * is applied to the bean definition registry before Spring Boot auto-configuration is + * processed, so its beans take precedence over Boot's {@code @ConditionalOnMissingBean} + * defaults. + * + * @return The plugin's {@link BeanRegistrar}, or {@code null} if the plugin does not define one + * @since 8.0 + */ + default BeanRegistrar getBeanRegistrar() { + return null; + } + /** * Makes the plugin excluded for a particular Environment * @param env The Environment diff --git a/grails-core/src/main/groovy/grails/plugins/Plugin.groovy b/grails-core/src/main/groovy/grails/plugins/Plugin.groovy index b3ca44ddca1..250ac5933d8 100644 --- a/grails-core/src/main/groovy/grails/plugins/Plugin.groovy +++ b/grails-core/src/main/groovy/grails/plugins/Plugin.groovy @@ -21,6 +21,7 @@ package grails.plugins import groovy.transform.CompileStatic import org.springframework.beans.BeansException +import org.springframework.beans.factory.BeanRegistrar import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContextAware @@ -105,10 +106,29 @@ abstract class Plugin implements GrailsApplicationLifeCycle, GrailsApplicationAw * Sub classes should override to provide implementations * * @return A closure that defines beans to be executed by Spring + * @deprecated since 8.0 in favour of {@link #beanRegistrar()}. The bean builder DSL continues + * to work, but {@link #beanRegistrar()} is the modern, Spring-native replacement. */ + @Deprecated(since = '8.0') @Override Closure doWithSpring() { null } + /** + * Sub classes should override to register beans with the Spring Framework + * {@link org.springframework.beans.factory.BeanRegistry} using a + * {@link org.springframework.beans.factory.BeanRegistrar}. This is the modern, Spring-native + * replacement for the {@link #doWithSpring()} bean builder DSL. + * + *

The returned registrar is applied before Spring Boot auto-configuration is processed, so + * beans registered here take precedence over Boot's {@code @ConditionalOnMissingBean} defaults.

+ * + * @return A {@link org.springframework.beans.factory.BeanRegistrar} that registers beans, + * or {@code null} if none (the default) + * @since 8.0 + */ + @Override + BeanRegistrar beanRegistrar() { null } + /** * Invoked in a phase where plugins can add dynamic methods. Subclasses should override */ diff --git a/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java b/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java index 8064850d832..4c9fdb6fb9f 100644 --- a/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java +++ b/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java @@ -42,6 +42,7 @@ import org.springframework.beans.BeanWrapper; import org.springframework.beans.BeanWrapperImpl; import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanRegistrar; import org.springframework.beans.factory.config.BeanFactoryPostProcessor; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.context.ApplicationContext; @@ -55,6 +56,7 @@ import grails.core.ArtefactHandler; import grails.core.GrailsApplication; +import grails.core.GrailsApplicationLifeCycle; import grails.core.support.GrailsApplicationAware; import grails.core.support.ParentApplicationContextAware; import grails.plugins.GrailsPlugin; @@ -416,6 +418,14 @@ public void doWithRuntimeConfiguration(RuntimeSpringConfiguration springConfig) } + @Override + public BeanRegistrar getBeanRegistrar() { + if (plugin instanceof GrailsApplicationLifeCycle) { + return ((GrailsApplicationLifeCycle) plugin).beanRegistrar(); + } + return null; + } + @Override public String getName() { return pluginGrailsClass.getLogicalPropertyName(); diff --git a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy index 8b0ea5b67eb..59a50474b59 100644 --- a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy +++ b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy @@ -18,6 +18,8 @@ */ package grails.boot.config +import org.springframework.beans.factory.BeanRegistrar +import org.springframework.beans.factory.BeanRegistry import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean @@ -25,6 +27,7 @@ import org.springframework.context.annotation.Configuration import grails.core.GrailsApplication import grails.plugins.GrailsPluginManager +import grails.plugins.Plugin import grails.util.Environment import grails.util.Holders import org.apache.grails.core.plugins.DefaultPluginDiscovery @@ -97,6 +100,54 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { ctx.close() } + void 'a plugin beanRegistrar bean registered early makes a @ConditionalOnMissingBean auto-config bean defer'() { + given: 'a context whose configuration would, by itself, register a conditional myResolver' + def ctx = new AnnotationConfigApplicationContext() + ctx.register(EarlyOrderingAutoConfigLikeConfig) + + and: 'a plugin exposing a BeanRegistrar promoted through plugin discovery' + registerDiscovery(ctx, EarlyOrderingRegistrarGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes' + ctx.refresh() + + then: 'the registrar (plugin) bean is the one named myResolver and the conditional default backed off' + ctx.getBean('myResolver') instanceof EarlyOrderingPluginResolver + ctx.getBeansOfType(EarlyOrderingBootDefaultResolver).isEmpty() + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'plugin beanRegistrar and doWithSpring can coexist on one plugin'() { + given: + def ctx = new AnnotationConfigApplicationContext() + registerDiscovery(ctx, EarlyOrderingDualGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: + ctx.refresh() + + then: 'both the DSL bean and the registrar bean are present' + ctx.getBean('dualDslBean') instanceof EarlyOrderingPluginResolver + ctx.getBean('dualRegistrarBean') instanceof EarlyOrderingPluginResolver + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + private static void registerDiscovery(AnnotationConfigApplicationContext ctx, Class pluginClass) { + def discovery = new DefaultPluginDiscovery([pluginClass] as Class[]) + discovery.loadPluginsFromClasspath = false + discovery.init(ctx.environment) + ctx.beanFactory.registerSingleton(PluginDiscovery.BEAN_NAME, discovery) + } + private static Class getEarlyOrderingPluginClass() { new GroovyClassLoader(EarlyPluginRegistrationOrderingSpec.classLoader).parseClass(''' class EarlyOrderingGrailsPlugin { @@ -125,3 +176,46 @@ class EarlyOrderingBootDefaultResolver { class EarlyOrderingPluginResolver { } + +class EarlyOrderingRegistrarGrailsPlugin extends Plugin { + + def version = '1.0' + + @Override + BeanRegistrar beanRegistrar() { + new EarlyOrderingResolverRegistrar() + } +} + +class EarlyOrderingResolverRegistrar implements BeanRegistrar { + + @Override + void register(BeanRegistry registry, org.springframework.core.env.Environment environment) { + registry.registerBean('myResolver', EarlyOrderingPluginResolver) + } +} + +class EarlyOrderingDualGrailsPlugin extends Plugin { + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + dualDslBean(EarlyOrderingPluginResolver) + } + } + + @Override + BeanRegistrar beanRegistrar() { + new EarlyOrderingDualRegistrar() + } +} + +class EarlyOrderingDualRegistrar implements BeanRegistrar { + + @Override + void register(BeanRegistry registry, org.springframework.core.env.Environment environment) { + registry.registerBean('dualRegistrarBean', EarlyOrderingPluginResolver) + } +} diff --git a/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc index f7b6593ea5d..289d17aee62 100644 --- a/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc +++ b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc @@ -23,7 +23,52 @@ Grails provides a number of hooks to leverage the different parts of the system ==== Hooking into the Grails Spring configuration -First, you can hook in Grails runtime configuration overriding the `doWithSpring` method from the link:{api}grails/plugins/Plugin.html[Plugin] class and returning a closure that defines additional beans. For example the following snippet is from one of the core Grails plugins that provides link:i18n.html[i18n] support: +The recommended way to register beans from a plugin is to override the `beanRegistrar` method from the link:{api}grails/plugins/Plugin.html[Plugin] class and return a Spring Framework {springapi}org/springframework/beans/factory/BeanRegistrar.html[BeanRegistrar]: + +[source,groovy] +---- +import org.springframework.beans.factory.BeanRegistrar +import org.springframework.beans.factory.BeanRegistry +import org.springframework.core.env.Environment +import org.springframework.web.servlet.i18n.CookieLocaleResolver +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor +import org.springframework.context.support.ReloadableResourceBundleMessageSource +import grails.plugins.Plugin + +class I18nGrailsPlugin extends Plugin { + + def version = "0.1" + + @Override + BeanRegistrar beanRegistrar() { + new I18nBeanRegistrar() + } +} + +class I18nBeanRegistrar implements BeanRegistrar { + + @Override + void register(BeanRegistry registry, Environment environment) { + registry.registerBean('messageSource', ReloadableResourceBundleMessageSource) { spec -> + spec.supplier { context -> + def messageSource = new ReloadableResourceBundleMessageSource() + messageSource.basename = 'WEB-INF/grails-app/i18n/messages' + messageSource + } + } + registry.registerBean('localeChangeInterceptor', LocaleChangeInterceptor) { spec -> + spec.supplier { context -> + new LocaleChangeInterceptor(paramName: 'lang') + } + } + registry.registerBean('localeResolver', CookieLocaleResolver) + } +} +---- + +Plugin beans are registered before Spring Boot processes its auto-configurations, so a bean registered here takes precedence over any Spring Boot default guarded by `@ConditionalOnMissingBean` — there is no need to override or remove Boot's beans afterwards. + +Alternatively, plugins may override the `doWithSpring` method and return a closure that defines beans using the link:spring.html#theBeanBuilderDSLExplained[Spring Bean Builder] syntax: [source,groovy] ---- @@ -48,7 +93,7 @@ class I18nGrailsPlugin extends Plugin { } ---- -This plugin configures the Grails `messageSource` bean and a couple of other beans to manage Locale resolution and switching. It using the link:spring.html#theBeanBuilderDSLExplained[Spring Bean Builder] syntax to do so. +NOTE: `doWithSpring` is deprecated as of Grails 8 in favour of `beanRegistrar`. It continues to work — the beans it defines register at the same early point in the lifecycle — but new code should prefer the `BeanRegistrar` API. ==== Customizing the Servlet Environment diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index c17cb0d63bc..878d423b782 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1346,3 +1346,70 @@ grails: - Presto - Trident ---- + +==== 31. Plugin Beans Now Register Before Spring Boot Auto-Configuration + +In Grails 7 and earlier, the beans a plugin contributed through `doWithSpring` were registered *after* Spring Boot had processed its auto-configurations. +A plugin that wanted to replace a Boot-provided bean therefore had to rely on bean-definition overriding, and Boot beans guarded by `@ConditionalOnMissingBean` never saw the plugin's bean in time to back off. + +In Grails 8 the plugin lifecycle is unified and retimed: plugin bean definitions — both `doWithSpring` and the new `beanRegistrar()` hook — are drained into the bean definition registry *before* Spring Boot expands its auto-configurations. The practical effects are: + +* **Plugin beans win `@ConditionalOnMissingBean` races.** When a plugin registers a bean, a Boot auto-configuration bean of the same name or type that is guarded by `@ConditionalOnMissingBean` now backs off in favor of the plugin's bean. Plugins no longer need to override or remove Boot's defaults after the fact. +* **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built exactly once, early in the context lifecycle, and reused for the rest of startup. Plugins are instantiated once; `Holders.grailsApplication` is available earlier than before. +* **Artefact discovery happens earlier.** Controllers, services, domain classes and other artefacts are discovered before plugin beans are registered, because core plugins iterate the application's artefacts while defining beans. The set of discovered artefacts is unchanged — only the timing moved. +* **Side-effectful `doWithSpring` closures still run once**, just earlier. Closures that read `grailsApplication.config` continue to work; the configuration is fully loaded (including plugin-contributed configuration) before any bean-definition code runs. + +**The bean builder DSL is deprecated.** `doWithSpring()` on `GrailsApplicationLifeCycle` and `grails.plugins.Plugin` is deprecated as of Grails 8 — it continues to work, but the recommended way to register beans is the new `beanRegistrar()` hook, which returns a Spring Framework `org.springframework.beans.factory.BeanRegistrar`. + +Before (deprecated bean builder DSL): + +[source,groovy] +---- +import grails.plugins.Plugin + +class MyGrailsPlugin extends Plugin { + + @Override + Closure doWithSpring() { + { -> + myService(MyServiceImpl) { + timeout = config.getProperty('myPlugin.timeout', Integer, 30) + } + } + } +} +---- + +After (Spring `BeanRegistrar`): + +[source,groovy] +---- +import grails.plugins.Plugin +import org.springframework.beans.factory.BeanRegistrar +import org.springframework.beans.factory.BeanRegistry +import org.springframework.core.env.Environment + +class MyGrailsPlugin extends Plugin { + + @Override + BeanRegistrar beanRegistrar() { + new MyPluginBeanRegistrar() + } +} + +class MyPluginBeanRegistrar implements BeanRegistrar { + + @Override + void register(BeanRegistry registry, Environment environment) { + registry.registerBean('myService', MyServiceImpl) { spec -> + spec.supplier { context -> + new MyServiceImpl(timeout: environment.getProperty('myPlugin.timeout', Integer, 30)) + } + } + } +} +---- + +Both hooks may be used on the same plugin during migration; when a registrar and the DSL register a bean under the same name, the registrar's definition wins. + +**Most applications and plugins need no action.** Behavior only changes where a plugin bean and a conditional Boot bean competed for the same name or type — the plugin bean now wins, which is almost always the intended outcome. From 12ffe36cbec973144ab4c42fc819810d261f0f4c Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 11:34:49 -0600 Subject: [PATCH 03/10] Verify application bean overriding and add end-to-end early plugin phase coverage Adds an ordering spec feature proving that under the default spring.main.allow-bean-definition-overriding=true an application doWithSpring bean still replaces a plugin bean of the same name after the retimed lifecycle split the two registrations. Ports the end-to-end integration coverage to app3: the loadafter plugin registers a probe bean in plain doWithSpring and the application defines a @ConditionalOnMissingBean default for the same name; PluginBeansBeforeAutoConfigurationSpec asserts the plugin's bean wins through real plugin discovery and a real boot. Extends the 8.0 upgrade notes with the bean-definition-overriding edge case (apps explicitly disabling overriding now see BeanDefinitionOverrideException where shadowing was previously silent) and the pre-auto-configuration registry contract for plugins that inspect the registry inside doWithSpring. --- ...EarlyPluginRegistrationOrderingSpec.groovy | 44 ++++++++++++++++++ .../src/en/guide/upgrading/upgrading80x.adoc | 3 ++ .../grails-app/init/app3/Application.groovy | 9 ++++ ...ginBeansBeforeAutoConfigurationSpec.groovy | 46 +++++++++++++++++++ .../loadafter/LoadafterGrailsPlugin.groovy | 10 ++++ 5 files changed, 112 insertions(+) create mode 100644 grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy diff --git a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy index 59a50474b59..fc854e0d357 100644 --- a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy +++ b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy @@ -141,6 +141,25 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { Environment.setInitializing(false) } + void 'an application doWithSpring bean still overrides a plugin bean of the same name under the default settings'() { + given: 'a plugin registering overrideProbe and an application registering a different bean under the same name' + def ctx = new AnnotationConfigApplicationContext() + registerDiscovery(ctx, EarlyOrderingOverrideGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + ctx.register(EarlyOrderingOverrideApplication) + + when: + ctx.refresh() + + then: 'the application bean wins, replacing the plugin bean registered in the early phase' + ctx.getBean('overrideProbe') instanceof EarlyOrderingAppResolver + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + private static void registerDiscovery(AnnotationConfigApplicationContext ctx, Class pluginClass) { def discovery = new DefaultPluginDiscovery([pluginClass] as Class[]) discovery.loadPluginsFromClasspath = false @@ -219,3 +238,28 @@ class EarlyOrderingDualRegistrar implements BeanRegistrar { registry.registerBean('dualRegistrarBean', EarlyOrderingPluginResolver) } } + +class EarlyOrderingAppResolver { +} + +class EarlyOrderingOverrideGrailsPlugin extends Plugin { + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + overrideProbe(EarlyOrderingPluginResolver) + } + } +} + +class EarlyOrderingOverrideApplication extends GrailsAutoConfiguration { + + @Override + Closure doWithSpring() { + { -> + overrideProbe(EarlyOrderingAppResolver) + } + } +} diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 878d423b782..3fe9412f2ec 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1358,6 +1358,7 @@ In Grails 8 the plugin lifecycle is unified and retimed: plugin bean definitions * **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built exactly once, early in the context lifecycle, and reused for the rest of startup. Plugins are instantiated once; `Holders.grailsApplication` is available earlier than before. * **Artefact discovery happens earlier.** Controllers, services, domain classes and other artefacts are discovered before plugin beans are registered, because core plugins iterate the application's artefacts while defining beans. The set of discovered artefacts is unchanged — only the timing moved. * **Side-effectful `doWithSpring` closures still run once**, just earlier. Closures that read `grailsApplication.config` continue to work; the configuration is fully loaded (including plugin-contributed configuration) before any bean-definition code runs. +* **Plugins that inspect the bean definition registry see a pre-auto-configuration registry.** A `doWithSpring` closure that checks for the presence of a bean definition (for example `dispatcherServlet` as a "web application" signal) now runs before Spring Boot has registered its auto-configured beans, so such a check returns `false` where it previously returned `true`. Plugin code should test a registration-order-independent signal instead — for example whether the application context is a web application context. **The bean builder DSL is deprecated.** `doWithSpring()` on `GrailsApplicationLifeCycle` and `grails.plugins.Plugin` is deprecated as of Grails 8 — it continues to work, but the recommended way to register beans is the new `beanRegistrar()` hook, which returns a Spring Framework `org.springframework.beans.factory.BeanRegistrar`. @@ -1412,4 +1413,6 @@ class MyPluginBeanRegistrar implements BeanRegistrar { Both hooks may be used on the same plugin during migration; when a registrar and the DSL register a bean under the same name, the registrar's definition wins. +**Bean-definition overriding edge case.** Grails defaults `spring.main.allow-bean-definition-overriding` to `true`, and under that default nothing changes: an application bean (from the application class, `resources.groovy` or `resources.xml`) that uses the same name as a plugin bean still replaces the plugin's bean. However, because plugin beans now register earlier and application beans register in a separate, later step, an application that explicitly sets `spring.main.allow-bean-definition-overriding` to `false` will see a `BeanDefinitionOverrideException` for an application bean that shadows a plugin bean — in previous releases the two definitions were merged before a single registry write, so the shadowing was silent even with overriding disabled. + **Most applications and plugins need no action.** Behavior only changes where a plugin bean and a conditional Boot bean competed for the same name or type — the plugin bean now wins, which is almost always the intended outcome. diff --git a/grails-test-examples/app3/grails-app/init/app3/Application.groovy b/grails-test-examples/app3/grails-app/init/app3/Application.groovy index 2c8e46b552d..38838293d71 100755 --- a/grails-test-examples/app3/grails-app/init/app3/Application.groovy +++ b/grails-test-examples/app3/grails-app/init/app3/Application.groovy @@ -21,9 +21,18 @@ package app3 import grails.boot.GrailsApp import grails.boot.config.GrailsAutoConfiguration +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.context.annotation.Bean class Application extends GrailsAutoConfiguration { static void main(String[] args) { GrailsApp.run(Application, args) } + + // Default for the probe the loadafter plugin registers in doWithSpring. Plugin beans now + // register ahead of auto-configuration, so this @ConditionalOnMissingBean default must defer + // to it — PluginBeansBeforeAutoConfigurationSpec asserts the plugin's value wins. + @Bean + @ConditionalOnMissingBean(name = 'earlyPluginProbe') + String earlyPluginProbe() { 'from-conditional-default' } } diff --git a/grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy b/grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy new file mode 100644 index 00000000000..471ea94c81e --- /dev/null +++ b/grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy @@ -0,0 +1,46 @@ +/* + * 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 app3 + +import grails.testing.mixin.integration.Integration +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import spock.lang.Specification + +/** + * End-to-end test of the retimed plugin lifecycle through real plugin discovery and a real + * application boot. The {@code loadafter} plugin (an app3 dependency) registers + * {@code earlyPluginProbe} in plain {@code doWithSpring}, which now runs ahead of Spring Boot + * auto-configuration; {@code Application} defines a + * {@code @ConditionalOnMissingBean(name='earlyPluginProbe')} default. The plugin's bean must win. + * + *

The strict ordering proof (the conditional default is never even created) is covered at unit + * level by {@code EarlyPluginRegistrationOrderingSpec} in grails-core, including its control case. + */ +@Integration +class PluginBeansBeforeAutoConfigurationSpec extends Specification { + + @Autowired + ApplicationContext applicationContext + + void "a plugin doWithSpring bean wins over the app's @ConditionalOnMissingBean default"() { + expect: 'the plugin registered the probe ahead of auto-config, so the conditional default deferred to it' + applicationContext.getBean('earlyPluginProbe') == 'from-plugin-doWithSpring' + } +} diff --git a/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy b/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy index 8a1c853f436..4ef1bc82107 100755 --- a/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy +++ b/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy @@ -33,4 +33,14 @@ Brief summary/description of the plugin. def loadAfter = ['springSecurityCore'] + // Exercises the retimed plugin lifecycle end-to-end (see app3 PluginBeansBeforeAutoConfigurationSpec): + // doWithSpring beans register ahead of auto-configuration through real plugin discovery, so the + // app's @ConditionalOnMissingBean default for this name must defer. + @Override + Closure doWithSpring() { + { -> + earlyPluginProbe(String, 'from-plugin-doWithSpring') + } + } + } From eb798493ecc633cb914e90f645f7cd97d5c93fdb Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 11:35:01 -0600 Subject: [PATCH 04/10] Resolve application classes for early artefact discovery via classes() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early plugin registration phase scanned each application source class's own package, but the Grails compiler injects a packageNames() override into the application class that lists every project package. Artefacts in packages other than the application class's own (e.g. a controller in a sibling package) were therefore discovered only later by GrailsApplicationPostProcessor, after the plugins' doWithSpring had already run — leaving controllers without beans and failing requests with NoSuchBeanDefinitionException. Early artefact discovery now resolves the application classes exactly the way GrailsApplicationPostProcessor does: classes() invoked on a GrailsAutoConfiguration instance (created solely to compute the scan), honoring the injected packageNames() as well as user overrides of classes(), packageNames() and limitScanningToApplication(). Sources that are not application classes no longer contribute artefacts, matching the previous lifecycle. Verified by UriMatchingInterceptorFunctionalSpec in the hibernate5 and hibernate7 app1 test examples, which exercises a controller and an interceptor living outside the application class's package. --- ...sEarlyPluginRegistrationPostProcessor.java | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java index d806c673cb7..8bbd614a059 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java @@ -19,6 +19,7 @@ package grails.boot.config; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import org.slf4j.Logger; @@ -181,10 +182,37 @@ private void registerApplicationArtefacts(DefaultGrailsApplication grailsApplica return; } for (Class source : sources) { - for (Class applicationClass : ApplicationClassScanner.scanApplicationClasses(source)) { - grailsApplication.addArtefact(applicationClass); + if (!GrailsApplicationClass.class.isAssignableFrom(source)) { + // non-application sources (plain configuration classes) never contribute artefacts + continue; + } + for (Object applicationClass : scanApplicationSource(source)) { + grailsApplication.addArtefact((Class) applicationClass); + } + } + } + + /** + * Resolves the classes that constitute the application for the given source class using the + * same code path {@code GrailsApplicationPostProcessor} relies on: {@code classes()} invoked + * on a {@link GrailsAutoConfiguration} instance. This matters because the Grails compiler + * injects a {@code packageNames()} override into the application class listing every project + * package, so scanning only the application class's own package would miss artefacts living + * in other packages. The instance created here is used solely to compute the scan; the + * lifecycle bean the application interacts with is still created by Spring later. + */ + private Collection scanApplicationSource(Class source) { + if (GrailsAutoConfiguration.class.isAssignableFrom(source)) { + try { + GrailsAutoConfiguration application = (GrailsAutoConfiguration) source.getDeclaredConstructor().newInstance(); + application.setApplicationContext(applicationContext); + return application.classes(); + } catch (Throwable e) { + LOG.warn("Unable to resolve application classes from [{}], falling back to package scan: {}", + source.getName(), e.toString()); } } + return ApplicationClassScanner.scanApplicationClasses(source); } /** From 0a84ee57d89bbff9d63767e25aa42763d90350da Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 11:35:18 -0600 Subject: [PATCH 05/10] Detect web applications without relying on the dispatcherServlet bean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GORM initializers decided whether to register the open-session-in- view interceptor by checking for the dispatcherServlet bean definition. That bean is registered by Spring Boot auto-configuration, so with plugin beans now draining before auto-configuration the check always returned false and the interceptor silently stopped registering in web applications (verified: openSessionInViewInterceptor present on 8.0.x, absent on this branch before this fix). AbstractDatastoreInitializer gains isWebApplicationRegistry(), which still honours a dispatcherServlet definition when present but otherwise checks whether the registry is the web application context itself — a signal independent of auto-configuration ordering. The Hibernate 5 and Hibernate 7 initializers and the MongoDB initializer use it in place of the bean-definition check. Note: Neo4jDataStoreSpringInitializer (grails-data-neo4j, a separate Gradle build) has the identical dispatcherServlet check and needs the same one-line change once it builds against this grails-datamapping-core. New OpenSessionInViewSpec integration tests in the hibernate5 and hibernate7 app1 examples pin the interceptor's registration. --- ...HibernateDatastoreSpringInitializer.groovy | 2 +- ...HibernateDatastoreSpringInitializer.groovy | 2 +- .../MongoDbDataStoreSpringInitializer.groovy | 2 +- .../AbstractDatastoreInitializer.groovy | 24 ++++++++++- .../OpenSessionInViewSpec.groovy | 42 +++++++++++++++++++ .../OpenSessionInViewSpec.groovy | 42 +++++++++++++++++++ 6 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 grails-test-examples/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy create mode 100644 grails-test-examples/hibernate7/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy diff --git a/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy b/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy index b21856d0a85..62727e13dc1 100644 --- a/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy +++ b/grails-data-hibernate5/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy @@ -183,7 +183,7 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer { } boolean osivEnabled = config.getProperty('hibernate.osiv.enabled', Boolean, true) - boolean isWebApplication = beanDefinitionRegistry?.containsBeanDefinition('dispatcherServlet') || + boolean isWebApplication = isWebApplicationRegistry(beanDefinitionRegistry) || beanDefinitionRegistry?.containsBeanDefinition('grailsControllerHelper') if (isWebApplication && osivEnabled && ClassUtils.isPresent('org.grails.plugin.hibernate.support.GrailsOpenSessionInViewInterceptor')) { diff --git a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy index 554ec612f46..58940a847fb 100644 --- a/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy +++ b/grails-data-hibernate7/grails-plugin/src/main/groovy/grails/orm/bootstrap/HibernateDatastoreSpringInitializer.groovy @@ -216,7 +216,7 @@ class HibernateDatastoreSpringInitializer extends AbstractDatastoreInitializer { } boolean osivEnabled = config.getProperty('hibernate.osiv.enabled', Boolean, true) - boolean isWebApplication = beanDefinitionRegistry?.containsBeanDefinition('dispatcherServlet') || + boolean isWebApplication = isWebApplicationRegistry(beanDefinitionRegistry) || beanDefinitionRegistry?.containsBeanDefinition('grailsControllerHelper') if (isWebApplication && osivEnabled && ClassUtils.isPresent('org.grails.plugin.hibernate.support.GrailsOpenSessionInViewInterceptor')) { diff --git a/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy b/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy index 46b39854444..68dfe37d573 100644 --- a/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy +++ b/grails-data-mongodb/core/src/main/groovy/grails/mongodb/bootstrap/MongoDbDataStoreSpringInitializer.groovy @@ -126,7 +126,7 @@ class MongoDbDataStoreSpringInitializer extends AbstractDatastoreInitializer { } def classLoader = getClass().getClassLoader() - if (beanDefinitionRegistry.containsBeanDefinition('dispatcherServlet') && ClassUtils.isPresent(OSIV_CLASS_NAME, classLoader)) { + if (isWebApplicationRegistry(beanDefinitionRegistry) && ClassUtils.isPresent(OSIV_CLASS_NAME, classLoader)) { String interceptorName = 'mongoOpenSessionInViewInterceptor' "${interceptorName}"(ClassUtils.forName(OSIV_CLASS_NAME, classLoader)) { datastore = ref('mongoDatastore') diff --git a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy index 112f993c091..5655eebb7e0 100644 --- a/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy +++ b/grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializer.groovy @@ -71,6 +71,7 @@ abstract class AbstractDatastoreInitializer implements ResourceLoaderAware { public static final String TRANSACTION_MANAGER_BEAN = 'transactionManager' public static final String ENTITY_CLASS_RESOURCE_PATTERN = '/**/*.class' public static final String OSIV_CLASS_NAME = 'org.grails.datastore.mapping.web.support.OpenSessionInViewInterceptor' + public static final String WEB_APPLICATION_CONTEXT_CLASS_NAME = 'org.springframework.web.context.WebApplicationContext' PathMatchingResourcePatternResolver resourcePatternResolver = new PathMatchingResourcePatternResolver() Collection persistentClasses = [] @@ -304,7 +305,7 @@ abstract class AbstractDatastoreInitializer implements ResourceLoaderAware { "${type}PersistenceContextInterceptorAggregator"(PersistenceContextInterceptorAggregator) def classLoader = Thread.currentThread().contextClassLoader - if (registry.containsBeanDefinition('dispatcherServlet') && ClassUtils.isPresent(OSIV_CLASS_NAME, classLoader)) { + if (isWebApplicationRegistry(registry) && ClassUtils.isPresent(OSIV_CLASS_NAME, classLoader)) { String interceptorName = "${type}OpenSessionInViewInterceptor" "${interceptorName}"(ClassUtils.forName(OSIV_CLASS_NAME, classLoader)) { datastore = ref("${type}Datastore") @@ -360,6 +361,27 @@ abstract class AbstractDatastoreInitializer implements ResourceLoaderAware { registry.containsBeanDefinition(beanName) || (builder.hasProperty('springConfig') && builder.springConfig.containsBean(beanName)) } + /** + * Determines whether the given registry belongs to a web application. The presence of the + * {@code dispatcherServlet} bean definition is only a reliable signal after Spring Boot + * auto-configuration has been processed; Grails plugin bean definitions register before + * that, so the type of the registry (the web application context itself) is checked as well. + * + * @param registry The bean definition registry + * @return true if the registry belongs to a web application + */ + protected boolean isWebApplicationRegistry(BeanDefinitionRegistry registry) { + if (registry == null) { + return false + } + if (registry.containsBeanDefinition('dispatcherServlet')) { + return true + } + ClassLoader classLoader = getClass().classLoader + return ClassUtils.isPresent(WEB_APPLICATION_CONTEXT_CLASS_NAME, classLoader) && + ClassUtils.forName(WEB_APPLICATION_CONTEXT_CLASS_NAME, classLoader).isInstance(registry) + } + /** * @return The class used to define the persistence interceptor */ diff --git a/grails-test-examples/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy new file mode 100644 index 00000000000..833493acd1f --- /dev/null +++ b/grails-test-examples/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy @@ -0,0 +1,42 @@ +/* + * 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 functionaltests + +import grails.testing.mixin.integration.Integration +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import spock.lang.Specification + +/** + * Verifies the Hibernate open-session-in-view interceptor still registers in a web application. + * Its registration is guarded by a web-application check inside the Hibernate plugin's bean + * definitions; because plugin beans now register before Spring Boot auto-configuration, that + * check can no longer rely on the {@code dispatcherServlet} bean definition being present. + */ +@Integration +class OpenSessionInViewSpec extends Specification { + + @Autowired + ApplicationContext applicationContext + + void "the open session in view interceptor is registered in a web application"() { + expect: + applicationContext.containsBean('openSessionInViewInterceptor') + } +} diff --git a/grails-test-examples/hibernate7/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy b/grails-test-examples/hibernate7/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy new file mode 100644 index 00000000000..833493acd1f --- /dev/null +++ b/grails-test-examples/hibernate7/app1/src/integration-test/groovy/functionaltests/OpenSessionInViewSpec.groovy @@ -0,0 +1,42 @@ +/* + * 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 functionaltests + +import grails.testing.mixin.integration.Integration +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import spock.lang.Specification + +/** + * Verifies the Hibernate open-session-in-view interceptor still registers in a web application. + * Its registration is guarded by a web-application check inside the Hibernate plugin's bean + * definitions; because plugin beans now register before Spring Boot auto-configuration, that + * check can no longer rely on the {@code dispatcherServlet} bean definition being present. + */ +@Integration +class OpenSessionInViewSpec extends Specification { + + @Autowired + ApplicationContext applicationContext + + void "the open session in view interceptor is registered in a web application"() { + expect: + applicationContext.containsBean('openSessionInViewInterceptor') + } +} From dd5ac787971c9dd7f032cfbfb4eff0027c8429c8 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 16:57:05 -0500 Subject: [PATCH 06/10] Remove the duplicate annotation handler mapping and adapter beans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controllers plugin registered its own RequestMappingHandlerMapping (annotationHandlerMapping) and RequestMappingHandlerAdapter (annotationHandlerAdapter) to support @Controller annotated beans, a remnant of the pre-Boot era. In a Grails application Spring's MVC configuration always provides the fully configured requestMappingHandlerMapping and requestMappingHandlerAdapter, so the Grails pair was never consulted: the mapping sorts after Spring's order-0 mapping, and the adapter tied with Spring's on order and lost by bean registration order. With plugin beans now registering before auto-configuration, the registration order of the two adapters flipped and the DispatcherServlet began selecting the bare Grails adapter — which lacks the JSON message converters — for every annotated handler method, including the actuator endpoints. Requests to /actuator/env and /actuator/health failed with HttpMessageNotWritableException: 'No converter for EnvironmentDescriptor with preset Content-Type null', surfacing as the issue-10279 functional test regression. Removing the never-consulted duplicates restores the previous effective behavior regardless of registration order. The localeChangeInterceptor wiring on the removed mapping was equally unreachable and had no effect. Documented in the 8.0 upgrade notes. Verified by ActuatorEnvClosureSpec (passes again, differential-checked: passes on 8.0.x, failed on this branch before this change) and the full functional test selection. --- .../web/controllers/ControllersGrailsPlugin.groovy | 10 ---------- grails-doc/src/en/guide/upgrading/upgrading80x.adoc | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersGrailsPlugin.groovy b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersGrailsPlugin.groovy index f4e3ba512b0..42fbd0b7a2a 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersGrailsPlugin.groovy +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersGrailsPlugin.groovy @@ -22,8 +22,6 @@ import groovy.util.logging.Slf4j import org.springframework.beans.factory.support.AbstractBeanDefinition import org.springframework.context.ApplicationContext -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter -import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping import grails.config.Settings import grails.core.GrailsControllerClass @@ -72,14 +70,6 @@ class ControllersGrailsPlugin extends Plugin { "${CompositeViewResolver.BEAN_NAME}"(CompositeViewResolver) - def handlerInterceptors = springConfig.containsBean('localeChangeInterceptor') ? [ref('localeChangeInterceptor')] : [] - def interceptorsClosure = { - interceptors = handlerInterceptors - } - // allow @Controller annotated beans - annotationHandlerMapping(RequestMappingHandlerMapping, interceptorsClosure) - annotationHandlerAdapter(RequestMappingHandlerAdapter) - for (controller in application.getArtefacts(ControllerArtefactHandler.TYPE)) { log.debug('Configuring controller {}', controller.fullName) if (controller.available) { diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 02609245376..97517104203 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1362,6 +1362,7 @@ In Grails 8 the plugin lifecycle is unified and retimed: plugin bean definitions * **Artefact discovery happens earlier.** Controllers, services, domain classes and other artefacts are discovered before plugin beans are registered, because core plugins iterate the application's artefacts while defining beans. The set of discovered artefacts is unchanged — only the timing moved. * **Side-effectful `doWithSpring` closures still run once**, just earlier. Closures that read `grailsApplication.config` continue to work; the configuration is fully loaded (including plugin-contributed configuration) before any bean-definition code runs. * **Plugins that inspect the bean definition registry see a pre-auto-configuration registry.** A `doWithSpring` closure that checks for the presence of a bean definition (for example `dispatcherServlet` as a "web application" signal) now runs before Spring Boot has registered its auto-configured beans, so such a check returns `false` where it previously returned `true`. Plugin code should test a registration-order-independent signal instead — for example whether the application context is a web application context. +* **The `annotationHandlerMapping` and `annotationHandlerAdapter` beans were removed.** These duplicated the request-mapping infrastructure that Spring's own MVC configuration already provides and were never consulted at runtime, but with plugin beans now registering first the bare duplicate adapter started shadowing Spring's fully configured one. `@Controller` annotated beans continue to be handled by Spring Boot's standard MVC infrastructure. **The bean builder DSL is deprecated.** `doWithSpring()` on `GrailsApplicationLifeCycle` and `grails.plugins.Plugin` is deprecated as of Grails 8 — it continues to work, but the recommended way to register beans is the new `beanRegistrar()` hook, which returns a Spring Framework `org.springframework.beans.factory.BeanRegistrar`. From 78b225e7e805d0ffd28cb21200a00b6d19132002 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 8 Jul 2026 15:40:30 -0400 Subject: [PATCH 07/10] Address review feedback on the plugin lifecycle change - Rename ApplicationClassScanner to ApplicationArtefactScanner to reflect that it scans the application's artefact classes, not the application class - Apply plugin beanRegistrar()s on the GrailsApplicationPostProcessor fallback path so the new API behaves identically in contexts not booted through GrailsApp - Reset the initializing flag if the early registration phase throws, so a failed context does not leak the system property to later contexts - Narrow the application-scan catch to Exception | LinkageError - Resolve String application sources to classes, and recover an application class from the registry when the stash holds none - Drop the consumed source-classes stash singleton - Guard setApplicationClass as set-once - Strengthen the doWithSpring deprecation wording - Replace the linear isArtefact scan with a Set lookup - Add a trailing newline to spring.factories - Docs: lead the beanRegistrar examples with the concise closure form, add a what's new entry, and note the double application-class instantiation - Tests: closure-coercion registrar, setApplicationClass set-once, and an app3 beanRegistrar() end-to-end example --- .../main/groovy/grails/boot/GrailsApp.groovy | 24 +++- ...oovy => ApplicationArtefactScanner.groovy} | 6 +- .../GrailsApplicationPostProcessor.groovy | 30 ++++- .../config/GrailsAutoConfiguration.groovy | 4 +- ...sEarlyPluginRegistrationPostProcessor.java | 117 +++++++++++------- .../grails/core/DefaultGrailsApplication.java | 11 +- .../core/GrailsApplicationLifeCycle.groovy | 5 +- .../main/groovy/grails/plugins/Plugin.groovy | 5 +- .../main/resources/META-INF/spring.factories | 2 +- ...EarlyPluginRegistrationOrderingSpec.groovy | 30 +++++ .../core/DefaultGrailsApplicationSpec.groovy | 51 ++++++++ .../src/en/guide/introduction/whatsNew.adoc | 29 +++++ .../hookingIntoRuntimeConfiguration.adoc | 34 +++-- .../src/en/guide/upgrading/upgrading80x.adoc | 21 ++-- .../grails-app/init/app3/Application.groovy | 6 + ...ginBeansBeforeAutoConfigurationSpec.groovy | 9 ++ .../loadafter/LoadafterGrailsPlugin.groovy | 15 +++ 17 files changed, 302 insertions(+), 97 deletions(-) rename grails-core/src/main/groovy/grails/boot/config/{ApplicationClassScanner.groovy => ApplicationArtefactScanner.groovy} (94%) create mode 100644 grails-core/src/test/groovy/grails/core/DefaultGrailsApplicationSpec.groovy diff --git a/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy b/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy index 7199b9920b3..0adc913a078 100644 --- a/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy +++ b/grails-core/src/main/groovy/grails/boot/GrailsApp.groovy @@ -152,10 +152,28 @@ class GrailsApp extends SpringApplication { @Override protected void postProcessApplicationContext(ConfigurableApplicationContext applicationContext) { super.postProcessApplicationContext(applicationContext) - Class[] sourceClasses = getAllSources().findAll { it instanceof Class } as Class[] - if (sourceClasses.length > 0) { + ClassLoader loader = getClassLoader() ?: Thread.currentThread().contextClassLoader + List> sourceClasses = [] + for (Object source in getAllSources()) { + if (source instanceof Class) { + sourceClasses.add((Class) source) + } + else if (source instanceof String) { + try { + // sources may be supplied as class names (spring.main.sources); resolve them so a + // String-specified application class is still available for early artefact discovery + sourceClasses.add(Class.forName((String) source, false, loader)) + } + catch (ClassNotFoundException | LinkageError ignored) { + // a String source that is not a resolvable class (e.g. a resource location) is not + // an application class, so there is nothing to stash for it + } + } + } + if (!sourceClasses.isEmpty()) { applicationContext.beanFactory.registerSingleton( - GrailsEarlyPluginRegistrationPostProcessor.APPLICATION_SOURCE_CLASSES_BEAN_NAME, sourceClasses) + GrailsEarlyPluginRegistrationPostProcessor.APPLICATION_SOURCE_CLASSES_BEAN_NAME, + sourceClasses.toArray(new Class[0])) } } diff --git a/grails-core/src/main/groovy/grails/boot/config/ApplicationClassScanner.groovy b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy similarity index 94% rename from grails-core/src/main/groovy/grails/boot/config/ApplicationClassScanner.groovy rename to grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy index e392a18bcb2..a69cfef692b 100644 --- a/grails-core/src/main/groovy/grails/boot/config/ApplicationClassScanner.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/ApplicationArtefactScanner.groovy @@ -24,7 +24,7 @@ import grails.boot.config.tools.ClassPathScanner import org.grails.compiler.injection.AbstractGrailsArtefactTransformer /** - * Discovers the classes that constitute a Grails application by scanning the classpath relative + * Discovers the artefact classes that make up a Grails application by scanning the classpath relative * to an application class. This is the single implementation of the default scanning performed by * {@link GrailsAutoConfiguration#classes()}, also used by * {@link GrailsEarlyPluginRegistrationPostProcessor} to perform artefact discovery before Spring @@ -33,9 +33,9 @@ import org.grails.compiler.injection.AbstractGrailsArtefactTransformer * @since 8.0 */ @CompileStatic -final class ApplicationClassScanner { +final class ApplicationArtefactScanner { - private ApplicationClassScanner() { + private ApplicationArtefactScanner() { } /** diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy index 3e4b90c0759..297ae6046fd 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsApplicationPostProcessor.groovy @@ -50,6 +50,7 @@ import grails.core.GrailsApplication import grails.core.GrailsApplicationClass import grails.core.GrailsApplicationLifeCycle import grails.plugins.DefaultGrailsPluginManager +import grails.plugins.GrailsPlugin import grails.plugins.GrailsPluginManager import grails.spring.BeanBuilder import grails.util.Environment @@ -168,8 +169,9 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces * still need to be registered. */ private void registerRemainingApplicationClasses() { + Set registeredClassNames = grailsApplication.allArtefacts*.name as Set for (cls in classes) { - if (!grailsApplication.isArtefact(cls)) { + if (!registeredClassNames.contains(cls.name)) { grailsApplication.addArtefact(cls) } } @@ -283,6 +285,12 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces springConfig.registerBeansWithRegistry(registry) + if (!earlyPluginRegistrationRan) { + // the early phase applies plugin registrars itself; on the fallback path (contexts not + // booted through GrailsApp) apply them here so a plugin's beanRegistrar() behaves the same + applyPluginBeanRegistrars(registry) + } + if (lifeCycle) { // the application's BeanRegistrar drains at the same point as its doWithSpring closure, // after the DSL flush so registrar beans win any name conflicts with the deprecated DSL @@ -294,6 +302,26 @@ class GrailsApplicationPostProcessor implements BeanDefinitionRegistryPostProces } } + /** + * Applies each enabled plugin's {@link BeanRegistrar} on the fallback path where the early + * plugin registration phase did not run, mirroring that phase so a plugin's {@code beanRegistrar()} + * is honoured in every context rather than only those booted through {@code GrailsApp}. Runs after + * the DSL flush so registrar beans win name conflicts with the deprecated {@code doWithSpring} DSL. + */ + private void applyPluginBeanRegistrars(BeanDefinitionRegistry registry) { + String[] activeProfiles = applicationContext.environment.activeProfiles + for (GrailsPlugin plugin in pluginManager.allPlugins) { + if (!plugin.supportsCurrentScopeAndEnvironment() || !plugin.isEnabled(activeProfiles)) { + continue + } + BeanRegistrar registrar = plugin.beanRegistrar + if (registrar != null) { + new BeanRegistryAdapter(registry, applicationContext, applicationContext.environment, registrar.getClass()) + .register(registrar) + } + } + } + @Override void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { BeanFactory parentBeanFactory = beanFactory.getParentBeanFactory() diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy index aeb8c724944..91190446d36 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy @@ -80,12 +80,12 @@ class GrailsAutoConfiguration implements GrailsApplicationClass, ApplicationCont */ Collection classes() { if (limitScanningToApplication()) { - return ApplicationClassScanner.scanApplicationClasses(getClass(), packageNames()) + return ApplicationArtefactScanner.scanApplicationClasses(getClass(), packageNames()) } Collection classes = new HashSet() classes.addAll(new ClassPathScanner().scan(new PathMatchingResourcePatternResolver(applicationContext), packageNames())) - classes.addAll(ApplicationClassScanner.loadTransformedClasses(getClass().classLoader)) + classes.addAll(ApplicationArtefactScanner.loadTransformedClasses(getClass().classLoader)) return classes } diff --git a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java index 8bbd614a059..e4c8ac44153 100644 --- a/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java +++ b/grails-core/src/main/groovy/grails/boot/config/GrailsEarlyPluginRegistrationPostProcessor.java @@ -31,6 +31,7 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; import org.springframework.beans.factory.support.BeanRegistryAdapter; +import org.springframework.beans.factory.support.DefaultSingletonBeanRegistry; import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.event.ContextRefreshedEvent; @@ -119,39 +120,52 @@ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) t return; } + // The initializing flag is a system property, so a leak on failure poisons every subsequent + // context in the same JVM (test forks especially). Reset it if anything below throws; the + // success path leaves it set and resets on refresh via the listener added at the end. Environment.setInitializing(true); + try { + DefaultGrailsApplication grailsApplication = new DefaultGrailsApplication(); + grailsApplication.setConfig(buildConfig()); + grailsApplication.setApplicationContext(applicationContext); + grailsApplication.setMainContext(applicationContext); - DefaultGrailsApplication grailsApplication = new DefaultGrailsApplication(); - grailsApplication.setConfig(buildConfig()); - grailsApplication.setApplicationContext(applicationContext); - grailsApplication.setMainContext(applicationContext); + DefaultGrailsPluginManager pluginManager = new DefaultGrailsPluginManager(grailsApplication, pluginDiscovery); + pluginManager.loadPlugins(); + pluginManager.setApplicationContext(applicationContext); - DefaultGrailsPluginManager pluginManager = new DefaultGrailsPluginManager(grailsApplication, pluginDiscovery); - pluginManager.loadPlugins(); - pluginManager.setApplicationContext(applicationContext); - - pluginManager.doArtefactConfiguration(); - grailsApplication.initialise(); - // register plugin provided classes first, this gives the opportunity - // for application classes to override those provided by a plugin - pluginManager.registerProvidedArtefacts(grailsApplication); - registerApplicationArtefacts(grailsApplication, registry); + pluginManager.doArtefactConfiguration(); + grailsApplication.initialise(); + // register plugin provided classes first, this gives the opportunity + // for application classes to override those provided by a plugin + pluginManager.registerProvidedArtefacts(grailsApplication); + registerApplicationArtefacts(grailsApplication, registry); + // the source-classes stash has been consumed; drop it so it does not linger as an + // autowire-by-type candidate for the life of the context + if (applicationContext.getBeanFactory() instanceof DefaultSingletonBeanRegistry singletonRegistry) { + singletonRegistry.destroySingleton(APPLICATION_SOURCE_CLASSES_BEAN_NAME); + } - RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration(); - pluginManager.doRuntimeConfiguration(springConfig); - springConfig.registerBeansWithRegistry(registry); - applyBeanRegistrars(pluginManager, registry); + RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration(); + pluginManager.doRuntimeConfiguration(springConfig); + springConfig.registerBeansWithRegistry(registry); + applyBeanRegistrars(pluginManager, registry); - ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); - beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication); - beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager); - beanFactory.registerSingleton(EARLY_REGISTRATION_COMPLETE_BEAN_NAME, Boolean.TRUE); - Holders.setGrailsApplication(grailsApplication); + ConfigurableListableBeanFactory beanFactory = applicationContext.getBeanFactory(); + beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication); + beanFactory.registerSingleton(GrailsPluginManager.BEAN_NAME, pluginManager); + beanFactory.registerSingleton(EARLY_REGISTRATION_COMPLETE_BEAN_NAME, Boolean.TRUE); + Holders.setGrailsApplication(grailsApplication); - // GrailsApplicationPostProcessor resets the initializing flag on refresh, but it is not - // present in every context that runs this phase — reset here as well so the flag (a system - // property) does not leak once the context is up. - applicationContext.addApplicationListener(this); + // GrailsApplicationPostProcessor resets the initializing flag on refresh, but it is not + // present in every context that runs this phase — reset here as well so the flag does not + // leak once the context is up. + applicationContext.addApplicationListener(this); + } + catch (RuntimeException | Error e) { + Environment.setInitializing(false); + throw e; + } } /** @@ -207,39 +221,48 @@ private Collection scanApplicationSource(Class source) { GrailsAutoConfiguration application = (GrailsAutoConfiguration) source.getDeclaredConstructor().newInstance(); application.setApplicationContext(applicationContext); return application.classes(); - } catch (Throwable e) { + } catch (Exception | LinkageError e) { LOG.warn("Unable to resolve application classes from [{}], falling back to package scan: {}", source.getName(), e.toString()); } } - return ApplicationClassScanner.scanApplicationClasses(source); + return ApplicationArtefactScanner.scanApplicationClasses(source); } /** * Resolves the application source classes to scan for artefacts, preferring the singleton - * stashed by {@link grails.boot.GrailsApp}. When the application was started through a plain - * {@code SpringApplication} no stash exists, but the primary sources are already registered as - * bean definitions by the time this phase runs, so any {@link GrailsApplicationClass} among - * them is recovered from the registry instead. + * stashed by {@link grails.boot.GrailsApp}. When the stash yields no {@link GrailsApplicationClass} + * — no stash exists (a plain {@code SpringApplication}), or the application class was supplied as + * a {@code String} source alongside other {@code Class} sources — any {@link GrailsApplicationClass} + * is additionally recovered from the registry, where the primary sources are already registered + * as bean definitions by the time this phase runs. */ private Class[] resolveApplicationSourceClasses(BeanDefinitionRegistry registry) { - Object stashedSources = applicationContext.getBeanFactory().getSingleton(APPLICATION_SOURCE_CLASSES_BEAN_NAME); - if (stashedSources instanceof Class[] sources) { - return sources; - } List> sources = new ArrayList<>(); - for (String beanDefinitionName : registry.getBeanDefinitionNames()) { - String beanClassName = registry.getBeanDefinition(beanDefinitionName).getBeanClassName(); - if (beanClassName == null) { - continue; + boolean haveApplicationClass = false; + Object stashedSources = applicationContext.getBeanFactory().getSingleton(APPLICATION_SOURCE_CLASSES_BEAN_NAME); + if (stashedSources instanceof Class[] stashed) { + for (Class source : stashed) { + sources.add(source); + if (GrailsApplicationClass.class.isAssignableFrom(source)) { + haveApplicationClass = true; + } } - try { - Class beanClass = ClassUtils.forName(beanClassName, applicationContext.getClassLoader()); - if (GrailsApplicationClass.class.isAssignableFrom(beanClass)) { - sources.add(beanClass); + } + if (!haveApplicationClass) { + for (String beanDefinitionName : registry.getBeanDefinitionNames()) { + String beanClassName = registry.getBeanDefinition(beanDefinitionName).getBeanClassName(); + if (beanClassName == null) { + continue; + } + try { + Class beanClass = ClassUtils.forName(beanClassName, applicationContext.getClassLoader()); + if (GrailsApplicationClass.class.isAssignableFrom(beanClass) && !sources.contains(beanClass)) { + sources.add(beanClass); + } + } catch (ClassNotFoundException | LinkageError ignored) { + // not resolvable here — cannot be an application class } - } catch (ClassNotFoundException | LinkageError ignored) { - // not resolvable here — cannot be an application class } } return sources.toArray(new Class[0]); diff --git a/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java b/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java index fdb754b9dff..2c579b1810e 100644 --- a/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java +++ b/grails-core/src/main/groovy/grails/core/DefaultGrailsApplication.java @@ -220,14 +220,19 @@ public GrailsApplicationClass getApplicationClass() { } /** - * Sets the application class. Used to adopt the application class when this instance was - * constructed before the {@link GrailsApplicationClass} was available, e.g. during early - * plugin registration ahead of Spring Boot auto-configuration. + * Sets the application class. May be set once — to adopt the application class when this + * instance was constructed before the {@link GrailsApplicationClass} was known, e.g. during + * early plugin registration ahead of Spring Boot auto-configuration — and cannot afterwards be + * reassigned to a different application class. * * @param applicationClass The application class + * @throws IllegalStateException if a different application class has already been set * @since 8.0 */ public void setApplicationClass(GrailsApplicationClass applicationClass) { + if (this.applicationClass != null && this.applicationClass != applicationClass) { + throw new IllegalStateException("The application class has already been set and cannot be changed"); + } this.applicationClass = applicationClass; } diff --git a/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy b/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy index 3dee78dd009..517c2a7cea8 100644 --- a/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy +++ b/grails-core/src/main/groovy/grails/core/GrailsApplicationLifeCycle.groovy @@ -34,8 +34,9 @@ interface GrailsApplicationLifeCycle { * Sub classes should override to provide implementations * * @return A closure that defines beans to be registered by Spring - * @deprecated since 8.0 in favour of {@link #beanRegistrar()}. The bean builder DSL continues - * to work, but {@link #beanRegistrar()} is the modern, Spring-native replacement. + * @deprecated since 8.0 in favour of {@link #beanRegistrar()}. The underlying bean builder DSL + * remains available but is no longer actively supported and will not receive fixes for new + * issues — you are strongly urged to migrate to {@link #beanRegistrar()}. */ @Deprecated(since = '8.0') Closure doWithSpring() diff --git a/grails-core/src/main/groovy/grails/plugins/Plugin.groovy b/grails-core/src/main/groovy/grails/plugins/Plugin.groovy index 250ac5933d8..20e6534ee9e 100644 --- a/grails-core/src/main/groovy/grails/plugins/Plugin.groovy +++ b/grails-core/src/main/groovy/grails/plugins/Plugin.groovy @@ -106,8 +106,9 @@ abstract class Plugin implements GrailsApplicationLifeCycle, GrailsApplicationAw * Sub classes should override to provide implementations * * @return A closure that defines beans to be executed by Spring - * @deprecated since 8.0 in favour of {@link #beanRegistrar()}. The bean builder DSL continues - * to work, but {@link #beanRegistrar()} is the modern, Spring-native replacement. + * @deprecated since 8.0 in favour of {@link #beanRegistrar()}. The underlying bean builder DSL + * remains available but is no longer actively supported and will not receive fixes for new + * issues — you are strongly urged to migrate to {@link #beanRegistrar()}. */ @Deprecated(since = '8.0') @Override diff --git a/grails-core/src/main/resources/META-INF/spring.factories b/grails-core/src/main/resources/META-INF/spring.factories index d8db197a83f..25ae99728e2 100644 --- a/grails-core/src/main/resources/META-INF/spring.factories +++ b/grails-core/src/main/resources/META-INF/spring.factories @@ -23,4 +23,4 @@ org.springframework.boot.env.PropertySourceLoader=\ org.springframework.boot.EnvironmentPostProcessor=grails.boot.config.GrailsEnvironmentPostProcessor org.springframework.boot.SpringApplicationRunListener=grails.config.external.ExternalConfigRunListener org.springframework.boot.bootstrap.BootstrapRegistryInitializer=org.apache.grails.core.GrailsBootstrapRegistryInitializer -org.springframework.context.ApplicationContextInitializer=grails.boot.config.GrailsPluginLifecycleInitializer \ No newline at end of file +org.springframework.context.ApplicationContextInitializer=grails.boot.config.GrailsPluginLifecycleInitializer diff --git a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy index fc854e0d357..86ec3bf0823 100644 --- a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy +++ b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy @@ -141,6 +141,24 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { Environment.setInitializing(false) } + void 'a plugin beanRegistrar defined as a coerced closure registers its bean'() { + given: 'a plugin whose beanRegistrar() is a closure coerced to BeanRegistrar (no separate class)' + def ctx = new AnnotationConfigApplicationContext() + registerDiscovery(ctx, EarlyOrderingClosureRegistrarGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: + ctx.refresh() + + then: 'the bean registered by the closure-coerced registrar is present' + ctx.getBean('closureRegistrarBean') instanceof EarlyOrderingPluginResolver + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + void 'an application doWithSpring bean still overrides a plugin bean of the same name under the default settings'() { given: 'a plugin registering overrideProbe and an application registering a different bean under the same name' def ctx = new AnnotationConfigApplicationContext() @@ -239,6 +257,18 @@ class EarlyOrderingDualRegistrar implements BeanRegistrar { } } +class EarlyOrderingClosureRegistrarGrailsPlugin extends Plugin { + + def version = '1.0' + + @Override + BeanRegistrar beanRegistrar() { + { BeanRegistry registry, org.springframework.core.env.Environment environment -> + registry.registerBean('closureRegistrarBean', EarlyOrderingPluginResolver) + } as BeanRegistrar + } +} + class EarlyOrderingAppResolver { } diff --git a/grails-core/src/test/groovy/grails/core/DefaultGrailsApplicationSpec.groovy b/grails-core/src/test/groovy/grails/core/DefaultGrailsApplicationSpec.groovy new file mode 100644 index 00000000000..01b216489e9 --- /dev/null +++ b/grails-core/src/test/groovy/grails/core/DefaultGrailsApplicationSpec.groovy @@ -0,0 +1,51 @@ +/* + * 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.core + +import spock.lang.Specification + +class DefaultGrailsApplicationSpec extends Specification { + + void 'setApplicationClass adopts the class once and rejects reassignment to a different class'() { + given: 'an application constructed without an application class (as the early phase does)' + def application = new DefaultGrailsApplication() + GrailsApplicationClass first = Mock(GrailsApplicationClass) + GrailsApplicationClass second = Mock(GrailsApplicationClass) + + when: 'the application class is adopted for the first time' + application.applicationClass = first + + then: 'it is set' + application.applicationClass.is(first) + + when: 'the same class is set again' + application.applicationClass = first + + then: 'the set-once guard treats an identical value as idempotent' + noExceptionThrown() + application.applicationClass.is(first) + + when: 'a different application class is set' + application.applicationClass = second + + then: 'reassignment is rejected and the original is retained' + thrown(IllegalStateException) + application.applicationClass.is(first) + } +} diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc index 482b962915d..b92b040d571 100644 --- a/grails-doc/src/en/guide/introduction/whatsNew.adoc +++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc @@ -39,6 +39,35 @@ 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. +==== Plugin Beans Register Before Spring Boot Auto-Configuration + +Grails {grailsMajorVersion} unifies and retimes the plugin lifecycle so that the beans a plugin contributes are registered before Spring Boot processes its auto-configurations. +A plugin bean now takes precedence over a Spring Boot default guarded by `@ConditionalOnMissingBean` — Boot backs off in favour of the plugin's bean, with no need to override or remove it afterwards. + +Plugins register beans through the new `beanRegistrar()` hook, which returns a Spring Framework {springapi}org/springframework/beans/factory/BeanRegistrar.html[BeanRegistrar]. Because `BeanRegistrar` is a functional interface, a closure coerced with `as BeanRegistrar` is all that is required: + +[source,groovy] +---- +import org.springframework.beans.factory.BeanRegistrar +import org.springframework.beans.factory.BeanRegistry +import org.springframework.core.env.Environment +import grails.plugins.Plugin + +class MyGrailsPlugin extends Plugin { + + @Override + BeanRegistrar beanRegistrar() { + { BeanRegistry registry, Environment environment -> + registry.registerBean('myService', MyServiceImpl) + } as BeanRegistrar + } +} +---- + +`beanRegistrar()` replaces the `doWithSpring` bean builder DSL, which is deprecated in Grails {grailsMajorVersion}. +The DSL continues to work for now, but you are strongly urged to migrate. +See the xref:upgrading#upgrading80x[upgrade guide] for details. + ==== 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/plugins/hookingIntoRuntimeConfiguration.adoc b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc index 289d17aee62..a0c9c3bee48 100644 --- a/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc +++ b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc @@ -23,7 +23,7 @@ Grails provides a number of hooks to leverage the different parts of the system ==== Hooking into the Grails Spring configuration -The recommended way to register beans from a plugin is to override the `beanRegistrar` method from the link:{api}grails/plugins/Plugin.html[Plugin] class and return a Spring Framework {springapi}org/springframework/beans/factory/BeanRegistrar.html[BeanRegistrar]: +The recommended way to register beans from a plugin is to override the `beanRegistrar` method from the link:{api}grails/plugins/Plugin.html[Plugin] class and return a Spring Framework {springapi}org/springframework/beans/factory/BeanRegistrar.html[BeanRegistrar]. `BeanRegistrar` is a functional interface, so a closure coerced with `as BeanRegistrar` is all that is required — no separate class: [source,groovy] ---- @@ -41,31 +41,25 @@ class I18nGrailsPlugin extends Plugin { @Override BeanRegistrar beanRegistrar() { - new I18nBeanRegistrar() - } -} - -class I18nBeanRegistrar implements BeanRegistrar { - - @Override - void register(BeanRegistry registry, Environment environment) { - registry.registerBean('messageSource', ReloadableResourceBundleMessageSource) { spec -> - spec.supplier { context -> - def messageSource = new ReloadableResourceBundleMessageSource() - messageSource.basename = 'WEB-INF/grails-app/i18n/messages' - messageSource + { BeanRegistry registry, Environment environment -> + registry.registerBean('messageSource', ReloadableResourceBundleMessageSource) { spec -> + spec.supplier { context -> + new ReloadableResourceBundleMessageSource(basename: 'WEB-INF/grails-app/i18n/messages') + } } - } - registry.registerBean('localeChangeInterceptor', LocaleChangeInterceptor) { spec -> - spec.supplier { context -> - new LocaleChangeInterceptor(paramName: 'lang') + registry.registerBean('localeChangeInterceptor', LocaleChangeInterceptor) { spec -> + spec.supplier { context -> + new LocaleChangeInterceptor(paramName: 'lang') + } } - } - registry.registerBean('localeResolver', CookieLocaleResolver) + registry.registerBean('localeResolver', CookieLocaleResolver) + } as BeanRegistrar } } ---- +For more involved or reusable registration you can instead implement `BeanRegistrar` as its own class and return an instance of it from `beanRegistrar()`. + Plugin beans are registered before Spring Boot processes its auto-configurations, so a bean registered here takes precedence over any Spring Boot default guarded by `@ConditionalOnMissingBean` — there is no need to override or remove Boot's beans afterwards. Alternatively, plugins may override the `doWithSpring` method and return a closure that defines beans using the link:spring.html#theBeanBuilderDSLExplained[Spring Bean Builder] syntax: diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 0ffc323493d..369dbd62538 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1422,6 +1422,7 @@ In Grails 8 the plugin lifecycle is unified and retimed: plugin bean definitions * **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built exactly once, early in the context lifecycle, and reused for the rest of startup. Plugins are instantiated once; `Holders.grailsApplication` is available earlier than before. * **Artefact discovery happens earlier.** Controllers, services, domain classes and other artefacts are discovered before plugin beans are registered, because core plugins iterate the application's artefacts while defining beans. The set of discovered artefacts is unchanged — only the timing moved. * **Side-effectful `doWithSpring` closures still run once**, just earlier. Closures that read `grailsApplication.config` continue to work; the configuration is fully loaded (including plugin-contributed configuration) before any bean-definition code runs. +* **The application class is instantiated a second time during early artefact discovery.** A throwaway instance is created solely to compute the application's class scan; the real lifecycle bean is still created by Spring. An application-class constructor with side effects therefore runs twice per boot, so keep application-class constructors free of side effects. * **Plugins that inspect the bean definition registry see a pre-auto-configuration registry.** A `doWithSpring` closure that checks for the presence of a bean definition (for example `dispatcherServlet` as a "web application" signal) now runs before Spring Boot has registered its auto-configured beans, so such a check returns `false` where it previously returned `true`. Plugin code should test a registration-order-independent signal instead — for example whether the application context is a web application context. * **The `annotationHandlerMapping` and `annotationHandlerAdapter` beans were removed.** These duplicated the request-mapping infrastructure that Spring's own MVC configuration already provides and were never consulted at runtime, but with plugin beans now registering first the bare duplicate adapter started shadowing Spring's fully configured one. `@Controller` annotated beans continue to be handled by Spring Boot's standard MVC infrastructure. @@ -1446,7 +1447,7 @@ class MyGrailsPlugin extends Plugin { } ---- -After (Spring `BeanRegistrar`): +After (Spring `BeanRegistrar`). `BeanRegistrar` is a functional interface, so a closure coerced with `as BeanRegistrar` is all that is required — a separate class is optional and only worth it for more involved or reusable registration: [source,groovy] ---- @@ -1459,19 +1460,13 @@ class MyGrailsPlugin extends Plugin { @Override BeanRegistrar beanRegistrar() { - new MyPluginBeanRegistrar() - } -} - -class MyPluginBeanRegistrar implements BeanRegistrar { - - @Override - void register(BeanRegistry registry, Environment environment) { - registry.registerBean('myService', MyServiceImpl) { spec -> - spec.supplier { context -> - new MyServiceImpl(timeout: environment.getProperty('myPlugin.timeout', Integer, 30)) + { BeanRegistry registry, Environment environment -> + registry.registerBean('myService', MyServiceImpl) { spec -> + spec.supplier { context -> + new MyServiceImpl(timeout: environment.getProperty('myPlugin.timeout', Integer, 30)) + } } - } + } as BeanRegistrar } } ---- diff --git a/grails-test-examples/app3/grails-app/init/app3/Application.groovy b/grails-test-examples/app3/grails-app/init/app3/Application.groovy index 38838293d71..3a2f9f97fd1 100755 --- a/grails-test-examples/app3/grails-app/init/app3/Application.groovy +++ b/grails-test-examples/app3/grails-app/init/app3/Application.groovy @@ -35,4 +35,10 @@ class Application extends GrailsAutoConfiguration { @Bean @ConditionalOnMissingBean(name = 'earlyPluginProbe') String earlyPluginProbe() { 'from-conditional-default' } + + // Default for the probe the loadafter plugin registers via beanRegistrar(). Registrar beans also + // register ahead of auto-configuration, so this @ConditionalOnMissingBean default must defer to it. + @Bean + @ConditionalOnMissingBean(name = 'registrarProbe') + String registrarProbe() { 'from-conditional-default' } } diff --git a/grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy b/grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy index 471ea94c81e..cd16296501f 100644 --- a/grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy +++ b/grails-test-examples/app3/src/integration-test/groovy/app3/PluginBeansBeforeAutoConfigurationSpec.groovy @@ -30,6 +30,10 @@ import spock.lang.Specification * auto-configuration; {@code Application} defines a * {@code @ConditionalOnMissingBean(name='earlyPluginProbe')} default. The plugin's bean must win. * + *

The {@code loadafter} plugin also registers {@code registrarProbe} through the new + * {@code beanRegistrar()} API (a closure coerced to a Spring {@code BeanRegistrar}), which the + * matching {@code @ConditionalOnMissingBean} default must likewise defer to. + * *

The strict ordering proof (the conditional default is never even created) is covered at unit * level by {@code EarlyPluginRegistrationOrderingSpec} in grails-core, including its control case. */ @@ -43,4 +47,9 @@ class PluginBeansBeforeAutoConfigurationSpec extends Specification { expect: 'the plugin registered the probe ahead of auto-config, so the conditional default deferred to it' applicationContext.getBean('earlyPluginProbe') == 'from-plugin-doWithSpring' } + + void "a plugin beanRegistrar bean wins over the app's @ConditionalOnMissingBean default"() { + expect: 'the plugin registrar registered the probe ahead of auto-config, so the conditional default deferred to it' + applicationContext.getBean('registrarProbe') == 'from-plugin-beanRegistrar' + } } diff --git a/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy b/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy index 4ef1bc82107..62254225deb 100755 --- a/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy +++ b/grails-test-examples/plugins/loadafter/src/main/groovy/loadafter/LoadafterGrailsPlugin.groovy @@ -20,6 +20,9 @@ package loadafter import grails.plugins.* +import org.springframework.beans.factory.BeanRegistrar +import org.springframework.beans.factory.BeanRegistry +import org.springframework.core.env.Environment class LoadafterGrailsPlugin extends Plugin { @@ -43,4 +46,16 @@ Brief summary/description of the plugin. } } + // Exercises the new beanRegistrar() API end-to-end using the concise closure-coercion form. + // Registrar beans also register ahead of auto-configuration, so the app's @ConditionalOnMissingBean + // default for this name must defer — PluginBeansBeforeAutoConfigurationSpec asserts the plugin's value wins. + @Override + BeanRegistrar beanRegistrar() { + { BeanRegistry registry, Environment environment -> + registry.registerBean('registrarProbe', String) { spec -> + spec.supplier { context -> 'from-plugin-beanRegistrar' } + } + } as BeanRegistrar + } + } From e73705204e8f0c6e3ee43f8469c5acbdcb195454 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 9 Jul 2026 14:12:47 -0600 Subject: [PATCH 08/10] Add test coverage for review-audit gaps - Assert the actuator /health endpoint also serializes to JSON (the same converter path the /env regression exposed) - Add a MongoDB open-session-in-view registration test mirroring the Hibernate one, covering the identical isWebApplicationRegistry() fix - Cover the bean-definition-overriding-disabled edge with a colliding plugin+application bean pair that must throw - Prove the early phase resets the initializing flag when it throws - Prove the early phase loads plugins with a single manager pass, and correct the upgrade note: Grails always constructs a plugin's reference and real instance, so the guarantee is one manager pass, not a single plugin instantiation --- ...EarlyPluginRegistrationOrderingSpec.groovy | 101 ++++++++++++++++++ .../src/en/guide/upgrading/upgrading80x.adoc | 2 +- .../issue10279/ActuatorEnvClosureSpec.groovy | 8 ++ .../tests/OpenSessionInViewSpec.groovy | 43 ++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy diff --git a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy index 86ec3bf0823..4a65e7f80c8 100644 --- a/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy +++ b/grails-core/src/test/groovy/grails/boot/config/EarlyPluginRegistrationOrderingSpec.groovy @@ -18,14 +18,21 @@ */ package grails.boot.config +import java.util.concurrent.atomic.AtomicInteger + import org.springframework.beans.factory.BeanRegistrar import org.springframework.beans.factory.BeanRegistry +import org.springframework.beans.factory.support.BeanDefinitionOverrideException import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.context.annotation.AnnotationConfigApplicationContext import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import org.springframework.core.env.StandardEnvironment + +import grails.core.DefaultGrailsApplication import grails.core.GrailsApplication +import grails.plugins.DefaultGrailsPluginManager import grails.plugins.GrailsPluginManager import grails.plugins.Plugin import grails.util.Environment @@ -178,6 +185,79 @@ class EarlyPluginRegistrationOrderingSpec extends Specification { Environment.setInitializing(false) } + void 'with bean-definition overriding disabled an application bean shadowing a plugin bean fails'() { + given: 'overriding disabled, a plugin registering overrideProbe and an app registering the same name' + def ctx = new AnnotationConfigApplicationContext() + ctx.allowBeanDefinitionOverriding = false + registerDiscovery(ctx, EarlyOrderingOverrideGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + ctx.register(EarlyOrderingOverrideApplication) + + when: 'the context refreshes' + ctx.refresh() + + then: 'the colliding application bean is rejected rather than silently merging (see the upgrade notes)' + thrown(BeanDefinitionOverrideException) + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'the early phase loads plugins with a single manager pass, not a throwaway extra one'() { + given: 'the plugin construction count from one bare plugin-manager load, as a baseline' + // Grails wraps each plugin in a GrailsClass (a reference instance) and then creates the + // real instance, so a single load constructs the plugin more than once; what the retimed + // lifecycle guarantees is a single manager pass rather than a throwaway pass plus the real + // one (the rejected earlier approach), which would multiply this count. + EarlyOrderingCountingGrailsPlugin.INSTANCE_COUNT.set(0) + def baselineDiscovery = new DefaultPluginDiscovery([EarlyOrderingCountingGrailsPlugin] as Class[]) + baselineDiscovery.loadPluginsFromClasspath = false + baselineDiscovery.init(new StandardEnvironment()) + new DefaultGrailsPluginManager(new DefaultGrailsApplication(), baselineDiscovery).loadPlugins() + int singleLoadCount = EarlyOrderingCountingGrailsPlugin.INSTANCE_COUNT.get() + + and: 'the early phase booted through the real initializer' + EarlyOrderingCountingGrailsPlugin.INSTANCE_COUNT.set(0) + def ctx = new AnnotationConfigApplicationContext() + registerDiscovery(ctx, EarlyOrderingCountingGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes' + ctx.refresh() + + then: 'the early phase instantiates the plugin no more than a single manager load — no throwaway second pass' + singleLoadCount > 0 + EarlyOrderingCountingGrailsPlugin.INSTANCE_COUNT.get() == singleLoadCount + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + + void 'the initializing flag is reset when the early phase throws'() { + given: 'a plugin whose doWithSpring throws while the early phase drains it' + def ctx = new AnnotationConfigApplicationContext() + registerDiscovery(ctx, EarlyOrderingThrowingGrailsPlugin) + new GrailsPluginLifecycleInitializer().initialize(ctx) + + when: 'the context refreshes' + ctx.refresh() + + then: 'the refresh fails...' + thrown(Exception) + + and: '...but the initializing flag (a system property) was reset, not leaked to later contexts' + !Environment.isInitializing() + + cleanup: + ctx.close() + Holders.clear() + Environment.setInitializing(false) + } + private static void registerDiscovery(AnnotationConfigApplicationContext ctx, Class pluginClass) { def discovery = new DefaultPluginDiscovery([pluginClass] as Class[]) discovery.loadPluginsFromClasspath = false @@ -269,6 +349,27 @@ class EarlyOrderingClosureRegistrarGrailsPlugin extends Plugin { } } +class EarlyOrderingCountingGrailsPlugin extends Plugin { + + static final AtomicInteger INSTANCE_COUNT = new AtomicInteger() + + def version = '1.0' + + EarlyOrderingCountingGrailsPlugin() { + INSTANCE_COUNT.incrementAndGet() + } +} + +class EarlyOrderingThrowingGrailsPlugin extends Plugin { + + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> throw new IllegalStateException('boom from doWithSpring') } + } +} + class EarlyOrderingAppResolver { } diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 369dbd62538..d542110fef5 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1419,7 +1419,7 @@ A plugin that wanted to replace a Boot-provided bean therefore had to rely on be In Grails 8 the plugin lifecycle is unified and retimed: plugin bean definitions — both `doWithSpring` and the new `beanRegistrar()` hook — are drained into the bean definition registry *before* Spring Boot expands its auto-configurations. The practical effects are: * **Plugin beans win `@ConditionalOnMissingBean` races.** When a plugin registers a bean, a Boot auto-configuration bean of the same name or type that is guarded by `@ConditionalOnMissingBean` now backs off in favor of the plugin's bean. Plugins no longer need to override or remove Boot's defaults after the fact. -* **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built exactly once, early in the context lifecycle, and reused for the rest of startup. Plugins are instantiated once; `Holders.grailsApplication` is available earlier than before. +* **A single plugin manager and `GrailsApplication`.** The plugin manager and the `grailsApplication` are built once, early in the context lifecycle, and reused for the rest of startup, so plugins are loaded by a single manager pass rather than a throwaway pass followed by the real one. `Holders.grailsApplication` is available earlier than before. * **Artefact discovery happens earlier.** Controllers, services, domain classes and other artefacts are discovered before plugin beans are registered, because core plugins iterate the application's artefacts while defining beans. The set of discovered artefacts is unchanged — only the timing moved. * **Side-effectful `doWithSpring` closures still run once**, just earlier. Closures that read `grailsApplication.config` continue to work; the configuration is fully loaded (including plugin-contributed configuration) before any bean-definition code runs. * **The application class is instantiated a second time during early artefact discovery.** A throwaway instance is created solely to compute the application's class scan; the real lifecycle bean is still created by Spring. An application-class constructor with side effects therefore runs twice per boot, so keep application-class constructors free of side effects. diff --git a/grails-test-examples/issue-10279/src/integration-test/groovy/issue10279/ActuatorEnvClosureSpec.groovy b/grails-test-examples/issue-10279/src/integration-test/groovy/issue10279/ActuatorEnvClosureSpec.groovy index 7ad8fa2dfd4..b8e6178c701 100644 --- a/grails-test-examples/issue-10279/src/integration-test/groovy/issue10279/ActuatorEnvClosureSpec.groovy +++ b/grails-test-examples/issue-10279/src/integration-test/groovy/issue10279/ActuatorEnvClosureSpec.groovy @@ -53,4 +53,12 @@ class ActuatorEnvClosureSpec extends Specification implements HttpClientSupport and: 'the string property defined in application.groovy is present in the response' response.assertContains('hello-from-groovy-config') } + + void 'actuator /health endpoint returns 200'() { + when: 'the actuator health endpoint is accessed' + def response = http('/actuator/health') + + then: 'it too serializes to JSON — the same converter path the /env regression exposed' + response.assertStatus(200) + } } diff --git a/grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy b/grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy new file mode 100644 index 00000000000..1a236b1be90 --- /dev/null +++ b/grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy @@ -0,0 +1,43 @@ +/* + * 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 grails.testing.mixin.integration.Integration +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.ApplicationContext +import spock.lang.Specification + +/** + * Verifies the MongoDB open-session-in-view interceptor still registers in a web application. + * Like the Hibernate interceptor, its registration is guarded by a web-application check inside + * the plugin's bean definitions; because plugin beans now register before Spring Boot + * auto-configuration, that check can no longer rely on the {@code dispatcherServlet} bean + * definition being present. + */ +@Integration(applicationClass = Application) +class OpenSessionInViewSpec extends Specification { + + @Autowired + ApplicationContext applicationContext + + void "the mongo open session in view interceptor is registered in a web application"() { + expect: + applicationContext.containsBean('mongoOpenSessionInViewInterceptor') + } +} From d054328fe19ec119af57d9af7e46e3ecde9f671e Mon Sep 17 00:00:00 2001 From: Walter Duque de Estrada Date: Thu, 9 Jul 2026 16:42:04 -0500 Subject: [PATCH 09/10] test(grails-data-mongodb): add unit-testable Mongo OSIV coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mongo OSIV regression coverage added in e737052 lives in grails-test-examples/mongodb/base, which requires a real Mongo already listening on localhost:27017 and can't be verified without that external dependency. grails-data-mongodb/core already has Testcontainers wired up via AutoStartedMongoSpec (auto-starts Mongo, needs only Docker), so add MongoOpenSessionInViewSpec there, exercising MongoDbDataStoreSpringInitializer.isWebApplicationRegistry() directly against a GenericWebApplicationContext with no dispatcherServlet bean definition present yet — the exact ordering the retimed plugin lifecycle now requires — plus a non-web control case. Co-Authored-By: Claude Sonnet 5 --- grails-data-mongodb/core/build.gradle | 6 ++ .../MongoOpenSessionInViewSpec.groovy | 82 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoOpenSessionInViewSpec.groovy diff --git a/grails-data-mongodb/core/build.gradle b/grails-data-mongodb/core/build.gradle index af14d921b77..37099e2efff 100644 --- a/grails-data-mongodb/core/build.gradle +++ b/grails-data-mongodb/core/build.gradle @@ -132,6 +132,12 @@ dependencies { testImplementation 'org.apache.grails.testing:grails-testing-support-core' testImplementation 'org.spockframework:spock-core' testImplementation project(':grails-testing-support-mongodb') + testImplementation project(':grails-datastore-web'), { + // test: OpenSessionInViewInterceptor, spring-web's GenericWebApplicationContext + } + testImplementation 'jakarta.servlet:jakarta.servlet-api', { + // test: GenericWebApplicationContext requires ServletContext on the classpath + } testImplementation 'org.testcontainers:testcontainers' testImplementation 'org.testcontainers:testcontainers-mongodb' diff --git a/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoOpenSessionInViewSpec.groovy b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoOpenSessionInViewSpec.groovy new file mode 100644 index 00000000000..0c991037f3f --- /dev/null +++ b/grails-data-mongodb/core/src/test/groovy/grails/mongodb/bootstrap/MongoOpenSessionInViewSpec.groovy @@ -0,0 +1,82 @@ +/* + * 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.mongodb.bootstrap + +import org.apache.grails.testing.mongo.AutoStartedMongoSpec +import org.grails.datastore.mapping.mongo.MongoDatastore +import org.grails.datastore.mapping.mongo.config.MongoSettings +import org.springframework.context.support.GenericApplicationContext +import org.springframework.web.context.support.GenericWebApplicationContext + +/** + * Verifies the MongoDB open-session-in-view interceptor registers whenever the target registry is a + * {@code WebApplicationContext}, independent of whether a {@code dispatcherServlet} bean definition has + * been registered yet. This is the registration-order-independent branch of + * {@code AbstractDatastoreInitializer#isWebApplicationRegistry} that lets the interceptor keep registering + * once plugin beans drain before Spring Boot auto-configuration (which is what registers + * {@code dispatcherServlet}). + */ +class MongoOpenSessionInViewSpec extends AutoStartedMongoSpec { + + void "the mongo open session in view interceptor registers in a web application context"() { + given: "the initializer targeting a WebApplicationContext with no dispatcherServlet bean definition" + def initializer = makeInitializer() + def applicationContext = new GenericWebApplicationContext() + + when: "the context is configured and refreshed" + initializer.configureForBeanDefinitionRegistry(applicationContext) + applicationContext.refresh() + + then: "the OSIV interceptor is registered" + applicationContext.containsBean('mongoOpenSessionInViewInterceptor') + + cleanup: + applicationContext.getBean(MongoDatastore).destroy() + applicationContext.close() + } + + void "the mongo open session in view interceptor is not registered outside a web application context (control)"() { + given: "the same initializer targeting a plain, non-web ApplicationContext" + def initializer = makeInitializer() + def applicationContext = new GenericApplicationContext() + + when: "the context is configured and refreshed" + initializer.configureForBeanDefinitionRegistry(applicationContext) + applicationContext.refresh() + + then: "the OSIV interceptor is not registered" + !applicationContext.containsBean('mongoOpenSessionInViewInterceptor') + + cleanup: + applicationContext.getBean(MongoDatastore).destroy() + applicationContext.close() + } + + private MongoDbDataStoreSpringInitializer makeInitializer() { + new MongoDbDataStoreSpringInitializer([ + (MongoSettings.SETTING_HOST): mongoHost, + (MongoSettings.SETTING_PORT): mongoPort, + ]) { + @Override + protected Map> loadDataServices(String secondaryDatastore = null) { + [:] + } + } + } +} From 1af592c93327c1e7fc898f169956cb8a9fff96ae Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Thu, 9 Jul 2026 14:58:33 -0700 Subject: [PATCH 10/10] Replace the mongo OSIV integration test with a unit test of the shared check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mongodb/base example does not register mongoOpenSessionInViewInterceptor — the open-session-in-view class lives in grails-datastore-web, which that app does not depend on — so the integration test asserted a bean that is never present and failed in the MongoDB CI job. Cover the actual fix instead with a unit test of AbstractDatastoreInitializer.isWebApplicationRegistry(), the shared method the Hibernate and MongoDB initializers both use. The web-application-context branch (which needs spring-web) remains covered end-to-end by the Hibernate open-session-in-view integration tests. --- ...astoreInitializerWebApplicationSpec.groovy | 65 +++++++++++++++++++ .../tests/OpenSessionInViewSpec.groovy | 43 ------------ 2 files changed, 65 insertions(+), 43 deletions(-) create mode 100644 grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializerWebApplicationSpec.groovy delete mode 100644 grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy diff --git a/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializerWebApplicationSpec.groovy b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializerWebApplicationSpec.groovy new file mode 100644 index 00000000000..0a76590f3ab --- /dev/null +++ b/grails-datamapping-core/src/test/groovy/org/grails/datastore/gorm/bootstrap/AbstractDatastoreInitializerWebApplicationSpec.groovy @@ -0,0 +1,65 @@ +/* + * 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.bootstrap + +import org.springframework.beans.factory.support.BeanDefinitionRegistry +import org.springframework.beans.factory.support.DefaultListableBeanFactory +import org.springframework.beans.factory.support.RootBeanDefinition +import spock.lang.Specification + +/** + * Unit coverage for the web-application detection that guards open-session-in-view registration in + * the Hibernate and MongoDB datastore initializers. Because plugin beans now register before Spring + * Boot auto-configuration, the check can no longer rely on the {@code dispatcherServlet} bean being + * present, so it also treats the web application context itself as the signal. + * + *

The web-application-context branch depends on {@code spring-web}, which this module does not + * pull in (that is exactly why the check resolves the type reflectively); that branch is covered + * end-to-end by the Hibernate open-session-in-view integration tests in a real web application. + */ +class AbstractDatastoreInitializerWebApplicationSpec extends Specification { + + private static boolean isWeb(BeanDefinitionRegistry registry) { + new TestDatastoreInitializer().isWebApplicationRegistry(registry) + } + + void 'a null registry is not a web application'() { + expect: + !isWeb(null) + } + + void 'a registry with a dispatcherServlet bean definition is a web application'() { + given: + def registry = new DefaultListableBeanFactory() + registry.registerBeanDefinition('dispatcherServlet', new RootBeanDefinition(Object)) + + expect: + isWeb(registry) + } + + void 'a plain registry with neither a dispatcherServlet nor a web context type is not a web application'() { + expect: + !isWeb(new DefaultListableBeanFactory()) + } + + static class TestDatastoreInitializer extends AbstractDatastoreInitializer { + Closure getBeanDefinitions(BeanDefinitionRegistry beanDefinitionRegistry) { { -> } } + protected Class getPersistenceInterceptorClass() { null } + } +} diff --git a/grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy b/grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy deleted file mode 100644 index 1a236b1be90..00000000000 --- a/grails-test-examples/mongodb/base/src/integration-test/groovy/functional/tests/OpenSessionInViewSpec.groovy +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 grails.testing.mixin.integration.Integration -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.ApplicationContext -import spock.lang.Specification - -/** - * Verifies the MongoDB open-session-in-view interceptor still registers in a web application. - * Like the Hibernate interceptor, its registration is guarded by a web-application check inside - * the plugin's bean definitions; because plugin beans now register before Spring Boot - * auto-configuration, that check can no longer rely on the {@code dispatcherServlet} bean - * definition being present. - */ -@Integration(applicationClass = Application) -class OpenSessionInViewSpec extends Specification { - - @Autowired - ApplicationContext applicationContext - - void "the mongo open session in view interceptor is registered in a web application"() { - expect: - applicationContext.containsBean('mongoOpenSessionInViewInterceptor') - } -}