diff --git a/rewrite-json/src/main/java/org/openrewrite/json/AddKeyValue.java b/rewrite-json/src/main/java/org/openrewrite/json/AddKeyValue.java new file mode 100644 index 00000000000..8c58c745d30 --- /dev/null +++ b/rewrite-json/src/main/java/org/openrewrite/json/AddKeyValue.java @@ -0,0 +1,142 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.json; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.openrewrite.ExecutionContext; +import org.openrewrite.Option; +import org.openrewrite.Recipe; +import org.openrewrite.TreeVisitor; +import org.openrewrite.internal.ListUtils; +import org.openrewrite.json.tree.Json; +import org.openrewrite.json.tree.JsonKey; +import org.openrewrite.json.tree.JsonRightPadded; +import org.openrewrite.json.tree.Space; +import org.openrewrite.marker.Markers; + +import java.util.Collections; +import java.util.List; + +import static java.util.Collections.emptyList; +import static org.openrewrite.Tree.randomId; + +@Value +@EqualsAndHashCode(callSuper = false) +public class AddKeyValue extends Recipe { + + @Option(displayName = "Key path", + description = "A JsonPath expression to locate the *parent* JSON entry.", + example = "'$.subjects.*' or '$.' or '$.x[1].y.*' etc.") + String keyPath; + + @Option(displayName = "Key", + description = "The key to create.", + example = "myKey") + String key; + + @Option(displayName = "Value", + description = "The value to add to the array at the specified key. Can be of any type." + + " String values should be quoted to be inserted as Strings.", + example = "`\"myValue\"` or `{\"a\": 1}` or `[ 123 ]`") + String value; + + @Option(displayName = "Prepend", + required = false, + description = "If set to `true` the value will be added to the beginning of the object") + boolean prepend; + + @Override + public String getDisplayName() { + return "Add value to JSON Object"; + } + + @Override + public String getDescription() { + return "Adds a `value` at the specified `keyPath` with the specified `key`, if the key doesn't already exist."; + } + + + @Override + public TreeVisitor getVisitor() { + return new JsonIsoVisitor() { + private final JsonPathMatcher pathMatcher = new JsonPathMatcher(keyPath); + + @Override + public Json.JsonObject visitObject(Json.JsonObject obj, ExecutionContext ctx) { + obj = super.visitObject(obj, ctx); + + if (pathMatcher.matches(getCursor()) && objectDoesNotContainKey(obj, key)) { + List originalMembers = obj.getMembers(); + boolean jsonIsEmpty = originalMembers.isEmpty() || originalMembers.get(0) instanceof Json.Empty; + Space space = jsonIsEmpty || prepend ? originalMembers.get(0).getPrefix() : Space.SINGLE_SPACE; + + JsonRightPadded newKey = rightPaddedKey(); + Json.Literal newValue = valueLiteral(); + Json newMember = new Json.Member(randomId(), space, Markers.EMPTY, newKey, newValue); + + if (jsonIsEmpty) { + return obj.withMembers(Collections.singletonList(newMember)); + } + + List newMembers = prepend ? + ListUtils.concat(newMember, originalMembers) : + ListUtils.concat(originalMembers, newMember); + return obj.withMembers(newMembers); + } + return obj; + } + + private Json.Literal valueLiteral() { + return new Json.Literal(randomId(), Space.SINGLE_SPACE, Markers.EMPTY, value, unQuote(value)); + } + + private JsonRightPadded rightPaddedKey() { + return new JsonRightPadded<>( + new Json.Literal(randomId(), Space.EMPTY, Markers.EMPTY, "\"" + key + "\"", key), + Space.EMPTY, Markers.EMPTY + ); + } + + private String unQuote(String value) { + if (value.startsWith("'") || value.startsWith("\"")) { + return value.substring(1, value.length() - 1); + } + return value; + } + + private boolean objectDoesNotContainKey(Json.JsonObject obj, String key) { + for (Json member : obj.getMembers()) { + if (member instanceof Json.Member) { + if (keyMatches(((Json.Member) member).getKey(), key)) { + return false; + } + } + } + return true; + } + + private boolean keyMatches(JsonKey jsonKey, String key) { + if (jsonKey instanceof Json.Literal) { + return key.equals(((Json.Literal) jsonKey).getValue()); + } else if (jsonKey instanceof Json.Identifier) { + return key.equals(((Json.Identifier) jsonKey).getName()); + } + return false; + } + }; + } +} diff --git a/rewrite-json/src/test/java/org/openrewrite/json/AddKeyValueTest.java b/rewrite-json/src/test/java/org/openrewrite/json/AddKeyValueTest.java new file mode 100644 index 00000000000..32f20c3ef07 --- /dev/null +++ b/rewrite-json/src/test/java/org/openrewrite/json/AddKeyValueTest.java @@ -0,0 +1,182 @@ +/* + * Copyright 2024 the original author or authors. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * https://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.openrewrite.json; + +import org.junit.jupiter.api.Test; +import org.openrewrite.DocumentExample; +import org.openrewrite.test.RewriteTest; + +import static org.openrewrite.json.Assertions.json; + +class AddKeyValueTest implements RewriteTest { + + @Test + @DocumentExample + void shouldAppendSimpleValue() { + rewriteRun( + spec -> spec.recipe(new AddKeyValue("$.", "key", "\"val\"", false)), + //language=json + json( + """ + { + "x": "x", + "l": [ + 1, 2 + ] + } + """, + """ + { + "x": "x", + "l": [ + 1, 2 + ], + "key": "val" + } + """ + ) + ); + } + + @Test + void shouldAppendToNestedObject() { + rewriteRun( + spec -> spec.recipe(new AddKeyValue("$.x.y.*", "key", "\"val\"", false)), + //language=json + json( + """ + { + "x": { + "y": { } + } + } + """, + """ + { + "x": { + "y": {"key": "val"} + } + } + """ + ) + ); + } + + @Test + void shouldAppendToNestedObjectInArray() { + rewriteRun( + spec -> spec.recipe(new AddKeyValue("$.x[1].y.*", "key", "\"val\"", false)), + //language=json + json( + """ + { + "x": [ + { }, + { + "y" : { } + } + ] + } + """, + """ + { + "x": [ + { }, + { + "y" : {"key": "val"} + } + ] + } + """ + ) + ); + } + + @Test + void shouldNotAppendIfExists() { + rewriteRun( + spec -> spec.recipe(new AddKeyValue("$.", "key", "\"val\"", false)), + //language=json + json( + """ + { + "key": "x" + } + """ + ) + ); + } + + @Test + void shouldAppendObject() { + rewriteRun( + spec -> spec.recipe(new AddKeyValue( + "$.", "key", """ + { "a": "b" } + """.trim(), false)), + //language=json + json( + """ + { + "x": "x", + "l": [ + 1, 2 + ] + } + """, + """ + { + "x": "x", + "l": [ + 1, 2 + ], + "key": { "a": "b" } + } + """ + ) + ); + } + + @Test + void shouldPrependObject() { + rewriteRun( + spec -> spec.recipe(new AddKeyValue( + "$.", "key", """ + { "a": "b" } + """.trim(), true)), + //language=json + json( + """ + { + "x": "x", + "l": [ + 1, 2 + ] + } + """, + """ + { + "key": { "a": "b" }, + "x": "x", + "l": [ + 1, 2 + ] + } + """ + ) + ); + } +}