spring-framework icon indicating copy to clipboard operation
spring-framework copied to clipboard

Parent Conditionals are ignored on component scanning

Open levitin opened this issue 3 years ago • 2 comments

Imagine you have the following configuration and the associated properties:

MyConfiguration.java
@Configuration
@EnableConfigurationProperties(MyProperties.class)
public class MyConfiguration {

  @Configuration
  @ConditionalOnProperty(value = "my.enabled", havingValue = "true")
  static class MyEnabledConfiguration {

    @Configuration
    @ConditionalOnProperty(value = "my.version", havingValue = "v1")
    static class MyEnabledFirstConfiguration {
      @Bean
      public MyClient firstClient() {
        return new FirstClient();
      }
    }

    @Configuration
    @ConditionalOnProperty(value = "my.version", havingValue = "v2", matchIfMissing = true)
    static class MyEnabledSecondConfiguration {
      @Bean
      public MyClient secondClient() {
        return new SecondClient();
      }

    }
  }

  @Configuration
  @ConditionalOnProperty(value = "my.enabled", havingValue = "false", matchIfMissing = true)
  static class MyDisabledConfiguration {

    @Bean
    public MyClient thirdClient() {
      return new ThirdClient();
    }
  }
MyProperties.java
@Getter
@Setter
@Validated
@ConfigurationProperties(prefix = "my")
public class MyProperties {

  private boolean enabled;

  private Version version = Version.V1;

  public enum Version {
    V1,
    V2
  }
}

In following case I expect the only single bean of type ThirdClient

my:
  enabled: false
  version: v1

And it actually works fine, if you write your test as following:

@SpringBootTest(classes = MyConfiguration.class)
class MySpringBootTest {

    @Autowired
    private ApplicationContext context;

    @Test
    void contextLoads() {
        assertThat(context.getBeansOfType(MyClient.class).size()).isEqualTo(1);
        assertThat(context.getBean(MyClient.class)).isInstanceOf(ThirdClient.class);
    }
}

However if you are using component scan, the parent conditional is ignored.

+ @SpringBootTest
- @SpringBootTest(classes = MyConfiguration.class)
class MySpringBootTest {
    ...
}

In this case you get the following two beans instead of expected single one as in example above.

  • FirstClient
  • ThirdClient

levitin avatar Oct 13 '22 21:10 levitin

Seems like @ComponentScan also accepts nested @Configuration classes. Maybe doing an additional check if the candidate has a parent conditional @Configuration before parsing the candidate?

raddatzk avatar Oct 14 '22 08:10 raddatzk

The behavior of condition evaluation during component scanning is out of Spring Boot's control as it's determined by Spring Framework. Here's a minimal example of the behavior that you have described that doesn't use Spring Boot:

package com.example;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.type.AnnotatedTypeMetadata;

@Configuration
public class MyConfiguration {

	@Configuration
	@Conditional(Disabled.class)
	static class DisabledConfiguration {

		@Configuration
		@Conditional(Enabled.class)
		static class FirstClientConfiguration {

			@Bean
			public MyClient firstClient() {
				return new FirstClient();
			}

		}

		@Configuration
		@Conditional(Disabled.class)
		static class SecondClientConfiguration {

			@Bean
			public MyClient secondClient() {
				return new SecondClient();
			}

		}

	}

	@Configuration
	@Conditional(Enabled.class)
	static class ThirdClientConfiguration {

		@Bean
		public MyClient thirdClient() {
			return new ThirdClient();
		}

	}
	
	static class Enabled implements Condition {

		@Override
		public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
			return true;
		}

	}
	
	static class Disabled implements Condition {

		@Override
		public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
			return false;
		}

	}
	
	public static interface MyClient {

	}
	
	public static class FirstClient implements MyClient {

	}
	
	public static class SecondClient implements MyClient {
		
	}
	
	public static class ThirdClient implements MyClient {
		
	}

}
package com.example;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import com.example.MyConfiguration.MyClient;
import com.example.MyConfiguration.ThirdClient;

@SpringJUnitConfig(MyConfiguration.class)
public class ContextConfigurationTests {

	@Autowired
	ApplicationContext context;

	@Test
	void contextLoads() {
		assertThat(context.getBeansOfType(MyClient.class).size()).isEqualTo(1);
		assertThat(context.getBean(MyClient.class)).isInstanceOf(ThirdClient.class);
	}

}
package com.example;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

import com.example.ComponentScanTests.EnableComponentScan;
import com.example.MyConfiguration.MyClient;
import com.example.MyConfiguration.ThirdClient;

@SpringJUnitConfig(EnableComponentScan.class)
public class ComponentScanTests {

	@Autowired
	ApplicationContext context;

	@Test
	void contextLoads() {
		assertThat(context.getBeansOfType(MyClient.class).size()).isEqualTo(1);
		assertThat(context.getBean(MyClient.class)).isInstanceOf(ThirdClient.class);
	}

	@ComponentScan("com.example")
	static class EnableComponentScan {

	}

}

We'll transfer this issue to the Spring Framework issue tracker so that the Framework team can take a look.

wilkinsona avatar Oct 24 '22 09:10 wilkinsona

As mentioned on #30750, the classpath scan finds the nested classes directly rather than through their containing class. As a consequence, it processes them in the order that it found them in the classpath. Through declaring those nested classes as non-static, classpath scanning does not consider them as independent anymore, so they will actually be processed through their containing class then - with the order for nested classes respected there. You can achieve the same effect by removing the @Configuration stereotype from the nested classes so that classpath scanning does not identify them anymore; this works with static classes as well since they will only be found through their containing class then.

jhoeller avatar Dec 29 '23 21:12 jhoeller