Skip to content

Commit 87b9f11

Browse files
timtebeekclaudegithub-actions[bot]
authored
Add annotation support to ChangeSpringPropertyValue (#907)
* Add annotation support to ChangeSpringPropertyValue Extend ChangeSpringPropertyValue to change property values in Java/Kotlin annotations, mirroring the pattern used in ChangeSpringPropertyKey. Supported annotations: - @value("${key:defaultValue}") - changes the defaultValue after the colon - @ConditionalOnProperty(name="key", havingValue="val") - changes havingValue - @SpringBootTest(properties="key=value") - changes the value after = - @TestPropertySource(properties="key=value") - changes the value after = Features: - Relaxed binding support for property key matching - Regex replacement support with capture groups - Proper escaping preservation for Kotlin's \$ syntax - Idempotent transformations (won't change already-matching values) Co-Authored-By: Claude Opus 4.5 <[email protected]> * Apply suggestions from code review Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Prune dead code paths * No else after return * Logically group tests * Logically group methods --------- Co-authored-by: Claude Opus 4.5 <[email protected]> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 54d8115 commit 87b9f11

File tree

2 files changed

+603
-43
lines changed

2 files changed

+603
-43
lines changed

src/main/java/org/openrewrite/java/spring/ChangeSpringPropertyValue.java

Lines changed: 257 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@
1919
import lombok.Value;
2020
import org.jspecify.annotations.Nullable;
2121
import org.openrewrite.*;
22+
import org.openrewrite.internal.ListUtils;
23+
import org.openrewrite.internal.NameCaseConvention;
2224
import org.openrewrite.internal.StringUtils;
25+
import org.openrewrite.java.AnnotationMatcher;
26+
import org.openrewrite.java.JavaIsoVisitor;
27+
import org.openrewrite.java.search.UsesType;
28+
import org.openrewrite.java.tree.Expression;
29+
import org.openrewrite.java.tree.J;
30+
import org.openrewrite.java.tree.JavaSourceFile;
31+
import org.openrewrite.kotlin.tree.K;
2332
import org.openrewrite.properties.tree.Properties;
2433
import org.openrewrite.yaml.tree.Yaml;
2534

35+
import java.util.regex.Matcher;
2636
import java.util.regex.Pattern;
2737

2838
@EqualsAndHashCode(callSuper = false)
@@ -31,7 +41,8 @@ public class ChangeSpringPropertyValue extends Recipe {
3141

3242
String displayName = "Change the value of a spring application property";
3343

34-
String description = "Change spring application property values existing in either Properties or Yaml files.";
44+
String description = "Change Spring application property values existing in either Properties or YAML files, " +
45+
"and in `@Value`, `@ConditionalOnProperty`, `@SpringBootTest`, or `@TestPropertySource` annotations.";
3546

3647
@Option(displayName = "Property key",
3748
description = "The name of the property key whose value is to be changed.",
@@ -75,13 +86,21 @@ public TreeVisitor<?, ExecutionContext> getVisitor() {
7586
Recipe changeProperties = new org.openrewrite.properties.ChangePropertyValue(propertyKey, newValue, oldValue, regex, relaxedBinding);
7687
String yamlValue = quoteValue(newValue) ? "\"" + newValue + "\"" : newValue;
7788
Recipe changeYaml = new org.openrewrite.yaml.ChangePropertyValue(propertyKey, yamlValue, oldValue, regex, relaxedBinding, null);
89+
TreeVisitor<?, ExecutionContext> javaVisitor = Preconditions.check(Preconditions.or(
90+
new UsesType<>("org.springframework.beans.factory.annotation.Value", false),
91+
new UsesType<>("org.springframework.boot.autoconfigure.condition.ConditionalOnProperty", false),
92+
new UsesType<>("org.springframework.boot..*Test", false),
93+
new UsesType<>("org.springframework.test.context.TestPropertySource", false)
94+
), new JavaPropertyValueVisitor());
7895
return new TreeVisitor<Tree, ExecutionContext>() {
7996
@Override
8097
public @Nullable Tree visit(@Nullable Tree tree, ExecutionContext ctx) {
8198
if (tree instanceof Properties.File) {
8299
tree = changeProperties.getVisitor().visit(tree, ctx);
83100
} else if (tree instanceof Yaml.Documents) {
84101
tree = changeYaml.getVisitor().visit(tree, ctx);
102+
} else if (tree instanceof JavaSourceFile) {
103+
tree = javaVisitor.visit(tree, ctx);
85104
}
86105
return tree;
87106
}
@@ -92,4 +111,241 @@ public TreeVisitor<?, ExecutionContext> getVisitor() {
92111
private boolean quoteValue(String value) {
93112
return scalarNeedsAQuote.matcher(value).matches();
94113
}
114+
115+
private class JavaPropertyValueVisitor extends JavaIsoVisitor<ExecutionContext> {
116+
private final AnnotationMatcher VALUE_MATCHER =
117+
new AnnotationMatcher("@org.springframework.beans.factory.annotation.Value");
118+
private final AnnotationMatcher CONDITIONAL_ON_PROPERTY_MATCHER =
119+
new AnnotationMatcher("@org.springframework.boot.autoconfigure.condition.ConditionalOnProperty");
120+
private final AnnotationMatcher SPRING_BOOT_TEST_MATCHER =
121+
new AnnotationMatcher("@org.springframework.boot..*Test");
122+
private final AnnotationMatcher TEST_PROPERTY_SOURCE_MATCHER =
123+
new AnnotationMatcher("@org.springframework.test.context.TestPropertySource");
124+
125+
// Pattern to match ${key:defaultValue} in @Value annotations
126+
private final Pattern valueAnnotationPattern = Pattern.compile("\\$\\{([^:}]+)(?::([^}]*))?\\}");
127+
// Pattern to match key=value in test annotations
128+
private final Pattern keyValuePattern = Pattern.compile("^([^=]+)=(.*)$");
129+
130+
@Override
131+
public J.Annotation visitAnnotation(J.Annotation annotation, ExecutionContext ctx) {
132+
J.Annotation a = super.visitAnnotation(annotation, ctx);
133+
134+
if (VALUE_MATCHER.matches(a)) {
135+
a = handleValueAnnotation(a);
136+
} else if (CONDITIONAL_ON_PROPERTY_MATCHER.matches(a)) {
137+
a = handleConditionalOnPropertyAnnotation(a);
138+
} else if (SPRING_BOOT_TEST_MATCHER.matches(a) || TEST_PROPERTY_SOURCE_MATCHER.matches(a)) {
139+
a = handleTestPropertiesAnnotation(a);
140+
}
141+
142+
return a;
143+
}
144+
145+
private J.Annotation handleValueAnnotation(J.Annotation annotation) {
146+
return annotation.withArguments(ListUtils.map(annotation.getArguments(), arg -> {
147+
if (arg instanceof J.Literal) {
148+
return changeValueInValueAnnotation((J.Literal) arg);
149+
}
150+
return arg;
151+
}));
152+
}
153+
154+
private J.Literal changeValueInValueAnnotation(J.Literal literal) {
155+
if (!(literal.getValue() instanceof String)) {
156+
return literal;
157+
}
158+
String value = (String) literal.getValue();
159+
String valueSource = literal.getValueSource();
160+
Matcher matcher = valueAnnotationPattern.matcher(value);
161+
162+
boolean changed = false;
163+
StringBuilder newValue = new StringBuilder();
164+
int lastEnd = 0;
165+
166+
while (matcher.find()) {
167+
String key = matcher.group(1);
168+
String defaultValue = matcher.group(2);
169+
170+
if (matchesPropertyKey(key) && defaultValue != null && matchesOldValue(defaultValue)) {
171+
String computedNewValue = computeNewValue(defaultValue);
172+
if (!computedNewValue.equals(defaultValue)) {
173+
newValue.append(value, lastEnd, matcher.start());
174+
newValue.append("${").append(key).append(":").append(computedNewValue).append("}");
175+
lastEnd = matcher.end();
176+
177+
// Also update valueSource by replacing the old default with new default
178+
// This preserves all other escaping (e.g., \$ in Kotlin)
179+
if (valueSource != null) {
180+
valueSource = valueSource.replace(defaultValue, computedNewValue);
181+
}
182+
183+
changed = true;
184+
}
185+
}
186+
}
187+
188+
if (changed) {
189+
newValue.append(value, lastEnd, value.length());
190+
String newValueStr = newValue.toString();
191+
if (valueSource == null) {
192+
valueSource = "\"" + newValueStr + "\"";
193+
}
194+
return literal.withValue(newValueStr).withValueSource(valueSource);
195+
}
196+
return literal;
197+
}
198+
199+
private J.Annotation handleConditionalOnPropertyAnnotation(J.Annotation annotation) {
200+
if (annotation.getArguments() == null) {
201+
return annotation;
202+
}
203+
204+
// First, find the property key from 'name' or 'value' attribute
205+
String foundKey = null;
206+
for (Expression arg : annotation.getArguments()) {
207+
if (arg instanceof J.Assignment) {
208+
J.Assignment assignment = (J.Assignment) arg;
209+
String attrName = ((J.Identifier) assignment.getVariable()).getSimpleName();
210+
if ("name".equals(attrName) || "value".equals(attrName)) {
211+
if (assignment.getAssignment() instanceof J.Literal) {
212+
Object val = ((J.Literal) assignment.getAssignment()).getValue();
213+
if (val instanceof String) {
214+
foundKey = (String) val;
215+
break;
216+
}
217+
}
218+
}
219+
} else if (arg instanceof J.Literal && ((J.Literal) arg).getValue() instanceof String) {
220+
// First unnamed argument is the property name
221+
foundKey = (String) ((J.Literal) arg).getValue();
222+
break;
223+
}
224+
}
225+
226+
if (foundKey == null || !matchesPropertyKey(foundKey)) {
227+
return annotation;
228+
}
229+
230+
// Now change the 'havingValue' attribute
231+
return annotation.withArguments(ListUtils.map(annotation.getArguments(), arg -> {
232+
if (arg instanceof J.Assignment) {
233+
J.Assignment assignment = (J.Assignment) arg;
234+
String attrName = ((J.Identifier) assignment.getVariable()).getSimpleName();
235+
if ("havingValue".equals(attrName)) {
236+
if (assignment.getAssignment() instanceof J.Literal) {
237+
J.Literal literal = (J.Literal) assignment.getAssignment();
238+
J.Literal newLiteral = changeValueInLiteral(literal);
239+
if (newLiteral != literal) {
240+
return assignment.withAssignment(newLiteral);
241+
}
242+
}
243+
}
244+
}
245+
return arg;
246+
}));
247+
}
248+
249+
private J.Literal changeValueInLiteral(J.Literal literal) {
250+
String value = (String) literal.getValue();
251+
if (matchesOldValue(value)) {
252+
String computedNewValue = computeNewValue(value);
253+
if (!computedNewValue.equals(value)) {
254+
return updateLiteral(literal, computedNewValue);
255+
}
256+
}
257+
return literal;
258+
}
259+
260+
private J.Annotation handleTestPropertiesAnnotation(J.Annotation annotation) {
261+
return annotation.withArguments(ListUtils.map(annotation.getArguments(), arg -> {
262+
if (arg instanceof J.Assignment) {
263+
J.Assignment assignment = (J.Assignment) arg;
264+
String attrName = ((J.Identifier) assignment.getVariable()).getSimpleName();
265+
if ("properties".equals(attrName)) {
266+
if (assignment.getAssignment() instanceof J.Literal) {
267+
J.Literal literal = (J.Literal) assignment.getAssignment();
268+
J.Literal newLiteral = changeValueInTestProperty(literal);
269+
return assignment.withAssignment(newLiteral);
270+
}
271+
if (assignment.getAssignment() instanceof J.NewArray) {
272+
J.NewArray array = (J.NewArray) assignment.getAssignment();
273+
return assignment.withAssignment(array.withInitializer(ListUtils.map(array.getInitializer(),
274+
element -> element instanceof J.Literal ? changeValueInTestProperty((J.Literal) element) : element)));
275+
}
276+
if (assignment.getAssignment() instanceof K.ListLiteral) {
277+
K.ListLiteral listLiteral = (K.ListLiteral) assignment.getAssignment();
278+
return assignment.withAssignment(listLiteral.withElements(ListUtils.map(listLiteral.getElements(),
279+
element -> element instanceof J.Literal ? changeValueInTestProperty((J.Literal) element) : element)));
280+
}
281+
}
282+
}
283+
return arg;
284+
}));
285+
}
286+
287+
private J.Literal changeValueInTestProperty(J.Literal literal) {
288+
String value = (String) literal.getValue();
289+
Matcher matcher = keyValuePattern.matcher(value);
290+
if (matcher.matches()) {
291+
String key = matcher.group(1);
292+
String propValue = matcher.group(2);
293+
if (matchesPropertyKey(key) && matchesOldValue(propValue)) {
294+
String computedNewValue = computeNewValue(propValue);
295+
if (!computedNewValue.equals(propValue)) {
296+
return updateLiteral(literal, key + "=" + computedNewValue);
297+
}
298+
}
299+
}
300+
return literal;
301+
}
302+
303+
private boolean matchesPropertyKey(String key) {
304+
if (!Boolean.FALSE.equals(relaxedBinding)) {
305+
// Normalize dots to hyphens for relaxed binding comparison
306+
// (NameCaseConvention doesn't handle dots as separators)
307+
String normalizedKey = key.replace('.', '-');
308+
String normalizedPropertyKey = propertyKey.replace('.', '-');
309+
return NameCaseConvention.equalsRelaxedBinding(normalizedKey, normalizedPropertyKey);
310+
}
311+
return key.equals(propertyKey);
312+
}
313+
314+
private boolean matchesOldValue(String value) {
315+
// Don't match if the value is already the target value (idempotency)
316+
if (value.equals(newValue)) {
317+
return false;
318+
}
319+
if (oldValue == null) {
320+
return true;
321+
}
322+
if (Boolean.TRUE.equals(regex)) {
323+
return Pattern.compile(oldValue).matcher(value).find();
324+
}
325+
return value.equals(oldValue);
326+
}
327+
328+
private String computeNewValue(String currentValue) {
329+
if (Boolean.TRUE.equals(regex) && oldValue != null) {
330+
String computed = Pattern.compile(oldValue).matcher(currentValue).replaceFirst(newValue);
331+
// Return original if no change to ensure idempotency
332+
return computed.equals(currentValue) ? currentValue : computed;
333+
}
334+
return newValue;
335+
}
336+
337+
private J.Literal updateLiteral(J.Literal literal, String newValueStr) {
338+
String valueSource = literal.getValueSource();
339+
if (valueSource == null) {
340+
return literal.withValue(newValueStr).withValueSource("\"" + newValueStr + "\"");
341+
}
342+
// Handle escaping for the valueSource
343+
char quote = valueSource.charAt(0);
344+
String escapedValue = newValueStr.replace("\\", "\\\\");
345+
if (quote == '"') {
346+
escapedValue = escapedValue.replace("\"", "\\\"");
347+
}
348+
return literal.withValue(newValueStr).withValueSource(quote + escapedValue + quote);
349+
}
350+
}
95351
}

0 commit comments

Comments
 (0)