From 5aa30fc68431096aaa9fbd5152cfabe87758bfad Mon Sep 17 00:00:00 2001 From: Dennis-Mircea Ciupitu Date: Wed, 20 May 2026 16:00:15 +0300 Subject: [PATCH] Inherit enclosing @Conditional on scan-discovered nested configs Signed-off-by: Dennis-Mircea Ciupitu --- .../context/annotation/Conditional.java | 20 +- ...onfigurationClassBeanDefinitionReader.java | 30 +- .../annotation/ConfigurationClassParser.java | 44 +++ .../ConfigurationClassPostProcessor.java | 3 +- .../EnclosingConditionConfigurationTests.java | 294 ++++++++++++++++++ 5 files changed, 383 insertions(+), 8 deletions(-) create mode 100644 spring-context/src/test/java/org/springframework/context/annotation/EnclosingConditionConfigurationTests.java diff --git a/spring-context/src/main/java/org/springframework/context/annotation/Conditional.java b/spring-context/src/main/java/org/springframework/context/annotation/Conditional.java index 27d16b727788..8ad41b280fbc 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/Conditional.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/Conditional.java @@ -44,12 +44,20 @@ * class will be subject to the conditions. * *

NOTE: Inheritance of {@code @Conditional} annotations - * is not supported; any conditions from superclasses or from overridden - * methods will not be considered. In order to enforce these semantics, - * {@code @Conditional} itself is not declared as - * {@link java.lang.annotation.Inherited @Inherited}; furthermore, any - * custom composed annotation that is meta-annotated with - * {@code @Conditional} must not be declared as {@code @Inherited}. + * from superclasses or from overridden methods is not supported. To enforce + * these semantics, {@code @Conditional} is not declared as + * {@link java.lang.annotation.Inherited @Inherited}, and any custom + * composed annotation meta-annotated with {@code @Conditional} + * must not be declared as {@code @Inherited} either. + * + *

Conditions declared on a lexically enclosing class, however, do gate + * registration of any nested static {@code @Configuration} classes within it. + * This applies regardless of how the nested class is discovered: as a member + * of its enclosing class, via {@link Import @Import}, via + * {@link ComponentScan @ComponentScan}, or through direct registration. + * The single exception is a nested class imported via {@code @Import} from + * outside its enclosing class, in which case only the importer's conditions + * apply. * * @author Phillip Webb * @author Sam Brannen diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java index a5f64e71afea..5bd98227bc4c 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java @@ -16,6 +16,7 @@ package org.springframework.context.annotation; +import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Method; import java.util.HashMap; @@ -54,6 +55,7 @@ import org.springframework.core.type.MethodMetadata; import org.springframework.core.type.StandardAnnotationMetadata; import org.springframework.core.type.StandardMethodMetadata; +import org.springframework.core.type.classreading.MetadataReaderFactory; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.MultiValueMap; @@ -95,6 +97,8 @@ class ConfigurationClassBeanDefinitionReader { private final ConditionEvaluator conditionEvaluator; + private final MetadataReaderFactory metadataReaderFactory; + /** * Create a new {@link ConfigurationClassBeanDefinitionReader} instance @@ -102,7 +106,7 @@ class ConfigurationClassBeanDefinitionReader { */ ConfigurationClassBeanDefinitionReader(BeanDefinitionRegistry registry, SourceExtractor sourceExtractor, ResourceLoader resourceLoader, Environment environment, BeanNameGenerator importBeanNameGenerator, - ImportRegistry importRegistry) { + ImportRegistry importRegistry, MetadataReaderFactory metadataReaderFactory) { this.registry = registry; this.sourceExtractor = sourceExtractor; @@ -111,6 +115,7 @@ class ConfigurationClassBeanDefinitionReader { this.importBeanNameGenerator = importBeanNameGenerator; this.importRegistry = importRegistry; this.conditionEvaluator = new ConditionEvaluator(registry, environment, resourceLoader); + this.metadataReaderFactory = metadataReaderFactory; } @@ -524,11 +529,34 @@ public boolean shouldSkip(ConfigurationClass configClass) { } if (skip == null) { skip = conditionEvaluator.shouldSkip(configClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN); + if (!skip && !configClass.isImported()) { + // Non-imported nested @Configuration: also honor REGISTER_BEAN-phase + // conditions declared on any lexically enclosing class. + skip = shouldSkipFromEnclosingClasses(configClass.getMetadata()); + } } this.skipped.put(configClass, skip); } return skip; } + + private boolean shouldSkipFromEnclosingClasses(AnnotationMetadata metadata) { + String enclosingClassName = metadata.getEnclosingClassName(); + while (enclosingClassName != null) { + AnnotationMetadata enclosing; + try { + enclosing = metadataReaderFactory.getMetadataReader(enclosingClassName).getAnnotationMetadata(); + } + catch (IOException ex) { + return false; + } + if (conditionEvaluator.shouldSkip(enclosing, ConfigurationPhase.REGISTER_BEAN)) { + return true; + } + enclosingClassName = enclosing.getEnclosingClassName(); + } + return false; + } } } diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java index 782bbe8ab991..b50423bb35a5 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java @@ -249,6 +249,10 @@ protected void processConfigurationClass(ConfigurationClass configClass, Predica if (this.conditionEvaluator.shouldSkip(configClass.getMetadata(), ConfigurationPhase.PARSE_CONFIGURATION)) { return; } + if (!configClass.isImported() && shouldSkipFromEnclosingClasses( + configClass.getMetadata(), ConfigurationPhase.PARSE_CONFIGURATION)) { + return; + } ConfigurationClass existingClass = this.configurationClasses.get(configClass); if (existingClass != null) { @@ -735,6 +739,13 @@ private List collectRegisterBeanConditions(ConfigurationClass configu if (enclosingConfigurationClass != null) { allConditions.addAll(this.conditionEvaluator.collectConditions(enclosingConfigurationClass.getMetadata())); } + if (!configurationClass.isImported()) { + for (AnnotationMetadata enclosing = getEnclosingClassMetadata(metadata); + enclosing != null; + enclosing = getEnclosingClassMetadata(enclosing)) { + allConditions.addAll(this.conditionEvaluator.collectConditions(enclosing)); + } + } return allConditions.stream().filter(REGISTER_BEAN_CONDITION_FILTER).toList(); } @@ -748,6 +759,39 @@ private List collectRegisterBeanConditions(ConfigurationClass configu return null; } + /** + * Determine whether any class in the lexical enclosing chain of the given + * metadata declares a {@code @Conditional} that vetoes registration for the + * given phase. Used to extend enclosing-class condition inheritance to + * nested {@code @Configuration} classes discovered via {@code @ComponentScan} + * or direct registration (paths where the enclosing class is not present in + * {@code importedBy}). + */ + private boolean shouldSkipFromEnclosingClasses(AnnotationMetadata metadata, ConfigurationPhase phase) { + for (AnnotationMetadata enclosing = getEnclosingClassMetadata(metadata); + enclosing != null; + enclosing = getEnclosingClassMetadata(enclosing)) { + if (this.conditionEvaluator.shouldSkip(enclosing, phase)) { + return true; + } + } + return false; + } + + private @Nullable AnnotationMetadata getEnclosingClassMetadata(AnnotationMetadata metadata) { + String enclosingClassName = metadata.getEnclosingClassName(); + if (enclosingClassName == null) { + return null; + } + try { + return this.metadataReaderFactory.getMetadataReader(enclosingClassName).getAnnotationMetadata(); + } + catch (IOException ex) { + // Enclosing metadata not readable — treat as if no condition applies. + return null; + } + } + @SuppressWarnings("serial") private class ImportStack extends ArrayDeque implements ImportRegistry { diff --git a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java index fb9bd07d4a9f..1fdb7c8fd82e 100644 --- a/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java @@ -459,7 +459,8 @@ else if (ConfigurationClassUtils.checkConfigurationClassCandidate(beanDef, this. if (this.reader == null) { this.reader = new ConfigurationClassBeanDefinitionReader( registry, this.sourceExtractor, this.resourceLoader, this.environment, - this.importBeanNameGenerator, parser.getImportRegistry()); + this.importBeanNameGenerator, parser.getImportRegistry(), + this.metadataReaderFactory); } this.reader.loadBeanDefinitions(configClasses); for (ConfigurationClass configClass : configClasses) { diff --git a/spring-context/src/test/java/org/springframework/context/annotation/EnclosingConditionConfigurationTests.java b/spring-context/src/test/java/org/springframework/context/annotation/EnclosingConditionConfigurationTests.java new file mode 100644 index 000000000000..11ed9fe9fe08 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/context/annotation/EnclosingConditionConfigurationTests.java @@ -0,0 +1,294 @@ +/* + * Copyright 2002-present the original author or authors. + * + * Licensed 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.springframework.context.annotation; + +import org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.core.type.AnnotatedTypeMetadata; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests that {@code @Conditional} annotations declared on a {@code @Configuration} + * class also gate registration of any nested static {@code @Configuration} classes + * when those nested classes are discovered via {@link ComponentScan} or direct + * registration — not only via the {@code processMemberClasses} recursion path + * or via {@link Import}. + * + *

Prior to this behavior, a nested {@code @Configuration} discovered through + * an independent path (component scan or direct registration) was processed using + * only its own metadata, silently bypassing any condition declared on its lexical + * enclosing class. + */ +class EnclosingConditionConfigurationTests { + + @Test + void scanDiscoveredInnerSkipsWhenEnclosingParseConditionFails() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + ScannerForParseDisabledOuter.class)) { + assertThat(context.containsBean("innerBean")).isFalse(); + } + } + + @Test + void scanDiscoveredInnerSkipsWhenEnclosingRegisterBeanConditionFails() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + ScannerForRegisterBeanDisabledOuter.class)) { + assertThat(context.containsBean("innerBean")).isFalse(); + } + } + + @Test + void scanDiscoveredInnerIsRegisteredWhenEnclosingConditionMatches() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + ScannerForEnabledOuter.class)) { + assertThat(context.containsBean("innerBean")).isTrue(); + } + } + + @Test + void directlyRegisteredInnerSkipsWhenEnclosingParseConditionFails() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + context.register(ParseDisabledOuter.Inner.class); + context.refresh(); + assertThat(context.containsBean("innerBean")).isFalse(); + } + } + + @Test + void deeplyNestedInnerSkipsWhenOutermostParseConditionFails() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + ScannerForDeeplyNestedDisabledOuter.class)) { + assertThat(context.containsBean("deepInnerBean")).isFalse(); + } + } + + @Test + void scanDiscoveredInnerSkipsWhenEnclosingInterfaceConditionFails() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + ScannerForInterfaceEnclosingDisabled.class)) { + assertThat(context.containsBean("innerBean")).isFalse(); + } + } + + @Test + void scanDiscoveredInnerSkipsWhenEnclosingFailsEvenIfInnerOwnConditionMatches() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + ScannerForMixedPhaseOuterFails.class)) { + assertThat(context.containsBean("innerBean")).isFalse(); + } + } + + + @Configuration(proxyBeanMethods = false) + @ComponentScan( + basePackageClasses = ParseDisabledOuter.class, + useDefaultFilters = false, + includeFilters = @Filter(Configuration.class), + resourcePattern = "EnclosingConditionConfigurationTests$ParseDisabledOuter*.class") + static class ScannerForParseDisabledOuter { + } + + + @Configuration(proxyBeanMethods = false) + @ComponentScan( + basePackageClasses = RegisterBeanDisabledOuter.class, + useDefaultFilters = false, + includeFilters = @Filter(Configuration.class), + resourcePattern = "EnclosingConditionConfigurationTests$RegisterBeanDisabledOuter*.class") + static class ScannerForRegisterBeanDisabledOuter { + } + + + @Configuration(proxyBeanMethods = false) + @ComponentScan( + basePackageClasses = EnabledOuter.class, + useDefaultFilters = false, + includeFilters = @Filter(Configuration.class), + resourcePattern = "EnclosingConditionConfigurationTests$EnabledOuter*.class") + static class ScannerForEnabledOuter { + } + + + @Configuration(proxyBeanMethods = false) + @ComponentScan( + basePackageClasses = DeeplyNestedDisabledOuter.class, + useDefaultFilters = false, + includeFilters = @Filter(Configuration.class), + resourcePattern = "EnclosingConditionConfigurationTests$DeeplyNestedDisabledOuter*.class") + static class ScannerForDeeplyNestedDisabledOuter { + } + + + @Configuration(proxyBeanMethods = false) + @ComponentScan( + basePackageClasses = InterfaceEnclosingDisabled.class, + useDefaultFilters = false, + includeFilters = @Filter(Configuration.class), + resourcePattern = "EnclosingConditionConfigurationTests$InterfaceEnclosingDisabled*.class") + static class ScannerForInterfaceEnclosingDisabled { + } + + + @Configuration(proxyBeanMethods = false) + @ComponentScan( + basePackageClasses = MixedPhaseOuterFails.class, + useDefaultFilters = false, + includeFilters = @Filter(Configuration.class), + resourcePattern = "EnclosingConditionConfigurationTests$MixedPhaseOuterFails*.class") + static class ScannerForMixedPhaseOuterFails { + } + + + @Configuration(proxyBeanMethods = false) + @Conditional(NeverMatchParseCondition.class) + static class ParseDisabledOuter { + + @Configuration(proxyBeanMethods = false) + static class Inner { + + @Bean + String innerBean() { + return "inner"; + } + } + } + + + @Configuration(proxyBeanMethods = false) + @Conditional(NeverMatchRegisterBeanCondition.class) + static class RegisterBeanDisabledOuter { + + @Configuration(proxyBeanMethods = false) + static class Inner { + + @Bean + String innerBean() { + return "inner"; + } + } + } + + + @Configuration(proxyBeanMethods = false) + @Conditional(AlwaysMatchCondition.class) + static class EnabledOuter { + + @Configuration(proxyBeanMethods = false) + static class Inner { + + @Bean + String innerBean() { + return "inner"; + } + } + } + + + @Configuration(proxyBeanMethods = false) + @Conditional(NeverMatchParseCondition.class) + static class DeeplyNestedDisabledOuter { + + @Configuration(proxyBeanMethods = false) + static class Middle { + + @Configuration(proxyBeanMethods = false) + static class DeepInner { + + @Bean + String deepInnerBean() { + return "deep"; + } + } + } + } + + + @Conditional(NeverMatchParseCondition.class) + interface InterfaceEnclosingDisabled { + + @Configuration(proxyBeanMethods = false) + class Inner { + + @Bean + String innerBean() { + return "inner"; + } + } + } + + + @Configuration(proxyBeanMethods = false) + @Conditional(NeverMatchRegisterBeanCondition.class) + static class MixedPhaseOuterFails { + + @Configuration(proxyBeanMethods = false) + @Conditional(AlwaysMatchCondition.class) + static class Inner { + + @Bean + String innerBean() { + return "inner"; + } + } + } + + + static class NeverMatchParseCondition implements ConfigurationCondition { + + @Override + public boolean matches(@NonNull ConditionContext context, @NonNull AnnotatedTypeMetadata metadata) { + return false; + } + + @Override + public @NonNull ConfigurationPhase getConfigurationPhase() { + return ConfigurationPhase.PARSE_CONFIGURATION; + } + } + + + static class NeverMatchRegisterBeanCondition implements ConfigurationCondition { + + @Override + public boolean matches(@NonNull ConditionContext context, @NonNull AnnotatedTypeMetadata metadata) { + return false; + } + + @Override + public @NonNull ConfigurationPhase getConfigurationPhase() { + return ConfigurationPhase.REGISTER_BEAN; + } + } + + + static class AlwaysMatchCondition implements ConfigurationCondition { + + @Override + public boolean matches(@NonNull ConditionContext context, @NonNull AnnotatedTypeMetadata metadata) { + return true; + } + + @Override + public @NonNull ConfigurationPhase getConfigurationPhase() { + return ConfigurationPhase.PARSE_CONFIGURATION; + } + } + +}