Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
import org.sonar.api.batch.fs.InputFile;
import org.sonar.java.model.JUtils;
import org.sonar.java.reporting.AnalyzerMessage;
Expand All @@ -40,6 +41,7 @@

import static org.sonar.java.utils.SpringUtils.collectAutowiredDependenciesOnClass;
import static org.sonar.java.utils.SpringUtils.collectDependenciesOnMethod;
import static org.sonar.java.utils.SpringUtils.composeProfiles;

/**
* Collects Spring bean definitions discovered during AST traversal, and registers them in the
Expand All @@ -55,6 +57,9 @@
* <p>Also captures:
* <ul>
* <li>{@code @Primary} designation</li>
* <li>{@code @Profile} expression, if any; for {@code @Bean} methods, the method's own {@code @Profile}
* is combined with (not overridden by) the one declared on the enclosing {@code @Configuration}/{@code @Component}
* class, since Spring requires both to match for the bean to be active</li>
* <li>Dependencies via {@code @Autowired} fields, constructors, and setters for class-level beans</li>
* <li>Dependencies via method parameters for {@code @Bean} method beans</li>
* <li>Implicit single-constructor injection (no {@code @Autowired} required)</li>
Expand Down Expand Up @@ -84,6 +89,7 @@ record BeanData(
InputFile inputFile,
AnalyzerMessage.TextSpan textSpan,
boolean isPrimary,
@Nullable String profiles,
Map<String, Set<String>> dependingBeans,
Map<String, Set<InjectionPoint>> dependencyInjectionPoints,
Set<String> typeHierarchy) {
Expand Down Expand Up @@ -127,19 +133,21 @@ public void visitNode(Tree tree) {
// also collect their names mapped by type to store in dependingBeans
Map<String, Set<String>> deps = projectToNames(injectionPoints);
Set<String> typeHierarchy = JUtils.collectTypeHierarchy(classTree.symbol());
String classProfiles = SpringUtils.extractProfiles(meta);
var beanData = new BeanData(
beanName, fqn, pkg,
context.getInputFile(),
AnalyzerMessage.textSpanFor(classTree.simpleName()),
meta.isAnnotatedWith(PRIMARY_ANNOTATION),
classProfiles,
deps,
injectionPoints,
typeHierarchy);
collectedBeans.add(beanData);
beansCollectedAtFileLevel.add(beanData);

for (MethodTree method : SpringUtils.getBeanMethods(classTree)) {
collectBeanMethod(method, pkg);
collectBeanMethod(method, pkg, classProfiles);
}
}
}
Expand Down Expand Up @@ -168,7 +176,8 @@ public void gatherSpringContextData(ModuleScannerContext context, SpringContextM
var location = new BeanLocation(data.inputFile(), data.textSpan());
var holderBuilder = new BeanDefinitionHolder.Builder(
data.type(), context.getModuleKey(), data.beanPackage(), location)
.dependingBeans(data.dependingBeans());
.dependingBeans(data.dependingBeans())
.profiles(data.profiles());
if (data.isPrimary()) {
holderBuilder.primary();
}
Expand Down Expand Up @@ -198,8 +207,9 @@ public boolean scanWithoutParsing(InputFileScannerContext ctx) {
*
* @param method The {@code @Bean} factory method to visit
* @param pkg The bean's package (carried through to be stored in BeanData)
* @param classProfiles The {@code @Profile} expression declared on the enclosing class, if any
*/
private void collectBeanMethod(MethodTree method, String pkg) {
private void collectBeanMethod(MethodTree method, String pkg, @Nullable String classProfiles) {
SymbolMetadata beanMeta = method.symbol().metadata();
List<String> beanNames = SpringUtils.extractBeanNameFromMethod(method);

Expand All @@ -215,10 +225,12 @@ private void collectBeanMethod(MethodTree method, String pkg) {
Map<String, Set<InjectionPoint>> injectionPoints = collectDependenciesOnMethod(method, inputFile);
Map<String, Set<String>> paramDeps = projectToNames(injectionPoints);
boolean isPrimary = beanMeta.isAnnotatedWith(PRIMARY_ANNOTATION);
String ownProfiles = SpringUtils.extractProfiles(beanMeta);
String profiles = composeProfiles(classProfiles, ownProfiles);
var textSpan = AnalyzerMessage.textSpanFor(method.simpleName());

for (String beanName : beanNames) {
var beanData = new BeanData(beanName, returnTypeFqn, pkg, inputFile, textSpan, isPrimary, paramDeps, injectionPoints, typeHierarchy);
var beanData = new BeanData(beanName, returnTypeFqn, pkg, inputFile, textSpan, isPrimary, profiles, paramDeps, injectionPoints, typeHierarchy);
collectedBeans.add(beanData);
beansCollectedAtFileLevel.add(beanData);
}
Expand All @@ -236,4 +248,5 @@ static Map<String, Set<String>> projectToNames(Map<String, Set<InjectionPoint>>
return names;
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ public class BeanDefinitionHolder {
*/
private Map<String, Set<String>> dependingBeans;

/** Comma-separated Spring profile expressions under which this bean is active, or {@code null} if unconditional. */
/**
* Spring profile expression under which this bean is active, or {@code null} if unconditional.
* Comma-separated values within one {@code @Profile} annotation are OR-ed (as Spring does);
* a class-level and a {@code @Bean} method-level {@code @Profile} are AND-ed by joining their
* (already OR-ed) expressions with a semicolon, since Spring requires both to match.
*/
@Nullable
private String profiles;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,9 @@ static Optional<List<BeanData>> readBeanDefinitionsFromCache(InputFileScannerCon
/**
* Serializes one bean into a single "|"-delimited line, reversed by {@link #deserializeBean}.
*
* Any string sourced from user code (bean name, dependency type keys, injection point names) is
* Base64-encoded first, since {@code |}, {@code :}, {@code ,}, {@code ;} or {@code #} could otherwise
* appear in an identifier and be mistaken for a field/entry separator.
* Any string sourced from user code (bean name, {@code @Profile} expression, dependency type keys,
* injection point names) is Base64-encoded first, since {@code |}, {@code :}, {@code ,}, {@code ;} or
* {@code #} could otherwise appear in an identifier and be mistaken for a field/entry separator.
*
* @param bean The bean to serialize
* @return The bean encoded as a single "|"-delimited line
Expand All @@ -172,12 +172,16 @@ private static String serializeBean(BeanData bean) {
var typeHierarchy = String.join(TYPE_HIERARCHY_SEPARATOR, bean.typeHierarchy());
var span = bean.textSpan();
var encodedName = Base64.getEncoder().encodeToString(bean.beanName().getBytes(StandardCharsets.UTF_8));
var encodedProfiles = bean.profiles() != null
? Base64.getEncoder().encodeToString(bean.profiles().getBytes(StandardCharsets.UTF_8))
: "";
return String.join(FIELD_SEPARATOR,
encodedName,
bean.type(),
bean.beanPackage(),
span.startLine + ":" + span.startCharacter + ":" + span.endLine + ":" + span.endCharacter,
Boolean.toString(bean.isPrimary()),
encodedProfiles,
deps,
typeHierarchy);
}
Expand Down Expand Up @@ -212,9 +216,12 @@ private static BeanData deserializeBean(String line, InputFile inputFile) {
Integer.parseInt(spanParts[2]),
Integer.parseInt(spanParts[3]));
boolean isPrimary = Boolean.parseBoolean(fields[4]);
String profiles = !fields[5].isEmpty()
? new String(Base64.getDecoder().decode(fields[5]), StandardCharsets.UTF_8)
: null;
Map<String, Set<InjectionPoint>> injectionPoints = new LinkedHashMap<>();
if (!fields[5].isEmpty()) {
for (String entry : fields[5].split(DEP_SEPARATOR)) {
if (!fields[6].isEmpty()) {
for (String entry : fields[6].split(DEP_SEPARATOR)) {
// indexOf is safe: Base64 output never contains ':', so the first ':' is unambiguously the key/value boundary.
int idx = entry.indexOf(DEP_KEY_VALUE_SEPARATOR);
String typeFqn = new String(Base64.getDecoder().decode(entry.substring(0, idx)), StandardCharsets.UTF_8);
Expand All @@ -225,10 +232,10 @@ private static BeanData deserializeBean(String line, InputFile inputFile) {
}
}
Map<String, Set<String>> deps = BeanDefinitionGatherer.projectToNames(injectionPoints);
Set<String> typeHierarchy = !fields[6].isEmpty()
? new LinkedHashSet<>(List.of(fields[6].split(TYPE_HIERARCHY_SEPARATOR)))
Set<String> typeHierarchy = !fields[7].isEmpty()
? new LinkedHashSet<>(List.of(fields[7].split(TYPE_HIERARCHY_SEPARATOR)))
: new LinkedHashSet<>();
return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, deps, injectionPoints, typeHierarchy);
return new BeanData(beanName, type, beanPackage, inputFile, textSpan, isPrimary, profiles, deps, injectionPoints, typeHierarchy);
}

/** Reverse of {@link #encodeInjectionPoint}. */
Expand Down
53 changes: 49 additions & 4 deletions java-frontend/src/main/java/org/sonar/java/utils/SpringUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,16 @@ public final class SpringUtils {
public static final String BEAN_ANNOTATION = "org.springframework.context.annotation.Bean";
public static final String SCOPE_ANNOTATION = "org.springframework.context.annotation.Scope";
public static final String CONFIGURATION_ANNOTATION = "org.springframework.context.annotation.Configuration";
public static final String PROFILE_ANNOTATION = "org.springframework.context.annotation.Profile";
public static final String ASYNC_ANNOTATION = "org.springframework.scheduling.annotation.Async";
public static final String DATA_REPOSITORY_ANNOTATION = "org.springframework.data.repository.Repository";
public static final String REST_CONTROLLER_ANNOTATION = "org.springframework.web.bind.annotation.RestController";
public static final String SPRING_BOOT_TEST_ANNOTATION = "org.springframework.boot.test.context.SpringBootTest";

private static final String VALUE_ATTRIBUTE = "value";
private static final String PROFILE_SEPARATOR = ",";
/** Joins the class-level and method-level {@code @Profile} expressions of a {@code @Bean} method, which are AND-ed together by Spring. */
private static final String PROFILE_AND_SEPARATOR = ";";

public static final List<String> STEREOTYPE_ANNOTATIONS = List.of(
COMPONENT_ANNOTATION,
Expand Down Expand Up @@ -119,7 +123,7 @@ public static List<MethodTree> getBeanMethods(ClassTree classTree) {
* Extracts the bean name from whichever stereotype annotation is present on the bean definition,
* falling back to the decapitalized simple name for an unnamed bean.
*
* @param meta The symbol metadata of the class declaring the bean
* @param meta The symbol metadata of the class declaring the bean
* @param simpleName The simple name of the class declaring the bean
* @return The resolved bean name
*/
Expand Down Expand Up @@ -183,7 +187,7 @@ public static String extractQualifierValue(SymbolMetadata metadata) {

/**
* Collects a class-level bean's dependencies from {@code @Autowired} fields, constructors and setters.
*
* <p>
* Also applies Spring's implicit single-constructor injection if no constructor is {@code @Autowired}
* and the class declares exactly one constructor. {@code hasAutowiredConstructor} guards against
* misapplying that fallback when an {@code @Autowired} constructor already exists alongside other,
Expand Down Expand Up @@ -223,8 +227,8 @@ public static Map<String, Set<InjectionPoint>> collectAutowiredDependenciesOnCla
/**
* Collect the given method's parameters as dependencies.
*
* @param method Method whose parameters are stored as dependencies, either {@code @Autowired} constructors/setters or
* {@code @Bean} factory methods
* @param method Method whose parameters are stored as dependencies, either {@code @Autowired} constructors/setters or
* {@code @Bean} factory methods
* @param inputFile The file {@code method} was parsed from, used to locate each injection point
* @return The collected dependencies, mapped by required type FQN to the {@link InjectionPoint}s that require it
*/
Expand All @@ -243,4 +247,45 @@ private static String dependencyKey(String fieldOrParamName, @Nullable String qu
return qualifier != null ? qualifier : fieldOrParamName;
}

/**
* Reads the {@code @Profile} annotation's "value" attribute, joining every profile name it lists with ",".
*
* @param metadata The symbol metadata of the class or {@code @Bean} method to check for a {@code @Profile}
* @return The joined profile expression, or {@code null} if none is declared
*/
@Nullable
public static String extractProfiles(SymbolMetadata metadata) {
Comment thread
asya-vorobeva marked this conversation as resolved.
List<SymbolMetadata.AnnotationValue> attrs = metadata.valuesForAnnotation(PROFILE_ANNOTATION);
List<String> profiles = attrs == null ? List.of() : attrs.stream()
.filter(attr -> VALUE_ATTRIBUTE.equals(attr.name()))
.filter(attr -> attr.value() instanceof Object[])
.flatMap(attr -> Arrays.stream((Object[]) attr.value()))
.filter(String.class::isInstance)
.map(String.class::cast)
.filter(profile -> !profile.isBlank())
.toList();
return profiles.isEmpty() ? null : String.join(PROFILE_SEPARATOR, profiles);
}

/**
* Combines a {@code @Bean} method's own {@code @Profile} with the one declared on its enclosing class.
*
* Spring requires both to match for the bean to be active, so the two expressions are AND-ed rather
* than one overriding the other.
*
* @param classProfiles Profile(s) of the enclosing class
* @param ownProfiles Profile(s) defined on the bean itself
* @return a semicolon-separated list of all the profiles
*/
@Nullable
public 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;
}

}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;

@Configuration
class ConfigurationWithBeanMethods {
Expand All @@ -12,6 +13,12 @@ ApplicationContext simpleServiceBean() {
return null;
}

@Profile("test")
@Bean
ApplicationContext methodOnlyProfileBean() {
return null;
}

@Bean(name = "namedBean")
ApplicationContext namedBeanMethod() {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

@Profile("prod")
@Component
class QualifiedFieldDependencies {

Expand Down
Loading
Loading