Skip to content

Commit 1d35938

Browse files
SONARJAVA-5975 S6856: Add support for record component extraction (#5520)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent bb49e1a commit 1d35938

File tree

8 files changed

+346
-19
lines changed

8 files changed

+346
-19
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"ruleKey": "S6856",
33
"hasTruePositives": false,
4-
"falseNegatives": 59,
4+
"falseNegatives": 61,
55
"falsePositives": 0
66
}

java-checks-test-sources/default/src/main/java/checks/spring/s6856/MissingPathVariableAnnotationCheck_ModelAttribute.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,22 @@ public class MissingPathVariableAnnotationCheck_ModelAttribute {
1010

1111
class ParentController {
1212
@ModelAttribute("viewCfg")
13-
public String getView(@PathVariable("view") final String view){
13+
public String getView(@PathVariable("view") final String view) {
1414
return "";
1515
}
1616
}
17+
1718
class ChildController extends ParentController {
1819
@GetMapping("/model/{view}") //Compliant, parent class defines 'view' path var in the model attribute
19-
public String list(@ModelAttribute("viewCfg") final String viewConfig){
20+
public String list(@ModelAttribute("viewCfg") final String viewConfig) {
2021
return "";
2122
}
2223
}
24+
2325
class MissingParentChildController extends MissingPathVariableParentInDifferentSample {
2426
@GetMapping("/model/{view}") // Noncompliant
2527
// FP: parent class in different file, cannot collect the model attribute
26-
public String list(@ModelAttribute("parentView") final String viewConfig){
28+
public String list(@ModelAttribute("parentView") final String viewConfig) {
2729
return "";
2830
}
2931
}
@@ -35,7 +37,7 @@ public String getUser(@PathVariable String id, @PathVariable String name) { // a
3537
}
3638

3739
@ModelAttribute("empty")
38-
public String emptyModel(String notPathVariable){
40+
public String emptyModel(String notPathVariable) {
3941
return "";
4042
}
4143

@@ -210,4 +212,15 @@ public String process(
210212
return "result";
211213
}
212214
}
215+
216+
// Test case: Records: must be noncompliant for spring-web < 5.3
217+
record ReportRecord(String project, int year, String month) {
218+
}
219+
220+
static class RecordBinding {
221+
@GetMapping("/reports/{project}/{year}/{month}") // Noncompliant
222+
public String getReport(ReportRecord record) {
223+
return "reportDetails";
224+
}
225+
}
213226
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package checks;
2+
3+
import org.springframework.web.bind.annotation.BindParam;
4+
5+
public class ExtractRecordPropertiesTestData {
6+
// Record with components
7+
record RecordWithComponents(String project, int year, String month) {
8+
}
9+
10+
// Empty record
11+
record EmptyRecord() {
12+
}
13+
14+
// Record with @BindParam annotation
15+
record RecordWithBindParam(@BindParam("order-name") String orderName, String details) {
16+
}
17+
18+
// Record with mixed @BindParam and regular components
19+
record RecordMixedBindParam(
20+
@BindParam("project-id") String projectId,
21+
String name,
22+
@BindParam("user-id") String userId
23+
) {
24+
}
25+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package checks;
2+
3+
import org.springframework.web.bind.annotation.BindParam;
4+
import org.springframework.web.bind.annotation.GetMapping;
5+
6+
public class MissingPathVariableAnnotationCheck_classAndRecord {
7+
static class ReportPeriod {
8+
private String project;
9+
private int year;
10+
private String month;
11+
12+
public String getProject() {
13+
return project;
14+
}
15+
16+
public int getYear() {
17+
return year;
18+
}
19+
20+
public String getMonth() {
21+
return month;
22+
}
23+
24+
public void setProject(String project) {
25+
this.project = project;
26+
}
27+
28+
public void setYear(int year) {
29+
this.year = year;
30+
}
31+
32+
public void setMonth(String month) {
33+
this.month = month;
34+
}
35+
}
36+
37+
record ReportPeriodRecord(String project, int year, String month) {
38+
}
39+
40+
static class ReportPeriodBind {
41+
@GetMapping("/reports/{project}/{year}/{month}")
42+
public String getReport(ReportPeriod period) {
43+
// Spring sees {project} in the URL and calls period.setProject()
44+
// Spring sees {year} in the URL and calls period.setYear()
45+
return "reportDetails";
46+
}
47+
48+
@GetMapping("/reports/{project}/{year}/{month}")
49+
public String getAnotherReport(ReportPeriodRecord period) {
50+
// Spring sees {project} in the URL and calls period.project()
51+
// Spring sees {year} in the URL and calls period.year()
52+
return "reportDetails";
53+
}
54+
55+
public record Order(@BindParam("order-name") String orderName, String details){}
56+
57+
@GetMapping("/{order-name}/details")
58+
public String getOrderDetails(Order order){
59+
// Spring sees {order-name} in the URL and calls order.orderName()
60+
return order.details();
61+
}
62+
63+
@GetMapping("/{orderName}/details") // Noncompliant {{Bind template variable "orderName" to a method parameter.}}
64+
public String getOrderDetailsWrongParameterName(Order order){
65+
// Spring sees {orderName} in the URL and can't find order's orderName because of the wrong binding
66+
return order.details();
67+
}
68+
}
69+
70+
}

java-checks/src/main/java/org/sonar/java/checks/spring/MissingPathVariableAnnotationCheck.java

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@
2121
import java.util.HashSet;
2222
import java.util.List;
2323
import java.util.Map;
24+
import java.util.Optional;
2425
import java.util.Set;
26+
import java.util.function.Function;
2527
import java.util.stream.Collectors;
2628
import java.util.stream.Stream;
2729
import javax.annotation.Nullable;
2830
import org.sonar.check.Rule;
2931
import org.sonar.java.annotations.VisibleForTesting;
32+
import org.sonar.plugins.java.api.DependencyVersionAware;
3033
import org.sonar.plugins.java.api.IssuableSubscriptionVisitor;
34+
import org.sonar.plugins.java.api.Version;
3135
import org.sonar.plugins.java.api.semantic.Symbol;
3236
import org.sonar.plugins.java.api.semantic.SymbolMetadata;
3337
import org.sonar.plugins.java.api.semantic.Type;
@@ -41,7 +45,7 @@
4145
import static org.sonar.java.checks.helpers.MethodTreeUtils.isSetterLike;
4246

4347
@Rule(key = "S6856")
44-
public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisitor {
48+
public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisitor implements DependencyVersionAware {
4549
private static final String PATH_VARIABLE_ANNOTATION = "org.springframework.web.bind.annotation.PathVariable";
4650
private static final String MAP = "java.util.Map";
4751
private static final String MODEL_ATTRIBUTE_ANNOTATION = "org.springframework.web.bind.annotation.ModelAttribute";
@@ -60,6 +64,10 @@ public class MissingPathVariableAnnotationCheck extends IssuableSubscriptionVisi
6064
"lombok.Data",
6165
"lombok.Setter");
6266

67+
private static final String BIND_PARAM_ANNOTATION = "org.springframework.web.bind.annotation.BindParam";
68+
69+
private SpringWebVersion springWebVersion;
70+
6371
@Override
6472
public List<Tree.Kind> nodesToVisit() {
6573
return List.of(Tree.Kind.CLASS);
@@ -191,14 +199,14 @@ private void checkParametersAndPathTemplate(MethodTree method, Set<String> model
191199
return;
192200
}
193201

194-
// finally, we handle the case where a uri parameter (/{aParam}/) doesn't match to path- or ModelAttribute- inherited variables
202+
// finally, we handle the case where a uri parameter (/{aParam}/) doesn't match to path-, ModelAttribute-, or class / record inherited variables
195203
Set<String> allPathVariables = methodParameters.stream()
196204
.map(ParameterInfo::value)
197205
.collect(Collectors.toSet());
198206
// Add properties inherited from @ModelAttribute methods
199207
allPathVariables.addAll(modelAttributeMethodParameters);
200-
// Add properties inherited from @ModelAttribute class parameters
201-
allPathVariables.addAll(extractModelAttributeClassProperties(method));
208+
// Add properties inherited from class and record parameters
209+
allPathVariables.addAll(extractClassAndRecordProperties(method));
202210

203211
templateVariables.stream()
204212
.filter(uri -> !allPathVariables.containsAll(uri.value()))
@@ -278,20 +286,29 @@ private static String removePropertyPlaceholder(String path){
278286
return path.replaceAll(PROPERTY_PLACEHOLDER_PATTERN, "");
279287
}
280288

281-
private static Set<String> extractModelAttributeClassProperties(MethodTree method) {
289+
private boolean requiresModelAttributeAnnotation(SymbolMetadata metadata) {
290+
// for spring-web < 5.3 we need to use ModelAttribute annotation to extract properties from classes / records
291+
return springWebVersion == SpringWebVersion.LESS_THAN_5_3 && !metadata.isAnnotatedWith(MODEL_ATTRIBUTE_ANNOTATION);
292+
}
293+
294+
private Set<String> extractClassAndRecordProperties(MethodTree method) {
282295
Set<String> properties = new HashSet<>();
283296

284297
for (var parameter : method.parameters()) {
285-
SymbolMetadata metadata = parameter.symbol().metadata();
286298
Type parameterType = parameter.type().symbolType();
287-
288-
if (!metadata.isAnnotatedWith(MODEL_ATTRIBUTE_ANNOTATION) || parameterType.isUnknown()
289-
|| isStandardDataType(parameterType) || parameterType.isSubtypeOf(MAP)) {
299+
if (parameterType.isUnknown()
300+
|| isStandardDataType(parameterType) || parameterType.isSubtypeOf(MAP)
301+
|| requiresModelAttributeAnnotation(parameter.symbol().metadata())) {
290302
continue;
291303
}
292304

293-
// Extract setter properties from the class
294-
properties.addAll(extractSetterProperties(parameterType));
305+
if (parameterType.isSubtypeOf("java.lang.Record") && springWebVersion != SpringWebVersion.LESS_THAN_5_3) {
306+
// Extract record's components
307+
properties.addAll(extractRecordProperties(parameterType));
308+
} else if (parameterType.isClass()) {
309+
// Extract setter properties from the class
310+
properties.addAll(extractSetterProperties(parameterType));
311+
}
295312
}
296313

297314
return properties;
@@ -345,6 +362,33 @@ private static Set<String> checkForLombokSetters(Symbol.TypeSymbol typeSymbol) {
345362
return properties;
346363
}
347364

365+
@VisibleForTesting
366+
static Set<String> extractRecordProperties(Type type) {
367+
Set<String> properties = new HashSet<>();
368+
// For records, extract component names from the record components
369+
// Records automatically generate accessor methods for their components
370+
type.symbol().memberSymbols().stream()
371+
.filter(Symbol::isVariableSymbol)
372+
.map(Symbol.VariableSymbol.class::cast)
373+
.filter(f -> !f.isStatic())
374+
.forEach(field -> properties.add(getComponentName(field)));
375+
376+
return properties;
377+
}
378+
379+
private static String getComponentName(Symbol.VariableSymbol field) {
380+
// Check if the component has @BindParam annotation for custom binding name
381+
String componentName = field.name();
382+
var bindParamValues = field.metadata().valuesForAnnotation(BIND_PARAM_ANNOTATION);
383+
if (bindParamValues != null) {
384+
Object value = bindParamValues.get(0).value();
385+
if (value instanceof String bindParamName && !bindParamName.isEmpty()) {
386+
componentName = bindParamName;
387+
}
388+
}
389+
return componentName;
390+
}
391+
348392
static class PathPatternParser {
349393
private PathPatternParser() {
350394
}
@@ -474,4 +518,23 @@ private static String substringToCurrentChar(int start) {
474518
}
475519

476520
}
521+
522+
@Override
523+
public boolean isCompatibleWithDependencies(Function<String, Optional<Version>> dependencyFinder) {
524+
Optional<Version> springWebCurrentVersion = dependencyFinder.apply("spring-web");
525+
if (springWebCurrentVersion.isEmpty()) {
526+
return false;
527+
}
528+
springWebVersion = getSpringWebVersion(springWebCurrentVersion.get());
529+
return true;
530+
}
531+
532+
private static SpringWebVersion getSpringWebVersion(Version springWebVersion) {
533+
return (springWebVersion.isLowerThan("5.3") ? SpringWebVersion.LESS_THAN_5_3 : SpringWebVersion.START_FROM_5_3);
534+
}
535+
536+
private enum SpringWebVersion {
537+
LESS_THAN_5_3,
538+
START_FROM_5_3;
539+
}
477540
}

0 commit comments

Comments
 (0)