Skip to content

SONARJAVA-6421 Extract @Profile expressions when gathering bean definitions - #6071

Open
NoemieBenard wants to merge 4 commits into
nb/sonarjava-6889-extend-modelfrom
nb/sonarjava-6421-add-profile-support
Open

SONARJAVA-6421 Extract @Profile expressions when gathering bean definitions#6071
NoemieBenard wants to merge 4 commits into
nb/sonarjava-6889-extend-modelfrom
nb/sonarjava-6421-add-profile-support

Conversation

@NoemieBenard

@NoemieBenard NoemieBenard commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • BeanDefinitionGatherer now extracts @Profile expressions (single or array-valued) from stereotype-annotated classes and @Bean methods, storing them on BeanDefinitionHolder via the existing profiles(...) builder step.
  • For @Bean methods, the method's own @Profile takes precedence over the one declared on the enclosing @Configuration/@Component class; if the method has none, it inherits the class's.
  • Updates the bean-definition cache serialization format to persist the new field (profiles inserted right after isPrimary).

@hashicorp-vault-sonar-prod

hashicorp-vault-sonar-prod Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

SONARJAVA-6421

Comment on lines +470 to +479
@Nullable
private static String composeProfiles(@Nullable String classProfiles, @Nullable String ownProfiles) {
if (classProfiles == null) {
return ownProfiles;
}
if (ownProfiles == null) {
return classProfiles;
}
return classProfiles + PROFILE_AND_SEPARATOR + ownProfiles;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Quality: composeProfiles branch for method-only @Profile is untested

composeProfiles has three branches, but the parameterized profileArguments cases only exercise class-only (inheritedProfileBean → "prod"), both-present (ownProfileBean → "prod;test") and both-absent (simpleComponent → null); no test resource declares a @Profile on a @Bean method inside a class without a class-level @Profile (grep over src/test/files/springcontext shows @Profile only in ProfiledComponent, MultiProfileComponent and ProfiledConfigurationWithBeanMethods, the latter always class-annotated). The classProfiles == null && ownProfiles != null path — the common Spring pattern of an unprofiled @Configuration with profile-gated @Bean methods — is therefore uncovered, so a future regression that drops the method-level expression in that case would not be caught. Add a @Bean-level-only @Profile fixture and a corresponding argument row.

Add a fixture with a method-level-only @Profile and assert the composed value is the method's own expression.:

// src/test/files/springcontext/ConfigurationWithProfiledBeanMethod.java
@Configuration
class ConfigurationWithProfiledBeanMethod {
  @Profile("test")
  @Bean
  ApplicationContext methodOnlyProfileBean() { return null; }
}

// BeanDefinitionGathererTest#profileArguments
// @Bean method's own @Profile is kept when the enclosing class has none
Arguments.of("src/test/files/springcontext/ConfigurationWithProfiledBeanMethod.java", "methodOnlyProfileBean", "test")
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

.orElse(null);
}

@Nullable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as for PR #6068. After extracting this method please add comment.

@asya-vorobeva asya-vorobeva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Profile annotation luckily does not evaluate SpEL expressions. But it supports syntax like this:
@Profile("dev & !test")

More precisely, it supports !, &, | operators.
Would be great to add support for such evaluation. But I'd suggest to do it in separate PR.

@NoemieBenard
NoemieBenard marked this pull request as ready for review September 3, 2026 09:53
@NoemieBenard
NoemieBenard force-pushed the nb/sonarjava-6421-add-profile-support branch from 3ab3792 to d9a3dc1 Compare September 3, 2026 11:55
@gitar-bot

gitar-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 2 resolved / 5 findings

Adds extraction of @Profile expressions from stereotype-annotated classes and @Bean methods, storing them on BeanDefinitionHolder and persisting them in the bean-definition cache. Method-level @Profile takes precedence over class-level.

Consider adding test coverage for the @Bean-method-only @Profile case (unprofiled @Configuration with profile-gated methods), and clarify the extractBeanName javadoc to remove the nonexistent name attribute example and the serializeBean javadoc to precisely list which fields are Base64-encoded versus written unencoded.

💡 Quality: composeProfiles branch for method-only @Profile is untested

📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:470-479 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:160-170 📄 java-frontend/src/test/files/springcontext/ProfiledConfigurationWithBeanMethods.java:8-21

composeProfiles has three branches, but the parameterized profileArguments cases only exercise class-only (inheritedProfileBean → "prod"), both-present (ownProfileBean → "prod;test") and both-absent (simpleComponent → null); no test resource declares a @Profile on a @Bean method inside a class without a class-level @Profile (grep over src/test/files/springcontext shows @Profile only in ProfiledComponent, MultiProfileComponent and ProfiledConfigurationWithBeanMethods, the latter always class-annotated). The classProfiles == null && ownProfiles != null path — the common Spring pattern of an unprofiled @Configuration with profile-gated @Bean methods — is therefore uncovered, so a future regression that drops the method-level expression in that case would not be caught. Add a @Bean-level-only @Profile fixture and a corresponding argument row.

Add a fixture with a method-level-only @Profile and assert the composed value is the method's own expression.
// src/test/files/springcontext/ConfigurationWithProfiledBeanMethod.java
@Configuration
class ConfigurationWithProfiledBeanMethod {
  @Profile("test")
  @Bean
  ApplicationContext methodOnlyProfileBean() { return null; }
}

// BeanDefinitionGathererTest#profileArguments
// @Bean method's own @Profile is kept when the enclosing class has none
Arguments.of("src/test/files/springcontext/ConfigurationWithProfiledBeanMethod.java", "methodOnlyProfileBean", "test")
💡 Quality: extractBeanName javadoc documents a nonexistent name attribute

📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:320-334

The new javadoc states that stereotype bean names are read from both the "value" and the "name" attribute and gives @Controller(name = "foo") as an example, but Spring's @Component/@Service/@Repository/@Controller/@RestController declare only value(), so that example does not compile and the "name".equals(v.name()) filter can never match for the annotations in SpringUtils.STEREOTYPE_ANNOTATIONS (only @Bean, handled separately in collectBeanMethodAttributeNames, has a name attribute — and no test resource uses name = on a stereotype). The doc therefore describes behavior the framework does not support and points a reader at dead code; drop the name alternative or correct the example to @Component("foo").

Fix the javadoc example and drop the unreachable "name" attribute filter for stereotype annotations.
/**
 * Finds an explicit bean name from whichever stereotype annotation is present, reading its "value"
 * attribute (e.g. {@code @Component("foo")}) — the only name attribute Spring's stereotype annotations declare.
 * A class only ever carries one stereotype annotation in valid code, but
 * this loops over all of them defensively rather than assuming which one is present.
 */
private static Optional<String> extractBeanName(SymbolMetadata meta) {
  for (String annotation : SpringUtils.STEREOTYPE_ANNOTATIONS) {
    List<SymbolMetadata.AnnotationValue> attrs = meta.valuesForAnnotation(annotation);
    if (attrs != null) {
      Optional<String> name = attrs.stream()
        .filter(v -> VALUE_ATTRIBUTE.equals(v.name()))
💡 Quality: serializeBean javadoc contradicts what the method actually encodes

📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:198-212

The added javadoc asserts that "any string sourced from user code ... is Base64-encoded first" and then lists bean name, dependency type keys and injection point names — but the method writes bean.type(), bean.beanPackage() and the ;-joined typeHierarchy unencoded, and omits the new profiles field, which this PR does Base64-encode (line 203-205). A maintainer adding a field will read the blanket claim as an invariant that is not upheld; list the encoded fields precisely (name, profiles, dependency type keys, injection point names) and note that type/package/hierarchy are safe unencoded because FQNs cannot contain the separators.

Make the javadoc match the fields the method actually encodes, including the new profiles field.
/**
 * Serializes one bean into a single "|"-delimited line. Strings that may contain a separator character
 * ({@code |}, {@code :}, {@code ,}, {@code ;} or {@code #}) are Base64-encoded first: the bean name, the
 * {@code @Profile} expression, the dependency type keys and the injection point names. Type FQNs, the
 * package and the type hierarchy are written as-is, since a fully-qualified name cannot contain any of
 * those characters.
 */
✅ 2 resolved
Quality: Profiles cache round-trip is untested (non-empty field never exercised)

📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:196-198 📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:282-284 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:372 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:492 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:566 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:755
Every cache test in the suite was updated with an empty profiles field (|false|||...), and the parse-based profile_annotation_is_captured tests never touch the cache, so the new non-null branches — Base64 encoding at lines 196-198 and Base64 decoding at lines 282-284 — are never executed by any test. A regression in the encode/decode pair (e.g. a field-order mistake) would ship green. Add one round-trip case with a real profile value.

Bug: Class-level @Profile is discarded when @bean method has its own

📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:64-65 📄 java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:365-366 📄 java-frontend/src/test/files/springcontext/ProfiledConfigurationWithBeanMethods.java:8-21 📄 java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:166-168
In Spring, a class-level @Profile and a method-level @Profile are AND-ed: ConfigurationClassParser skips the whole @Configuration class when the class condition does not match, so the @Bean method's condition is additive, never an override. With the new fixture ProfiledConfigurationWithBeanMethods, ownProfileBean is stored with profiles = "test" (line 366 picks ownProfiles and drops classProfiles), but the bean is actually only registered when both prod and test are active — so a consumer of BeanDefinitionHolder.getProfiles() will consider it a live candidate under a test-only profile set, which it never is. Compose the two conditions instead of overriding (and update the javadoc at lines 64-65 accordingly).

🤖 Prompt for agents
Code Review: Adds extraction of `@Profile` expressions from stereotype-annotated classes and `@Bean` methods, storing them on `BeanDefinitionHolder` and persisting them in the bean-definition cache. Method-level `@Profile` takes precedence over class-level.
  
  Consider adding test coverage for the `@Bean`-method-only `@Profile` case (unprofiled `@Configuration` with profile-gated methods), and clarify the `extractBeanName` javadoc to remove the nonexistent `name` attribute example and the `serializeBean` javadoc to precisely list which fields are Base64-encoded versus written unencoded.

1. 💡 Quality: composeProfiles branch for method-only @Profile is untested
   Files: java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:470-479, java-frontend/src/test/java/org/sonar/java/model/springcontext/BeanDefinitionGathererTest.java:160-170, java-frontend/src/test/files/springcontext/ProfiledConfigurationWithBeanMethods.java:8-21

   `composeProfiles` has three branches, but the parameterized `profileArguments` cases only exercise class-only (`inheritedProfileBean` → "prod"), both-present (`ownProfileBean` → "prod;test") and both-absent (`simpleComponent` → null); no test resource declares a `@Profile` on a `@Bean` method inside a class without a class-level `@Profile` (grep over `src/test/files/springcontext` shows `@Profile` only in ProfiledComponent, MultiProfileComponent and ProfiledConfigurationWithBeanMethods, the latter always class-annotated). The `classProfiles == null && ownProfiles != null` path — the common Spring pattern of an unprofiled `@Configuration` with profile-gated `@Bean` methods — is therefore uncovered, so a future regression that drops the method-level expression in that case would not be caught. Add a `@Bean`-level-only `@Profile` fixture and a corresponding argument row.

   Fix (Add a fixture with a method-level-only @Profile and assert the composed value is the method's own expression.):
   // src/test/files/springcontext/ConfigurationWithProfiledBeanMethod.java
   @Configuration
   class ConfigurationWithProfiledBeanMethod {
     @Profile("test")
     @Bean
     ApplicationContext methodOnlyProfileBean() { return null; }
   }
   
   // BeanDefinitionGathererTest#profileArguments
   // @Bean method's own @Profile is kept when the enclosing class has none
   Arguments.of("src/test/files/springcontext/ConfigurationWithProfiledBeanMethod.java", "methodOnlyProfileBean", "test")

2. 💡 Quality: extractBeanName javadoc documents a nonexistent `name` attribute
   Files: java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:320-334

   The new javadoc states that stereotype bean names are read from both the "value" and the "name" attribute and gives `@Controller(name = "foo")` as an example, but Spring's `@Component`/`@Service`/`@Repository`/`@Controller`/`@RestController` declare only `value()`, so that example does not compile and the `"name".equals(v.name())` filter can never match for the annotations in `SpringUtils.STEREOTYPE_ANNOTATIONS` (only `@Bean`, handled separately in `collectBeanMethodAttributeNames`, has a `name` attribute — and no test resource uses `name =` on a stereotype). The doc therefore describes behavior the framework does not support and points a reader at dead code; drop the `name` alternative or correct the example to `@Component("foo")`.

   Fix (Fix the javadoc example and drop the unreachable "name" attribute filter for stereotype annotations.):
   /**
    * Finds an explicit bean name from whichever stereotype annotation is present, reading its "value"
    * attribute (e.g. {@code @Component("foo")}) — the only name attribute Spring's stereotype annotations declare.
    * A class only ever carries one stereotype annotation in valid code, but
    * this loops over all of them defensively rather than assuming which one is present.
    */
   private static Optional<String> extractBeanName(SymbolMetadata meta) {
     for (String annotation : SpringUtils.STEREOTYPE_ANNOTATIONS) {
       List<SymbolMetadata.AnnotationValue> attrs = meta.valuesForAnnotation(annotation);
       if (attrs != null) {
         Optional<String> name = attrs.stream()
           .filter(v -> VALUE_ATTRIBUTE.equals(v.name()))

3. 💡 Quality: serializeBean javadoc contradicts what the method actually encodes
   Files: java-frontend/src/main/java/org/sonar/java/model/springcontext/BeanDefinitionGatherer.java:198-212

   The added javadoc asserts that "any string sourced from user code ... is Base64-encoded first" and then lists bean name, dependency type keys and injection point names — but the method writes `bean.type()`, `bean.beanPackage()` and the `;`-joined `typeHierarchy` unencoded, and omits the new `profiles` field, which this PR does Base64-encode (line 203-205). A maintainer adding a field will read the blanket claim as an invariant that is not upheld; list the encoded fields precisely (name, profiles, dependency type keys, injection point names) and note that type/package/hierarchy are safe unencoded because FQNs cannot contain the separators.

   Fix (Make the javadoc match the fields the method actually encodes, including the new profiles field.):
   /**
    * Serializes one bean into a single "|"-delimited line. Strings that may contain a separator character
    * ({@code |}, {@code :}, {@code ,}, {@code ;} or {@code #}) are Base64-encoded first: the bean name, the
    * {@code @Profile} expression, the dependency type keys and the injection point names. Type FQNs, the
    * package and the type hierarchy are written as-is, since a fully-qualified name cannot contain any of
    * those characters.
    */

Options

Auto-apply is off → Gitar will not commit updates to this branch.
Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Auto-apply Compact
gitar auto-apply:on         
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar

@sonarqube-next

sonarqube-next Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Quality Gate failed Quality Gate failed

Failed conditions
Vulnerability dependency risks too severe (required < 'medium' severity)

See analysis details on SonarQube


/** Reads the {@code @Profile} annotation's "value" attribute, joining every profile name it lists. */
@Nullable
private static String extractProfiles(SymbolMetadata metadata) {

@asya-vorobeva asya-vorobeva Sep 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as for PR #6068. Instead of making this class more and more unreadable, let's extract these methods and related machinery to SpringUtils. And also let's make documentation standard. Moreover, another benefit of extraction is that you can test methods independently in SpringUtilsTest class (which also will provide additional documentation).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants