From 2dabe093633d7d95b419a638c9fb25c1e881774b Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 13 Jan 2026 10:44:18 +0000 Subject: [PATCH] Avoid losing type information in on bean condition messages Previously, a type in a bean condition was handled as a ResolvableType. When the type was not present on the classpath it would be represented as ResolvableType.NONE which renders as ?. This resulted in some type information being omitted from the condition evaluation report. This commit introduces a pair, ByteType, the holds both the name of the type as a String as well as the ResolvableType that represents it. When that ResolvableType is NONE, the String name of the type is used thereby avoiding the information loss. Fixes gh-48835 --- .../condition/OnBeanCondition.java | 69 +++++++++++-------- .../condition/ConditionalOnBeanTests.java | 32 +++++++++ .../ConditionalOnMissingBeanTests.java | 33 +++++++++ 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java index abfeacb4445..767e871811c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/condition/OnBeanCondition.java @@ -206,11 +206,11 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat ConfigurableListableBeanFactory beanFactory = getSearchBeanFactory(spec); ClassLoader classLoader = spec.getContext().getClassLoader(); boolean considerHierarchy = spec.getStrategy() != SearchStrategy.CURRENT; - Set parameterizedContainers = spec.getParameterizedContainers(); + Set parameterizedContainers = spec.getParameterizedContainers(); MatchResult result = new MatchResult(); Set beansIgnoredByType = getNamesOfBeansIgnoredByType(beanFactory, considerHierarchy, spec.getIgnoredTypes(), parameterizedContainers); - for (ResolvableType type : spec.getTypes()) { + for (BeanType type : spec.getTypes()) { Map typeMatchedDefinitions = getBeanDefinitionsForType(beanFactory, considerHierarchy, type, parameterizedContainers); Set typeMatchedNames = matchedNamesFrom(typeMatchedDefinitions, @@ -299,9 +299,9 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat } private Set getNamesOfBeansIgnoredByType(ListableBeanFactory beanFactory, boolean considerHierarchy, - Set ignoredTypes, Set parameterizedContainers) { + Set ignoredTypes, Set parameterizedContainers) { Set result = null; - for (ResolvableType ignoredType : ignoredTypes) { + for (BeanType ignoredType : ignoredTypes) { Collection ignoredNames = getBeanDefinitionsForType(beanFactory, considerHierarchy, ignoredType, parameterizedContainers) .keySet(); @@ -311,18 +311,20 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat } private Map getBeanDefinitionsForType(ListableBeanFactory beanFactory, - boolean considerHierarchy, ResolvableType type, Set parameterizedContainers) { + boolean considerHierarchy, BeanType type, Set parameterizedContainers) { Map result = collectBeanDefinitionsForType(beanFactory, considerHierarchy, type, parameterizedContainers, null); - return (result != null) ? result : Collections.emptyMap(); + return (result != null) ? result : Collections.emptyMap(); } private Map collectBeanDefinitionsForType(ListableBeanFactory beanFactory, - boolean considerHierarchy, ResolvableType type, Set parameterizedContainers, + boolean considerHierarchy, BeanType type, Set parameterizedContainers, Map result) { - result = putAll(result, beanFactory.getBeanNamesForType(type, true, false), beanFactory); - for (ResolvableType parameterizedContainer : parameterizedContainers) { - ResolvableType generic = ResolvableType.forClassWithGenerics(parameterizedContainer.resolve(), type); + result = putAll(result, beanFactory.getBeanNamesForType(type.resolvableType(), true, false), beanFactory); + for (BeanType parameterizedContainer : parameterizedContainers) { + Class resolved = parameterizedContainer.resolvableType().resolve(); + Assert.state(resolved != null, "'resolved' must not be null"); + ResolvableType generic = ResolvableType.forClassWithGenerics(resolved, type.resolvableType()); result = putAll(result, beanFactory.getBeanNamesForType(generic, true, false), beanFactory); } if (considerHierarchy && beanFactory instanceof HierarchicalBeanFactory hierarchicalBeanFactory) { @@ -549,13 +551,13 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat private final Set names; - private final Set types; + private final Set types; private final Set annotations; - private final Set ignoredTypes; + private final Set ignoredTypes; - private final Set parameterizedContainers; + private final Set parameterizedContainers; private final SearchStrategy strategy; @@ -572,7 +574,7 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat this.ignoredTypes = resolveWhenPossible(extract(attributes, "ignored", "ignoredType")); this.parameterizedContainers = resolveWhenPossible(extract(attributes, "parameterizedContainer")); this.strategy = annotation.getValue("search", SearchStrategy.class).orElse(null); - Set types = resolveWhenPossible(extractTypes(attributes)); + Set types = resolveWhenPossible(extractTypes(attributes)); BeanTypeDeductionException deductionException = null; if (types.isEmpty() && this.names.isEmpty() && this.annotations.isEmpty()) { try { @@ -613,18 +615,18 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat Collections.addAll(result, additional); } - private Set resolveWhenPossible(Set classNames) { + private Set resolveWhenPossible(Set classNames) { if (classNames.isEmpty()) { return Collections.emptySet(); } - Set resolved = new LinkedHashSet<>(classNames.size()); + Set resolved = new LinkedHashSet<>(classNames.size()); for (String className : classNames) { try { Class type = resolve(className, this.context.getClassLoader()); - resolved.add(ResolvableType.forRawClass(type)); + resolved.add(new BeanType(className, ResolvableType.forRawClass(type))); } catch (ClassNotFoundException | NoClassDefFoundError ex) { - resolved.add(ResolvableType.NONE); + resolved.add(new BeanType(className, ResolvableType.NONE)); } } return resolved; @@ -653,14 +655,14 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat return "@" + ClassUtils.getShortName(this.annotationType); } - private Set deducedBeanType(ConditionContext context, AnnotatedTypeMetadata metadata) { + private Set deducedBeanType(ConditionContext context, AnnotatedTypeMetadata metadata) { if (metadata instanceof MethodMetadata && metadata.isAnnotated(Bean.class.getName())) { return deducedBeanTypeForBeanMethod(context, (MethodMetadata) metadata); } return Collections.emptySet(); } - private Set deducedBeanTypeForBeanMethod(ConditionContext context, MethodMetadata metadata) { + private Set deducedBeanTypeForBeanMethod(ConditionContext context, MethodMetadata metadata) { try { return Set.of(getReturnType(context, metadata)); } @@ -669,7 +671,7 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat } } - private ResolvableType getReturnType(ConditionContext context, MethodMetadata metadata) + private BeanType getReturnType(ConditionContext context, MethodMetadata metadata) throws ClassNotFoundException, LinkageError { // Safe to load at this point since we are in the REGISTER_BEAN phase ClassLoader classLoader = context.getClassLoader(); @@ -677,12 +679,12 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat if (isParameterizedContainer(returnType.resolve())) { returnType = returnType.getGeneric(); } - return returnType; + return new BeanType(returnType.toString(), returnType); } private boolean isParameterizedContainer(Class type) { return (type != null) && this.parameterizedContainers.stream() - .map(ResolvableType::resolve) + .map((beanType) -> beanType.resolvableType().resolve(type)) .anyMatch((container) -> container != null && container.isAssignableFrom(type)); } @@ -716,7 +718,7 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat return (this.strategy != null) ? this.strategy : SearchStrategy.ALL; } - Set getTypes() { + Set getTypes() { return this.types; } @@ -732,11 +734,11 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat return this.annotations; } - private Set getIgnoredTypes() { + private Set getIgnoredTypes() { return this.ignoredTypes; } - private Set getParameterizedContainers() { + private Set getParameterizedContainers() { return this.parameterizedContainers; } @@ -843,12 +845,12 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat this.unmatchedAnnotations.add(annotation); } - private void recordMatchedType(ResolvableType type, Collection matchingNames) { - this.matchedTypes.put(type.toString(), matchingNames); + private void recordMatchedType(BeanType type, Collection matchingNames) { + this.matchedTypes.put(type.name.toString(), matchingNames); this.namesOfAllMatches.addAll(matchingNames); } - private void recordUnmatchedType(ResolvableType type) { + private void recordUnmatchedType(BeanType type) { this.unmatchedTypes.add(type.toString()); } @@ -903,4 +905,13 @@ class OnBeanCondition extends FilteringSpringBootCondition implements Configurat } + record BeanType(String name, ResolvableType resolvableType) { + + @Override + public String toString() { + return ResolvableType.NONE.equals(this.resolvableType) ? this.name : this.resolvableType.toString(); + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java index e1f171b821b..b5fb27ef291 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnBeanTests.java @@ -23,6 +23,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.Collection; import java.util.Date; +import java.util.Map; import java.util.function.Consumer; import org.junit.jupiter.api.Test; @@ -31,6 +32,7 @@ import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport.ConditionAndOutcome; import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport.ConditionAndOutcomes; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -347,6 +349,25 @@ class ConditionalOnBeanTests { "customGenericExampleBean", "parameterizedContainerGenericExampleBean"))); } + @Test + void whenTypeDoesNotExistItIsStillDescribedInConditionOutcomeMessage() { + this.contextRunner.withUserConfiguration(OnBeanForTypeThatDoesNotExist.class).run((context) -> { + Map conditionAndOutcomesBySource = ConditionEvaluationReport + .get(context.getBeanFactory()) + .getConditionAndOutcomesBySource(); + assertThat(conditionAndOutcomesBySource).hasEntrySatisfying(OnBeanForTypeThatDoesNotExist.class.getName(), + (conditionAndOutcomes) -> { + assertThat(conditionAndOutcomes).hasSize(1) + .first() + .extracting(ConditionAndOutcome::getOutcome) + .satisfies((outcome) -> { + assertThat(outcome.isMatch()).isFalse(); + assertThat(outcome.getMessage()).contains("com.example.DoesNotExist"); + }); + }); + }); + } + private Consumer beansAndContainersNamed(Class type, String... names) { return (context) -> { String[] beans = context.getBeanNamesForType(type); @@ -833,4 +854,15 @@ class ConditionalOnBeanTests { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnBean(type = "com.example.DoesNotExist") + static class OnBeanForTypeThatDoesNotExist { + + @Bean + String bean() { + return "bean"; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java index 521d5807aef..a2e27673e60 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBeanTests.java @@ -23,6 +23,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.Collection; import java.util.Date; +import java.util.Map; import java.util.function.Consumer; import org.junit.jupiter.api.Test; @@ -31,6 +32,8 @@ import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport.ConditionAndOutcome; +import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport.ConditionAndOutcomes; import org.springframework.boot.autoconfigure.condition.scan.ScanBean; import org.springframework.boot.autoconfigure.condition.scan.ScannedFactoryBeanConfiguration; import org.springframework.boot.autoconfigure.condition.scan.ScannedFactoryBeanWithBeanMethodArgumentsConfiguration; @@ -503,6 +506,25 @@ class ConditionalOnMissingBeanTests { .satisfies(beansAndContainersNamed(GenericExampleBean.class, "customGenericExampleBean"))); } + @Test + void whenTypeDoesNotExistItIsStillDescribedInConditionOutcomeMessage() { + this.contextRunner.withUserConfiguration(OnMissingBeanForTypeThatDoesNotExist.class).run((context) -> { + Map conditionAndOutcomesBySource = ConditionEvaluationReport + .get(context.getBeanFactory()) + .getConditionAndOutcomesBySource(); + assertThat(conditionAndOutcomesBySource) + .hasEntrySatisfying(OnMissingBeanForTypeThatDoesNotExist.class.getName(), (conditionAndOutcomes) -> { + assertThat(conditionAndOutcomes).hasSize(1) + .first() + .extracting(ConditionAndOutcome::getOutcome) + .satisfies((outcome) -> { + assertThat(outcome.isMatch()).isTrue(); + assertThat(outcome.getMessage()).contains("com.example.DoesNotExist"); + }); + }); + }); + } + private Consumer beansAndContainersNamed(Class type, String... names) { return (context) -> { String[] beans = context.getBeanNamesForType(type); @@ -1136,4 +1158,15 @@ class ConditionalOnMissingBeanTests { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnMissingBean(type = "com.example.DoesNotExist") + static class OnMissingBeanForTypeThatDoesNotExist { + + @Bean + String bean() { + return "bean"; + } + + } + }