From 298c5578ca651d9695854546bfbf7a4356f2e319 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Sat, 3 Feb 2024 10:39:56 +0100 Subject: [PATCH 01/11] feat: json-patch support --- .../org/hisp/dhis/jsontree/JsonPatch.java | 36 ++ .../org/hisp/dhis/jsontree/JsonPointer.java | 44 ++ .../org/hisp/dhis/jsontree/JsonString.java | 6 +- .../dhis/jsontree/JsonPatchSuiteTest.java | 40 ++ ...Test.java => JsonPatchValidationTest.java} | 48 +- src/test/resources/json-patch-tests/LICENSE | 7 + .../json-patch-tests/spec_tests.json | 233 ++++++++ .../resources/json-patch-tests/tests.json | 507 ++++++++++++++++++ 8 files changed, 875 insertions(+), 46 deletions(-) create mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonPatch.java create mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonPointer.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java rename src/test/java/org/hisp/dhis/jsontree/{example/JsonPatchTest.java => JsonPatchValidationTest.java} (61%) create mode 100644 src/test/resources/json-patch-tests/LICENSE create mode 100644 src/test/resources/json-patch-tests/spec_tests.json create mode 100644 src/test/resources/json-patch-tests/tests.json diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java new file mode 100644 index 0000000..b4dfb59 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java @@ -0,0 +1,36 @@ +package org.hisp.dhis.jsontree; + +import static org.hisp.dhis.jsontree.Validation.YesNo.YES; + +/** + * A JSON patch operation as defined by RFC-6902 + * (see jsonpatch.com). + * + * @author Jan Bernitt + * @since 1.1 + */ +public interface JsonPatch extends JsonObject { + + enum Op {ADD, REMOVE, REPLACE, COPY, MOVE, TEST} + + @Required + @Validation( dependentRequired = { "=add", "=replace", "=copy", "=move", "=test" }, caseInsensitive = YES ) + default Op getOperation() { + return getString( "op" ).parsed( str -> Op.valueOf( str.toUpperCase() ) ); + } + + @Required + default JsonPointer getPath() { + return getString( "path" ).parsed( JsonPointer::new ); + } + + @Validation( dependentRequired = { "add", "replace", "test" } ) + default JsonMixed getValue() { + return get( "value", JsonMixed.class ); + } + + @Validation( dependentRequired = { "copy", "move" } ) + default JsonPointer getFrom() { + return getString( "from" ).parsed( JsonPointer::new ); + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java new file mode 100644 index 0000000..e3eac10 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java @@ -0,0 +1,44 @@ +package org.hisp.dhis.jsontree; + +import java.util.List; +import java.util.stream.Stream; + +import static org.hisp.dhis.jsontree.Validation.NodeType.STRING; + +/** + * As defined by RFC-6901. + * + * @author Jan Bernitt + * @since 1.1 + * + * @param value a pointer expression + */ +@Validation( type = STRING, pattern = "(\\/(([^\\/~])|(~[01]))*)" ) +public record JsonPointer(String value) { + + /** + * Returns individual segments as otherwise escaped / cannot be distinguished from an unescaped / that separates + * segments. + * + * @return the decoded segments of this pointer + */ + public List decode() { + return Stream.of(value.substring( 1 ).split( "/" )).map( JsonPointer::decode ).toList(); + } + + private static String decode(String segment) { + return segment.replace( "~1", "/" ).replace( "~0", "~" ); + } + + /** + * @return this pointer as path as it is used in the {@link JsonValue} and {@link JsonNode} APIs + */ + public String path() { + return "$."+String.join( ".", decode() ); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonString.java b/src/main/java/org/hisp/dhis/jsontree/JsonString.java index fa495e0..2e2d329 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonString.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonString.java @@ -27,6 +27,9 @@ */ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.internal.Maybe; +import org.hisp.dhis.jsontree.internal.Surly; + import java.util.function.Function; import static org.hisp.dhis.jsontree.Validation.NodeType.STRING; @@ -64,7 +67,8 @@ default String string( String orDefault ) { * @return {@code null} when {@link #string()} returns {@code null} otherwise the result of calling provided parser * with result of {@link #string()}. */ - default T parsed( Function parser ) { + @Maybe + default T parsed( @Surly Function parser ) { String value = string(); return value == null ? null : parser.apply( value ); } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java new file mode 100644 index 0000000..19a4a0c --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java @@ -0,0 +1,40 @@ +package org.hisp.dhis.jsontree; + +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.DynamicTest.dynamicTest; + +/** + * Runs the JSON patch test suite provided by + * json-patch-tests. + * + * @author Jan Bernitt + */ +class JsonPatchSuiteTest { + + public interface JsonPatchScenario extends JsonObject { + + default String comment() { return getString( "comment" ).string(); } + default JsonMixed doc() { return get( "doc", JsonMixed.class); } + default JsonPatch patch() { return get( "patch", JsonPatch.class ); } + default JsonMixed expected() { return get( "expected", JsonMixed.class ); } + default String error() { return getString( "error" ).string(); } + default boolean disabled() { return getBoolean( "disabled" ).booleanValue(false); } + } + + @TestFactory + List testScenarios() { + return JsonMixed.of( Path.of("src/test/resources/json-patch-tests/tests.json") ) + .asList( JsonPatchScenario.class ) + .stream().map( scenario -> dynamicTest( scenario.comment(), () -> assertScenario( scenario ) ) ) + .toList(); + } + + private void assertScenario(JsonPatchScenario scenario) { + + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/example/JsonPatchTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java similarity index 61% rename from src/test/java/org/hisp/dhis/jsontree/example/JsonPatchTest.java rename to src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java index dfa8ea5..3fa8dc2 100644 --- a/src/test/java/org/hisp/dhis/jsontree/example/JsonPatchTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java @@ -1,59 +1,17 @@ -package org.hisp.dhis.jsontree.example; +package org.hisp.dhis.jsontree; -import org.hisp.dhis.jsontree.JsonMixed; -import org.hisp.dhis.jsontree.JsonObject; -import org.hisp.dhis.jsontree.Required; -import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.Rule; import org.junit.jupiter.api.Test; -import java.lang.annotation.Retention; -import java.lang.annotation.Target; import java.util.Map; import java.util.Set; -import static java.lang.annotation.ElementType.METHOD; -import static java.lang.annotation.RetentionPolicy.RUNTIME; import static org.hisp.dhis.jsontree.Assertions.assertValidationError; -import static org.hisp.dhis.jsontree.Validation.YesNo.YES; /** - * A test that shows how the JSON patch standard could be modeled. + * Basic validation test for {@link JsonPatch} objects. */ -class JsonPatchTest { - - public enum Op { ADD, REMOVE, REPLACE, COPY, MOVE, TEST } - - @Retention( RUNTIME ) - @Target( METHOD ) - @Validation(pattern = "(\\/(([^\\/~])|(~[01]))*)") - @interface JsonPointer {} - - public interface JsonPatch extends JsonObject { - - @Required - @Validation(dependentRequired = {"=add", "=replace", "=copy", "=move", "=test"}, caseInsensitive = YES) - default Op getOperation() { - return getString( "op" ).parsed( Op::valueOf ); - } - - @JsonPointer - @Required - default String getPath() { - return getString( "path" ).string(); - } - - @Validation(dependentRequired = {"add", "replace", "test"}) - default JsonMixed getValue() { - return get( "value", JsonMixed.class ); - } - - @JsonPointer - @Validation(dependentRequired = {"copy", "move"}) - default String getFrom() { - return getString( "from" ).string(); - } - } +class JsonPatchValidationTest { @Test void testAny_InvalidPath() { diff --git a/src/test/resources/json-patch-tests/LICENSE b/src/test/resources/json-patch-tests/LICENSE new file mode 100644 index 0000000..cf32171 --- /dev/null +++ b/src/test/resources/json-patch-tests/LICENSE @@ -0,0 +1,7 @@ +Copyright 2014 The 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 + + http://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. \ No newline at end of file diff --git a/src/test/resources/json-patch-tests/spec_tests.json b/src/test/resources/json-patch-tests/spec_tests.json new file mode 100644 index 0000000..c160535 --- /dev/null +++ b/src/test/resources/json-patch-tests/spec_tests.json @@ -0,0 +1,233 @@ +[ + { + "comment": "4.1. add with missing object", + "doc": { "q": { "bar": 2 } }, + "patch": [ {"op": "add", "path": "/a/b", "value": 1} ], + "error": + "path /a does not exist -- missing objects are not created recursively" + }, + + { + "comment": "A.1. Adding an Object Member", + "doc": { + "foo": "bar" +}, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux" } +], + "expected": { + "baz": "qux", + "foo": "bar" +} + }, + + { + "comment": "A.2. Adding an Array Element", + "doc": { + "foo": [ "bar", "baz" ] +}, + "patch": [ + { "op": "add", "path": "/foo/1", "value": "qux" } +], + "expected": { + "foo": [ "bar", "qux", "baz" ] +} + }, + + { + "comment": "A.3. Removing an Object Member", + "doc": { + "baz": "qux", + "foo": "bar" +}, + "patch": [ + { "op": "remove", "path": "/baz" } +], + "expected": { + "foo": "bar" +} + }, + + { + "comment": "A.4. Removing an Array Element", + "doc": { + "foo": [ "bar", "qux", "baz" ] +}, + "patch": [ + { "op": "remove", "path": "/foo/1" } +], + "expected": { + "foo": [ "bar", "baz" ] +} + }, + + { + "comment": "A.5. Replacing a Value", + "doc": { + "baz": "qux", + "foo": "bar" +}, + "patch": [ + { "op": "replace", "path": "/baz", "value": "boo" } +], + "expected": { + "baz": "boo", + "foo": "bar" +} + }, + + { + "comment": "A.6. Moving a Value", + "doc": { + "foo": { + "bar": "baz", + "waldo": "fred" + }, + "qux": { + "corge": "grault" + } +}, + "patch": [ + { "op": "move", "from": "/foo/waldo", "path": "/qux/thud" } +], + "expected": { + "foo": { + "bar": "baz" + }, + "qux": { + "corge": "grault", + "thud": "fred" + } +} + }, + + { + "comment": "A.7. Moving an Array Element", + "doc": { + "foo": [ "all", "grass", "cows", "eat" ] +}, + "patch": [ + { "op": "move", "from": "/foo/1", "path": "/foo/3" } +], + "expected": { + "foo": [ "all", "cows", "eat", "grass" ] +} + + }, + + { + "comment": "A.8. Testing a Value: Success", + "doc": { + "baz": "qux", + "foo": [ "a", 2, "c" ] +}, + "patch": [ + { "op": "test", "path": "/baz", "value": "qux" }, + { "op": "test", "path": "/foo/1", "value": 2 } +], + "expected": { + "baz": "qux", + "foo": [ "a", 2, "c" ] + } + }, + + { + "comment": "A.9. Testing a Value: Error", + "doc": { + "baz": "qux" +}, + "patch": [ + { "op": "test", "path": "/baz", "value": "bar" } +], + "error": "string not equivalent" + }, + + { + "comment": "A.10. Adding a nested Member Object", + "doc": { + "foo": "bar" +}, + "patch": [ + { "op": "add", "path": "/child", "value": { "grandchild": { } } } +], + "expected": { + "foo": "bar", + "child": { + "grandchild": { + } + } +} + }, + + { + "comment": "A.11. Ignoring Unrecognized Elements", + "doc": { + "foo":"bar" +}, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux", "xyz": 123 } +], + "expected": { + "foo":"bar", + "baz":"qux" +} + }, + + { + "comment": "A.12. Adding to a Non-existent Target", + "doc": { + "foo": "bar" +}, + "patch": [ + { "op": "add", "path": "/baz/bat", "value": "qux" } +], + "error": "add to a non-existent target" + }, + + { + "comment": "A.13 Invalid JSON Patch Document", + "doc": { + "foo": "bar" + }, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux", "op": "remove" } +], + "error": "operation has two 'op' members", + "disabled": true + }, + + { + "comment": "A.14. ~ Escape Ordering", + "doc": { + "/": 9, + "~1": 10 + }, + "patch": [{"op": "test", "path": "/~01", "value": 10}], + "expected": { + "/": 9, + "~1": 10 + } + }, + + { + "comment": "A.15. Comparing Strings and Numbers", + "doc": { + "/": 9, + "~1": 10 + }, + "patch": [{"op": "test", "path": "/~01", "value": "10"}], + "error": "number is not equal to string" + }, + + { + "comment": "A.16. Adding an Array Value", + "doc": { + "foo": ["bar"] + }, + "patch": [{ "op": "add", "path": "/foo/-", "value": ["abc", "def"] }], + "expected": { + "foo": ["bar", ["abc", "def"]] + } + } + +] diff --git a/src/test/resources/json-patch-tests/tests.json b/src/test/resources/json-patch-tests/tests.json new file mode 100644 index 0000000..0cf7854 --- /dev/null +++ b/src/test/resources/json-patch-tests/tests.json @@ -0,0 +1,507 @@ +[ + { "comment": "empty list, empty docs", + "doc": {}, + "patch": [], + "expected": {} }, + + { "comment": "empty patch list", + "doc": {"foo": 1}, + "patch": [], + "expected": {"foo": 1} }, + + { "comment": "rearrangements OK?", + "doc": {"foo": 1, "bar": 2}, + "patch": [], + "expected": {"bar":2, "foo": 1} }, + + { "comment": "rearrangements OK? How about one level down ... array", + "doc": [{"foo": 1, "bar": 2}], + "patch": [], + "expected": [{"bar":2, "foo": 1}] }, + + { "comment": "rearrangements OK? How about one level down...", + "doc": {"foo":{"foo": 1, "bar": 2}}, + "patch": [], + "expected": {"foo":{"bar":2, "foo": 1}} }, + + { "comment": "add replaces any existing field", + "doc": {"foo": null}, + "patch": [{"op": "add", "path": "/foo", "value":1}], + "expected": {"foo": 1} }, + + { "comment": "toplevel array", + "doc": [], + "patch": [{"op": "add", "path": "/0", "value": "foo"}], + "expected": ["foo"] }, + + { "comment": "toplevel array, no change", + "doc": ["foo"], + "patch": [], + "expected": ["foo"] }, + + { "comment": "toplevel object, numeric string", + "doc": {}, + "patch": [{"op": "add", "path": "/foo", "value": "1"}], + "expected": {"foo":"1"} }, + + { "comment": "toplevel object, integer", + "doc": {}, + "patch": [{"op": "add", "path": "/foo", "value": 1}], + "expected": {"foo":1} }, + + { "comment": "Toplevel scalar values OK?", + "doc": "foo", + "patch": [{"op": "replace", "path": "", "value": "bar"}], + "expected": "bar", + "disabled": true }, + + { "comment": "replace object document with array document?", + "doc": {}, + "patch": [{"op": "add", "path": "", "value": []}], + "expected": [] }, + + { "comment": "replace array document with object document?", + "doc": [], + "patch": [{"op": "add", "path": "", "value": {}}], + "expected": {} }, + + { "comment": "append to root array document?", + "doc": [], + "patch": [{"op": "add", "path": "/-", "value": "hi"}], + "expected": ["hi"] }, + + { "comment": "Add, / target", + "doc": {}, + "patch": [ {"op": "add", "path": "/", "value":1 } ], + "expected": {"":1} }, + + { "comment": "Add, /foo/ deep target (trailing slash)", + "doc": {"foo": {}}, + "patch": [ {"op": "add", "path": "/foo/", "value":1 } ], + "expected": {"foo":{"": 1}} }, + + { "comment": "Add composite value at top level", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": [1, 2]}], + "expected": {"foo": 1, "bar": [1, 2]} }, + + { "comment": "Add into composite value", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "add", "path": "/baz/0/foo", "value": "world"}], + "expected": {"foo": 1, "baz": [{"qux": "hello", "foo": "world"}]} }, + + { "comment": "Add out of bounds positive index", + "doc": {"bar": [1, 2]}, + "patch": [{"op": "add", "path": "/bar/8", "value": "5"}], + "error": "Out of bounds (upper)" }, + + { "comment": "Add out of bounds negative index", + "doc": {"bar": [1, 2]}, + "patch": [{"op": "add", "path": "/bar/-1", "value": "5"}], + "error": "Out of bounds (lower)" }, + + { "comment": "Add new object member: true", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": true}], + "expected": {"foo": 1, "bar": true} }, + + { "comment": "Add new object member: false", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": false}], + "expected": {"foo": 1, "bar": false} }, + + { "comment": "Add new object member: null", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": null}], + "expected": {"foo": 1, "bar": null} }, + + { "comment": "0 can be an array index or object element name", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/0", "value": "bar"}], + "expected": {"foo": 1, "0": "bar" } }, + + { "comment": "Add new array element at the end", + "doc": ["foo"], + "patch": [{"op": "add", "path": "/1", "value": "bar"}], + "expected": ["foo", "bar"] }, + + { "comment": "Add new array element in the middle", + "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1", "value": "bar"}], + "expected": ["foo", "bar", "sil"] }, + + { "comment": "Add new array element at the beginning", + "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/0", "value": "bar"}], + "expected": ["bar", "foo", "sil"] }, + + { "comment": "push item to array via last index + 1", + "doc": ["foo", "sil"], + "patch": [{"op":"add", "path": "/2", "value": "bar"}], + "expected": ["foo", "sil", "bar"] }, + + { "comment": "add item to array at index > length should fail", + "doc": ["foo", "sil"], + "patch": [{"op":"add", "path": "/3", "value": "bar"}], + "error": "index is greater than number of items in array" }, + + { "comment": "test against implementation-specific numeric parsing", + "doc": {"1e0": "foo"}, + "patch": [{"op": "test", "path": "/1e0", "value": "foo"}], + "expected": {"1e0": "foo"} }, + + { "comment": "test with bad number should fail", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/1e0", "value": "bar"}], + "error": "test op shouldn't get array element 1" }, + + { "comment": "Add member to array fail", + "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/bar", "value": 42}], + "error": "Object operation on array target" }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1", "value": ["bar", "baz"]}], + "expected": ["foo", ["bar", "baz"], "sil"], + "comment": "value in array add not flattened" }, + + { "comment": "Remove object member", + "doc": {"foo": 1, "bar": [1, 2, 3, 4]}, + "patch": [{"op": "remove", "path": "/bar"}], + "expected": {"foo": 1} }, + + { "comment": "Remove object member in array", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "remove", "path": "/baz/0/qux"}], + "expected": {"foo": 1, "baz": [{}]} }, + + { "comment": "Replace object member", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "replace", "path": "/foo", "value": [1, 2, 3, 4]}], + "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]} }, + + { "comment": "Replace object member in array", + "doc": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]}, + "patch": [{"op": "replace", "path": "/baz/0/qux", "value": "world"}], + "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "world"}]} }, + + { "comment": "Replace array element at beginning", + "doc": ["foo"], + "patch": [{"op": "replace", "path": "/0", "value": "bar"}], + "expected": ["bar"] }, + + { "comment": "Replace array element with 0 at beginning", + "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": 0}], + "expected": [0] }, + + { "comment": "Replace array element with true at beginning", + "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": true}], + "expected": [true] }, + + { "comment": "Replace array element with false at beginning", + "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": false}], + "expected": [false] }, + + { "comment": "Replace array element with null at beginning", + "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": null}], + "expected": [null] }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "replace", "path": "/1", "value": ["bar", "baz"]}], + "expected": ["foo", ["bar", "baz"]], + "comment": "value in array replace not flattened" }, + + { "comment": "replace whole document", + "doc": {"foo": "bar"}, + "patch": [{"op": "replace", "path": "", "value": {"baz": "qux"}}], + "expected": {"baz": "qux"} }, + + { "comment": "test replace with missing parent key should fail", + "doc": {"bar": "baz"}, + "patch": [{"op": "replace", "path": "/foo/bar", "value": false}], + "error": "replace op should fail with missing parent key" }, + + { "comment": "spurious patch properties", + "doc": {"foo": 1}, + "patch": [{"op": "test", "path": "/foo", "value": 1, "spurious": 1}], + "expected": {"foo": 1} }, + + { "doc": {"foo": null}, + "patch": [{"op": "test", "path": "/foo", "value": null}], + "expected": {"foo": null}, + "comment": "null value should be valid obj property" }, + + { "doc": {"foo": null}, + "patch": [{"op": "replace", "path": "/foo", "value": "truthy"}], + "expected": {"foo": "truthy"}, + "comment": "null value should be valid obj property to be replaced with something truthy" }, + + { "doc": {"foo": null}, + "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], + "expected": {"bar": null}, + "comment": "null value should be valid obj property to be moved" }, + + { "doc": {"foo": null}, + "patch": [{"op": "copy", "from": "/foo", "path": "/bar"}], + "expected": {"foo": null, "bar": null}, + "comment": "null value should be valid obj property to be copied" }, + + { "doc": {"foo": null}, + "patch": [{"op": "remove", "path": "/foo"}], + "expected": {}, + "comment": "null value should be valid obj property to be removed" }, + + { "doc": {"foo": "bar"}, + "patch": [{"op": "replace", "path": "/foo", "value": null}], + "expected": {"foo": null}, + "comment": "null value should still be valid obj property replace other value" }, + + { "doc": {"foo": {"foo": 1, "bar": 2}}, + "patch": [{"op": "test", "path": "/foo", "value": {"bar": 2, "foo": 1}}], + "expected": {"foo": {"foo": 1, "bar": 2}}, + "comment": "test should pass despite rearrangement" }, + + { "doc": {"foo": [{"foo": 1, "bar": 2}]}, + "patch": [{"op": "test", "path": "/foo", "value": [{"bar": 2, "foo": 1}]}], + "expected": {"foo": [{"foo": 1, "bar": 2}]}, + "comment": "test should pass despite (nested) rearrangement" }, + + { "doc": {"foo": {"bar": [1, 2, 5, 4]}}, + "patch": [{"op": "test", "path": "/foo", "value": {"bar": [1, 2, 5, 4]}}], + "expected": {"foo": {"bar": [1, 2, 5, 4]}}, + "comment": "test should pass - no error" }, + + { "comment": "Test value exists but does not match", + "doc": {"foo": {"bar": [1, 2, 5, 4]}}, + "patch": [{"op": "test", "path": "/foo", "value": [1, 2]}], + "error": "test op should fail" }, + + { "comment": "Whole document", + "doc": { "foo": 1 }, + "patch": [{"op": "test", "path": "", "value": {"foo": 1}}], + "disabled": true }, + + { "comment": "Empty-string element", + "doc": { "": 1 }, + "patch": [{"op": "test", "path": "/", "value": 1}], + "expected": { "": 1 } }, + + { "comment": "Test complex scenario", + "doc": { + "foo": ["bar", "baz"], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + " ": 7, + "m~n": 8 + }, + "patch": [{"op": "test", "path": "/foo", "value": ["bar", "baz"]}, + {"op": "test", "path": "/foo/0", "value": "bar"}, + {"op": "test", "path": "/", "value": 0}, + {"op": "test", "path": "/a~1b", "value": 1}, + {"op": "test", "path": "/c%d", "value": 2}, + {"op": "test", "path": "/e^f", "value": 3}, + {"op": "test", "path": "/g|h", "value": 4}, + {"op": "test", "path": "/i\\j", "value": 5}, + {"op": "test", "path": "/k\"l", "value": 6}, + {"op": "test", "path": "/ ", "value": 7}, + {"op": "test", "path": "/m~0n", "value": 8}], + "expected": { + "": 0, + " ": 7, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "foo": [ + "bar", + "baz" + ], + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + "m~n": 8 + } + }, + { "comment": "Move to same location has no effect", + "doc": {"foo": 1}, + "patch": [{"op": "move", "from": "/foo", "path": "/foo"}], + "expected": {"foo": 1} }, + + { "comment": "Move to different property in same object", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], + "expected": {"baz": [{"qux": "hello"}], "bar": 1} }, + + { "comment": "Move one level up", + "doc": {"baz": [{"qux": "hello"}], "bar": 1}, + "patch": [{"op": "move", "from": "/baz/0/qux", "path": "/baz/1"}], + "expected": {"baz": [{}, "hello"], "bar": 1} }, + + { "comment": "Copy one level up", + "doc": {"baz": [{"qux": "hello"}], "bar": 1}, + "patch": [{"op": "copy", "from": "/baz/0", "path": "/boo"}], + "expected": {"baz":[{"qux":"hello"}],"bar":1,"boo":{"qux":"hello"}} }, + + { "comment": "replacing the root of the document is possible with add", + "doc": {"foo": "bar"}, + "patch": [{"op": "add", "path": "", "value": {"baz": "qux"}}], + "expected": {"baz":"qux"}}, + + { "comment": "Adding to \"/-\" adds to the end of the array", + "doc": [ 1, 2 ], + "patch": [ { "op": "add", "path": "/-", "value": { "foo": [ "bar", "baz" ] } } ], + "expected": [ 1, 2, { "foo": [ "bar", "baz" ] } ]}, + + { "comment": "Adding to \"/-\" adds to the end of the array, even n levels down", + "doc": [ 1, 2, [ 3, [ 4, 5 ] ] ], + "patch": [ { "op": "add", "path": "/2/1/-", "value": { "foo": [ "bar", "baz" ] } } ], + "expected": [ 1, 2, [ 3, [ 4, 5, { "foo": [ "bar", "baz" ] } ] ] ]}, + + { "comment": "test remove with bad number should fail", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "remove", "path": "/baz/1e0/qux"}], + "error": "remove op shouldn't remove from array with bad number" }, + + { "comment": "test remove on array", + "doc": [1, 2, 3, 4], + "patch": [{"op": "remove", "path": "/0"}], + "expected": [2, 3, 4] }, + + { "comment": "test repeated removes", + "doc": [1, 2, 3, 4], + "patch": [{ "op": "remove", "path": "/1" }, + { "op": "remove", "path": "/2" }], + "expected": [1, 3] }, + + { "comment": "test remove with bad index should fail", + "doc": [1, 2, 3, 4], + "patch": [{"op": "remove", "path": "/1e0"}], + "error": "remove op shouldn't remove from array with bad number" }, + + { "comment": "test replace with bad number should fail", + "doc": [""], + "patch": [{"op": "replace", "path": "/1e0", "value": false}], + "error": "replace op shouldn't replace in array with bad number" }, + + { "comment": "test copy with bad number should fail", + "doc": {"baz": [1,2,3], "bar": 1}, + "patch": [{"op": "copy", "from": "/baz/1e0", "path": "/boo"}], + "error": "copy op shouldn't work with bad number" }, + + { "comment": "test move with bad number should fail", + "doc": {"foo": 1, "baz": [1,2,3,4]}, + "patch": [{"op": "move", "from": "/baz/1e0", "path": "/foo"}], + "error": "move op shouldn't work with bad number" }, + + { "comment": "test add with bad number should fail", + "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1e0", "value": "bar"}], + "error": "add op shouldn't add to array with bad number" }, + + { "comment": "missing 'path' parameter", + "doc": {}, + "patch": [ { "op": "add", "value": "bar" } ], + "error": "missing 'path' parameter" }, + + { "comment": "'path' parameter with null value", + "doc": {}, + "patch": [ { "op": "add", "path": null, "value": "bar" } ], + "error": "null is not valid value for 'path'" }, + + { "comment": "invalid JSON Pointer token", + "doc": {}, + "patch": [ { "op": "add", "path": "foo", "value": "bar" } ], + "error": "JSON Pointer should start with a slash" }, + + { "comment": "missing 'value' parameter to add", + "doc": [ 1 ], + "patch": [ { "op": "add", "path": "/-" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing 'value' parameter to replace", + "doc": [ 1 ], + "patch": [ { "op": "replace", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing 'value' parameter to test", + "doc": [ null ], + "patch": [ { "op": "test", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing value parameter to test - where undef is falsy", + "doc": [ false ], + "patch": [ { "op": "test", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing from parameter to copy", + "doc": [ 1 ], + "patch": [ { "op": "copy", "path": "/-" } ], + "error": "missing 'from' parameter" }, + + { "comment": "missing from location to copy", + "doc": { "foo": 1 }, + "patch": [ { "op": "copy", "from": "/bar", "path": "/foo" } ], + "error": "missing 'from' location" }, + + { "comment": "missing from parameter to move", + "doc": { "foo": 1 }, + "patch": [ { "op": "move", "path": "" } ], + "error": "missing 'from' parameter" }, + + { "comment": "missing from location to move", + "doc": { "foo": 1 }, + "patch": [ { "op": "move", "from": "/bar", "path": "/foo" } ], + "error": "missing 'from' location" }, + + { "comment": "duplicate ops", + "doc": { "foo": "bar" }, + "patch": [ { "op": "add", "path": "/baz", "value": "qux", + "op": "move", "from":"/foo" } ], + "error": "patch has two 'op' members", + "disabled": true }, + + { "comment": "unrecognized op should fail", + "doc": {"foo": 1}, + "patch": [{"op": "spam", "path": "/foo", "value": 1}], + "error": "Unrecognized op 'spam'" }, + + { "comment": "test with bad array number that has leading zeros", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/00", "value": "foo"}], + "error": "test op should reject the array value, it has leading zeros" }, + + { "comment": "test with bad array number that has leading zeros", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/01", "value": "bar"}], + "error": "test op should reject the array value, it has leading zeros" }, + + { "comment": "Removing nonexistent field", + "doc": {"foo" : "bar"}, + "patch": [{"op": "remove", "path": "/baz"}], + "error": "removing a nonexistent field should fail" }, + + { "comment": "Removing deep nonexistent path", + "doc": {"foo" : "bar"}, + "patch": [{"op": "remove", "path": "/missing1/missing2"}], + "error": "removing a nonexistent field should fail" }, + + { "comment": "Removing nonexistent index", + "doc": ["foo", "bar"], + "patch": [{"op": "remove", "path": "/2"}], + "error": "removing a nonexistent index should fail" }, + + { "comment": "Patch with different capitalisation than doc", + "doc": {"foo":"bar"}, + "patch": [{"op": "add", "path": "/FOO", "value": "BAR"}], + "expected": {"foo": "bar", "FOO": "BAR"} + } + +] From 88288cf216f643d1eb9ec5be58b90f8aaccff709 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Sat, 3 Feb 2024 14:28:40 +0100 Subject: [PATCH 02/11] Resolves #52 - JsonValue equality checks --- CHANGELOG.md | 10 ++ .../dhis/jsontree/JsonAbstractObject.java | 4 +- .../org/hisp/dhis/jsontree/JsonValue.java | 50 ++++++ .../jsontree/JsonValueIsEquivalentTest.java | 148 ++++++++++++++++++ 4 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonValueIsEquivalentTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c833b..9c9d6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ # ChangeLog ## [Unreleased] v1.1 + +**Features** +* Added: _json-patch_ support, see `JsonPatch`, `JsonPointer` +* Added: node level bulk modification API, see `JsonNode.Add`, `JsonNode.Remove` +* Added: test for same information `JsonValue#equivalentTo` +* Added: test for same definition (ignoring formatting) `JsonValue#identicalTo` + +**Breaking Changes** + +**Bugfixes** \ No newline at end of file diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index d3a1c3a..f1fbfeb 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -109,9 +109,7 @@ default Stream> entries() { * @throws JsonTreeException in case this value is not an JSON object */ default List names() { - List names = new ArrayList<>(); - keys().forEach( names::add ); - return names; + return keys().toList(); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index 7d5217c..8cf23a1 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -34,6 +34,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.function.BiPredicate; import java.util.function.Function; import java.util.function.Predicate; @@ -295,6 +296,55 @@ default List toListFromVarargs( Class elementType : List.of( toElement.apply( as( elementType ) ) ); } + /** + * The same information does not imply the value is identically defined. + * There can be differences in formatting, the order of object members or leading or tailing zeros for numbers. + *

+ * Equivalence is always symmetric; if A is equivalent to B then B must also be equivalent to A. + * + * @param other the value to compare with + * @return true, if this value represents the same information, else false + * @since 1.1 + */ + default boolean equivalentTo(JsonValue other) { + return equivalentTo( this, other, JsonValue::equivalentTo ); + } + + /** + * The two values only differ in formatting (whitespace outside of values). + *

+ * All values that are identical are also {@link #equivalentTo(JsonValue)}. + *

+ * Identical is always symmetric; if A is identical to B then B must also be identical to A. + * + * @param other the value to compare with + * @return true, if this value only differs in formatting from the other value, otherwise false + * @since 1.1 + */ + default boolean identicalTo(JsonValue other) { + if (!equivalentTo( this, other, JsonValue::identicalTo )) return false; + if (isNumber()) return toJson().equals( other.toJson() ); + if (!isObject()) return true; + // keys must be in same order + return asObject().names().equals( other.asObject().names() ); + } + + private static boolean equivalentTo(JsonValue a, JsonValue b, BiPredicate compare ) { + if (a.type() != b.type()) return false; + if (a.isUndefined()) return true; // includes null + if (a.isString()) return a.as( JsonString.class ).string().equals( b.as( JsonString.class ).string() ); + if (a.isBoolean()) return a.as(JsonBoolean.class).booleanValue() == b.as( JsonBoolean.class ).booleanValue(); + if (a.isNumber()) return a.as( JsonNumber.class ).doubleValue() == b.as( JsonNumber.class ).doubleValue(); + if (a.isArray()) { + JsonArray ar = a.as( JsonArray.class ); + JsonArray br = b.as( JsonArray.class ); + return ar.size() == br.size() && ar.indexes().allMatch( i -> compare.test( ar.get( i ), br.get( i ) )); + } + JsonObject ao = a.asObject(); + JsonObject bo = b.asObject(); + return ao.size() == bo.size() && ao.keys().allMatch( key -> compare.test( ao.get( key ), bo.get( key ) ) ); + } + /** * Access the node in the JSON document. This can be the low level API that is concerned with extraction by path. *

diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonValueIsEquivalentTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonValueIsEquivalentTest.java new file mode 100644 index 0000000..143982b --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonValueIsEquivalentTest.java @@ -0,0 +1,148 @@ +package org.hisp.dhis.jsontree; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the {@link JsonValue#equivalentTo(JsonValue)} method. + */ +class JsonValueIsEquivalentTest { + + @Test + void testEquivalentTo_Undefined_Undefined() { + JsonMixed root = JsonMixed.of( "{}" ); + assertEquivalent( root.get( "foo" ), root.get( "bar" ) ); + } + + @Test + void testEquivalentTo_Undefined_NonUndefined() { + JsonValue undefined = JsonMixed.of( "{}" ).get( "foo" ); + assertNotEquivalent( undefined, JsonValue.of( "null" ) ); + assertNotEquivalent( undefined, JsonValue.of( "true" ) ); + assertNotEquivalent( undefined, JsonValue.of( "false" ) ); + assertNotEquivalent( undefined, JsonValue.of( "1" ) ); + assertNotEquivalent( undefined, JsonValue.of( "\"1\"" ) ); + assertNotEquivalent( undefined, JsonValue.of( "[]" ) ); + assertNotEquivalent( undefined, JsonValue.of( "{}" ) ); + } + + @Test + void testEquivalentTo_Null_Null() { + assertEquivalent(JsonValue.of( "null" ), JsonValue.of( "null" )); + } + + @Test + void testEquivalentTo_Null_NonNull() { + JsonValue nil = Json.ofNull(); + assertNotEquivalent( nil, JsonMixed.of( "{}" ).get( 0 ) ); + assertNotEquivalent( nil, JsonValue.of( "true" ) ); + assertNotEquivalent( nil, JsonValue.of( "false" ) ); + assertNotEquivalent( nil, JsonValue.of( "1" ) ); + assertNotEquivalent( nil, JsonValue.of( "\"1\"" ) ); + assertNotEquivalent( nil, JsonValue.of( "[]" ) ); + assertNotEquivalent( nil, JsonValue.of( "{}" ) ); + } + + @Test + void testEquivalentTo_String_String() { + assertEquivalent(JsonValue.of( "\"hello\"" ), JsonValue.of( "\"hello\"" )); + assertNotEquivalent(JsonValue.of( "\"hello you\"" ), JsonValue.of( "\"hello\"" )); + } + + @Test + void testEquivalentTo_String_NonString() { + assertNotEquivalent(JsonValue.of( "\"null\"" ), JsonValue.of( "null" )); + assertNotEquivalent(JsonValue.of( "\"true\"" ), JsonValue.of( "true" )); + assertNotEquivalent(JsonValue.of( "\"false\"" ), JsonValue.of( "false" )); + } + + @Test + void testEquivalentTo_Boolean_Boolean() { + assertEquivalent(JsonValue.of( "true" ), JsonValue.of( "true" )); + assertEquivalent(JsonValue.of( "false" ), JsonValue.of( "false" )); + assertNotEquivalent(JsonValue.of( "true" ), JsonValue.of( "false" )); + } + + @Test + void testEquivalentTo_Number_Number() { + assertEquivalent(JsonValue.of( "1" ), JsonValue.of( "1" )); + assertEquivalent(JsonValue.of( "1.0" ), JsonValue.of( "1.0" )); + assertEquivalent(JsonValue.of( "1" ), JsonValue.of( "1.0" )); + assertNotEquivalent(JsonValue.of( "2" ), JsonValue.of( "2.5" )); + } + + @Test + void testEquivalentTo_Array_Array() { + assertEquivalent(JsonValue.of( "[]" ), JsonValue.of( "[ ]" )); + assertEquivalent(JsonValue.of( "[1]" ), JsonValue.of( "[ 1 ]" )); + assertEquivalent(JsonValue.of( "[1,2]" ), JsonValue.of( "[ 1,2 ]" )); + assertEquivalent(JsonValue.of( "[1,[2]]" ), JsonValue.of( "[ 1,[ 2] ]" )); + assertNotEquivalent(JsonValue.of( "[2,1]" ), JsonValue.of( "[1,2 ]" )); + } + + @Test + void testEquivalentTo_Object_Object() { + assertEquivalent( JsonValue.of(""" + {}"""), JsonValue.of(""" + {}""" )); + assertEquivalent( JsonValue.of(""" + {"a": "b", "c": 4}"""), JsonValue.of(""" + {"c": 4, "a":"b"}""" )); + assertNotEquivalent( JsonValue.of(""" + {"a": "b", "c": 4}"""), JsonValue.of(""" + {"c": 3, "a":"b"}""" )); + assertNotEquivalent( JsonValue.of(""" + {"a": "b", "c": 4}"""), JsonValue.of(""" + {"a":"b", "c": 4, "d": null}""" )); + } + + @Test + void testEquivalentTo_Mixed() { + assertEquivalent( JsonValue.of(""" + {"x": 10, "c": [4, {"foo": "bar", "y": 20}]}"""), JsonValue.of(""" + {"c": [4, {"y":20, "foo": "bar"}], "x":10}""" )); + } + + @Test + void testIdenticalTo_Number() { + assertIdentical( JsonValue.of( "1"), JsonValue.of( "1") ); + assertIdentical( JsonValue.of( "1.0"), JsonValue.of( "1.0") ); + assertEquivalentButNotIdentical( JsonValue.of( "1"), JsonValue.of( "1.0") ); + } + + @Test + void testIdenticalTo_Object() { + assertIdentical( JsonValue.of(""" + {"a": "b", "c": 4}"""), JsonValue.of(""" + {"a":"b","c":4}""" )); + assertEquivalentButNotIdentical( JsonValue.of(""" + {"a": "b", "c": 4}"""), JsonValue.of(""" + {"c":4, "a":"b"}""" )); + assertEquivalentButNotIdentical( JsonValue.of(""" + {"a": "b", "c": [{},{"x": 1, "y": 1}]}"""), JsonValue.of(""" + {"a": "b", "c": [{},{"y": 1, "x": 1}]}""" )); + } + + private static void assertEquivalent(JsonValue a, JsonValue b) { + assertTrue( a.equivalentTo( b ) ); + assertTrue( b.equivalentTo( a ) ); + } + + private static void assertNotEquivalent(JsonValue a, JsonValue b) { + assertFalse( a.equivalentTo( b ) ); + assertFalse( b.equivalentTo( a ) ); + } + + private static void assertIdentical(JsonValue a, JsonValue b) { + assertTrue( a.identicalTo( b ) ); + assertTrue( b.identicalTo( a ) ); + } + + private static void assertEquivalentButNotIdentical(JsonValue a, JsonValue b) { + assertEquivalent( a, b ); + assertFalse( a.identicalTo( b ) ); + assertFalse( b.identicalTo( a ) ); + } +} From 4e20e4201a3632fd9a245946ef797a29e004f210 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Sun, 4 Feb 2024 10:25:07 +0100 Subject: [PATCH 03/11] feat: json-patch validation --- .../java/org/hisp/dhis/jsontree/JsonNode.java | 16 ++ .../org/hisp/dhis/jsontree/JsonPatch.java | 61 +++++++ .../dhis/jsontree/JsonPatchException.java | 14 ++ .../org/hisp/dhis/jsontree/JsonPointer.java | 11 +- .../java/org/hisp/dhis/jsontree/JsonTree.java | 41 +++++ .../org/hisp/dhis/jsontree/JsonValue.java | 25 ++- .../dhis/jsontree/JsonPatchConflictTest.java | 154 ++++++++++++++++++ .../dhis/jsontree/JsonPatchSuiteTest.java | 14 +- .../jsontree/JsonPatchValidationTest.java | 4 +- 9 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 5c89d0f..28a27a6 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -38,6 +38,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Iterator; +import java.util.List; import java.util.Map.Entry; import java.util.Optional; import java.util.Set; @@ -742,6 +743,19 @@ default JsonNode removeElements( int from, int to ) { } ) ); } + sealed interface Operation { String path(); } + record Insert(String path, JsonNode value) implements Operation {} + record Remove(String path) implements Operation {} + + /** + * Operations cannot target a path that was removed or added in the same patch. + * + * + * @param ops + * @return + */ + JsonNode patch( List ops) throws JsonPatchException; + private void checkType( JsonNodeType expected, JsonNodeType actual, String operation ) { if ( actual != expected ) throw new JsonTreeException( @@ -755,4 +769,6 @@ static String parentPath( String path ) { int end = path.lastIndexOf( '.' ); return end < 0 ? "" : path.substring( 0, end ); } + + } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java index b4dfb59..f3d9025 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java @@ -1,5 +1,11 @@ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.JsonNode.Insert; +import org.hisp.dhis.jsontree.JsonNode.Remove; + +import java.util.ArrayList; +import java.util.List; + import static org.hisp.dhis.jsontree.Validation.YesNo.YES; /** @@ -33,4 +39,59 @@ default JsonMixed getValue() { default JsonPointer getFrom() { return getString( "from" ).parsed( JsonPointer::new ); } + + static JsonValue apply(JsonValue value, JsonList ops) throws JsonPatchException { + return JsonValue.of( value.node().patch(JsonPatch.operations( value.as( JsonMixed.class ), ops ))); + } + + /** + * Converts a json-patch to {@link JsonNode.Operation}s. + * + * @param value the target value + * @param with the patch to apply + * @return list of {@link JsonNode.Operation}s to apply to get the patch effect + */ + private static List operations(JsonMixed value, JsonList with) { + List ops = new ArrayList<>(); + int i = 0; + for (JsonPatch op : with) { + op.validate( JsonPatch.class ); + String path = op.getPath().path(); + switch ( op.getOperation() ) { + case ADD -> ops.add( new Insert(path, op.getValue().node() ) ); + case REMOVE -> ops.add( new Remove( path ) ); + case REPLACE -> { + ops.add( new Remove( path ) ); + ops.add( new Insert( path, op.getValue().node() )); + } + case MOVE -> { + String from = op.getFrom().path(); + ops.add( new Remove( from ) ); + ops.add( new Insert( path, value.get( from ).node())); + } + case COPY -> { + String from = op.getFrom().path(); + ops.add( new Insert( path, value.get( from ).node()) ); + } + case TEST -> { + if ( !value.get( path ).equivalentTo( op.getValue() ) ) + throw new JsonPatchException("operation %d failed its test: %s".formatted( i, op.toJson() ) ); + } + } + i++; + } + return ops; + } + // V A L I D A T I O N S + // pointer: + // leading zeros in pointer seg + // tailing / + // pointer does not start with / + // null as pointer path + // pointer + tree: + // pointer implies parent of different node type + // index must be in range 0-length (length means append new) + + // operation: + // target does not exist (remove/replace) } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java new file mode 100644 index 0000000..fd29f98 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java @@ -0,0 +1,14 @@ +package org.hisp.dhis.jsontree; + +/** + * When a {@link JsonPatch} operation fails. + * + * @author Jan Bernitt + * @since 1.1 + */ +public final class JsonPatchException extends IllegalArgumentException { + + public JsonPatchException(String msg ) { + super( msg ); + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java index e3eac10..45c9086 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java @@ -13,7 +13,7 @@ * * @param value a pointer expression */ -@Validation( type = STRING, pattern = "(\\/(([^\\/~])|(~[01]))*)" ) +@Validation( type = STRING, pattern = "(/([^/~]|(~[01]))*)*" ) public record JsonPointer(String value) { /** @@ -23,6 +23,7 @@ public record JsonPointer(String value) { * @return the decoded segments of this pointer */ public List decode() { + if (value.isEmpty()) return List.of(); return Stream.of(value.substring( 1 ).split( "/" )).map( JsonPointer::decode ).toList(); } @@ -34,11 +35,15 @@ private static String decode(String segment) { * @return this pointer as path as it is used in the {@link JsonValue} and {@link JsonNode} APIs */ public String path() { - return "$."+String.join( ".", decode() ); + if (value.isEmpty()) return ""; + return String.join( ".", decode() ); } @Override public String toString() { - return value; + return value+" = "+path(); } + + // TODO additions: when a path ends with an index and + the value should be an array, + // all its elements should be inserted in the target at the given index } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index f81afad..413bf78 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -33,11 +33,14 @@ import java.io.Serializable; import java.util.AbstractMap.SimpleEntry; import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.Set; import java.util.function.Consumer; import java.util.function.IntConsumer; import java.util.function.Predicate; @@ -186,6 +189,13 @@ public final JsonNode replaceWith( String json ) { return JsonNode.of( newJson.toString() ); } + @Override + public final JsonNode patch( List ops ) { + checkPatch( ops ); + + return this; + } + @Override public final void visit( JsonNodeType type, Consumer visitor ) { if ( type == null || type == getType() ) { @@ -222,6 +232,37 @@ public final String toString() { abstract T parseValue(); } + // - operations must not target a parent of a prior operation + // - operations must not target a child of a prior operation + // - operations must not target same path as a prior insert + static void checkPatch( List ops ) { + if (ops.size() < 2) return; + Set removeTargets = new HashSet<>(); + Set insertTargets = new HashSet<>(); + Set parents = new HashSet<>(); + int i = 0; + for ( JsonNode.Operation op : ops ) { + String path = op.path(); + boolean isRemove = op instanceof JsonNode.Remove; + boolean isInsert = !isRemove; + if (insertTargets.contains( path )) + throw new JsonPatchException("operation %d targets same path of prior insert: %s".formatted( i, op ) ); + if (parents.contains( path )) + throw new JsonPatchException("operation %d targets parent of prior operation: %s".formatted( i, op ) ); + if (isInsert) insertTargets.add( path ); + if (isRemove) removeTargets.add( path ); + while ( path.lastIndexOf('.' ) >= 0 ) { + path = path.substring( 0, path.lastIndexOf( '.' ) ); + if (insertTargets.contains( path )) + throw new JsonPatchException("operation %d targets child of prior insert: %s".formatted( i, op ) ); + if (removeTargets.contains( path )) + throw new JsonPatchException("operation %d targets child of prior remove: %s".formatted( i, op ) ); + parents.add( path ); + } + i++; + } + } + private static final class LazyJsonObject extends LazyJsonNode>> implements Iterable> { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index 8cf23a1..a4cc679 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -297,8 +297,8 @@ default List toListFromVarargs( Class elementType } /** - * The same information does not imply the value is identically defined. - * There can be differences in formatting, the order of object members or leading or tailing zeros for numbers. + * The same information does not imply the value is identically defined. There can be differences in formatting, the + * order of object members or how the same numerical value is encoded for a number. *

* Equivalence is always symmetric; if A is equivalent to B then B must also be equivalent to A. * @@ -345,6 +345,27 @@ private static boolean equivalentTo(JsonValue a, JsonValue b, BiPredicate compare.test( ao.get( key ), bo.get( key ) ) ); } + /** + * Create a new value based on this value and a patch. + *

+ * Operations apply "as if" they were applied in sequence. + * However, operations that alter already altered subtrees are not allowed. + * This entails the following: + *

    + *
  • operations must not target a parent of a prior operation
  • + *
  • operations must not target a child of a prior operation
  • + *
  • operations must not target same path as a prior insert
  • + *
+ * + * @param ops operations to apply "atomically" + * @return a new value with the effects of the patch operations (this value stays unchanged) + * @throws JsonPatchException when the patch fails either because the operation was incorrectly defined or could not be applied to the value. This includes the a failing test operation. + * @since 1.1 + */ + default JsonValue patch(JsonList ops) throws JsonPatchException { + return JsonPatch.apply( this, ops ); + } + /** * Access the node in the JSON document. This can be the low level API that is concerned with extraction by path. *

diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java new file mode 100644 index 0000000..840b40c --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java @@ -0,0 +1,154 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.JsonNode.Insert; +import org.hisp.dhis.jsontree.JsonNode.Remove; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.hisp.dhis.jsontree.JsonNode.NULL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Checks the conflict detection when running a {@link JsonNode#patch(List)}. + */ +class JsonPatchConflictTest { + + @Test + void testPatch_RemoveSamePathBefore() { + assertNoConflict( + new Remove( "foo.bar" ), + new Insert( "foo.bar", NULL ) ); + } + + @Test + void testPatch_RemoveSamePathAfter() { + assertSameConflict( + new Insert( "foo.bar", NULL ), + new Remove( "foo.bar" )); + } + + @Test + void testPatch_RemoveSamePathRemove() { + assertNoConflict( + new Remove( "foo.bar" ), + new Remove( "foo.bar" ) ); + } + + @Test + void testPatch_InsertSamePathInsert() { + assertSameConflict( + new Insert( "foo.bar", NULL ), + new Insert( "foo.bar", NULL ) ); + } + + @Test + void testPatch_InsertSiblingsPathInsert() { + assertNoConflict( + new Insert( "foo.x", NULL ), + new Insert( "foo.y", NULL ) ); + } + + @Test + void testPatch_RemoveChildPathAfter() { + assertChildConflict( + new Insert( "foo", NULL ), + new Remove( "foo.bar" ) ); + assertChildConflict( + new Insert( "foo.bar", NULL ), + new Remove( "foo.bar.baz" ) ); + } + @Test + void testPatch_RemoveChildPathBefore() { + assertParentConflict( + new Remove( "foo.bar" ), + new Insert( "foo", NULL )); + assertParentConflict( + new Remove( "foo.bar.baz" ), + new Insert( "foo.bar", NULL )); + } + + @Test + void testPatch_RemoveParentPathBefore() { + assertChildConflict( + new Remove( "foo" ), + new Insert( "foo.bar", NULL )); + assertChildConflict( + new Remove( "foo.bar" ), + new Insert( "foo.bar.baz", NULL )); + } + + @Test + void testPatch_InsertParentPathBefore() { + assertChildConflict( + new Insert( "foo.bar", NULL ), + new Insert( "foo.bar.baz", NULL )); + } + + @Test + void testPatch_RemoveParentPathAfter() { + assertParentConflict( + new Insert( "foo.bar", NULL ), + new Remove( "foo" ) ); + assertParentConflict( + new Insert( "foo.bar.baz", NULL ), + new Remove( "foo.bar" ) ); + } + + @Test + void testPatch_InsertParentPathAfter() { + assertParentConflict( + new Insert( "foo.bar.baz", NULL ), + new Insert( "foo.bar", NULL )); + } + + @Test + void testPatch_RemoveParentPathRemoveAfter() { + assertParentConflict( + new Remove( "foo.bar.baz" ), + new Remove( "foo.bar" ) ); + } + + @Test + void testPatch_RemoveParentPathRemoveBefore() { + assertChildConflict( + new Remove( "foo.bar" ), + new Remove( "foo.bar.baz" )); + } + + @Test + void testPatch_Misc() { + assertNoConflict( + new Insert( "foo.x", NULL ), + new Remove( "bar.x" ), + new Insert( "foo.y", NULL ), + new Remove( "que" ), + new Insert( "y", NULL ), + new Insert( "que", NULL ) + ); + } + + private static void assertParentConflict(JsonNode.Operation... ops) { + JsonPatchException ex = assertThrows( JsonPatchException.class, + () -> JsonTree.checkPatch( List.of( ops ) ) ); + assertTrue( ex.getMessage().contains( " parent " ) ); + } + + private static void assertChildConflict(JsonNode.Operation... ops) { + JsonPatchException ex = assertThrows( JsonPatchException.class, + () -> JsonTree.checkPatch( List.of( ops ) ) ); + assertTrue( ex.getMessage().contains( " child " ) ); + } + + private static void assertSameConflict(JsonNode.Operation... ops) { + JsonPatchException ex = assertThrows( JsonPatchException.class, + () -> JsonTree.checkPatch( List.of( ops ) ) ); + assertTrue( ex.getMessage().contains( " same " ) ); + } + + private static void assertNoConflict(JsonNode.Operation... ops) { + assertDoesNotThrow( () -> JsonTree.checkPatch( List.of(ops) ) ); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java index 19a4a0c..fddf54b 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java @@ -1,11 +1,14 @@ package org.hisp.dhis.jsontree; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; import java.nio.file.Path; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.DynamicTest.dynamicTest; /** @@ -20,7 +23,7 @@ public interface JsonPatchScenario extends JsonObject { default String comment() { return getString( "comment" ).string(); } default JsonMixed doc() { return get( "doc", JsonMixed.class); } - default JsonPatch patch() { return get( "patch", JsonPatch.class ); } + default JsonList patch() { return getList( "patch", JsonPatch.class ); } default JsonMixed expected() { return get( "expected", JsonMixed.class ); } default String error() { return getString( "error" ).string(); } default boolean disabled() { return getBoolean( "disabled" ).booleanValue(false); } @@ -35,6 +38,13 @@ List testScenarios() { } private void assertScenario(JsonPatchScenario scenario) { - + String error = scenario.error(); + if ( error != null) { + assertThrows( JsonPatchException.class, () -> scenario.doc().patch( scenario.patch() ) ); + } else { + JsonValue patched = scenario.doc().patch( scenario.patch() ); + assertTrue( scenario.expected().equivalentTo( patched ), + () -> "expected %s but was: %s".formatted( scenario.expected(), patched ) ); + } } } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java index 3fa8dc2..b21d6d7 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java @@ -17,7 +17,7 @@ class JsonPatchValidationTest { void testAny_InvalidPath() { assertValidationError( """ { "op": "remove", "path": "hello" }""", JsonPatch.class, Rule.PATTERN, - "(\\/(([^\\/~])|(~[01]))*)", "hello"); + "(/([^/~]|(~[01]))*)*", "hello"); } @Test @@ -78,6 +78,6 @@ void testMove_MissingFrom() { void testMove_InvalidFrom() { assertValidationError( """ { "op": "move", "from": "hello", "path":"/" }""", JsonPatch.class, Rule.PATTERN, - "(\\/(([^\\/~])|(~[01]))*)", "hello"); + "(/([^/~]|(~[01]))*)*", "hello"); } } From c814b6fd083d6483787dea2714fb0df78a21a5f3 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Sun, 4 Feb 2024 11:39:11 +0100 Subject: [PATCH 04/11] feat: acceptNull to declare null satisfies required validation --- CHANGELOG.md | 3 +- .../dhis/jsontree/JsonAbstractObject.java | 11 +++++ .../org/hisp/dhis/jsontree/JsonPatch.java | 2 +- .../org/hisp/dhis/jsontree/Validation.java | 7 +++ .../jsontree/validation/ObjectValidation.java | 7 +-- .../jsontree/validation/ObjectValidator.java | 45 ++++++++++++------- .../validation/PropertyValidation.java | 9 ++-- .../JsonValidationDependentRequiredTest.java | 37 +++++++++++++++ .../JsonValidationRequiredTest.java | 22 ++++++++- 9 files changed, 119 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c9d6cc..a478e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ **Features** * Added: _json-patch_ support, see `JsonPatch`, `JsonPointer` -* Added: node level bulk modification API, see `JsonNode.Add`, `JsonNode.Remove` +* Added: node bulk modification API: `JsonNode#patch` + `JsonNode.Insert`, `JsonNode.Remove` * Added: test for same information `JsonValue#equivalentTo` * Added: test for same definition (ignoring formatting) `JsonValue#identicalTo` +* Added: `@Validation#acceptNull` to declare that `null` value satisfies required **Breaking Changes** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index f1fbfeb..f7e5d75 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -75,6 +75,17 @@ default boolean isUndefined( String name ) { return get( name ).isUndefined(); } + /** + * Test if the object property is defined which includes being defined JSON {@code null}. + * + * @param name name of the object member + * @return true if this object has a member of the provided name + * @since 1.1 + */ + default boolean exists(String name) { + return get(name).exists(); + } + /** * @return The keys of this map. * @throws JsonTreeException in case this node does exist but is not an object node diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java index f3d9025..0426af9 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java @@ -30,7 +30,7 @@ default JsonPointer getPath() { return getString( "path" ).parsed( JsonPointer::new ); } - @Validation( dependentRequired = { "add", "replace", "test" } ) + @Validation( dependentRequired = { "add", "replace", "test" }, acceptNull = YES) default JsonMixed getValue() { return get( "value", JsonMixed.class ); } diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 42a4868..6225c9d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -350,4 +350,11 @@ public String toString() { * @return the names of the groups the annotated property belongs to */ String[] dependentRequired() default {}; + + /** + * @return when {@link YesNo#YES} a JSON {@code null} value satisfies being {@link #required()} or + * {@link #dependentRequired()} + * @since 1.1 + */ + YesNo acceptNull() default YesNo.AUTO; } diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java index 5a6a6f7..b17fd9b 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -221,7 +221,7 @@ private static PropertyValidation fromItems( AnnotatedElement src ) { @Surly private static PropertyValidation toPropertyValidation( Class type ) { - ValueValidation values = !type.isPrimitive() ? null : new ValueValidation( YES, Set.of(), Set.of(), List.of() ); + ValueValidation values = !type.isPrimitive() ? null : new ValueValidation( YES, Set.of(), AUTO, Set.of(), List.of() ); StringValidation strings = !type.isEnum() ? null : new StringValidation( anyOfStrings( type ), AUTO,-1, -1, "" ); return new PropertyValidation( anyOfTypes( type ), values, strings, null, null, null, null ); } @@ -243,11 +243,12 @@ private static PropertyValidation toPropertyValidation( @Surly Validation src ) private static ValueValidation toValueValidation( @Surly Validation src ) { boolean oneOfValuesEmpty = src.oneOfValues().length == 0 || isAutoUnquotedJsonStrings( src.oneOfValues() ); boolean dependentRequiresEmpty = src.dependentRequired().length == 0; - if ( src.required().isAuto() && oneOfValuesEmpty && dependentRequiresEmpty ) return null; + if ( src.required().isAuto() && oneOfValuesEmpty && dependentRequiresEmpty && src.acceptNull().isAuto() ) return null; Set oneOfValues = oneOfValuesEmpty ? Set.of() : Set.copyOf( Stream.of( src.oneOfValues() ).map( e -> JsonValue.of( e ).toMinimizedJson() ).toList() ); - return new ValueValidation( src.required(), Set.of( src.dependentRequired() ), oneOfValues, List.of() ); + return new ValueValidation( src.required(), Set.of( src.dependentRequired() ), src.acceptNull(), oneOfValues, + List.of() ); } private static boolean isAutoUnquotedJsonStrings(String[] values) { diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java index 4ea9cd1..5e38082 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -27,6 +27,7 @@ import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; +import java.util.function.BiPredicate; import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.Supplier; @@ -162,7 +163,10 @@ private static Validator create( PropertyValidation node, String property ) { PropertyValidation.ValueValidation values = node.values(); boolean isRequiredYes = values != null && values.required().isYes(); boolean isRequiredAuto = (values == null || values.required().isAuto()) && isRequiredImplicitly( node, anyOf ); - Validator required = !isRequiredYes && !isRequiredAuto ? null : new Required( property ); + boolean isAllowNull = values != null && values.allowNull().isYes(); + Validator required = !isRequiredYes && !isRequiredAuto + ? null + : new Required( isAllowNull ? not(JsonValue::exists) : JsonValue::isUndefined, property ); return Guard.of( required, whenDefined ); } @@ -228,8 +232,10 @@ private static Validator createDependentRequired( @Surly Map p.values() == null || p.values().dependentRequired().isEmpty() ) ) return null; Map> groupPropertyRole = new HashMap<>(); + Map> isUndefined = new HashMap<>(); properties.forEach( ( name, property ) -> { PropertyValidation.ValueValidation values = property.values(); + isUndefined.put( name, isUndefined( values ) ); if ( values != null && !values.dependentRequired().isEmpty() ) { values.dependentRequired().forEach( role -> { String group = role.replace( "!", "" ).replace( "?", "" ); @@ -242,7 +248,7 @@ private static Validator createDependentRequired( @Surly Map all = new ArrayList<>(); groupPropertyRole.forEach( ( group, members ) -> { if ( members.values().stream().noneMatch( ObjectValidator::isDependentRequiredRole ) ) { - all.add( new DependentRequiredCodependent( Set.copyOf( members.keySet() ) ) ); + all.add( new DependentRequiredCodependent(Map.copyOf( isUndefined ), Set.copyOf( members.keySet() ) ) ); } else { Set> memberEntries = members.entrySet(); List present = memberEntries.stream().filter( e -> @@ -258,13 +264,21 @@ private static Validator createDependentRequired( @Surly Map e.getValue().substring( e.getValue().indexOf( '=' ) + 1 ) ) ); all.add( - new DependentRequired( Set.copyOf( present ), Set.copyOf( absent ), Map.copyOf( equals ), + new DependentRequired( Map.copyOf( isUndefined ), + Set.copyOf( present ), Set.copyOf( absent ), Map.copyOf( equals ), Set.copyOf( dependent ), Set.copyOf( exclusiveDependent ) ) ); } } ); return All.of( all.toArray( Validator[]::new ) ); } + @Surly + private static BiPredicate isUndefined( PropertyValidation.ValueValidation values ) { + if (values == null || !values.allowNull().isYes()) return JsonMixed::isUndefined; + BiPredicate test = JsonMixed::exists; + return test.negate(); + } + private static boolean isDependentRequiredRole( String group ) { return group.endsWith( "?" ) || group.endsWith( "!" ) || group.endsWith( "^" ) || group.contains( "=" ); } @@ -347,11 +361,11 @@ public void validate( JsonMixed value, Consumer addError ) { } } - private record Required(String property) implements Validator { + private record Required(Predicate isUndefined, String property) implements Validator { @Override public void validate( JsonMixed value, Consumer addError ) { - if ( value.isUndefined() ) + if ( isUndefined.test( value ) ) addError.accept( Error.of( Rule.REQUIRED, value, "%s is required but was " + (value.isNull() ? "null" : "undefined"), property ) ); } @@ -574,19 +588,20 @@ public void validate( JsonMixed value, Consumer addError ) { * @param dependents a set of properties that become required when triggering * @param exclusiveDependent a set of properties that become required when triggering but are also mutual exclusive */ - private record DependentRequired(Set present, Set absent, Map equals, + private record DependentRequired(Map> isUndefined, + Set present, Set absent, Map equals, Set dependents, Set exclusiveDependent) implements Validator { @Override public void validate( JsonMixed value, Consumer addError ) { if ( !value.isObject() ) return; - boolean presentNotMet = !present.isEmpty() && present.stream().anyMatch( value::isUndefined ); - boolean absentNotMet = !absent.isEmpty() && absent.stream().anyMatch( not( value::isUndefined ) ); + boolean presentNotMet = !present.isEmpty() && present.stream().anyMatch( p -> isUndefined.get( p ).test( value, p )); + boolean absentNotMet = !absent.isEmpty() && absent.stream().anyMatch( p -> !isUndefined.get( p ).test( value, p )); boolean equalsNotMet = !equals.isEmpty() && equals.entrySet().stream() .anyMatch( e -> !e.getValue().equals( value.getString( e.getKey() ).string() ) ); if ( presentNotMet || absentNotMet || equalsNotMet ) return; - if ( !dependents.isEmpty() && dependents.stream().anyMatch( value::isUndefined ) ) { - Set missing = Set.copyOf( dependents.stream().filter( value::isUndefined ).toList() ); + if ( !dependents.isEmpty() && dependents.stream().anyMatch( p -> isUndefined.get( p ).test( value, p ))) { + Set missing = Set.copyOf( dependents.stream().filter( p -> isUndefined.get( p ).test( value, p )).toList()); if (!equals.isEmpty()) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, value, "object with %s requires all of %s, missing: %s", equals, dependents, missing )); @@ -604,7 +619,7 @@ public void validate( JsonMixed value, Consumer addError ) { } if ( !exclusiveDependent.isEmpty() ) { Set defined = Set.copyOf( - exclusiveDependent.stream().filter( p -> !value.isUndefined( p ) ).toList() ); + exclusiveDependent.stream().filter( p -> !isUndefined.get( p ).test( value, p )).toList() ); if ( defined.size() == 1 ) return; // it is exclusively defined => OK if ( !equals.isEmpty()) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, value, @@ -630,15 +645,15 @@ public void validate( JsonMixed value, Consumer addError ) { } } - private record DependentRequiredCodependent(Set codependent) implements Validator { + private record DependentRequiredCodependent(Map> isUndefined, Set codependent) implements Validator { @Override public void validate( JsonMixed value, Consumer addError ) { if ( !value.isObject() ) return; - if ( codependent.stream().anyMatch( value::isUndefined ) && codependent.stream() - .anyMatch( not( value::isUndefined ) ) ) + if ( codependent.stream().anyMatch( p -> isUndefined.get( p ).test( value, p )) && codependent.stream() + .anyMatch( p -> !isUndefined.get( p ).test( value, p ))) addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, value, "object with any of %1$s all of %1$s are required, missing: %s", codependent, - Set.copyOf( codependent.stream().filter( value::isUndefined ).toList() ) ) ); + Set.copyOf( codependent.stream().filter( p -> isUndefined.get( p ).test( value, p ) ).toList() ) ) ); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java index 105053b..b6b81fc 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java @@ -70,8 +70,8 @@ PropertyValidation withItems( @Maybe PropertyValidation items ) { PropertyValidation withCustoms( @Surly List validators ) { if ( validators.isEmpty() && (values == null || values.customs.isEmpty()) ) return this; ValueValidation newValues = values == null - ? new ValueValidation( YesNo.AUTO, Set.of(), Set.of(), validators ) - : new ValueValidation( values.required, values.dependentRequired, values.anyOfJsons, validators ); + ? new ValueValidation( YesNo.AUTO, Set.of(), YesNo.AUTO, Set.of(), validators ) + : new ValueValidation( values.required, values.dependentRequired, values.allowNull, values.anyOfJsons, validators ); return new PropertyValidation( anyOfTypes, newValues, strings, numbers, arrays, objects, items ); } @@ -94,17 +94,20 @@ public PropertyValidation varargs() { * * @param required is the value required to exist or is undefined/null OK, non {@link YesNo#YES} is off * @param dependentRequired the groups this property is a member of for dependent requires + * @param allowNull when {@link YesNo#YES} a JSON {@code null} value satisfies being {@link #required()} or {@link #dependentRequired()} * @param anyOfJsons the JSON value must be one of the provided JSON values, empty set is off * @param customs a validator defined by class is used (custom or user defined validators), empty list is * off */ - record ValueValidation(@Surly YesNo required, @Surly Set dependentRequired, @Surly Set anyOfJsons, + record ValueValidation(@Surly YesNo required, @Surly Set dependentRequired, @Surly YesNo allowNull, + @Surly Set anyOfJsons, @Surly List customs) { ValueValidation overlay( @Maybe ValueValidation with ) { return with == null ? this : new ValueValidation( overlayY( required, with.required ), overlayC( dependentRequired, with.dependentRequired ), + overlayY( allowNull, with.allowNull ), overlayC( anyOfJsons, with.anyOfJsons ), overlayAdditive( customs, with.customs ) ); } diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java index cf5f51c..9f4593f 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java @@ -2,13 +2,16 @@ import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; +import org.hisp.dhis.jsontree.Required; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.Rule; import org.junit.jupiter.api.Test; +import java.util.Map; import java.util.Set; import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.hisp.dhis.jsontree.Validation.YesNo.YES; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; /** @@ -62,6 +65,19 @@ default String no() { } } + public interface JsonDependentRequiredExampleD extends JsonObject { + + @Required + @Validation(dependentRequired = "=update") + default String getOp() { return getString( "op" ).string(); } + + @Validation(dependentRequired = "update") + default String getPath() { return getString( "path" ).string(); } + + @Validation(dependentRequired = "update", acceptNull = YES) + default JsonMixed getValue() { return get( "value", JsonMixed.class ); } + } + @Test void testDependentRequired_Codependent() { assertValidationError( """ @@ -70,6 +86,9 @@ void testDependentRequired_Codependent() { assertValidationError( """ {"lastName":"peter"}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, Set.of( "firstName", "lastName" ), Set.of( "firstName" ) ); + assertValidationError( """ + {"lastName":"peter", "firstName": null}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, + Set.of( "firstName", "lastName" ), Set.of( "firstName" ) ); assertDoesNotThrow( () -> JsonMixed.of( """ {}""" ).validate( JsonDependentRequiredExampleA.class ) ); @@ -100,4 +119,22 @@ void testDependentRequired_AbsentDependent() { assertDoesNotThrow( () -> JsonMixed.of( """ {"street":"main", "no":"11"}""" ).validate( JsonDependentRequiredExampleC.class ) ); } + + @Test + void testDependentRequired_AllowNull() { + assertValidationError( """ + {"op":"update"}""", JsonDependentRequiredExampleD.class, Rule.DEPENDENT_REQUIRED, + Map.of("op", "update"), Set.of("value", "path"), Set.of("value", "path") ); + assertValidationError( """ + {"op":"update", "value": null}""", JsonDependentRequiredExampleD.class, Rule.DEPENDENT_REQUIRED, + Map.of("op", "update"), Set.of("value", "path"), Set.of("path") ); + + assertDoesNotThrow( () -> JsonMixed.of( """ + {"op":"other"}""" ).validate( JsonDependentRequiredExampleD.class ) ); + assertDoesNotThrow( () -> JsonMixed.of( """ + {"op":"update", "path": "x", "value": null}""" ).validate( JsonDependentRequiredExampleD.class ) ); + assertDoesNotThrow( () -> JsonMixed.of( """ + {"op":"update", "path": "x", "value": 1}""" ).validate( JsonDependentRequiredExampleD.class ) ); + } + } diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java index b075644..e1b49b8 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java @@ -41,6 +41,8 @@ import java.util.Set; import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.hisp.dhis.jsontree.Validation.YesNo.YES; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -88,9 +90,16 @@ default JsonFoo getB() { } } + public interface JsonRequiredExampleA extends JsonObject { + + @Required + @Validation(acceptNull = YES) + default JsonMixed value() { return get( "value", JsonMixed.class ); } + } + @Test void testIsA() { - Assertions.assertTrue( JsonMixed.ofNonStandard( "{'bar':'x'}" ).isA( JsonFoo.class ) ); + assertTrue( JsonMixed.ofNonStandard( "{'bar':'x'}" ).isA( JsonFoo.class ) ); JsonMixed val = JsonMixed.ofNonStandard( "{'key':'x', 'value': 1}" ); JsonEntry e = val.as( JsonEntry.class ); assertTrue( val.isA( JsonEntry.class ) ); @@ -195,6 +204,17 @@ void testAsObject_NotAnObjectRecursive() { assertValidationError( json, JsonRoot.class, Rule.TYPE, Set.of( NodeType.OBJECT ), NodeType.ARRAY ); } + @Test + void testRequired_AcceptNull() { + Validation.Error error = assertValidationError( """ + {"a": 1}""", JsonRequiredExampleA.class, Rule.REQUIRED, "value" ); + assertEquals( "$.value", error.path() ); + assertDoesNotThrow( () -> JsonMixed.of(""" + {"value":null}""").validate( JsonRequiredExampleA.class )); + assertDoesNotThrow( () -> JsonMixed.of(""" + {"value":1}""").validate( JsonRequiredExampleA.class )); + } + private static void assertAsObject( Class of, String actualJson ) { JsonObject obj = JsonMixed.ofNonStandard( actualJson ).asA( of ); assertNotNull( obj ); From 07fe74d2a4dad76034b926102ea03ebbb7cf2577 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 24 Jun 2024 09:44:29 +0200 Subject: [PATCH 05/11] feat: more patch (incomplete) --- CHANGELOG.md | 41 +++- .../java/org/hisp/dhis/jsontree/JsonNode.java | 45 +++- .../hisp/dhis/jsontree/JsonNodeOperation.java | 149 +++++++++++++ .../org/hisp/dhis/jsontree/JsonPatch.java | 17 +- .../dhis/jsontree/JsonPatchException.java | 23 ++ .../org/hisp/dhis/jsontree/JsonPointer.java | 16 +- .../java/org/hisp/dhis/jsontree/JsonTree.java | 60 ++---- .../hisp/dhis/jsontree/JsonTreeOperation.java | 117 ++++++++++ .../org/hisp/dhis/jsontree/JsonValue.java | 5 + .../org/hisp/dhis/jsontree/PathSelector.java | 28 +++ .../jsontree/validation/ObjectValidator.java | 9 +- .../dhis/jsontree/JsonNodeOperationTest.java | 204 ++++++++++++++++++ .../org/hisp/dhis/jsontree/JsonNodeTest.java | 14 ++ .../dhis/jsontree/JsonPatchConflictTest.java | 154 ------------- .../dhis/jsontree/JsonPatchSuiteTest.java | 9 +- .../resources/json-patch-tests/tests.json | 21 +- 16 files changed, 675 insertions(+), 237 deletions(-) create mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java create mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java create mode 100644 src/main/java/org/hisp/dhis/jsontree/PathSelector.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java delete mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a478e35..b4cb443 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,37 @@ # ChangeLog -## [Unreleased] v1.1 +## v1.1 - Bulk Modification APIs - [Unreleased] -**Features** -* Added: _json-patch_ support, see `JsonPatch`, `JsonPointer` -* Added: node bulk modification API: `JsonNode#patch` + `JsonNode.Insert`, `JsonNode.Remove` -* Added: test for same information `JsonValue#equivalentTo` -* Added: test for same definition (ignoring formatting) `JsonValue#identicalTo` -* Added: `@Validation#acceptNull` to declare that `null` value satisfies required +> [!Note] +> ### Major Features +> * **Added**: [JSON Patch](https://jsonpatch.com/) support; `JsonValue#patch` (`JsonPatch`, `JsonPointer`) +> * **Added**: bulk modification API: `JsonNode#patch` + `JsonNodeOperation` +> * **Added**: `@Validation#acceptNull()`, `null` value satisfies required property -**Breaking Changes** +> [!Tip] +> ### Minor API Improvements +> * **Added**: JSON value test for same information `JsonValue#equivalentTo` +> * **Added**: JSON value test for same definition (ignoring formatting) `JsonValue#identicalTo` +> * **Added**: `JsonAbstractObject#exists(String)` test if object member exists +> * **Changed**: `JsonNode#equals` and `JsonNode#hashCode` are now based on the json input -**Bugfixes** \ No newline at end of file + +> [!Warning] +> ### Breaking Changes + +> [!Caution] +> ### Bugfixes + + +## v1.0 Matured APIs - January 2024 +Unfortunately no detailed changelog was maintained prior to version 1.0. + +The following is a recollection from memory on major improvements in versions +close the 1.0 release. + +> [!Note] +> ### Major Features +> * **Added**: [JSON Schema Validation](https://json-schema.org/) support; +> `JsonAbstractObject#validate` and `JsonAbstractArray#validateEach` + +> `@Validation` and `@Required` +> \ No newline at end of file diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 28a27a6..e6156d3 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -246,6 +246,22 @@ default JsonNode get( String path ) format( "This is a leaf node of type %s that does not have any children at path: %s", getType(), path ) ); } + /** + * Access node by path with default. + * + * @param path a simple or nested path relative to this node + * @param orDefault value to return in no node at the given path exist in this subtree + * @return the node at path or the provided default if no such node exists + * @since 1.1 + */ + default JsonNode getOrDefault( String path, JsonNode orDefault ) { + try { + return get( path ); + } catch ( JsonPathException ex ) { + return orDefault; + } + } + /** * Size of an array of number of object members. *

@@ -743,18 +759,26 @@ default JsonNode removeElements( int from, int to ) { } ) ); } - sealed interface Operation { String path(); } - record Insert(String path, JsonNode value) implements Operation {} - record Remove(String path) implements Operation {} - /** - * Operations cannot target a path that was removed or added in the same patch. - * + * A {@link JsonNode} is an immutable value. For similar reasons all operations in a patch apply to the same target + * value state. The state does not "update" in-between the operations. This means a set of operations can be + * considered declarative. Inserts and removes that contradict each other make the patch invalid and a + * {@link JsonPatchException} is thrown. + *

+ *

Patching array elements

+ * The immutable nature is especially relevant for operations targeting arrays. For example, to insert 3 element at + * position 4 it is not correct to generate 3 insert operations at index 4,5,6 as each of these indexes refers to + * the array before the change. Instead, use a single operation with {@link JsonNodeOperation.Insert#merge()} and a + * value array containing the three elements. * - * @param ops - * @return + * @param ops the set of patch operations to apply (paths are relative to this node) + * @return the root node of the patched tree (this node and tree stay unchanged); removing the root results in + * {@link JsonNode#NULL} + * @throws JsonPatchException when any two of the given operations contradict each other (for example removing parts + * of an insert or inserting into a removed subtree) or when any of the arrays + * or objects targeted for insert do not exist */ - JsonNode patch( List ops) throws JsonPatchException; + JsonNode patch( List ops) throws JsonPatchException; private void checkType( JsonNodeType expected, JsonNodeType actual, String operation ) { if ( actual != expected ) @@ -770,5 +794,8 @@ static String parentPath( String path ) { return end < 0 ? "" : path.substring( 0, end ); } + static String nextIndexPath(String path) { + return path; + } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java b/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java new file mode 100644 index 0000000..b15da34 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java @@ -0,0 +1,149 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.JsonBuilder.JsonArrayBuilder; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; + +import static java.util.stream.Collectors.toMap; +import static org.hisp.dhis.jsontree.JsonBuilder.createArray; +import static org.hisp.dhis.jsontree.JsonNodeType.OBJECT; +import static org.hisp.dhis.jsontree.JsonPatchException.clash; + +/** + * {@linkplain JsonNodeOperation}s are used to make bulk modifications using {@link JsonNode#patch(List)}. + *

+ * {@linkplain JsonNodeOperation} is a path based operation that is not yet "bound" to target. + *

+ * The order of operations made into a set does not matter. Any order has the same outcome when applied to the same + * target. + * + * @author Jan Bernitt + * @since 1.1 + */ +sealed public interface JsonNodeOperation { + + /** + * @return the target of the operation + */ + String path(); + + /** + * @return true when this operation targets an array index + */ + default boolean isArrayOp() { + return path().endsWith( "]" ); + } + + /** + * @return true when this is an {@link Insert} operation + */ + default boolean isRemove() { + return this instanceof Remove; + } + + /** + * @param path relative path to remove + */ + record Remove(String path) implements JsonNodeOperation {} + + /** + *

Insert into Arrays

+ * In an array the value is inserted before the existing value at the path index. That means the current value at + * the path index will be after the inserted value in the updated tree. + *

+ *

Merge

+ *
    + *
  • object + object = add all properties of inserted object to target object
  • + *
  • array + array = insert all elements of inserted array at target index into the target array
  • + *
  • array + primitive = append inserted element to target array
  • + *
  • primitive + primitive = create array with current value and inserted value
  • + *
  • * + object = trying to merge an object value into a non object target is an error
  • + *
+ * + * @param path relative path to the target property, this either is the root, an object member or an array index or + * range + * @param value the new value + * @param merge when true, insert the value's items not the value itself + */ + record Insert(String path, JsonNode value, boolean merge) implements JsonNodeOperation { + public Insert(String path, JsonNode value) { this(path, value, false); } + } + + /** + * As each target path may only occur once a set of operations may need folding inserts for arrays. This means each + * operation that wants to insert at the same index in the same target array is merged into a single operation + * inserting all the values in the order they occur in the #ops parameter. + * + * @param ops a set of ops that may contain multiple inserts targeting the same array index + * @return a list of operations where the clashing array inserts have been merged by concatenating the inserted + * elements + * @throws JsonPathException if the ops is found to contain other operations clashing on same path (that are not + * array inserts) + */ + static List mergeArrayInserts(List ops) { + if (ops.stream().filter( JsonNodeOperation::isArrayOp ).count() < 2) return ops; + return List.copyOf( ops.stream() + .collect( toMap(JsonNodeOperation::path, Function.identity(), (op1, op2) -> { + if (!op1.isArrayOp() || op1.isRemove() || op2.isRemove() ) + throw JsonPatchException.clash( ops, op1, op2 ); + JsonNode merged = createArray( arr -> { + Consumer add = op -> { + Insert insert = (Insert) op; + if ( insert.merge() ) { + arr.addElements( insert.value().elements(), JsonArrayBuilder::addElement ); + } else { + arr.addElement( insert.value() ); + } + }; + add.accept( op1 ); + add.accept( op2 ); + } ); + return new Insert( op1.path(), merged, true ); + }, LinkedHashMap::new ) ).values()); + } + + /** + * @param ops set of patch operations + * @implNote array merge inserts don't need special handling as it is irrelevant how many elements are inserted at + * the target index as each operation is independent and uniquely targets an insert position in the target array in + * its state before any change + */ + static void checkPatch( List ops ) { + if (ops.size() < 2) return; + Map opsByPath = new HashMap<>(); + Set parents = new HashSet<>(); + for ( JsonNodeOperation op : ops ) { + String path = op.path(); + if (op instanceof Insert insert && insert.merge && insert.value.getType() == OBJECT) { + insert.value.keys().forEach( p -> checkPatchPath( ops, op, path+"."+p, opsByPath, parents ) ); + checkPatchParents( ops, op, path, opsByPath, parents ); + } else { + checkPatchPath( ops, op, path, opsByPath, parents ); + checkPatchParents( ops, op, JsonNode.parentPath( path ), opsByPath, parents ); + } + } + } + + private static void checkPatchPath( List ops, JsonNodeOperation op, String path, + Map opsByPath, Set parents ) { + if ( opsByPath.containsKey( path ) ) throw clash( ops, opsByPath.get( path ), op ); + if ( parents.contains( path ) ) throw clash( ops, op, null ); + opsByPath.put( path, op ); + } + + private static void checkPatchParents( List ops, JsonNodeOperation op, String path, + Map opsByPath, Set parents ) { + while ( !path.isEmpty() ) { + if ( opsByPath.containsKey( path ) ) throw clash( ops, opsByPath.get( path ), op ); + parents.add( path ); + path = JsonNode.parentPath( path ); + } + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java index 0426af9..382b9b4 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java @@ -1,7 +1,7 @@ package org.hisp.dhis.jsontree; -import org.hisp.dhis.jsontree.JsonNode.Insert; -import org.hisp.dhis.jsontree.JsonNode.Remove; +import org.hisp.dhis.jsontree.JsonNodeOperation.Insert; +import org.hisp.dhis.jsontree.JsonNodeOperation.Remove; import java.util.ArrayList; import java.util.List; @@ -45,24 +45,25 @@ static JsonValue apply(JsonValue value, JsonList ops) throws JsonPatc } /** - * Converts a json-patch to {@link JsonNode.Operation}s. + * Converts a json-patch to {@link JsonNodeOperation}s. * * @param value the target value * @param with the patch to apply - * @return list of {@link JsonNode.Operation}s to apply to get the patch effect + * @return list of {@link JsonNodeOperation}s to apply to get the patch effect */ - private static List operations(JsonMixed value, JsonList with) { - List ops = new ArrayList<>(); + private static List operations(JsonMixed value, JsonList with) { + List ops = new ArrayList<>(); int i = 0; for (JsonPatch op : with) { op.validate( JsonPatch.class ); String path = op.getPath().path(); + //FIXME if path is - (append) then this must be substituted with the actual first index after the last switch ( op.getOperation() ) { case ADD -> ops.add( new Insert(path, op.getValue().node() ) ); case REMOVE -> ops.add( new Remove( path ) ); case REPLACE -> { - ops.add( new Remove( path ) ); - ops.add( new Insert( path, op.getValue().node() )); + ops.add( new Remove( path)); + ops.add( new Insert( JsonNode.nextIndexPath( path ), op.getValue().node() )); } case MOVE -> { String from = op.getFrom().path(); diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java index fd29f98..96527c1 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java @@ -1,5 +1,10 @@ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.internal.Maybe; +import org.hisp.dhis.jsontree.internal.Surly; + +import java.util.List; + /** * When a {@link JsonPatch} operation fails. * @@ -8,6 +13,24 @@ */ public final class JsonPatchException extends IllegalArgumentException { + @Surly + public static JsonPatchException clash(@Surly List ops, @Surly JsonNodeOperation a, @Maybe JsonNodeOperation b ) { + String ap = a.path(); + if ( b == null ) return clash( ops, a, + ops.stream().filter( op -> op.path().startsWith( ap ) ).findFirst() + .orElseThrow( () -> new JsonPatchException( "" ) ) ); + int aIndex = ops.indexOf( a ); + int bIndex = ops.lastIndexOf(b); // use last in case 2 identical operations + String bp = b.path(); + if ( ap.equals( bp )) + return new JsonPatchException( "operation %d has same target as operation %d: %s %s".formatted( aIndex, bIndex, a, b ) ); + if ( bp.startsWith( ap ) && bp.length() > ap.length()) return clash( ops, b, a ); + if ( ap.startsWith( bp ) && ap.length() > bp.length()) + return new JsonPatchException( "operation %d targets child of operation %d: %s %s".formatted( aIndex, bIndex, a, b ) ); + // this should only happen for object merge clashes + return new JsonPatchException( "operation %d contradicts operation %d: %s %s".formatted( aIndex, bIndex, a, b ) ); + } + public JsonPatchException(String msg ) { super( msg ); } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java index 45c9086..0152e53 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java @@ -1,8 +1,10 @@ package org.hisp.dhis.jsontree; import java.util.List; +import java.util.stream.Collectors; import java.util.stream.Stream; +import static java.util.stream.Collectors.joining; import static org.hisp.dhis.jsontree.Validation.NodeType.STRING; /** @@ -13,7 +15,7 @@ * * @param value a pointer expression */ -@Validation( type = STRING, pattern = "(/([^/~]|(~[01]))*)*" ) +@Validation( type = STRING, pattern = "(/((~[01])|([^/~]))*)*" ) public record JsonPointer(String value) { /** @@ -36,7 +38,17 @@ private static String decode(String segment) { */ public String path() { if (value.isEmpty()) return ""; - return String.join( ".", decode() ); + return decode().stream().map( JsonPointer::toPath ).collect( joining()); + } + + private static String toPath(String segment) { + if (segment.isEmpty()) return segment; + if (segment.chars().allMatch( JsonPointer::isArrayIndex )) return "["+segment+"]"; + return "."+segment; + } + + private static boolean isArrayIndex(int c) { + return c == '-' || c >= '0' && c <= '9'; } @Override diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 413bf78..0a4229c 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -27,20 +27,23 @@ */ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.JsonNodeOperation.Insert; +import org.hisp.dhis.jsontree.JsonNodeOperation.Remove; import org.hisp.dhis.jsontree.internal.Maybe; import org.hisp.dhis.jsontree.internal.Surly; import java.io.Serializable; import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.Optional; -import java.util.Set; import java.util.function.Consumer; import java.util.function.IntConsumer; import java.util.function.Predicate; @@ -108,8 +111,6 @@ private abstract static class LazyJsonNode implements JsonNode { protected Integer end; private transient T value; - //TODO remember the index in parent array/object to improve size() performance? - LazyJsonNode( JsonTree tree, String path, int start ) { this.tree = tree; this.path = path; @@ -190,9 +191,12 @@ public final JsonNode replaceWith( String json ) { } @Override - public final JsonNode patch( List ops ) { - checkPatch( ops ); - + public final JsonNode patch( List ops ) { + if (ops.isEmpty()) return getRoot(); + if (isRoot() && ops.size() == 1 && ops.get( 0 ).path().isEmpty()) + return ops.get( 0 ) instanceof Insert i ? i.value() : JsonNode.NULL; // root insert/remove + List treeOps = JsonTreeOperation.of( ops, this ); + //TODO return this; } @@ -226,43 +230,23 @@ public final String toString() { return getDeclaration(); } + + @Override + public int hashCode() { + return Arrays.hashCode( tree.json ); + } + + @Override + public boolean equals( Object obj ) { + return this == obj || obj instanceof LazyJsonNode other && Arrays.equals(tree.json, other.tree.json); + } + /** * @return parses the JSON to a value as described by {@link #value()} */ abstract T parseValue(); } - // - operations must not target a parent of a prior operation - // - operations must not target a child of a prior operation - // - operations must not target same path as a prior insert - static void checkPatch( List ops ) { - if (ops.size() < 2) return; - Set removeTargets = new HashSet<>(); - Set insertTargets = new HashSet<>(); - Set parents = new HashSet<>(); - int i = 0; - for ( JsonNode.Operation op : ops ) { - String path = op.path(); - boolean isRemove = op instanceof JsonNode.Remove; - boolean isInsert = !isRemove; - if (insertTargets.contains( path )) - throw new JsonPatchException("operation %d targets same path of prior insert: %s".formatted( i, op ) ); - if (parents.contains( path )) - throw new JsonPatchException("operation %d targets parent of prior operation: %s".formatted( i, op ) ); - if (isInsert) insertTargets.add( path ); - if (isRemove) removeTargets.add( path ); - while ( path.lastIndexOf('.' ) >= 0 ) { - path = path.substring( 0, path.lastIndexOf( '.' ) ); - if (insertTargets.contains( path )) - throw new JsonPatchException("operation %d targets child of prior insert: %s".formatted( i, op ) ); - if (removeTargets.contains( path )) - throw new JsonPatchException("operation %d targets child of prior remove: %s".formatted( i, op ) ); - parents.add( path ); - } - i++; - } - } - private static final class LazyJsonObject extends LazyJsonNode>> implements Iterable> { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java b/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java new file mode 100644 index 0000000..5d1650f --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java @@ -0,0 +1,117 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.internal.Maybe; +import org.hisp.dhis.jsontree.internal.Surly; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import static java.lang.Integer.parseInt; +import static java.util.Comparator.comparing; +import static org.hisp.dhis.jsontree.JsonNode.parentPath; + +/** + * {@link JsonTreeOperation}s are used internally to make modifications to a {@link JsonTree}. + *

+ * In contrast to {@link JsonNodeOperation}s they are based on a {@link #target()} {@link JsonNode} and their order + * matters as they have to be applied in order of their {@link #sortIndex()} to cut and paste a new {@link JsonTree} + * together without forcing intermediate representations (which is most of all a performance optimisation.). + *

+ * This means the {@link JsonNodeOperation#path()} has been resolved to a {@link JsonNode} in an actual target + * {@link JsonTree}. The paths are interpreted relative to the target {@link JsonNode} provided in the + * {@link #of(JsonNodeOperation, JsonNode)} method. + * + * @author Jan Bernitt + * @since 1.1 + */ +sealed interface JsonTreeOperation { + + /** + * @return the node to modify or if no such mode exists the parent that gets modified + */ + JsonNode target(); + + /** + * The goal of this index is to bring the operations into an order that allows to copy together a new tree from the + * original tree and nodes brought in by the operations. + * + * @return the index in the underlying JSON input that is (or is near) the start position where a modification takes + * place. Usually this is the start of the modified node but for example if new members or elements are added to an + * object/array these are appended, therefore this points to the end of the parent they are appended to + */ + default int sortIndex() { + return target().startIndex(); + } + + /* + * Object operations + */ + + record RemoveMember(JsonNode target, String name) implements JsonTreeOperation { } + + record ReplaceMember(JsonNode target, String name, JsonNode value) implements JsonTreeOperation { } + + /** + * + * @param target the parent members are added to + * @param values a map of all members to add + */ + record AddMembers(JsonNode target, Map values) implements JsonTreeOperation { + + @Override public int sortIndex() { + return target.endIndex(); + } + } + + /* + * Array operations + */ + + record RemoveElements(JsonNode target, int index) implements JsonTreeOperation { } + + record InsertElements(JsonNode target, int index, List value) implements JsonTreeOperation {} + + record AppendElements(JsonNode target, List values) implements JsonTreeOperation { + + @Override public int sortIndex() { + return target.endIndex(); + } + } + + @Surly + static List of(List ops, JsonNode target) { + return ops.stream() + .flatMap( e -> of(e, target).stream() ) + .sorted(comparing( JsonTreeOperation::sortIndex )) + .toList(); + } + + @Surly + static List of(JsonNodeOperation op, JsonNode target) { + String path = op.path(); + String name = path.substring( path.lastIndexOf( '/' ) +1 ); + if (op instanceof JsonNodeOperation.Remove ) { + JsonNode t = target.getOrDefault( path, null ); + if (t == null) return List.of(); + if (t.getParent().getType() == JsonNodeType.OBJECT) + return List.of(new RemoveMember( t, name )); + return List.of(new RemoveElements( t, parseInt( name ) )); + } + if (op instanceof JsonNodeOperation.Insert insert) { + JsonNode t = target.getOrDefault( path, null ); + JsonNode value = insert.value(); + if (t == null) { + JsonNode parent = target.get( parentPath( path ) ); + boolean isObjectParent = parent.getType() == JsonNodeType.OBJECT; + if (isObjectParent) return List.of(new AddMembers( parent, Map.of(name, value ) )); + return List.of(new InsertElements( parent, parseInt( name ), List.of(value) )); + } + if (t.getType() == JsonNodeType.OBJECT) return List.of(new ReplaceMember( t, name, value )); + return List.of(new AppendElements( t, List.of(value) )); + } + throw new UnsupportedOperationException(op.toString()); + } + +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index a4cc679..e0bee37 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -33,10 +33,12 @@ import java.io.Reader; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Stream; /** * The {@link JsonValue} is a virtual read-only view for {@link JsonNode} representing an actual {@link JsonTree}. @@ -356,6 +358,8 @@ private static boolean equivalentTo(JsonValue a, JsonValue b, BiPredicateoperations must not target a child of a prior operation *

  • operations must not target same path as a prior insert
  • * + * That means in a valid sequence of operations all removes can be reordered to occur before any insert + * and can be done in a random order among the removes. * * @param ops operations to apply "atomically" * @return a new value with the effects of the patch operations (this value stays unchanged) @@ -447,4 +451,5 @@ default T find( Class type, Predicate test ) { ? JsonMixed.of( "{}" ).get( "notFound", type ) : match.get().lift( store ).as( type ); } + } diff --git a/src/main/java/org/hisp/dhis/jsontree/PathSelector.java b/src/main/java/org/hisp/dhis/jsontree/PathSelector.java new file mode 100644 index 0000000..ca2b894 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/PathSelector.java @@ -0,0 +1,28 @@ +package org.hisp.dhis.jsontree; + +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Stream; + +/** + * + * @param as + * @param select + * @param followSelected + * @param items + * @param + */ +public record PathSelector(Class as, Predicate select, boolean followSelected, + Map> items) { + + static Stream visit(T root, PathSelector selector ) { + Stream.Builder add = Stream.builder(); + if (selector.select.test( root )) { + selector.items.forEach( (path, pathSelector ) -> { + // is path an array? + // + } ); + } + return add.build(); + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java index 5e38082..9944991 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -31,6 +31,7 @@ import java.util.function.Consumer; import java.util.function.Predicate; import java.util.function.Supplier; +import java.util.regex.Pattern; import java.util.stream.Stream; import static java.lang.Double.isNaN; @@ -198,7 +199,7 @@ private static Validator create( @Maybe PropertyValidation.StringValidation stri : new EnumAnyString( strings.anyOfStrings(), strings.caseInsensitive().isYes() ), strings.minLength() <= 0 ? null : new MinLength( strings.minLength() ), strings.maxLength() <= 1 ? null : new MaxLength( strings.maxLength() ), - strings.pattern().isEmpty() ? null : new Pattern( strings.pattern() ) ); + strings.pattern().isEmpty() ? null : new Pattern( java.util.regex.Pattern.compile( strings.pattern() ) ) ); } @Maybe @@ -429,15 +430,15 @@ public void validate( JsonMixed value, Consumer addError ) { } } - private record Pattern(String regex) implements Validator { + private record Pattern(java.util.regex.Pattern regex) implements Validator { @Override public void validate( JsonMixed value, Consumer addError ) { if ( !value.isString() ) return; String actual = value.string(); - if ( !actual.matches( regex ) ) + if ( !regex.matcher( actual ).matches() ) addError.accept( Error.of( Rule.PATTERN, value, - "must match %s but was: %s", regex, actual ) ); + "must match %s but was: %s", regex.pattern(), actual ) ); } } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java new file mode 100644 index 0000000..247b127 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java @@ -0,0 +1,204 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.JsonNodeOperation.Insert; +import org.hisp.dhis.jsontree.JsonNodeOperation.Remove; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.hisp.dhis.jsontree.JsonNode.NULL; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Checks the conflict detection when running a {@link JsonNode#patch(List)}. + */ +class JsonNodeOperationTest { + + @Test + void testPatch_RemoveSamePathBefore() { + assertRejected("operation 0 has same target as operation 1:", + new Remove( "foo.bar" ), + new Insert( "foo.bar", NULL ) ); + } + + @Test + void testPatch_RemoveSamePathAfter() { + assertRejected("operation 0 has same target as operation 1:", + new Insert( "foo.bar", NULL ), + new Remove( "foo.bar" )); + } + + @Test + void testPatch_RemoveSamePathRemove() { + assertRejected("operation 0 has same target as operation 1:", + new Remove( "foo.bar" ), + new Remove( "foo.bar" ) ); + } + + @Test + void testPatch_InsertSamePathInsert() { + assertRejected("operation 0 has same target as operation 1:", + new Insert( "foo.bar", NULL ), + new Insert( "foo.bar", NULL ) ); + } + + @Test + void testPatch_InsertSiblingsPathInsert() { + assertAccepted( + new Insert( "foo.x", NULL ), + new Insert( "foo.y", NULL ) ); + } + + @Test + void testPatch_RemoveChildPathAfter() { + assertRejected("operation 1 targets child of operation 0:", + new Insert( "foo", NULL ), + new Remove( "foo.bar" ) ); + assertRejected("operation 1 targets child of operation 0:", + new Insert( "foo.bar", NULL ), + new Remove( "foo.bar.baz" ) ); + } + @Test + void testPatch_RemoveChildPathBefore() { + assertRejected("operation 0 targets child of operation 1:", + new Remove( "foo.bar" ), + new Insert( "foo", NULL )); + assertRejected("operation 0 targets child of operation 1:", + new Remove( "foo.bar.baz" ), + new Insert( "foo.bar", NULL )); + } + + @Test + void testPatch_RemoveParentPathBefore() { + assertRejected("operation 1 targets child of operation 0:", + new Remove( "foo" ), + new Insert( "foo.bar", NULL )); + assertRejected("operation 1 targets child of operation 0:", + new Remove( "foo.bar" ), + new Insert( "foo.bar.baz", NULL )); + } + + @Test + void testPatch_InsertParentPathBefore() { + assertRejected("operation 1 targets child of operation 0:", + new Insert( "foo.bar", NULL ), + new Insert( "foo.bar.baz", NULL )); + } + + @Test + void testPatch_RemoveParentPathAfter() { + assertRejected("operation 0 targets child of operation 1:", + new Insert( "foo.bar", NULL ), + new Remove( "foo" ) ); + assertRejected("operation 0 targets child of operation 1:", + new Insert( "foo.bar.baz", NULL ), + new Remove( "foo.bar" ) ); + } + + @Test + void testPatch_InsertParentPathAfter() { + assertRejected("operation 0 targets child of operation 1:", + new Insert( "foo.bar.baz", NULL ), + new Insert( "foo.bar", NULL )); + } + + @Test + void testPatch_RemoveParentPathRemoveAfter() { + assertRejected("operation 0 targets child of operation 1:", + new Remove( "foo.bar.baz" ), + new Remove( "foo.bar" ) ); + } + + @Test + void testPatch_RemoveParentPathRemoveBefore() { + assertRejected("operation 1 targets child of operation 0:", + new Remove( "foo.bar" ), + new Remove( "foo.bar.baz" )); + } + + @Test + void testPatch_InsertArrayInsert() { + assertAccepted( + new Insert( "foo[0]", NULL ), + new Insert( "foo[1]", NULL )); + } + + @Test + void testPatch_Misc() { + assertAccepted( + new Insert( "foo.x", NULL ), + new Remove( "bar.x" ), + new Insert( "foo.y", NULL ), + new Remove( "fo" ), + new Insert( "y", NULL ), + new Insert( "que", NULL ) + ); + } + + @Test + void testPatch_ObjectMerge() { + assertAccepted( + new Insert( "foo", JsonNode.of( """ + {"x": 1, "y": 2}""" ), true), + new Insert( "foo.z", JsonNode.of( "3" ) )); + + assertAccepted( + new Insert( "foo", JsonNode.of( """ + {"x": 1, "y": 2}""" ), true), + new Insert( "foo", JsonNode.of( """ + {"z": 3, "zero": 0}""" ), true)); + + assertRejected("operation 1 targets child of operation 0:", + new Insert( "foo", JsonNode.of( """ + {"z": 1, "y": 2}""" ), true), + new Insert( "foo.z", JsonNode.of( "3" ) )); + + assertRejected("operation 0 has same target as operation 1:", + new Insert( "foo", JsonNode.of( """ + {"x": 1, "y": 2}""" ), true), + new Insert( "foo", JsonNode.of( """ + {"x": 3, "zero": 0}""" ), true)); + } + + @Test + void testMergeArrayInserts_Uniform() { + assertEquals( List.of( + new Insert("foo[0]", JsonNode.of( "[1,2,3,4]" ), true)), + + JsonNodeOperation.mergeArrayInserts( List.of( + new Insert( "foo[0]", JsonNode.of( "1" ) ), + new Insert( "foo[0]", JsonNode.of( "[2,3]" ), true ), + new Insert( "foo[0]", JsonNode.of( "4" ) ) + ) )); + } + + @Test + void testMergeArrayInserts_Mixed() { + assertEquals( List.of( + new Remove( "x" ), + new Insert( "foo[0]", JsonNode.of( "[1,2,3,4]" ), true ), + new Insert( "bar", NULL ) ), + + JsonNodeOperation.mergeArrayInserts( List.of( + new Remove( "x" ), + new Insert( "foo[0]", JsonNode.of( "1" ) ), + new Insert( "bar", NULL ), + new Insert( "foo[0]", JsonNode.of( "[2,3]" ), true ), + new Insert( "foo[0]", JsonNode.of( "4" ) ) + ) ) ); + } + + private static void assertRejected(String error, JsonNodeOperation... ops) { + JsonPatchException ex = assertThrows( JsonPatchException.class, + () -> JsonNodeOperation.checkPatch( List.of( ops ) ) ); + String msg = ex.getMessage(); + assertEquals( error, msg.substring( 0, Math.min( msg.length(), error.length() ) ), msg ); + } + + private static void assertAccepted( JsonNodeOperation... ops) { + assertDoesNotThrow( () -> JsonNodeOperation.checkPatch( List.of(ops) ) ); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java index 77a38bb..05dda29 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java @@ -34,6 +34,7 @@ import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; /** @@ -43,6 +44,11 @@ */ class JsonNodeTest { + @Test + void testEquals() { + assertEquals( JsonNode.of( "1" ), JsonNode.of( "1" ) ); + } + @Test void testGet_String() { assertGetThrowsJsonPathException( "\"hello\"", @@ -75,6 +81,14 @@ void testGet_Object() { assertEquals( 42, b.get( "c" ).value() ); } + @Test + void testGet_EmptyProperty() { + JsonNode root = JsonNode.of( """ + {"": "hello"}""" ); + assertSame( root, root.get( "" ) ); + assertEquals( "hello", root.get( "{}" ).value() ); + } + @Test void testGet_Object_NoValueAtPath() { assertGetThrowsJsonPathException( "{\"a\":{\"b\":{\"c\":42}}}", "b", diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java deleted file mode 100644 index 840b40c..0000000 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPatchConflictTest.java +++ /dev/null @@ -1,154 +0,0 @@ -package org.hisp.dhis.jsontree; - -import org.hisp.dhis.jsontree.JsonNode.Insert; -import org.hisp.dhis.jsontree.JsonNode.Remove; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.hisp.dhis.jsontree.JsonNode.NULL; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Checks the conflict detection when running a {@link JsonNode#patch(List)}. - */ -class JsonPatchConflictTest { - - @Test - void testPatch_RemoveSamePathBefore() { - assertNoConflict( - new Remove( "foo.bar" ), - new Insert( "foo.bar", NULL ) ); - } - - @Test - void testPatch_RemoveSamePathAfter() { - assertSameConflict( - new Insert( "foo.bar", NULL ), - new Remove( "foo.bar" )); - } - - @Test - void testPatch_RemoveSamePathRemove() { - assertNoConflict( - new Remove( "foo.bar" ), - new Remove( "foo.bar" ) ); - } - - @Test - void testPatch_InsertSamePathInsert() { - assertSameConflict( - new Insert( "foo.bar", NULL ), - new Insert( "foo.bar", NULL ) ); - } - - @Test - void testPatch_InsertSiblingsPathInsert() { - assertNoConflict( - new Insert( "foo.x", NULL ), - new Insert( "foo.y", NULL ) ); - } - - @Test - void testPatch_RemoveChildPathAfter() { - assertChildConflict( - new Insert( "foo", NULL ), - new Remove( "foo.bar" ) ); - assertChildConflict( - new Insert( "foo.bar", NULL ), - new Remove( "foo.bar.baz" ) ); - } - @Test - void testPatch_RemoveChildPathBefore() { - assertParentConflict( - new Remove( "foo.bar" ), - new Insert( "foo", NULL )); - assertParentConflict( - new Remove( "foo.bar.baz" ), - new Insert( "foo.bar", NULL )); - } - - @Test - void testPatch_RemoveParentPathBefore() { - assertChildConflict( - new Remove( "foo" ), - new Insert( "foo.bar", NULL )); - assertChildConflict( - new Remove( "foo.bar" ), - new Insert( "foo.bar.baz", NULL )); - } - - @Test - void testPatch_InsertParentPathBefore() { - assertChildConflict( - new Insert( "foo.bar", NULL ), - new Insert( "foo.bar.baz", NULL )); - } - - @Test - void testPatch_RemoveParentPathAfter() { - assertParentConflict( - new Insert( "foo.bar", NULL ), - new Remove( "foo" ) ); - assertParentConflict( - new Insert( "foo.bar.baz", NULL ), - new Remove( "foo.bar" ) ); - } - - @Test - void testPatch_InsertParentPathAfter() { - assertParentConflict( - new Insert( "foo.bar.baz", NULL ), - new Insert( "foo.bar", NULL )); - } - - @Test - void testPatch_RemoveParentPathRemoveAfter() { - assertParentConflict( - new Remove( "foo.bar.baz" ), - new Remove( "foo.bar" ) ); - } - - @Test - void testPatch_RemoveParentPathRemoveBefore() { - assertChildConflict( - new Remove( "foo.bar" ), - new Remove( "foo.bar.baz" )); - } - - @Test - void testPatch_Misc() { - assertNoConflict( - new Insert( "foo.x", NULL ), - new Remove( "bar.x" ), - new Insert( "foo.y", NULL ), - new Remove( "que" ), - new Insert( "y", NULL ), - new Insert( "que", NULL ) - ); - } - - private static void assertParentConflict(JsonNode.Operation... ops) { - JsonPatchException ex = assertThrows( JsonPatchException.class, - () -> JsonTree.checkPatch( List.of( ops ) ) ); - assertTrue( ex.getMessage().contains( " parent " ) ); - } - - private static void assertChildConflict(JsonNode.Operation... ops) { - JsonPatchException ex = assertThrows( JsonPatchException.class, - () -> JsonTree.checkPatch( List.of( ops ) ) ); - assertTrue( ex.getMessage().contains( " child " ) ); - } - - private static void assertSameConflict(JsonNode.Operation... ops) { - JsonPatchException ex = assertThrows( JsonPatchException.class, - () -> JsonTree.checkPatch( List.of( ops ) ) ); - assertTrue( ex.getMessage().contains( " same " ) ); - } - - private static void assertNoConflict(JsonNode.Operation... ops) { - assertDoesNotThrow( () -> JsonTree.checkPatch( List.of(ops) ) ); - } -} diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java index fddf54b..ad5344a 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java @@ -2,7 +2,10 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import java.nio.file.Path; import java.util.List; @@ -26,14 +29,16 @@ public interface JsonPatchScenario extends JsonObject { default JsonList patch() { return getList( "patch", JsonPatch.class ); } default JsonMixed expected() { return get( "expected", JsonMixed.class ); } default String error() { return getString( "error" ).string(); } - default boolean disabled() { return getBoolean( "disabled" ).booleanValue(false); } + default JsonValue disabled() { return get( "disabled" ); } } @TestFactory List testScenarios() { return JsonMixed.of( Path.of("src/test/resources/json-patch-tests/tests.json") ) .asList( JsonPatchScenario.class ) - .stream().map( scenario -> dynamicTest( scenario.comment(), () -> assertScenario( scenario ) ) ) + .stream() + .filter( scenario -> !scenario.disabled().exists() ) + .map( scenario -> dynamicTest( scenario.comment(), () -> assertScenario( scenario ) ) ) .toList(); } diff --git a/src/test/resources/json-patch-tests/tests.json b/src/test/resources/json-patch-tests/tests.json index 0cf7854..24ce7e5 100644 --- a/src/test/resources/json-patch-tests/tests.json +++ b/src/test/resources/json-patch-tests/tests.json @@ -72,13 +72,13 @@ { "comment": "Add, / target", "doc": {}, - "patch": [ {"op": "add", "path": "/", "value":1 } ], - "expected": {"":1} }, + "patch": [ {"op": "add", "path": "/x", "value":1 } ], + "expected": {"x":1} }, - { "comment": "Add, /foo/ deep target (trailing slash)", + { "comment": "Add, /foo/x deep target (trailing slash)", "doc": {"foo": {}}, - "patch": [ {"op": "add", "path": "/foo/", "value":1 } ], - "expected": {"foo":{"": 1}} }, + "patch": [ {"op": "add", "path": "/foo/x", "value":1 } ], + "expected": {"foo":{"x": 1}} }, { "comment": "Add composite value at top level", "doc": {"foo": 1}, @@ -282,18 +282,19 @@ { "comment": "Whole document", "doc": { "foo": 1 }, - "patch": [{"op": "test", "path": "", "value": {"foo": 1}}], - "disabled": true }, + "patch": [{"op": "test", "path": "", "value": {"foo": 1}}] + }, { "comment": "Empty-string element", "doc": { "": 1 }, "patch": [{"op": "test", "path": "/", "value": 1}], - "expected": { "": 1 } }, + "expected": { "": 1 }, + "disabled": "empty property name not supported" + }, { "comment": "Test complex scenario", "doc": { "foo": ["bar", "baz"], - "": 0, "a/b": 1, "c%d": 2, "e^f": 3, @@ -305,7 +306,6 @@ }, "patch": [{"op": "test", "path": "/foo", "value": ["bar", "baz"]}, {"op": "test", "path": "/foo/0", "value": "bar"}, - {"op": "test", "path": "/", "value": 0}, {"op": "test", "path": "/a~1b", "value": 1}, {"op": "test", "path": "/c%d", "value": 2}, {"op": "test", "path": "/e^f", "value": 3}, @@ -315,7 +315,6 @@ {"op": "test", "path": "/ ", "value": 7}, {"op": "test", "path": "/m~0n", "value": 8}], "expected": { - "": 0, " ": 7, "a/b": 1, "c%d": 2, From d0b78237c0e3bcc1cc08196d9446ad140fb95b9a Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Wed, 26 Jun 2024 15:52:02 +0200 Subject: [PATCH 06/11] feat: JsonPath API and special key handling --- .../dhis/jsontree/JsonAbstractObject.java | 6 + .../java/org/hisp/dhis/jsontree/JsonNode.java | 34 +- .../hisp/dhis/jsontree/JsonNodeOperation.java | 9 +- .../org/hisp/dhis/jsontree/JsonPatch.java | 7 +- .../java/org/hisp/dhis/jsontree/JsonPath.java | 291 ++++++++++++++++++ .../hisp/dhis/jsontree/JsonPathException.java | 8 + .../java/org/hisp/dhis/jsontree/JsonTree.java | 114 +++---- .../hisp/dhis/jsontree/JsonTreeOperation.java | 5 +- .../hisp/dhis/jsontree/JsonTypedAccess.java | 5 +- .../org/hisp/dhis/jsontree/JsonMapTest.java | 27 ++ .../dhis/jsontree/JsonNodeOperationTest.java | 124 ++++---- .../org/hisp/dhis/jsontree/JsonNodeTest.java | 13 +- .../org/hisp/dhis/jsontree/JsonPathTest.java | 128 ++++++++ .../org/hisp/dhis/jsontree/JsonTreeTest.java | 2 +- 14 files changed, 623 insertions(+), 150 deletions(-) create mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonPath.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index f7e5d75..1ae3d37 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -87,8 +87,14 @@ default boolean exists(String name) { } /** + * Note that keys may differ from the member names as defined in the JSON document in case that their literal + * interpretation would have clashed with key syntax. In that case the property name is "escaped" so that using the + * returned key with {@link #get(String)} will return the value. Use {@link #names()} to receive the literal + * property names as defined in the document. + * * @return The keys of this map. * @throws JsonTreeException in case this node does exist but is not an object node + * @see #names() * @since 0.11 (as Stream) */ default Stream keys() { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index e6156d3..82e9eec 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -216,7 +216,7 @@ static JsonNode of( Reader json, GetListener onGet ) { */ default JsonValue lift( JsonTypedAccessStore store ) { JsonVirtualTree root = new JsonVirtualTree( getRoot(), store ); - return isRoot() ? root : root.get( getPath() ); + return isRoot() ? root : root.get( getPath().toString() ); } /** @@ -225,7 +225,7 @@ default JsonValue lift( JsonTypedAccessStore store ) { */ @Surly default JsonNode getParent() { - return isRoot() ? this : getRoot().get( parentPath( getPath() ) ); + return isRoot() ? this : getRoot().get( getPath().dropLastSegment().toString() ); } /** @@ -362,6 +362,9 @@ default JsonNode member( String name ) * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). *

    * The members are iterated in order of declaration in the underlying document. + *

    + * In contrast to {@link #keys()} the entries in this method will always have the literal property as their {@link Entry#getKey()}. + * This means also they are not fully safe to be used for {@link #get(String)}. * * @return this {@link #value()} as a sequence of {@link Entry} * @throws JsonTreeException if this node is not an object node that could have members @@ -386,6 +389,19 @@ default Iterable keys() { throw new JsonTreeException( getType() + " node has no keys property." ); } + /** + * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). + *

    + * The names are iterated in order of declaration in the underlying document. + * + * @return the raw property names of this object node + * @throws JsonTreeException if this node is not an object node that could have members + * @since 1.1 + */ + default Iterable names() { + throw new JsonTreeException( getType() + " node has no names property." ); + } + /** * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). *

    @@ -517,7 +533,7 @@ default int count( JsonNodeType type ) { /** * @return path within the overall content this node represents */ - String getPath(); + JsonPath getPath(); /** * @return the plain JSON of this node as defined in the overall content @@ -786,16 +802,4 @@ private void checkType( JsonNodeType expected, JsonNodeType actual, String opera format( "`%s` only allowed for %s but was: %s", operation, expected, actual ) ); } - static String parentPath( String path ) { - if ( path.endsWith( "]" ) ) { - return path.substring( 0, path.lastIndexOf( '[' ) ); - } - int end = path.lastIndexOf( '.' ); - return end < 0 ? "" : path.substring( 0, end ); - } - - static String nextIndexPath(String path) { - return path; - } - } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java b/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java index b15da34..e299784 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java @@ -29,6 +29,11 @@ */ sealed public interface JsonNodeOperation { + static String parentPath( String path ) { + //TODO move callers to JsonPath + return JsonPath.of( path ).dropLastSegment().toString(); + } + /** * @return the target of the operation */ @@ -126,7 +131,7 @@ static void checkPatch( List ops ) { checkPatchParents( ops, op, path, opsByPath, parents ); } else { checkPatchPath( ops, op, path, opsByPath, parents ); - checkPatchParents( ops, op, JsonNode.parentPath( path ), opsByPath, parents ); + checkPatchParents( ops, op, parentPath( path ), opsByPath, parents ); } } } @@ -143,7 +148,7 @@ private static void checkPatchParents( List ops, JsonNodeOper while ( !path.isEmpty() ) { if ( opsByPath.containsKey( path ) ) throw clash( ops, opsByPath.get( path ), op ); parents.add( path ); - path = JsonNode.parentPath( path ); + path = parentPath( path ); } } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java index 382b9b4..e68ff67 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java @@ -17,6 +17,11 @@ */ public interface JsonPatch extends JsonObject { + static String nextIndexPath( String path ) { + //FIXME implement properly + return path; + } + enum Op {ADD, REMOVE, REPLACE, COPY, MOVE, TEST} @Required @@ -63,7 +68,7 @@ private static List operations(JsonMixed value, JsonList ops.add( new Remove( path ) ); case REPLACE -> { ops.add( new Remove( path)); - ops.add( new Insert( JsonNode.nextIndexPath( path ), op.getValue().node() )); + ops.add( new Insert( nextIndexPath( path ), op.getValue().node() )); } case MOVE -> { String from = op.getFrom().path(); diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java new file mode 100644 index 0000000..b511f1e --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -0,0 +1,291 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.internal.Surly; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static java.lang.Integer.parseInt; +import static java.util.stream.Stream.concat; + +/** + * Represents a JSON path and the logic of how to split it into segments. + *

    + * Segments are always evaluated (split) left to right. Each segment is expected to start with the symbol identifying + * the type of segment. There are three notations: + * + *

      + *
    • dot object member access: {@code .property}
    • + *
    • curly bracket object member access: {@code {property}}
    • + *
    • square bracket array index access: {@code [index]}
    • + *
    + *

    + * A segment is only identified as such if its open and closing symbol is found. + * That means an opening bracket without a closing one is not a segment start symbol and understood literally. + * Similarly, an opening bracket were the closing bracket is not found before another opening bracket is also not a start symbol and also understood literally. + * Literally means it becomes part of the property name that started further left. + * In the same manner an array index access is only understood as such if the string in square brackets is indeed an integer number. + * Otherwise, the symbols are understood literally. + *

    + * These rules are chosen to have maximum literal interpretation while providing a way to encode paths that contain notation symbols. + * A general escaping mechanism is avoided as this would force users to always encode and decode paths just to make a few corner cases work which are not possible with the chosen compromise. + * + * @author Jan Bernitt + * @since 1.1 + */ +public record JsonPath(List segments) { + + /** + * A path pointing to the root or self + */ + public static final JsonPath EMPTY = new JsonPath( List.of() ); + + /** + * @param path a JSON path string + * @return the provided path as {@link JsonPath} object + * @throws JsonPathException when the path cannot be split into segments as it is not a valid path + */ + public static JsonPath of( String path ) { + return new JsonPath( splitIntoSegments( path ) ); + } + + /** + * Elevate an object member name to a key. + * + * @param name a plain object member name + * @return the provided plain name unless it needs "escaping" to be understood as the path key (segment) referring + * to the provided name member + */ + public static String keyOf( String name ) { + return keyOf( name, false ); + } + + /** + * @param name a plain object member name + * @param forceSegment when true, the returned key must also be a valid segment, otherwise it can be a plain name + * that is extended to a segment later by the caller + * @return the plain name when possible and no segment is forced, otherwise the corresponding segment key + */ + private static String keyOf( String name, boolean forceSegment ) { + if ( name.startsWith( "{" ) || name.startsWith( "[" ) ) return "." + name; + if ( name.indexOf( '.' ) >= 0 ) return "{" + name + "}"; + return forceSegment ? "." + name : name; + } + + /** + * Extends this path on the right (end) + * + * @param name a plain object member name + * @return a new path instance that adds the provided object member name segment to this path to create a new + * absolute path for the same root + */ + public JsonPath extendedWith( String name ) { + return extendedWithSegment( keyOf( name, true ) ); + } + + /** + * Extends this path on the right (end) + * + * @param index a valid array index + * @return a new path instance that adds the provided array index segment to this path to create a new absolute path + * for the same root + */ + public JsonPath extendedWith( int index ) { + if ( index < 0 ) throw new JsonPathException( this, + "Path array index must be zero or positive but was: %d".formatted( index ) ); + return extendedWithSegment( "[" + index + "]" ); + } + + private JsonPath extendedWithSegment( String segment ) { + return new JsonPath( concat( segments.stream(), Stream.of( segment ) ).toList() ); + } + + /** + * Shortens this path on the left (start) + * + * @param parent a direct or indirect parent of this path + * @return a relative path to the node this path points to when starting from the provided parent + * @throws JsonPathException if the parent path provided wasn't a parent of this path + */ + public JsonPath shortenedBy( JsonPath parent ) { + if ( parent.isEmpty() ) return this; + if ( !toString().startsWith( parent.toString() ) ) throw new JsonPathException( parent, + "Path %s is not a parent of %s".formatted( parent.toString(), toString() ) ); + return new JsonPath( segments.subList( parent.size(), size() ) ); + } + + /** + * Drops the left most path segment. + * + * @return a path starting with to the next segment of this path (it's "child" path) + * @throws JsonPathException when called on the root (empty path) + * @see #dropLastSegment() + */ + @Surly + public JsonPath dropFirstSegment() { + if ( isEmpty() ) throw new JsonPathException( this, "Root/self path does not have a child." ); + int size = segments.size(); + return size == 1 ? EMPTY : new JsonPath( segments.subList( 1, size ) ); + } + + /** + * Drops the right most path segment. + * + * @return a path ending before the segment of this path (this node's parent's path) + * @throws JsonPathException when called on the root (empty path) + * @see #dropFirstSegment() + */ + @Surly + public JsonPath dropLastSegment() { + if ( isEmpty() ) + throw new JsonPathException( this, "Root/self path does not have a parent." ); + int size = segments.size(); + return size == 1 ? EMPTY : new JsonPath( segments.subList( 0, size - 1 ) ); + } + + /** + * @return true, when this path is the root (points to itself) + */ + public boolean isEmpty() { + return segments.isEmpty(); + } + + /** + * @return the number of segments in this path, zero for the root (self) + */ + public int size() { + return segments.size(); + } + + public boolean startsWithArray() { + if ( isEmpty() ) return false; + return segments.get( 0 ).charAt( 0 ) == '['; + } + + public boolean startsWithObject() { + if ( isEmpty() ) return false; + char c0 = segments.get( 0 ).charAt( 0 ); + return c0 == '.' || c0 == '{'; + } + + public int arrayIndexAtStart() { + if ( isEmpty() ) throw new JsonPathException( this, "Root/self path does not designate an array index." ); + if ( !startsWithArray() ) + throw new JsonPathException( this, "Path %s does not start with an array.".formatted( toString() ) ); + String seg0 = segments.get( 0 ); + return parseInt( seg0.substring( 1, seg0.length() - 1 ) ); + } + + public String objectMemberAtStart() { + if ( isEmpty() ) throw new JsonPathException( this, "Root/self path does not designate a property name." ); + if ( !startsWithObject() ) + throw new JsonPathException( this, "Path %s does not start with an object.".formatted( toString() ) ); + String seg0 = segments.get( 0 ); + return seg0.charAt( 0 ) == '.' ? seg0.substring( 1 ) : seg0.substring( 1, seg0.length() - 1 ); + } + + @Override + public String toString() { + return String.join( "", segments ); + } + + @Override + public boolean equals( Object obj ) { + return obj instanceof JsonPath && segments.equals( ((JsonPath) obj).segments ); + } + + /** + * @param path the path to slit into segments + * @return splits the path into segments each starting with a character that {@link #isSegmentOpen(char)} + * @throws JsonPathException when the path cannot be split into segments as it is not a valid path + */ + private static List splitIntoSegments( String path ) + throws JsonPathException { + int len = path.length(); + int i = 0; + int s = 0; + List res = new ArrayList<>(); + while ( i < len ) { + if ( isDotSegmentOpen( path, i ) ) { + i++; // advance past the . + if ( i < len && path.charAt( i ) != '.' ) { + i++; // if it is not a dot the first char after the . is never the end + while ( i < len && !isDotSegmentClose( path, i ) ) i++; + } + } else if ( isSquareSegmentOpen( path, i ) ) { + while ( !isSquareSegmentClose( path, i ) ) i++; + i++; // include the ] + } else if ( isCurlySegmentOpen( path, i ) ) { + while ( !isCurlySegmentClose( path, i ) ) i++; + i++; // include the } + } else throw new JsonPathException( path, + "Malformed path %s, invalid start of segment at position %d.".formatted( path, i ) ); + res.add( path.substring( s, i ) ); + s = i; + } + return res; + } + + private static boolean isDotSegmentOpen( String path, int index ) { + return path.charAt( index ) == '.'; + } + + /** + * Dot segment: {@code .property} + * + * @param index into path + * @return when it is a dot, a valid start of a curly segment or a valid start of a square segment + */ + private static boolean isDotSegmentClose( String path, int index ) { + return path.charAt( index ) == '.' || isCurlySegmentOpen( path, index ) || isSquareSegmentOpen( path, index ); + } + + private static boolean isCurlySegmentOpen( String path, int index ) { + if ( path.charAt( index ) != '{' ) return false; + // there must be a curly end before next . + int i = index + 1; + do { + i = path.indexOf( '}', i ); + if ( i < 0 ) return false; + if ( isCurlySegmentClose( path, i ) ) return true; + i++; + } + while ( i < path.length() ); + return false; + } + + /** + * Curly segment: {@code {property}} + * + * @param index into path + * @return next closing } that is directly followed by a segment start (or end of path) + */ + private static boolean isCurlySegmentClose( String path, int index ) { + return path.charAt( index ) == '}' && (index + 1 >= path.length() || isSegmentOpen( + path.charAt( index + 1 ) )); + } + + private static boolean isSquareSegmentOpen( String path, int index ) { + if ( path.charAt( index ) != '[' ) return false; + // there must be a curly end before next . + int i = index + 1; + while ( i < path.length() && path.charAt( i ) >= '0' && path.charAt( i ) <= '9' ) i++; + return i > index + 1 && i < path.length() && isSquareSegmentClose( path, i ); + } + + /** + * Square segment: {@code [index]} + * + * @param index into path + * @return next closing ] that is directly followed by a segment start (or end of path) + */ + private static boolean isSquareSegmentClose( String path, int index ) { + return path.charAt( index ) == ']' && (index + 1 >= path.length() || isSegmentOpen( + path.charAt( index + 1 ) )); + } + + private static boolean isSegmentOpen( char c ) { + return c == '.' || c == '{' || c == '['; + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPathException.java b/src/main/java/org/hisp/dhis/jsontree/JsonPathException.java index 16bdd2a..397f287 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPathException.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPathException.java @@ -36,6 +36,10 @@ */ public final class JsonPathException extends NoSuchElementException { + /** + * Note that this cannot be of type {@link JsonPath} as only instances with a valid path can be constructed but this + * exception might precisely be about an invalid path. + */ private final String path; public JsonPathException( String path, String message ) { @@ -43,6 +47,10 @@ public JsonPathException( String path, String message ) { this.path = path; } + public JsonPathException( JsonPath path, String message ) { + this(path.toString(), message); + } + public String getPath() { return path; } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 0a4229c..96906e1 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -28,15 +28,12 @@ package org.hisp.dhis.jsontree; import org.hisp.dhis.jsontree.JsonNodeOperation.Insert; -import org.hisp.dhis.jsontree.JsonNodeOperation.Remove; import org.hisp.dhis.jsontree.internal.Maybe; import org.hisp.dhis.jsontree.internal.Surly; import java.io.Serializable; import java.util.AbstractMap.SimpleEntry; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -78,7 +75,7 @@ * @implNote This uses records because JVMs starting with JDK 21/22 will consider record fields as {@code @Stable} which * might help optimize access to the {@link #json} char array. */ -record JsonTree(@Surly char[] json, @Surly HashMap nodesByPath, @Maybe JsonNode.GetListener onGet) +record JsonTree(@Surly char[] json, @Surly HashMap nodesByPath, @Maybe JsonNode.GetListener onGet) implements Serializable { static JsonTree of( @Surly String json, @Maybe JsonNode.GetListener onGet ) { @@ -105,13 +102,13 @@ static JsonTree ofNonStandard( @Surly String json ) { private abstract static class LazyJsonNode implements JsonNode { final JsonTree tree; - final String path; + final JsonPath path; final int start; protected Integer end; private transient T value; - LazyJsonNode( JsonTree tree, String path, int start ) { + LazyJsonNode( JsonTree tree, JsonPath path, int start ) { this.tree = tree; this.path = path; this.start = start; @@ -123,7 +120,7 @@ public final JsonNode getRoot() { } @Override - public final String getPath() { + public final JsonPath getPath() { return path; } @@ -252,7 +249,7 @@ private static final class LazyJsonObject extends LazyJsonNode> parseValue() { @Override public JsonNode member( String name ) throws JsonPathException { - String mPath = path + "." + name; - JsonNode member = tree.nodesByPath.get( mPath ); + JsonPath propertyPath = path.extendedWith( name ); + JsonNode member = tree.nodesByPath.get( propertyPath ); if ( member != null ) { return member; } @@ -331,22 +328,22 @@ public JsonNode member( String name ) index = expectColonSeparator( json, property.endIndex() ); if ( name.equals( property.value() ) ) { int mStart = index; - return tree.nodesByPath.computeIfAbsent( mPath, + return tree.nodesByPath.computeIfAbsent( propertyPath, key -> tree.autoDetect( key, mStart ) ); } else { index = skipNodeAutodetect( json, index ); index = expectCommaSeparatorOrEnd( json, index, '}' ); } } - throw new JsonPathException( mPath, - format( "Path `%s` does not exist, object `%s` does not have a property `%s`", mPath, path, name ) ); + throw new JsonPathException( propertyPath, + format( "Path `%s` does not exist, object `%s` does not have a property `%s`", propertyPath, path, name ) ); } @Override public Iterator> members( boolean cacheNodes ) { return new Iterator<>() { private final char[] json = tree.json; - private final Map nodesByPath = tree.nodesByPath; + private final Map nodesByPath = tree.nodesByPath; private int startIndex = skipWhitespace( json, expectChar( json, start, '{' ) ); @Override @@ -360,13 +357,13 @@ public Entry next() { throw new NoSuchElementException( "next() called without checking hasNext()" ); LazyJsonString.Span property = LazyJsonString.parseString( json, startIndex ); String name = property.value(); - String mPath = path + "." + name; + JsonPath propertyPath = path.extendedWith( name ); int startIndexVal = expectColonSeparator( json, property.endIndex() ); JsonNode member = cacheNodes - ? nodesByPath.computeIfAbsent( mPath, key -> tree.autoDetect( key, startIndexVal ) ) - : nodesByPath.get( mPath ); + ? nodesByPath.computeIfAbsent( propertyPath, key -> tree.autoDetect( key, startIndexVal ) ) + : nodesByPath.get( propertyPath ); if ( member == null ) { - member = tree.autoDetect( mPath, startIndexVal ); + member = tree.autoDetect( propertyPath, startIndexVal ); } else if ( member.endIndex() < startIndexVal ) { // duplicate keys case: just skip the duplicate startIndex = expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndexVal ), '}' ); @@ -378,11 +375,20 @@ public Entry next() { }; } + @Override + public Iterable names() { + return keys(false); + } + @Override public Iterable keys() { + return keys(true); + } + + private Iterable keys(boolean escape) { return () -> new Iterator<>() { private final char[] json = tree.json; - private final Map nodesByPath = tree.nodesByPath; + private final Map nodesByPath = tree.nodesByPath; private int startIndex = skipWhitespace( json, expectChar( json, start, '{' ) ); @Override @@ -397,14 +403,14 @@ public String next() { LazyJsonString.Span property = LazyJsonString.parseString( json, startIndex ); String name = property.value(); // advance to next member or end... - String mPath = path + "." + name; - JsonNode member = nodesByPath.get( mPath ); + JsonPath propertyPath = path.extendedWith( name ); + JsonNode member = nodesByPath.get( propertyPath ); startIndex = expectColonSeparator( json, property.endIndex() ); // move after : // move after value startIndex = member == null || member.endIndex() < startIndex // (duplicates) ? expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndex ), '}' ) : expectCommaSeparatorOrEnd( json, member.endIndex(), '}' ); - return name; + return escape ? JsonPath.keyOf( name ) : name; } }; } @@ -414,7 +420,7 @@ private static final class LazyJsonArray extends LazyJsonNode private Integer size; - LazyJsonArray( JsonTree tree, String path, int start ) { + LazyJsonArray( JsonTree tree, JsonPath path, int start ) { super( tree, path, start ); } @@ -482,18 +488,18 @@ public JsonNode element( int index ) format( "Path `%s` does not exist, array index is negative: %d", path, index ) ); } char[] json = tree.json; - JsonNode predecessor = index == 0 ? null : tree.nodesByPath.get( path + '[' + (index - 1) + ']' ); + JsonNode predecessor = index == 0 ? null : tree.nodesByPath.get( path.extendedWith( index - 1) ); int s = predecessor != null ? skipWhitespace( json, expectCommaSeparatorOrEnd( json, predecessor.endIndex(), ']' ) ) : skipWhitespace( json, expectChar( json, start, '[' ) ); int skipN = predecessor != null ? 0 : index; int startIndex = predecessor == null ? 0 : index - 1; - return tree.nodesByPath.computeIfAbsent( path + '[' + index + ']', + return tree.nodesByPath.computeIfAbsent( path.extendedWith( index), key -> tree.autoDetect( key, skipWhitespace( json, skipToElement( skipN, json, s, skipped -> checkIndexExists( this, skipped + startIndex, key ) ) ) ) ); } - private static void checkIndexExists( JsonNode parent, int length, String path ) { + private static void checkIndexExists( JsonNode parent, int length, JsonPath path ) { throw new JsonPathException( path, format( "Path `%s` does not exist, array `%s` has only `%d` elements.", path, parent.getPath(), length ) ); @@ -503,7 +509,7 @@ private static void checkIndexExists( JsonNode parent, int length, String path ) public Iterator elements( boolean cacheNodes ) { return new Iterator<>() { private final char[] json = tree.json; - private final Map nodesByPath = tree.nodesByPath; + private final Map nodesByPath = tree.nodesByPath; private int startIndex = skipWhitespace( json, expectChar( json, start, '[' ) ); private int n = 0; @@ -517,7 +523,7 @@ public boolean hasNext() { public JsonNode next() { if ( !hasNext() ) throw new NoSuchElementException( "next() called without checking hasNext()" ); - String ePath = path + '[' + n + "]"; + JsonPath ePath = path.extendedWith( n ); JsonNode e = cacheNodes ? nodesByPath.computeIfAbsent( ePath, key -> tree.autoDetect( key, startIndex ) ) @@ -549,7 +555,7 @@ private static int skipToElement( int n, char[] json, int index, IntConsumer onE private static final class LazyJsonNumber extends LazyJsonNode { - LazyJsonNumber( JsonTree tree, String path, int start ) { + LazyJsonNumber( JsonTree tree, JsonPath path, int start ) { super( tree, path, start ); } @@ -575,7 +581,7 @@ Number parseValue() { private static final class LazyJsonString extends LazyJsonNode { - LazyJsonString( JsonTree tree, String path, int start ) { + LazyJsonString( JsonTree tree, JsonPath path, int start ) { super( tree, path, start ); } @@ -638,7 +644,7 @@ static Span parseString( char[] json, int start ) { private static final class LazyJsonBoolean extends LazyJsonNode { - LazyJsonBoolean( JsonTree tree, String path, int start ) { + LazyJsonBoolean( JsonTree tree, JsonPath path, int start ) { super( tree, path, start ); } @@ -657,7 +663,7 @@ Boolean parseValue() { private static final class LazyJsonNull extends LazyJsonNode { - LazyJsonNull( JsonTree tree, String path, int start ) { + LazyJsonNull( JsonTree tree, JsonPath path, int start ) { super( tree, path, start ); } @@ -689,41 +695,37 @@ public String toString() { */ JsonNode get( String path ) { if ( nodesByPath.isEmpty() ) - nodesByPath.put( "", autoDetect( "", skipWhitespace( json, 0 ) ) ); + nodesByPath.put( JsonPath.EMPTY, autoDetect( JsonPath.EMPTY, skipWhitespace( json, 0 ) ) ); if ( path.startsWith( "$" ) ) { path = path.substring( 1 ); } if ( onGet != null && !path.isEmpty() ) onGet.accept( path ); - JsonNode node = nodesByPath.get( path ); + JsonPath fullPath = JsonPath.of( path ); + JsonNode node = nodesByPath.get( fullPath ); if ( node != null ) { return node; } - JsonNode parent = getClosestIndexedParent( path, nodesByPath ); - String pathToGo = path.substring( parent.getPath().length() ); - while ( !pathToGo.isEmpty() ) { - if ( pathToGo.startsWith( "[" ) ) { - checkNodeIs( parent, JsonNodeType.ARRAY, path ); - int index = parseInt( pathToGo.substring( 1, pathToGo.indexOf( ']' ) ) ); + JsonNode parent = getClosestIndexedParent( fullPath, nodesByPath ); + JsonPath pathToGo = fullPath.shortenedBy( parent.getPath() ); + while ( !pathToGo.isEmpty() ) { // root here is relative, meaning: have we navigated to the target + if ( pathToGo.startsWithArray() ) { + checkNodeIs( parent, JsonNodeType.ARRAY, fullPath ); + int index = pathToGo.arrayIndexAtStart(); parent = parent.element( index ); - pathToGo = pathToGo.substring( pathToGo.indexOf( ']' ) + 1 ); - } else if ( pathToGo.startsWith( "." ) ) { - checkNodeIs( parent, JsonNodeType.OBJECT, path ); - String property = getHeadProperty( pathToGo ); - parent = parent.member( property ); - pathToGo = pathToGo.substring( 1 + property.length() ); - } else if ( pathToGo.startsWith( "{" ) ) { - // map access syntax: {property} - checkNodeIs( parent, JsonNodeType.OBJECT, path ); - String property = pathToGo.substring( 1, pathToGo.indexOf( '}' ) ); + pathToGo = pathToGo.dropFirstSegment(); + } else if ( pathToGo.startsWithObject() ) { + checkNodeIs( parent, JsonNodeType.OBJECT, fullPath ); + String property = pathToGo.objectMemberAtStart(); parent = parent.member( property ); - pathToGo = pathToGo.substring( 2 + property.length() ); + pathToGo = pathToGo.dropFirstSegment(); } else { - throw new JsonPathException( path, format( "Malformed path %s at %s.", path, pathToGo ) ); + throw new JsonPathException( fullPath, format( "Malformed path %s at %s.", path, pathToGo ) ); } } return parent; } + @Deprecated // replace with JsonPath private static String getHeadProperty( String path ) { int index = 1; while ( index < path.length() @@ -733,7 +735,7 @@ private static String getHeadProperty( String path ) { return path.substring( 1, index ); } - private JsonNode autoDetect( String path, int atIndex ) { + private JsonNode autoDetect( JsonPath path, int atIndex ) { JsonNode node = nodesByPath.get( path ); if ( node != null ) { return node; @@ -765,7 +767,7 @@ private JsonNode autoDetect( String path, int atIndex ) { } } - private static void checkNodeIs( JsonNode parent, JsonNodeType expected, String path ) { + private static void checkNodeIs( JsonNode parent, JsonNodeType expected, JsonPath path ) { if ( parent.getType() != expected ) { throw new JsonPathException( path, format( "Path `%s` does not exist, parent `%s` is not an %s but a %s node.", path, @@ -786,8 +788,8 @@ private static void checkValidEscapedChar( char[] json, int index ) { } } - private static JsonNode getClosestIndexedParent( String path, Map nodesByPath ) { - String parentPath = JsonNode.parentPath( path ); + private static JsonNode getClosestIndexedParent( JsonPath path, Map nodesByPath ) { + JsonPath parentPath = path.dropLastSegment(); JsonNode parent = nodesByPath.get( parentPath ); if ( parent != null ) { return parent; diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java b/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java index 5d1650f..9549880 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java @@ -1,16 +1,13 @@ package org.hisp.dhis.jsontree; -import org.hisp.dhis.jsontree.internal.Maybe; import org.hisp.dhis.jsontree.internal.Surly; -import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.Objects; import static java.lang.Integer.parseInt; import static java.util.Comparator.comparing; -import static org.hisp.dhis.jsontree.JsonNode.parentPath; +import static org.hisp.dhis.jsontree.JsonNodeOperation.parentPath; /** * {@link JsonTreeOperation}s are used internally to make modifications to a {@link JsonTree}. diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccess.java b/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccess.java index beb5a02..5b6cbf0 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccess.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccess.java @@ -220,9 +220,8 @@ public static Set accessSet( JsonObject from, String path, Type to, JsonTyped Function toKey = getKeyMapper( rawKeyType ); @SuppressWarnings( { "rawtypes", "unchecked" } ) Map res = rawKeyType.isEnum() ? new EnumMap( rawKeyType ) : new HashMap<>(); - for ( String member : map.names() ) { - res.put( toKey.apply( member ), valueAccess.access( map, member, valueType, store ) ); - } + map.keys().forEach( key -> + res.put( toKey.apply( key ), valueAccess.access( map, key, valueType, store ) )); return res; } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java index d3b9d97..d656ad2 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Map; +import static java.util.Map.entry; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; @@ -135,4 +136,30 @@ void testViewAsMap_Values() { JsonMap view = obj.project( arr -> arr.getNumber( 0 ) ); assertEquals( List.of( 1, 2 ), view.values().map( JsonNumber::intValue ).toList() ); } + + @Test + void testKeys_Special() { + String json = """ + {".":1, "{uid}":2, "[6]":3, "x{y}z": 4}"""; + JsonMap map = JsonMixed.of( json ).asMap( JsonNumber.class ); + assertEquals( List.of( "{.}", ".{uid}", ".[6]", "x{y}z" ), map.keys().toList() ); + } + + @Test + void testValues_Special() { + String json = """ + {".":1, "{uid}":2, "[6]":3, "x{y}z": 4}"""; + JsonMap map = JsonMixed.of( json ).asMap( JsonNumber.class ); + assertEquals( List.of( 1, 2, 3, 4 ), map.values().map( JsonNumber::intValue ).toList() ); + } + + @Test + void testEntries_Special() { + String json = """ + {".":1, "{uid}":2, "[6]":3, "x{y}z": 4}"""; + JsonMap map = JsonMixed.of( json ).asMap( JsonNumber.class ); + assertEquals( List.of( entry( "{.}", 1 ), entry( ".{uid}", 2 ), + entry( ".[6]", 3 ), entry( "x{y}z", 4 ) ), + map.entries().map( e -> entry( e.getKey(), e.getValue().intValue() ) ).toList() ); + } } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java index 247b127..8200d26 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonNodeOperationTest.java @@ -20,174 +20,174 @@ class JsonNodeOperationTest { @Test void testPatch_RemoveSamePathBefore() { assertRejected("operation 0 has same target as operation 1:", - new Remove( "foo.bar" ), - new Insert( "foo.bar", NULL ) ); + new Remove( ".foo.bar" ), + new Insert( ".foo.bar", NULL ) ); } @Test void testPatch_RemoveSamePathAfter() { assertRejected("operation 0 has same target as operation 1:", - new Insert( "foo.bar", NULL ), - new Remove( "foo.bar" )); + new Insert( ".foo.bar", NULL ), + new Remove( ".foo.bar" )); } @Test void testPatch_RemoveSamePathRemove() { assertRejected("operation 0 has same target as operation 1:", - new Remove( "foo.bar" ), - new Remove( "foo.bar" ) ); + new Remove( ".foo.bar" ), + new Remove( ".foo.bar" ) ); } @Test void testPatch_InsertSamePathInsert() { assertRejected("operation 0 has same target as operation 1:", - new Insert( "foo.bar", NULL ), - new Insert( "foo.bar", NULL ) ); + new Insert( ".foo.bar", NULL ), + new Insert( ".foo.bar", NULL ) ); } @Test void testPatch_InsertSiblingsPathInsert() { assertAccepted( - new Insert( "foo.x", NULL ), - new Insert( "foo.y", NULL ) ); + new Insert( ".foo.x", NULL ), + new Insert( ".foo.y", NULL ) ); } @Test void testPatch_RemoveChildPathAfter() { assertRejected("operation 1 targets child of operation 0:", - new Insert( "foo", NULL ), - new Remove( "foo.bar" ) ); + new Insert( ".foo", NULL ), + new Remove( ".foo.bar" ) ); assertRejected("operation 1 targets child of operation 0:", - new Insert( "foo.bar", NULL ), - new Remove( "foo.bar.baz" ) ); + new Insert( ".foo.bar", NULL ), + new Remove( ".foo.bar.baz" ) ); } @Test void testPatch_RemoveChildPathBefore() { assertRejected("operation 0 targets child of operation 1:", - new Remove( "foo.bar" ), - new Insert( "foo", NULL )); + new Remove( ".foo.bar" ), + new Insert( ".foo", NULL )); assertRejected("operation 0 targets child of operation 1:", - new Remove( "foo.bar.baz" ), - new Insert( "foo.bar", NULL )); + new Remove( ".foo.bar.baz" ), + new Insert( ".foo.bar", NULL )); } @Test void testPatch_RemoveParentPathBefore() { assertRejected("operation 1 targets child of operation 0:", - new Remove( "foo" ), - new Insert( "foo.bar", NULL )); + new Remove( ".foo" ), + new Insert( ".foo.bar", NULL )); assertRejected("operation 1 targets child of operation 0:", - new Remove( "foo.bar" ), - new Insert( "foo.bar.baz", NULL )); + new Remove( ".foo.bar" ), + new Insert( ".foo.bar.baz", NULL )); } @Test void testPatch_InsertParentPathBefore() { assertRejected("operation 1 targets child of operation 0:", - new Insert( "foo.bar", NULL ), - new Insert( "foo.bar.baz", NULL )); + new Insert( ".foo.bar", NULL ), + new Insert( ".foo.bar.baz", NULL )); } @Test void testPatch_RemoveParentPathAfter() { assertRejected("operation 0 targets child of operation 1:", - new Insert( "foo.bar", NULL ), - new Remove( "foo" ) ); + new Insert( ".foo.bar", NULL ), + new Remove( ".foo" ) ); assertRejected("operation 0 targets child of operation 1:", - new Insert( "foo.bar.baz", NULL ), - new Remove( "foo.bar" ) ); + new Insert( ".foo.bar.baz", NULL ), + new Remove( ".foo.bar" ) ); } @Test void testPatch_InsertParentPathAfter() { assertRejected("operation 0 targets child of operation 1:", - new Insert( "foo.bar.baz", NULL ), - new Insert( "foo.bar", NULL )); + new Insert( ".foo.bar.baz", NULL ), + new Insert( ".foo.bar", NULL )); } @Test void testPatch_RemoveParentPathRemoveAfter() { assertRejected("operation 0 targets child of operation 1:", - new Remove( "foo.bar.baz" ), - new Remove( "foo.bar" ) ); + new Remove( ".foo.bar.baz" ), + new Remove( ".foo.bar" ) ); } @Test void testPatch_RemoveParentPathRemoveBefore() { assertRejected("operation 1 targets child of operation 0:", - new Remove( "foo.bar" ), - new Remove( "foo.bar.baz" )); + new Remove( ".foo.bar" ), + new Remove( ".foo.bar.baz" )); } @Test void testPatch_InsertArrayInsert() { assertAccepted( - new Insert( "foo[0]", NULL ), - new Insert( "foo[1]", NULL )); + new Insert( ".foo[0]", NULL ), + new Insert( ".foo[1]", NULL )); } @Test void testPatch_Misc() { assertAccepted( - new Insert( "foo.x", NULL ), - new Remove( "bar.x" ), - new Insert( "foo.y", NULL ), - new Remove( "fo" ), - new Insert( "y", NULL ), - new Insert( "que", NULL ) + new Insert( ".foo.x", NULL ), + new Remove( ".bar.x" ), + new Insert( ".foo.y", NULL ), + new Remove( ".fo" ), + new Insert( ".y", NULL ), + new Insert( ".que", NULL ) ); } @Test void testPatch_ObjectMerge() { assertAccepted( - new Insert( "foo", JsonNode.of( """ + new Insert( ".foo", JsonNode.of( """ {"x": 1, "y": 2}""" ), true), - new Insert( "foo.z", JsonNode.of( "3" ) )); + new Insert( ".foo.z", JsonNode.of( "3" ) )); assertAccepted( - new Insert( "foo", JsonNode.of( """ + new Insert( ".foo", JsonNode.of( """ {"x": 1, "y": 2}""" ), true), - new Insert( "foo", JsonNode.of( """ + new Insert( ".foo", JsonNode.of( """ {"z": 3, "zero": 0}""" ), true)); assertRejected("operation 1 targets child of operation 0:", - new Insert( "foo", JsonNode.of( """ + new Insert( ".foo", JsonNode.of( """ {"z": 1, "y": 2}""" ), true), - new Insert( "foo.z", JsonNode.of( "3" ) )); + new Insert( ".foo.z", JsonNode.of( "3" ) )); assertRejected("operation 0 has same target as operation 1:", - new Insert( "foo", JsonNode.of( """ + new Insert( ".foo", JsonNode.of( """ {"x": 1, "y": 2}""" ), true), - new Insert( "foo", JsonNode.of( """ + new Insert( ".foo", JsonNode.of( """ {"x": 3, "zero": 0}""" ), true)); } @Test void testMergeArrayInserts_Uniform() { assertEquals( List.of( - new Insert("foo[0]", JsonNode.of( "[1,2,3,4]" ), true)), + new Insert(".foo[0]", JsonNode.of( "[1,2,3,4]" ), true)), JsonNodeOperation.mergeArrayInserts( List.of( - new Insert( "foo[0]", JsonNode.of( "1" ) ), - new Insert( "foo[0]", JsonNode.of( "[2,3]" ), true ), - new Insert( "foo[0]", JsonNode.of( "4" ) ) + new Insert( ".foo[0]", JsonNode.of( "1" ) ), + new Insert( ".foo[0]", JsonNode.of( "[2,3]" ), true ), + new Insert( ".foo[0]", JsonNode.of( "4" ) ) ) )); } @Test void testMergeArrayInserts_Mixed() { assertEquals( List.of( - new Remove( "x" ), - new Insert( "foo[0]", JsonNode.of( "[1,2,3,4]" ), true ), - new Insert( "bar", NULL ) ), + new Remove( ".x" ), + new Insert( ".foo[0]", JsonNode.of( "[1,2,3,4]" ), true ), + new Insert( ".bar", NULL ) ), JsonNodeOperation.mergeArrayInserts( List.of( - new Remove( "x" ), - new Insert( "foo[0]", JsonNode.of( "1" ) ), - new Insert( "bar", NULL ), - new Insert( "foo[0]", JsonNode.of( "[2,3]" ), true ), - new Insert( "foo[0]", JsonNode.of( "4" ) ) + new Remove( ".x" ), + new Insert( ".foo[0]", JsonNode.of( "1" ) ), + new Insert( ".bar", NULL ), + new Insert( ".foo[0]", JsonNode.of( "[2,3]" ), true ), + new Insert( ".foo[0]", JsonNode.of( "4" ) ) ) ) ); } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java index 05dda29..ce69c4c 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java @@ -52,25 +52,25 @@ void testEquals() { @Test void testGet_String() { assertGetThrowsJsonPathException( "\"hello\"", - "This is a leaf node of type STRING that does not have any children at path: foo" ); + "This is a leaf node of type STRING that does not have any children at path: .foo" ); } @Test void testGet_Number() { assertGetThrowsJsonPathException( "42", - "This is a leaf node of type NUMBER that does not have any children at path: foo" ); + "This is a leaf node of type NUMBER that does not have any children at path: .foo" ); } @Test void testGet_Boolean() { assertGetThrowsJsonPathException( "true", - "This is a leaf node of type BOOLEAN that does not have any children at path: foo" ); + "This is a leaf node of type BOOLEAN that does not have any children at path: .foo" ); } @Test void testGet_Null() { assertGetThrowsJsonPathException( "null", - "This is a leaf node of type NULL that does not have any children at path: foo" ); + "This is a leaf node of type NULL that does not have any children at path: .foo" ); } @Test @@ -109,7 +109,8 @@ void testGet_Array() { @Test void testGet_Array_NoValueAtPath() { - assertGetThrowsJsonPathException( "[1,2]", "a", "Malformed path a at a." ); + assertGetThrowsJsonPathException( "[1,2]", "a", "Malformed path a, invalid start of segment at position 0." ); + assertGetThrowsJsonPathException( "[1,2]", ".a", "Path `.a` does not exist, parent `` is not an OBJECT but a ARRAY node." ); assertGetThrowsJsonPathException( "[[1,2],[]]", "[1][0]", "Path `[1][0]` does not exist, array `[1]` has only `0` elements." ); assertGetThrowsJsonPathException( "[[1,2],[]]", "[0].a", @@ -208,7 +209,7 @@ void testPathCanBeRecorded() { } private static void assertGetThrowsJsonPathException( String json, String expected ) { - assertGetThrowsJsonPathException( json, "foo", expected ); + assertGetThrowsJsonPathException( json, ".foo", expected ); } private static void assertGetThrowsJsonPathException( String json, String path, String expected ) { diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java new file mode 100644 index 0000000..fc8fd2d --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java @@ -0,0 +1,128 @@ +package org.hisp.dhis.jsontree; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrowsExactly; + +/** + * Tests the splitting of {@link JsonPath} into segments. + * + * @author Jan Bernitt + * @since 1.1 + */ +class JsonPathTest { + + @Test + void testSegments_Dot_Uniform() { + assertSegments(".xxx", List.of(".xxx")); + assertSegments(".xxx.yyy", List.of(".xxx", ".yyy")); + assertSegments(".xxx.yy.z", List.of(".xxx", ".yy", ".z")); + } + + @Test + void testSegments_Dot_Empty() { + assertSegments(".xxx.", List.of(".xxx", ".")); + assertSegments(".xxx..a", List.of(".xxx", ".", ".a")); + //but... (first char after . is never a segment start!) + assertSegments(".xxx.[1]", List.of(".xxx", ".[1]")); + assertSegments(".xxx.{1}", List.of(".xxx", ".{1}")); + } + + @Test + void testSegments_Dot_CurlyProperty() { + assertSegments(".xxx.{curl}", List.of(".xxx", ".{curl}")); + assertSegments(".xxx.{curl}.y", List.of(".xxx", ".{curl}", ".y")); + assertSegments(".xxx.{curl}[42]", List.of(".xxx", ".{curl}", "[42]")); + assertSegments(".xxx.{curl}{42}", List.of(".xxx", ".{curl}", "{42}")); + // closing } is only recognised as such if followed by a segment start (or end of path) + assertSegments(".xxx.{curl}}", List.of(".xxx", ".{curl}}")); + assertSegments(".xxx.{curl}}.y", List.of(".xxx", ".{curl}}", ".y")); + assertSegments(".xxx.{curl}}[42]", List.of(".xxx", ".{curl}}", "[42]")); + assertSegments(".xxx.{curl}}{42}", List.of(".xxx", ".{curl}}", "{42}")); + } + + @Test + void testSegments_Curly_Uniform() { + assertSegments("{xxx}", List.of("{xxx}")); + assertSegments("{xxx}{yyy}", List.of("{xxx}", "{yyy}")); + assertSegments("{xxx}{yy}{z}", List.of("{xxx}", "{yy}", "{z}")); + } + + @Test + void testSegments_Curly_DotProperty() { + assertSegments("{.suffix}", List.of("{.suffix}")); + assertSegments("{hello.world}", List.of("{hello.world}")); + assertSegments("{prefix.}", List.of("{prefix.}")); + assertSegments("{.suffix}.xxx", List.of("{.suffix}", ".xxx")); + assertSegments("{hello.world}{curl}", List.of("{hello.world}", "{curl}")); + assertSegments("{prefix.}[42]", List.of("{prefix.}", "[42]")); + assertSegments(".aaa{.suffix}", List.of(".aaa","{.suffix}")); + assertSegments(".aaa{hello.world}", List.of(".aaa","{hello.world}")); + assertSegments(".aaa{prefix.}", List.of(".aaa","{prefix.}")); + } + + @Test + void testSegments_Square_Uniform() { + assertSegments("[111]", List.of("[111]")); + assertSegments("[111][222]", List.of("[111]", "[222]")); + assertSegments("[111][22][3]", List.of("[111]", "[22]", "[3]")); + } + + @Test + void testSegments_DotSquare_Trivial() { + assertSegments(".xxx[1]", List.of(".xxx", "[1]")); + assertSegments(".xxx[1][2]", List.of(".xxx", "[1]", "[2]")); + assertSegments(".xxx[1].y[2]", List.of(".xxx", "[1]", ".y", "[2]")); + } + + @Test + void testSegments_DotCurly_Trivial() { + assertSegments(".xxx{1}", List.of(".xxx", "{1}")); + assertSegments(".xxx{1}{2}", List.of(".xxx", "{1}", "{2}")); + assertSegments(".xxx{1}.y{2}", List.of(".xxx", "{1}", ".y", "{2}")); + } + + @Test + void testParent_Root() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, JsonPath.EMPTY::dropLastSegment ); + assertEquals( "Root/self path does not have a parent.", ex.getMessage() ); + } + + @Test + void testParent_Dot_Uniform() { + assertParent( "", ".x" ); + assertParent( ".x", ".x.y" ); + assertParent( ".x.yy", ".x.yy.zzz" ); + } + + @Test + void testParent_Curly_Uniform() { + assertParent( "", ".{x}" ); + assertParent( "{x}", "{x}{y}" ); + assertParent( "{x}{yy}", "{x}{yy}{zzz}" ); + } + + @Test + void testParent_Square_Uniform() { + assertParent( "", "[1]" ); + assertParent( "[1]", "[1][22]" ); + assertParent( "[1][22]", "[1][22][333]" ); + } + + @Test + void testParent_() { + assertParent( "[1].xv", "[1].xv{.}" ); + assertParent( "[1]{xv}", "[1]{xv}.{h}" ); + } + + private void assertSegments( String path, List segments ) { + assertEquals( segments, JsonPath.of( path ).segments()); + } + + private void assertParent(String expected, String actual) { + assertEquals( expected, JsonPath.of( actual ).dropLastSegment().toString() ); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java index 2746ca5..2a068fd 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java @@ -454,7 +454,7 @@ void testArray_NoSuchIndex() { void testArray_NegativeIndex() { JsonNode doc = JsonNode.of( "{\"a\": { \"b\" : [12, false] } }" ); JsonPathException ex = assertThrowsExactly( JsonPathException.class, () -> doc.get( ".a.b[-1]" ) ); - assertEquals( "Path `.a.b` does not exist, array index is negative: -1", + assertEquals( "Path `.a.b[-1]` does not exist, object `.a` does not have a property `b[-1]`", ex.getMessage() ); } From 4301f9aab78d62477eb838a6adc3718bd84e5dbf Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Wed, 26 Jun 2024 18:58:28 +0200 Subject: [PATCH 07/11] fix: names() gives access to the raw object member names --- .../dhis/jsontree/JsonAbstractObject.java | 15 ++--- .../java/org/hisp/dhis/jsontree/JsonNode.java | 20 ++++++- .../java/org/hisp/dhis/jsontree/JsonPath.java | 30 +++++++--- .../java/org/hisp/dhis/jsontree/JsonTree.java | 56 ++++++------------- .../org/hisp/dhis/jsontree/JsonValue.java | 2 +- .../org/hisp/dhis/jsontree/JsonNodeTest.java | 2 +- .../hisp/dhis/jsontree/JsonObjectTest.java | 9 +++ .../org/hisp/dhis/jsontree/JsonPathTest.java | 2 +- .../org/hisp/dhis/jsontree/JsonTreeTest.java | 2 +- 9 files changed, 76 insertions(+), 62 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index 1ae3d37..c5671c7 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -88,9 +88,9 @@ default boolean exists(String name) { /** * Note that keys may differ from the member names as defined in the JSON document in case that their literal - * interpretation would have clashed with key syntax. In that case the property name is "escaped" so that using the - * returned key with {@link #get(String)} will return the value. Use {@link #names()} to receive the literal - * property names as defined in the document. + * interpretation would have clashed with key syntax. In that case the object member name is "escaped" so that using + * the returned key with {@link #get(String)} will return the value. Use {@link #names()} to receive the literal + * object member names as defined in the document. * * @return The keys of this map. * @throws JsonTreeException in case this node does exist but is not an object node @@ -120,13 +120,14 @@ default Stream> entries() { } /** - * Lists JSON object property names in order of declaration. + * Lists raw JSON object member names in order of declaration. * - * @return The list of property names in the order they were defined. - * @throws JsonTreeException in case this value is not an JSON object + * @return The list of object member names in the order they were defined. + * @throws JsonTreeException in case this node does exist but is not an object node + * @see #keys() */ default List names() { - return keys().toList(); + return isUndefined() || isEmpty() ? List.of() : stream( node().names().spliterator(), false ).toList(); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 82e9eec..7db808c 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -125,7 +125,7 @@ static JsonNode of( String json ) { * @since 0.10 */ static JsonNode ofNonStandard( String json ) { - return JsonTree.ofNonStandard( json ).get( "$" ); + return JsonTree.ofNonStandard( json ).get( JsonPath.ROOT ); } /** @@ -137,7 +137,7 @@ static JsonNode ofNonStandard( String json ) { * @since 0.10 */ static JsonNode of( String json, GetListener onGet ) { - return JsonTree.of( json, onGet ).get( "$" ); + return JsonTree.of( json, onGet ).get( JsonPath.ROOT ); } /** @@ -237,11 +237,24 @@ default JsonNode getParent() { * @throws JsonPathException when no such node exists in the subtree of this node */ @Surly - default JsonNode get( String path ) + default JsonNode get(@Surly String path ) throws JsonPathException { if ( path.isEmpty() ) return this; if ( "$".equals( path ) ) return getRoot(); if ( path.startsWith( "$" ) ) return getRoot().get( path.substring( 1 ) ); + if (!path.startsWith( "{" ) && !path.startsWith( "[" ) && !path.startsWith( "." )) + path = "."+path; + return get( JsonPath.of( path ) ); + } + + /** + * + * @param path a path understood relative to this node's {@link #getPath()} + * @return the node at the given path + * @since 1.1 + */ + @Surly + default JsonNode get(@Surly JsonPath path) { throw new JsonPathException( path, format( "This is a leaf node of type %s that does not have any children at path: %s", getType(), path ) ); } @@ -532,6 +545,7 @@ default int count( JsonNodeType type ) { /** * @return path within the overall content this node represents + * @since 1.1 (with {@link JsonPath} type) */ JsonPath getPath(); diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java index b511f1e..658571a 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -4,7 +4,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.stream.Stream; import static java.lang.Integer.parseInt; import static java.util.stream.Stream.concat; @@ -39,7 +38,7 @@ public record JsonPath(List segments) { /** * A path pointing to the root or self */ - public static final JsonPath EMPTY = new JsonPath( List.of() ); + public static final JsonPath ROOT = new JsonPath( List.of() ); /** * @param path a JSON path string @@ -73,6 +72,16 @@ private static String keyOf( String name, boolean forceSegment ) { return forceSegment ? "." + name : name; } + /** + * Extends this path on the right (end) + * + * @param subPath the path to add to the end of this one + * @return a new path instance that starts with all segments of this path followed by all segments of the provided sub-path + */ + public JsonPath extendedWith( JsonPath subPath ) { + return extendedWith( subPath.segments ); + } + /** * Extends this path on the right (end) * @@ -81,7 +90,7 @@ private static String keyOf( String name, boolean forceSegment ) { * absolute path for the same root */ public JsonPath extendedWith( String name ) { - return extendedWithSegment( keyOf( name, true ) ); + return extendedWith( List.of(keyOf( name, true )) ); } /** @@ -94,11 +103,13 @@ public JsonPath extendedWith( String name ) { public JsonPath extendedWith( int index ) { if ( index < 0 ) throw new JsonPathException( this, "Path array index must be zero or positive but was: %d".formatted( index ) ); - return extendedWithSegment( "[" + index + "]" ); + return extendedWith( List.of("[" + index + "]")); } - private JsonPath extendedWithSegment( String segment ) { - return new JsonPath( concat( segments.stream(), Stream.of( segment ) ).toList() ); + private JsonPath extendedWith( List subSegments ) { + return subSegments.isEmpty() + ? this + : new JsonPath( concat( segments.stream(), subSegments.stream() ).toList() ); } /** @@ -126,7 +137,7 @@ public JsonPath shortenedBy( JsonPath parent ) { public JsonPath dropFirstSegment() { if ( isEmpty() ) throw new JsonPathException( this, "Root/self path does not have a child." ); int size = segments.size(); - return size == 1 ? EMPTY : new JsonPath( segments.subList( 1, size ) ); + return size == 1 ? ROOT : new JsonPath( segments.subList( 1, size ) ); } /** @@ -141,7 +152,7 @@ public JsonPath dropLastSegment() { if ( isEmpty() ) throw new JsonPathException( this, "Root/self path does not have a parent." ); int size = segments.size(); - return size == 1 ? EMPTY : new JsonPath( segments.subList( 0, size - 1 ) ); + return size == 1 ? ROOT : new JsonPath( segments.subList( 0, size - 1 ) ); } /** @@ -224,7 +235,8 @@ private static List splitIntoSegments( String path ) res.add( path.substring( s, i ) ); s = i; } - return res; + // make immutable + return List.copyOf( res ); } private static boolean isDotSegmentOpen( String path, int index ) { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 96906e1..2446d27 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -116,7 +116,7 @@ private abstract static class LazyJsonNode implements JsonNode { @Override public final JsonNode getRoot() { - return tree.get( "$" ); + return tree.get( JsonPath.ROOT ); } @Override @@ -264,13 +264,8 @@ public JsonNodeType getType() { } @Surly @Override - public JsonNode get( String path ) { - if ( path.isEmpty() ) return this; - if ( "$".equals( path ) ) return getRoot(); - if ( path.startsWith( "$" ) ) return getRoot().get( path.substring( 1 ) ); - if ( path.startsWith( "{" ) ) return tree.get( this.path + path ); - // trim any leading . of the relative path - return tree.get( this.path + "." + (path.startsWith( "." ) ? path.substring( 1 ) : path) ); + public JsonNode get(@Surly JsonPath path ) { + return tree.get( this.path.extendedWith( path ) ); } @Override @@ -430,11 +425,8 @@ public Iterator iterator() { } @Surly @Override - public JsonNode get( String path ) { - if ( path.isEmpty() ) return this; - if ( "$".equals( path ) ) return getRoot(); - if ( path.startsWith( "$" ) ) return getRoot().get( path.substring( 1 ) ); - return tree.get( this.path + path ); + public JsonNode get( @Surly JsonPath path ) { + return tree.get( this.path.extendedWith( path ) ); } @Override @@ -693,48 +685,34 @@ public String toString() { * given path is not a valid path expression * @throws JsonFormatException when this document contains malformed JSON that confuses the parser */ - JsonNode get( String path ) { + JsonNode get( JsonPath path ) { if ( nodesByPath.isEmpty() ) - nodesByPath.put( JsonPath.EMPTY, autoDetect( JsonPath.EMPTY, skipWhitespace( json, 0 ) ) ); - if ( path.startsWith( "$" ) ) { - path = path.substring( 1 ); - } - if ( onGet != null && !path.isEmpty() ) onGet.accept( path ); - JsonPath fullPath = JsonPath.of( path ); - JsonNode node = nodesByPath.get( fullPath ); - if ( node != null ) { + nodesByPath.put( JsonPath.ROOT, autoDetect( JsonPath.ROOT, skipWhitespace( json, 0 ) ) ); + if ( onGet != null && !path.isEmpty() ) onGet.accept( path.toString() ); + JsonNode node = nodesByPath.get( path ); + if ( node != null ) return node; - } - JsonNode parent = getClosestIndexedParent( fullPath, nodesByPath ); - JsonPath pathToGo = fullPath.shortenedBy( parent.getPath() ); - while ( !pathToGo.isEmpty() ) { // root here is relative, meaning: have we navigated to the target + // find by finding the closest already indexed parent and navigate down from there... + JsonNode parent = getClosestIndexedParent( path, nodesByPath ); + JsonPath pathToGo = path.shortenedBy( parent.getPath() ); + while ( !pathToGo.isEmpty() ) { // meaning: are we at the target node? (self) if ( pathToGo.startsWithArray() ) { - checkNodeIs( parent, JsonNodeType.ARRAY, fullPath ); + checkNodeIs( parent, JsonNodeType.ARRAY, path ); int index = pathToGo.arrayIndexAtStart(); parent = parent.element( index ); pathToGo = pathToGo.dropFirstSegment(); } else if ( pathToGo.startsWithObject() ) { - checkNodeIs( parent, JsonNodeType.OBJECT, fullPath ); + checkNodeIs( parent, JsonNodeType.OBJECT, path ); String property = pathToGo.objectMemberAtStart(); parent = parent.member( property ); pathToGo = pathToGo.dropFirstSegment(); } else { - throw new JsonPathException( fullPath, format( "Malformed path %s at %s.", path, pathToGo ) ); + throw new JsonPathException( path, format( "Malformed path %s at %s.", path, pathToGo ) ); } } return parent; } - @Deprecated // replace with JsonPath - private static String getHeadProperty( String path ) { - int index = 1; - while ( index < path.length() - && path.charAt( index ) != '.' && path.charAt( index ) != '[' && path.charAt( index ) != '{' ) { - index++; - } - return path.substring( 1, index ); - } - private JsonNode autoDetect( JsonPath path, int atIndex ) { JsonNode node = nodesByPath.get( path ); if ( node != null ) { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index e0bee37..bfed599 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -327,7 +327,7 @@ default boolean identicalTo(JsonValue other) { if (!equivalentTo( this, other, JsonValue::identicalTo )) return false; if (isNumber()) return toJson().equals( other.toJson() ); if (!isObject()) return true; - // keys must be in same order + // names must be in same order return asObject().names().equals( other.asObject().names() ); } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java index ce69c4c..40992f2 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java @@ -109,7 +109,7 @@ void testGet_Array() { @Test void testGet_Array_NoValueAtPath() { - assertGetThrowsJsonPathException( "[1,2]", "a", "Malformed path a, invalid start of segment at position 0." ); + assertGetThrowsJsonPathException( "[1,2]", "a", "Path `.a` does not exist, parent `` is not an OBJECT but a ARRAY node." ); assertGetThrowsJsonPathException( "[1,2]", ".a", "Path `.a` does not exist, parent `` is not an OBJECT but a ARRAY node." ); assertGetThrowsJsonPathException( "[[1,2],[]]", "[1][0]", "Path `[1][0]` does not exist, array `[1]` has only `0` elements." ); diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java index cc6c2e6..044a259 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java @@ -50,6 +50,15 @@ void testNames_NonEmpty() { assertEquals( List.of( "a", "b" ), value.names() ); } + @Test + void testNames_Special() { + //language=json + String json = """ + {".":1,"{uid}":2,"[0]": 3}"""; + JsonMixed value = JsonMixed.of( json ); + assertEquals( List.of( ".", "{uid}", "[0]" ), value.names() ); + } + @Test void testProject() { //language=json diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java index fc8fd2d..58ba07e 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java @@ -87,7 +87,7 @@ void testSegments_DotCurly_Trivial() { @Test void testParent_Root() { - JsonPathException ex = assertThrowsExactly( JsonPathException.class, JsonPath.EMPTY::dropLastSegment ); + JsonPathException ex = assertThrowsExactly( JsonPathException.class, JsonPath.ROOT::dropLastSegment ); assertEquals( "Root/self path does not have a parent.", ex.getMessage() ); } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java index 2a068fd..0394994 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonTreeTest.java @@ -481,7 +481,7 @@ void testString_MissingQuotes() { JsonNode doc = JsonNode.of( "{\"a\": hello }" ); JsonFormatException ex = assertThrowsExactly( JsonFormatException.class, () -> doc.get( ".a" ) ); - String nl = System.getProperty( "line.separator" ); + String nl = System.lineSeparator(); assertEquals( "Unexpected character at position 6," + nl + "{\"a\": hello }" + nl + " ^ expected start of a JSON value but found: `h`", From b15c23b8dbafed35f64ed90d4169bb57671bcde2 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 27 Jun 2024 10:40:16 +0200 Subject: [PATCH 08/11] chore: javadoc, tweaks and tests --- .../java/org/hisp/dhis/jsontree/JsonPath.java | 18 ++- .../dhis/jsontree/JsonTypedAccessStore.java | 2 +- .../org/hisp/dhis/jsontree/JsonValue.java | 10 +- .../org/hisp/dhis/jsontree/JsonPathTest.java | 146 +++++++++++++++++- 4 files changed, 168 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java index 658571a..493efb2 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -41,6 +41,8 @@ public record JsonPath(List segments) { public static final JsonPath ROOT = new JsonPath( List.of() ); /** + * "Parse" a path {@link String} into its {@link JsonPath} form + * * @param path a JSON path string * @return the provided path as {@link JsonPath} object * @throws JsonPathException when the path cannot be split into segments as it is not a valid path @@ -49,6 +51,16 @@ public static JsonPath of( String path ) { return new JsonPath( splitIntoSegments( path ) ); } + /** + * Create a path for an array index + * + * @param index array index + * @return an array index selecting path + */ + public static JsonPath of(int index) { + return ROOT.extendedWith( index ); + } + /** * Elevate an object member name to a key. * @@ -188,8 +200,9 @@ public int arrayIndexAtStart() { return parseInt( seg0.substring( 1, seg0.length() - 1 ) ); } + @Surly public String objectMemberAtStart() { - if ( isEmpty() ) throw new JsonPathException( this, "Root/self path does not designate a property name." ); + if ( isEmpty() ) throw new JsonPathException( this, "Root/self path does not designate a object member name." ); if ( !startsWithObject() ) throw new JsonPathException( this, "Path %s does not start with an object.".formatted( toString() ) ); String seg0 = segments.get( 0 ); @@ -203,7 +216,8 @@ public String toString() { @Override public boolean equals( Object obj ) { - return obj instanceof JsonPath && segments.equals( ((JsonPath) obj).segments ); + if (!(obj instanceof JsonPath other)) return false; + return segments.equals( other.segments ); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccessStore.java b/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccessStore.java index 8cf851d..d0fd358 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccessStore.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTypedAccessStore.java @@ -43,7 +43,7 @@ * While they "map" values this mapping takes place on access only. Everything before that is just as virtual (a view) * as the {@link JsonValue} tree. *

    - * One could also think of accessors as "automatic" implementation of an abstract method as if it became a default + * One could also think of accessors as an "automatic" implementation of an abstract method as if it became a default * method in an interface. The "implementation" here is derived from the return type of the method. Each accessor knows * how to access and map to a particular java tye. The store then contains the set of known java target type and their * way to access them given a {@link JsonValue} tree. diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index bfed599..530e452 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -41,7 +41,8 @@ import java.util.stream.Stream; /** - * The {@link JsonValue} is a virtual read-only view for {@link JsonNode} representing an actual {@link JsonTree}. + * The {@link JsonValue} is a virtual read-only view for {@link JsonNode}, which + * is representing an actual {@link JsonTree}. *

    * As usual there are specific node type for the JSON building blocks: *

      @@ -64,9 +65,10 @@ * The API is designed to: *
        *
      • be extended by further type extending {@link JsonValue}, such as - * {@link JsonDate} but also further specific object type
      • - *
      • fail at point of assertion. This means traversing the virtual tree does - * not cause errors unless explicitly provoked.
      • + * {@link JsonDate}, but also further specific object types + *
      • fail at the point of assertion/use not traversal. + * This means traversing the virtual tree does not cause errors unless explicitly + * provoked by a "terminal operation" or malformed input
      • *
      • be implemented by a single class which only builds a lookup path and * checks or provides the leaf values on demand. Interfaces not directly * implemented by this class are dynamically created using a diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java index 58ba07e..d62379b 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java @@ -5,7 +5,10 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Tests the splitting of {@link JsonPath} into segments. @@ -113,11 +116,152 @@ void testParent_Square_Uniform() { } @Test - void testParent_() { + void testParent_Mixed() { assertParent( "[1].xv", "[1].xv{.}" ); assertParent( "[1]{xv}", "[1]{xv}.{h}" ); } + @Test + void testEmpty() { + assertTrue( JsonPath.of( "" ).isEmpty() ); + assertTrue( JsonPath.ROOT.isEmpty() ); + assertFalse( JsonPath.of( ".x" ).isEmpty() ); + assertFalse( JsonPath.of( "[0]" ).isEmpty() ); + assertFalse( JsonPath.of( "{x}").isEmpty() ); + } + + @Test + void testSize() { + assertEquals( 0, JsonPath.ROOT.size() ); + assertEquals( 0, JsonPath.of( "" ).size() ); + assertEquals( 1, JsonPath.of( ".yeah" ).size() ); + assertEquals( 1, JsonPath.of( "[1234]" ).size() ); + assertEquals( 1, JsonPath.of( "{dotty.}" ).size() ); + assertEquals( 2, JsonPath.of( ".yeah.yeah" ).size() ); + assertEquals( 2, JsonPath.of( ".links[1234]" ).size() ); + assertEquals( 2, JsonPath.of( "{dotty.}.dot" ).size() ); + assertEquals( 3, JsonPath.of( ".yeah.yeah.yeahs" ).size() ); + } + + @Test + void testDropFirstSegment() { + assertEquals( JsonPath.of( ".two" ), JsonPath.of( ".one.two" ).dropFirstSegment() ); + assertEquals( JsonPath.of( ".yeah.yeahs" ), JsonPath.of( ".yeah.yeah.yeahs" ).dropFirstSegment() ); + } + + @Test + void testDropFirstSegment_Empty() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, + JsonPath.ROOT::dropFirstSegment ); + assertEquals( "Root/self path does not have a child.", ex.getMessage() ); + } + + @Test + void testDropFirstSegment_One() { + assertSame( JsonPath.ROOT, JsonPath.of( ".hello" ).dropFirstSegment() ); + } + + @Test + void testDropLastSegment() { + assertEquals( JsonPath.of( ".one" ), JsonPath.of( ".one.two" ).dropLastSegment() ); + assertEquals( JsonPath.of( ".yeah.yeah" ), JsonPath.of( ".yeah.yeah.yeahs" ).dropLastSegment() ); + } + + @Test + void testDropLastSegment_Empty() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, + JsonPath.ROOT::dropLastSegment ); + assertEquals( "Root/self path does not have a parent.", ex.getMessage() ); + } + + @Test + void testDropLastSegment_One() { + assertSame( JsonPath.ROOT, JsonPath.of( ".hello" ).dropLastSegment() ); + } + + @Test + void testExtendedWith_Name() { + assertEquals( JsonPath.of(".abc.def"), JsonPath.of( ".abc" ).extendedWith( "def" ) ); + assertEquals( JsonPath.of(".abc{.}"), JsonPath.of( ".abc" ).extendedWith( "." ) ); + assertEquals( JsonPath.of(".abc.[42]"), JsonPath.of( ".abc" ).extendedWith( "[42]" ) ); + } + + @Test + void testExtendedWith_Index() { + assertEquals( JsonPath.of(".answer[42]"), JsonPath.of( ".answer" ).extendedWith( 42 ) ); + } + + @Test + void testExtendedWith_Path() { + assertEquals( JsonPath.of( ".answer[42]" ), JsonPath.of( ".answer" ).extendedWith( JsonPath.of( 42 ) ) ); + } + + @Test + void testExtendedWith_Index_Negative() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, + () -> JsonPath.of( ".x" ).extendedWith( -1 ) ); + assertEquals( "Path array index must be zero or positive but was: -1", ex.getMessage() ); + } + + @Test + void testShortenedBy() { + assertEquals( JsonPath.of( ".bar" ), JsonPath.of( ".foo.bar" ).shortenedBy( JsonPath.of( ".foo" ) ) ); + } + + @Test + void testShortenedBy_NoParent() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, + () -> JsonPath.of( ".foo.bar" ).shortenedBy( JsonPath.of( ".xyz" ) ) ); + assertEquals( "Path .xyz is not a parent of .foo.bar", ex.getMessage() ); + } + + @Test + void testArrayIndexAtStart() { + assertEquals( 42, JsonPath.of( "[42]" ).arrayIndexAtStart() ); + assertEquals( 42, JsonPath.of( "[42].foo" ).arrayIndexAtStart() ); + assertEquals( 42, JsonPath.of( "[42]{foo}" ).arrayIndexAtStart() ); + assertEquals( 42, JsonPath.of( "[42][0]" ).arrayIndexAtStart() ); + } + + @Test + void testArrayIndexAtStart_Empty() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, + JsonPath.ROOT::arrayIndexAtStart ); + assertEquals( "Root/self path does not designate an array index.", ex.getMessage() ); + } + + @Test + void testArrayIndexAtStart_NoArray() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, () -> + JsonPath.of(".foo").arrayIndexAtStart() ); + assertEquals( "Path .foo does not start with an array.", ex.getMessage() ); + } + + @Test + void testObjectMemberAtStart() { + assertEquals( "foo", JsonPath.of( ".foo" ).objectMemberAtStart() ); + assertEquals( "foo", JsonPath.of( ".foo[42]" ).objectMemberAtStart() ); + assertEquals( "foo", JsonPath.of( ".foo{bar}" ).objectMemberAtStart() ); + assertEquals( "foo", JsonPath.of( ".foo.bar" ).objectMemberAtStart() ); + assertEquals( ".", JsonPath.of( "{.}.bar" ).objectMemberAtStart() ); + assertEquals( "{", JsonPath.of( ".{.}.bar" ).objectMemberAtStart() ); + assertEquals( "[3]", JsonPath.of( ".[3].bar" ).objectMemberAtStart() ); + } + + @Test + void testObjectMemberAtStart_Empty() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, + JsonPath.ROOT::objectMemberAtStart ); + assertEquals( "Root/self path does not designate a object member name.", ex.getMessage() ); + } + + @Test + void testObjectMemberAtStart_NoObject() { + JsonPathException ex = assertThrowsExactly( JsonPathException.class, () -> + JsonPath.of("[42].foo").objectMemberAtStart() ); + assertEquals( "Path [42].foo does not start with an object.", ex.getMessage() ); + } + private void assertSegments( String path, List segments ) { assertEquals( segments, JsonPath.of( path ).segments()); } From a36ac85c652213ddcc2e82d517898fb7a590b5d1 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 27 Jun 2024 10:50:37 +0200 Subject: [PATCH 09/11] chore: remove patch (not ready for release) --- .../org/hisp/dhis/jsontree/JsonPatch.java | 103 ---- .../dhis/jsontree/JsonPatchException.java | 2 +- .../org/hisp/dhis/jsontree/JsonValue.java | 23 - .../dhis/jsontree/JsonPatchSuiteTest.java | 55 -- .../jsontree/JsonPatchValidationTest.java | 83 --- .../resources/json-patch-tests/tests.json | 506 ------------------ 6 files changed, 1 insertion(+), 771 deletions(-) delete mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonPatch.java delete mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java delete mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java delete mode 100644 src/test/resources/json-patch-tests/tests.json diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java deleted file mode 100644 index e68ff67..0000000 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPatch.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.hisp.dhis.jsontree; - -import org.hisp.dhis.jsontree.JsonNodeOperation.Insert; -import org.hisp.dhis.jsontree.JsonNodeOperation.Remove; - -import java.util.ArrayList; -import java.util.List; - -import static org.hisp.dhis.jsontree.Validation.YesNo.YES; - -/** - * A JSON patch operation as defined by RFC-6902 - * (see jsonpatch.com). - * - * @author Jan Bernitt - * @since 1.1 - */ -public interface JsonPatch extends JsonObject { - - static String nextIndexPath( String path ) { - //FIXME implement properly - return path; - } - - enum Op {ADD, REMOVE, REPLACE, COPY, MOVE, TEST} - - @Required - @Validation( dependentRequired = { "=add", "=replace", "=copy", "=move", "=test" }, caseInsensitive = YES ) - default Op getOperation() { - return getString( "op" ).parsed( str -> Op.valueOf( str.toUpperCase() ) ); - } - - @Required - default JsonPointer getPath() { - return getString( "path" ).parsed( JsonPointer::new ); - } - - @Validation( dependentRequired = { "add", "replace", "test" }, acceptNull = YES) - default JsonMixed getValue() { - return get( "value", JsonMixed.class ); - } - - @Validation( dependentRequired = { "copy", "move" } ) - default JsonPointer getFrom() { - return getString( "from" ).parsed( JsonPointer::new ); - } - - static JsonValue apply(JsonValue value, JsonList ops) throws JsonPatchException { - return JsonValue.of( value.node().patch(JsonPatch.operations( value.as( JsonMixed.class ), ops ))); - } - - /** - * Converts a json-patch to {@link JsonNodeOperation}s. - * - * @param value the target value - * @param with the patch to apply - * @return list of {@link JsonNodeOperation}s to apply to get the patch effect - */ - private static List operations(JsonMixed value, JsonList with) { - List ops = new ArrayList<>(); - int i = 0; - for (JsonPatch op : with) { - op.validate( JsonPatch.class ); - String path = op.getPath().path(); - //FIXME if path is - (append) then this must be substituted with the actual first index after the last - switch ( op.getOperation() ) { - case ADD -> ops.add( new Insert(path, op.getValue().node() ) ); - case REMOVE -> ops.add( new Remove( path ) ); - case REPLACE -> { - ops.add( new Remove( path)); - ops.add( new Insert( nextIndexPath( path ), op.getValue().node() )); - } - case MOVE -> { - String from = op.getFrom().path(); - ops.add( new Remove( from ) ); - ops.add( new Insert( path, value.get( from ).node())); - } - case COPY -> { - String from = op.getFrom().path(); - ops.add( new Insert( path, value.get( from ).node()) ); - } - case TEST -> { - if ( !value.get( path ).equivalentTo( op.getValue() ) ) - throw new JsonPatchException("operation %d failed its test: %s".formatted( i, op.toJson() ) ); - } - } - i++; - } - return ops; - } - // V A L I D A T I O N S - // pointer: - // leading zeros in pointer seg - // tailing / - // pointer does not start with / - // null as pointer path - // pointer + tree: - // pointer implies parent of different node type - // index must be in range 0-length (length means append new) - - // operation: - // target does not exist (remove/replace) -} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java b/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java index 96527c1..a7ae8ae 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java @@ -6,7 +6,7 @@ import java.util.List; /** - * When a {@link JsonPatch} operation fails. + * When a patch operation fails. * * @author Jan Bernitt * @since 1.1 diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index 530e452..ef3826c 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -349,29 +349,6 @@ private static boolean equivalentTo(JsonValue a, JsonValue b, BiPredicate compare.test( ao.get( key ), bo.get( key ) ) ); } - /** - * Create a new value based on this value and a patch. - *

        - * Operations apply "as if" they were applied in sequence. - * However, operations that alter already altered subtrees are not allowed. - * This entails the following: - *

          - *
        • operations must not target a parent of a prior operation
        • - *
        • operations must not target a child of a prior operation
        • - *
        • operations must not target same path as a prior insert
        • - *
        - * That means in a valid sequence of operations all removes can be reordered to occur before any insert - * and can be done in a random order among the removes. - * - * @param ops operations to apply "atomically" - * @return a new value with the effects of the patch operations (this value stays unchanged) - * @throws JsonPatchException when the patch fails either because the operation was incorrectly defined or could not be applied to the value. This includes the a failing test operation. - * @since 1.1 - */ - default JsonValue patch(JsonList ops) throws JsonPatchException { - return JsonPatch.apply( this, ops ); - } - /** * Access the node in the JSON document. This can be the low level API that is concerned with extraction by path. *

        diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java deleted file mode 100644 index ad5344a..0000000 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPatchSuiteTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package org.hisp.dhis.jsontree; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DynamicTest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestFactory; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.DynamicTest.dynamicTest; - -/** - * Runs the JSON patch test suite provided by - * json-patch-tests. - * - * @author Jan Bernitt - */ -class JsonPatchSuiteTest { - - public interface JsonPatchScenario extends JsonObject { - - default String comment() { return getString( "comment" ).string(); } - default JsonMixed doc() { return get( "doc", JsonMixed.class); } - default JsonList patch() { return getList( "patch", JsonPatch.class ); } - default JsonMixed expected() { return get( "expected", JsonMixed.class ); } - default String error() { return getString( "error" ).string(); } - default JsonValue disabled() { return get( "disabled" ); } - } - - @TestFactory - List testScenarios() { - return JsonMixed.of( Path.of("src/test/resources/json-patch-tests/tests.json") ) - .asList( JsonPatchScenario.class ) - .stream() - .filter( scenario -> !scenario.disabled().exists() ) - .map( scenario -> dynamicTest( scenario.comment(), () -> assertScenario( scenario ) ) ) - .toList(); - } - - private void assertScenario(JsonPatchScenario scenario) { - String error = scenario.error(); - if ( error != null) { - assertThrows( JsonPatchException.class, () -> scenario.doc().patch( scenario.patch() ) ); - } else { - JsonValue patched = scenario.doc().patch( scenario.patch() ); - assertTrue( scenario.expected().equivalentTo( patched ), - () -> "expected %s but was: %s".formatted( scenario.expected(), patched ) ); - } - } -} diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java deleted file mode 100644 index b21d6d7..0000000 --- a/src/test/java/org/hisp/dhis/jsontree/JsonPatchValidationTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.hisp.dhis.jsontree; - -import org.hisp.dhis.jsontree.Validation.Rule; -import org.junit.jupiter.api.Test; - -import java.util.Map; -import java.util.Set; - -import static org.hisp.dhis.jsontree.Assertions.assertValidationError; - -/** - * Basic validation test for {@link JsonPatch} objects. - */ -class JsonPatchValidationTest { - - @Test - void testAny_InvalidPath() { - assertValidationError( """ - { "op": "remove", "path": "hello" }""", JsonPatch.class, Rule.PATTERN, - "(/([^/~]|(~[01]))*)*", "hello"); - } - - @Test - void testAny_MissingOp() { - assertValidationError( """ - { "path": "/hello" }""", JsonPatch.class, Rule.REQUIRED, "op"); - } - - @Test - void testAny_InvalidOp() { - assertValidationError( """ - { "op": "update", "path": "/hello" }""", JsonPatch.class, Rule.ENUM, - Set.of("ADD", "MOVE", "COPY", "REMOVE", "REPLACE", "TEST"), "update"); - } - - @Test - void testAny_MissingPath() { - assertValidationError( """ - { "op": "remove"}""", JsonPatch.class, Rule.REQUIRED, "path"); - } - - @Test - void testAdd_MissingValue() { - assertValidationError( """ - { "op": "add", "path": "/foo"}""", JsonPatch.class, Rule.DEPENDENT_REQUIRED, - Map.of("op", "add"), Set.of("value"), Set.of("value")); - } - - @Test - void testReplace_MissingValue() { - assertValidationError( """ - { "op": "replace", "path": "/foo"}""", JsonPatch.class, Rule.DEPENDENT_REQUIRED, - Map.of("op", "replace"), Set.of("value"), Set.of("value")); - } - - @Test - void testTest_MissingValue() { - assertValidationError( """ - { "op": "test", "path": "/foo"}""", JsonPatch.class, Rule.DEPENDENT_REQUIRED, - Map.of("op", "test"), Set.of("value"), Set.of("value")); - } - - @Test - void testCopy_MissingFrom() { - assertValidationError( """ - { "op": "copy", "path": "/foo"}""", JsonPatch.class, Rule.DEPENDENT_REQUIRED, - Map.of("op", "copy"), Set.of("from"), Set.of("from")); - } - - @Test - void testMove_MissingFrom() { - assertValidationError( """ - { "op": "move", "path": "/foo"}""", JsonPatch.class, Rule.DEPENDENT_REQUIRED, - Map.of("op", "move"), Set.of("from"), Set.of("from")); - } - - @Test - void testMove_InvalidFrom() { - assertValidationError( """ - { "op": "move", "from": "hello", "path":"/" }""", JsonPatch.class, Rule.PATTERN, - "(/([^/~]|(~[01]))*)*", "hello"); - } -} diff --git a/src/test/resources/json-patch-tests/tests.json b/src/test/resources/json-patch-tests/tests.json deleted file mode 100644 index 24ce7e5..0000000 --- a/src/test/resources/json-patch-tests/tests.json +++ /dev/null @@ -1,506 +0,0 @@ -[ - { "comment": "empty list, empty docs", - "doc": {}, - "patch": [], - "expected": {} }, - - { "comment": "empty patch list", - "doc": {"foo": 1}, - "patch": [], - "expected": {"foo": 1} }, - - { "comment": "rearrangements OK?", - "doc": {"foo": 1, "bar": 2}, - "patch": [], - "expected": {"bar":2, "foo": 1} }, - - { "comment": "rearrangements OK? How about one level down ... array", - "doc": [{"foo": 1, "bar": 2}], - "patch": [], - "expected": [{"bar":2, "foo": 1}] }, - - { "comment": "rearrangements OK? How about one level down...", - "doc": {"foo":{"foo": 1, "bar": 2}}, - "patch": [], - "expected": {"foo":{"bar":2, "foo": 1}} }, - - { "comment": "add replaces any existing field", - "doc": {"foo": null}, - "patch": [{"op": "add", "path": "/foo", "value":1}], - "expected": {"foo": 1} }, - - { "comment": "toplevel array", - "doc": [], - "patch": [{"op": "add", "path": "/0", "value": "foo"}], - "expected": ["foo"] }, - - { "comment": "toplevel array, no change", - "doc": ["foo"], - "patch": [], - "expected": ["foo"] }, - - { "comment": "toplevel object, numeric string", - "doc": {}, - "patch": [{"op": "add", "path": "/foo", "value": "1"}], - "expected": {"foo":"1"} }, - - { "comment": "toplevel object, integer", - "doc": {}, - "patch": [{"op": "add", "path": "/foo", "value": 1}], - "expected": {"foo":1} }, - - { "comment": "Toplevel scalar values OK?", - "doc": "foo", - "patch": [{"op": "replace", "path": "", "value": "bar"}], - "expected": "bar", - "disabled": true }, - - { "comment": "replace object document with array document?", - "doc": {}, - "patch": [{"op": "add", "path": "", "value": []}], - "expected": [] }, - - { "comment": "replace array document with object document?", - "doc": [], - "patch": [{"op": "add", "path": "", "value": {}}], - "expected": {} }, - - { "comment": "append to root array document?", - "doc": [], - "patch": [{"op": "add", "path": "/-", "value": "hi"}], - "expected": ["hi"] }, - - { "comment": "Add, / target", - "doc": {}, - "patch": [ {"op": "add", "path": "/x", "value":1 } ], - "expected": {"x":1} }, - - { "comment": "Add, /foo/x deep target (trailing slash)", - "doc": {"foo": {}}, - "patch": [ {"op": "add", "path": "/foo/x", "value":1 } ], - "expected": {"foo":{"x": 1}} }, - - { "comment": "Add composite value at top level", - "doc": {"foo": 1}, - "patch": [{"op": "add", "path": "/bar", "value": [1, 2]}], - "expected": {"foo": 1, "bar": [1, 2]} }, - - { "comment": "Add into composite value", - "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, - "patch": [{"op": "add", "path": "/baz/0/foo", "value": "world"}], - "expected": {"foo": 1, "baz": [{"qux": "hello", "foo": "world"}]} }, - - { "comment": "Add out of bounds positive index", - "doc": {"bar": [1, 2]}, - "patch": [{"op": "add", "path": "/bar/8", "value": "5"}], - "error": "Out of bounds (upper)" }, - - { "comment": "Add out of bounds negative index", - "doc": {"bar": [1, 2]}, - "patch": [{"op": "add", "path": "/bar/-1", "value": "5"}], - "error": "Out of bounds (lower)" }, - - { "comment": "Add new object member: true", - "doc": {"foo": 1}, - "patch": [{"op": "add", "path": "/bar", "value": true}], - "expected": {"foo": 1, "bar": true} }, - - { "comment": "Add new object member: false", - "doc": {"foo": 1}, - "patch": [{"op": "add", "path": "/bar", "value": false}], - "expected": {"foo": 1, "bar": false} }, - - { "comment": "Add new object member: null", - "doc": {"foo": 1}, - "patch": [{"op": "add", "path": "/bar", "value": null}], - "expected": {"foo": 1, "bar": null} }, - - { "comment": "0 can be an array index or object element name", - "doc": {"foo": 1}, - "patch": [{"op": "add", "path": "/0", "value": "bar"}], - "expected": {"foo": 1, "0": "bar" } }, - - { "comment": "Add new array element at the end", - "doc": ["foo"], - "patch": [{"op": "add", "path": "/1", "value": "bar"}], - "expected": ["foo", "bar"] }, - - { "comment": "Add new array element in the middle", - "doc": ["foo", "sil"], - "patch": [{"op": "add", "path": "/1", "value": "bar"}], - "expected": ["foo", "bar", "sil"] }, - - { "comment": "Add new array element at the beginning", - "doc": ["foo", "sil"], - "patch": [{"op": "add", "path": "/0", "value": "bar"}], - "expected": ["bar", "foo", "sil"] }, - - { "comment": "push item to array via last index + 1", - "doc": ["foo", "sil"], - "patch": [{"op":"add", "path": "/2", "value": "bar"}], - "expected": ["foo", "sil", "bar"] }, - - { "comment": "add item to array at index > length should fail", - "doc": ["foo", "sil"], - "patch": [{"op":"add", "path": "/3", "value": "bar"}], - "error": "index is greater than number of items in array" }, - - { "comment": "test against implementation-specific numeric parsing", - "doc": {"1e0": "foo"}, - "patch": [{"op": "test", "path": "/1e0", "value": "foo"}], - "expected": {"1e0": "foo"} }, - - { "comment": "test with bad number should fail", - "doc": ["foo", "bar"], - "patch": [{"op": "test", "path": "/1e0", "value": "bar"}], - "error": "test op shouldn't get array element 1" }, - - { "comment": "Add member to array fail", - "doc": ["foo", "sil"], - "patch": [{"op": "add", "path": "/bar", "value": 42}], - "error": "Object operation on array target" }, - - { "doc": ["foo", "sil"], - "patch": [{"op": "add", "path": "/1", "value": ["bar", "baz"]}], - "expected": ["foo", ["bar", "baz"], "sil"], - "comment": "value in array add not flattened" }, - - { "comment": "Remove object member", - "doc": {"foo": 1, "bar": [1, 2, 3, 4]}, - "patch": [{"op": "remove", "path": "/bar"}], - "expected": {"foo": 1} }, - - { "comment": "Remove object member in array", - "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, - "patch": [{"op": "remove", "path": "/baz/0/qux"}], - "expected": {"foo": 1, "baz": [{}]} }, - - { "comment": "Replace object member", - "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, - "patch": [{"op": "replace", "path": "/foo", "value": [1, 2, 3, 4]}], - "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]} }, - - { "comment": "Replace object member in array", - "doc": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]}, - "patch": [{"op": "replace", "path": "/baz/0/qux", "value": "world"}], - "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "world"}]} }, - - { "comment": "Replace array element at beginning", - "doc": ["foo"], - "patch": [{"op": "replace", "path": "/0", "value": "bar"}], - "expected": ["bar"] }, - - { "comment": "Replace array element with 0 at beginning", - "doc": [""], - "patch": [{"op": "replace", "path": "/0", "value": 0}], - "expected": [0] }, - - { "comment": "Replace array element with true at beginning", - "doc": [""], - "patch": [{"op": "replace", "path": "/0", "value": true}], - "expected": [true] }, - - { "comment": "Replace array element with false at beginning", - "doc": [""], - "patch": [{"op": "replace", "path": "/0", "value": false}], - "expected": [false] }, - - { "comment": "Replace array element with null at beginning", - "doc": [""], - "patch": [{"op": "replace", "path": "/0", "value": null}], - "expected": [null] }, - - { "doc": ["foo", "sil"], - "patch": [{"op": "replace", "path": "/1", "value": ["bar", "baz"]}], - "expected": ["foo", ["bar", "baz"]], - "comment": "value in array replace not flattened" }, - - { "comment": "replace whole document", - "doc": {"foo": "bar"}, - "patch": [{"op": "replace", "path": "", "value": {"baz": "qux"}}], - "expected": {"baz": "qux"} }, - - { "comment": "test replace with missing parent key should fail", - "doc": {"bar": "baz"}, - "patch": [{"op": "replace", "path": "/foo/bar", "value": false}], - "error": "replace op should fail with missing parent key" }, - - { "comment": "spurious patch properties", - "doc": {"foo": 1}, - "patch": [{"op": "test", "path": "/foo", "value": 1, "spurious": 1}], - "expected": {"foo": 1} }, - - { "doc": {"foo": null}, - "patch": [{"op": "test", "path": "/foo", "value": null}], - "expected": {"foo": null}, - "comment": "null value should be valid obj property" }, - - { "doc": {"foo": null}, - "patch": [{"op": "replace", "path": "/foo", "value": "truthy"}], - "expected": {"foo": "truthy"}, - "comment": "null value should be valid obj property to be replaced with something truthy" }, - - { "doc": {"foo": null}, - "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], - "expected": {"bar": null}, - "comment": "null value should be valid obj property to be moved" }, - - { "doc": {"foo": null}, - "patch": [{"op": "copy", "from": "/foo", "path": "/bar"}], - "expected": {"foo": null, "bar": null}, - "comment": "null value should be valid obj property to be copied" }, - - { "doc": {"foo": null}, - "patch": [{"op": "remove", "path": "/foo"}], - "expected": {}, - "comment": "null value should be valid obj property to be removed" }, - - { "doc": {"foo": "bar"}, - "patch": [{"op": "replace", "path": "/foo", "value": null}], - "expected": {"foo": null}, - "comment": "null value should still be valid obj property replace other value" }, - - { "doc": {"foo": {"foo": 1, "bar": 2}}, - "patch": [{"op": "test", "path": "/foo", "value": {"bar": 2, "foo": 1}}], - "expected": {"foo": {"foo": 1, "bar": 2}}, - "comment": "test should pass despite rearrangement" }, - - { "doc": {"foo": [{"foo": 1, "bar": 2}]}, - "patch": [{"op": "test", "path": "/foo", "value": [{"bar": 2, "foo": 1}]}], - "expected": {"foo": [{"foo": 1, "bar": 2}]}, - "comment": "test should pass despite (nested) rearrangement" }, - - { "doc": {"foo": {"bar": [1, 2, 5, 4]}}, - "patch": [{"op": "test", "path": "/foo", "value": {"bar": [1, 2, 5, 4]}}], - "expected": {"foo": {"bar": [1, 2, 5, 4]}}, - "comment": "test should pass - no error" }, - - { "comment": "Test value exists but does not match", - "doc": {"foo": {"bar": [1, 2, 5, 4]}}, - "patch": [{"op": "test", "path": "/foo", "value": [1, 2]}], - "error": "test op should fail" }, - - { "comment": "Whole document", - "doc": { "foo": 1 }, - "patch": [{"op": "test", "path": "", "value": {"foo": 1}}] - }, - - { "comment": "Empty-string element", - "doc": { "": 1 }, - "patch": [{"op": "test", "path": "/", "value": 1}], - "expected": { "": 1 }, - "disabled": "empty property name not supported" - }, - - { "comment": "Test complex scenario", - "doc": { - "foo": ["bar", "baz"], - "a/b": 1, - "c%d": 2, - "e^f": 3, - "g|h": 4, - "i\\j": 5, - "k\"l": 6, - " ": 7, - "m~n": 8 - }, - "patch": [{"op": "test", "path": "/foo", "value": ["bar", "baz"]}, - {"op": "test", "path": "/foo/0", "value": "bar"}, - {"op": "test", "path": "/a~1b", "value": 1}, - {"op": "test", "path": "/c%d", "value": 2}, - {"op": "test", "path": "/e^f", "value": 3}, - {"op": "test", "path": "/g|h", "value": 4}, - {"op": "test", "path": "/i\\j", "value": 5}, - {"op": "test", "path": "/k\"l", "value": 6}, - {"op": "test", "path": "/ ", "value": 7}, - {"op": "test", "path": "/m~0n", "value": 8}], - "expected": { - " ": 7, - "a/b": 1, - "c%d": 2, - "e^f": 3, - "foo": [ - "bar", - "baz" - ], - "g|h": 4, - "i\\j": 5, - "k\"l": 6, - "m~n": 8 - } - }, - { "comment": "Move to same location has no effect", - "doc": {"foo": 1}, - "patch": [{"op": "move", "from": "/foo", "path": "/foo"}], - "expected": {"foo": 1} }, - - { "comment": "Move to different property in same object", - "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, - "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], - "expected": {"baz": [{"qux": "hello"}], "bar": 1} }, - - { "comment": "Move one level up", - "doc": {"baz": [{"qux": "hello"}], "bar": 1}, - "patch": [{"op": "move", "from": "/baz/0/qux", "path": "/baz/1"}], - "expected": {"baz": [{}, "hello"], "bar": 1} }, - - { "comment": "Copy one level up", - "doc": {"baz": [{"qux": "hello"}], "bar": 1}, - "patch": [{"op": "copy", "from": "/baz/0", "path": "/boo"}], - "expected": {"baz":[{"qux":"hello"}],"bar":1,"boo":{"qux":"hello"}} }, - - { "comment": "replacing the root of the document is possible with add", - "doc": {"foo": "bar"}, - "patch": [{"op": "add", "path": "", "value": {"baz": "qux"}}], - "expected": {"baz":"qux"}}, - - { "comment": "Adding to \"/-\" adds to the end of the array", - "doc": [ 1, 2 ], - "patch": [ { "op": "add", "path": "/-", "value": { "foo": [ "bar", "baz" ] } } ], - "expected": [ 1, 2, { "foo": [ "bar", "baz" ] } ]}, - - { "comment": "Adding to \"/-\" adds to the end of the array, even n levels down", - "doc": [ 1, 2, [ 3, [ 4, 5 ] ] ], - "patch": [ { "op": "add", "path": "/2/1/-", "value": { "foo": [ "bar", "baz" ] } } ], - "expected": [ 1, 2, [ 3, [ 4, 5, { "foo": [ "bar", "baz" ] } ] ] ]}, - - { "comment": "test remove with bad number should fail", - "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, - "patch": [{"op": "remove", "path": "/baz/1e0/qux"}], - "error": "remove op shouldn't remove from array with bad number" }, - - { "comment": "test remove on array", - "doc": [1, 2, 3, 4], - "patch": [{"op": "remove", "path": "/0"}], - "expected": [2, 3, 4] }, - - { "comment": "test repeated removes", - "doc": [1, 2, 3, 4], - "patch": [{ "op": "remove", "path": "/1" }, - { "op": "remove", "path": "/2" }], - "expected": [1, 3] }, - - { "comment": "test remove with bad index should fail", - "doc": [1, 2, 3, 4], - "patch": [{"op": "remove", "path": "/1e0"}], - "error": "remove op shouldn't remove from array with bad number" }, - - { "comment": "test replace with bad number should fail", - "doc": [""], - "patch": [{"op": "replace", "path": "/1e0", "value": false}], - "error": "replace op shouldn't replace in array with bad number" }, - - { "comment": "test copy with bad number should fail", - "doc": {"baz": [1,2,3], "bar": 1}, - "patch": [{"op": "copy", "from": "/baz/1e0", "path": "/boo"}], - "error": "copy op shouldn't work with bad number" }, - - { "comment": "test move with bad number should fail", - "doc": {"foo": 1, "baz": [1,2,3,4]}, - "patch": [{"op": "move", "from": "/baz/1e0", "path": "/foo"}], - "error": "move op shouldn't work with bad number" }, - - { "comment": "test add with bad number should fail", - "doc": ["foo", "sil"], - "patch": [{"op": "add", "path": "/1e0", "value": "bar"}], - "error": "add op shouldn't add to array with bad number" }, - - { "comment": "missing 'path' parameter", - "doc": {}, - "patch": [ { "op": "add", "value": "bar" } ], - "error": "missing 'path' parameter" }, - - { "comment": "'path' parameter with null value", - "doc": {}, - "patch": [ { "op": "add", "path": null, "value": "bar" } ], - "error": "null is not valid value for 'path'" }, - - { "comment": "invalid JSON Pointer token", - "doc": {}, - "patch": [ { "op": "add", "path": "foo", "value": "bar" } ], - "error": "JSON Pointer should start with a slash" }, - - { "comment": "missing 'value' parameter to add", - "doc": [ 1 ], - "patch": [ { "op": "add", "path": "/-" } ], - "error": "missing 'value' parameter" }, - - { "comment": "missing 'value' parameter to replace", - "doc": [ 1 ], - "patch": [ { "op": "replace", "path": "/0" } ], - "error": "missing 'value' parameter" }, - - { "comment": "missing 'value' parameter to test", - "doc": [ null ], - "patch": [ { "op": "test", "path": "/0" } ], - "error": "missing 'value' parameter" }, - - { "comment": "missing value parameter to test - where undef is falsy", - "doc": [ false ], - "patch": [ { "op": "test", "path": "/0" } ], - "error": "missing 'value' parameter" }, - - { "comment": "missing from parameter to copy", - "doc": [ 1 ], - "patch": [ { "op": "copy", "path": "/-" } ], - "error": "missing 'from' parameter" }, - - { "comment": "missing from location to copy", - "doc": { "foo": 1 }, - "patch": [ { "op": "copy", "from": "/bar", "path": "/foo" } ], - "error": "missing 'from' location" }, - - { "comment": "missing from parameter to move", - "doc": { "foo": 1 }, - "patch": [ { "op": "move", "path": "" } ], - "error": "missing 'from' parameter" }, - - { "comment": "missing from location to move", - "doc": { "foo": 1 }, - "patch": [ { "op": "move", "from": "/bar", "path": "/foo" } ], - "error": "missing 'from' location" }, - - { "comment": "duplicate ops", - "doc": { "foo": "bar" }, - "patch": [ { "op": "add", "path": "/baz", "value": "qux", - "op": "move", "from":"/foo" } ], - "error": "patch has two 'op' members", - "disabled": true }, - - { "comment": "unrecognized op should fail", - "doc": {"foo": 1}, - "patch": [{"op": "spam", "path": "/foo", "value": 1}], - "error": "Unrecognized op 'spam'" }, - - { "comment": "test with bad array number that has leading zeros", - "doc": ["foo", "bar"], - "patch": [{"op": "test", "path": "/00", "value": "foo"}], - "error": "test op should reject the array value, it has leading zeros" }, - - { "comment": "test with bad array number that has leading zeros", - "doc": ["foo", "bar"], - "patch": [{"op": "test", "path": "/01", "value": "bar"}], - "error": "test op should reject the array value, it has leading zeros" }, - - { "comment": "Removing nonexistent field", - "doc": {"foo" : "bar"}, - "patch": [{"op": "remove", "path": "/baz"}], - "error": "removing a nonexistent field should fail" }, - - { "comment": "Removing deep nonexistent path", - "doc": {"foo" : "bar"}, - "patch": [{"op": "remove", "path": "/missing1/missing2"}], - "error": "removing a nonexistent field should fail" }, - - { "comment": "Removing nonexistent index", - "doc": ["foo", "bar"], - "patch": [{"op": "remove", "path": "/2"}], - "error": "removing a nonexistent index should fail" }, - - { "comment": "Patch with different capitalisation than doc", - "doc": {"foo":"bar"}, - "patch": [{"op": "add", "path": "/FOO", "value": "BAR"}], - "expected": {"foo": "bar", "FOO": "BAR"} - } - -] From 7e82bb6e69f08f8f31a4d013211381a62b9a39e8 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 27 Jun 2024 12:13:33 +0200 Subject: [PATCH 10/11] chore: drop patch related code for the release --- CHANGELOG.md | 2 + .../java/org/hisp/dhis/jsontree/JsonNode.java | 21 -- .../java/org/hisp/dhis/jsontree/JsonTree.java | 10 - .../hisp/dhis/jsontree/JsonTreeOperation.java | 114 --------- .../org/hisp/dhis/jsontree/PathSelector.java | 28 --- .../json-patch-tests/spec_tests.json | 233 ------------------ 6 files changed, 2 insertions(+), 406 deletions(-) delete mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java delete mode 100644 src/main/java/org/hisp/dhis/jsontree/PathSelector.java delete mode 100644 src/test/resources/json-patch-tests/spec_tests.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b4cb443..833bb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ > [!Warning] > ### Breaking Changes +> * **Changed**: `JsonNode#getPath` returns a `JsonPath` (`String` before) +> * **Changed**: `JsonNode#keys` returns paths with escaping when needed > [!Caution] > ### Bugfixes diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 7db808c..f5def39 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -789,27 +789,6 @@ default JsonNode removeElements( int from, int to ) { } ) ); } - /** - * A {@link JsonNode} is an immutable value. For similar reasons all operations in a patch apply to the same target - * value state. The state does not "update" in-between the operations. This means a set of operations can be - * considered declarative. Inserts and removes that contradict each other make the patch invalid and a - * {@link JsonPatchException} is thrown. - *

        - *

        Patching array elements

        - * The immutable nature is especially relevant for operations targeting arrays. For example, to insert 3 element at - * position 4 it is not correct to generate 3 insert operations at index 4,5,6 as each of these indexes refers to - * the array before the change. Instead, use a single operation with {@link JsonNodeOperation.Insert#merge()} and a - * value array containing the three elements. - * - * @param ops the set of patch operations to apply (paths are relative to this node) - * @return the root node of the patched tree (this node and tree stay unchanged); removing the root results in - * {@link JsonNode#NULL} - * @throws JsonPatchException when any two of the given operations contradict each other (for example removing parts - * of an insert or inserting into a removed subtree) or when any of the arrays - * or objects targeted for insert do not exist - */ - JsonNode patch( List ops) throws JsonPatchException; - private void checkType( JsonNodeType expected, JsonNodeType actual, String operation ) { if ( actual != expected ) throw new JsonTreeException( diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 2446d27..0b84d16 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -187,16 +187,6 @@ public final JsonNode replaceWith( String json ) { return JsonNode.of( newJson.toString() ); } - @Override - public final JsonNode patch( List ops ) { - if (ops.isEmpty()) return getRoot(); - if (isRoot() && ops.size() == 1 && ops.get( 0 ).path().isEmpty()) - return ops.get( 0 ) instanceof Insert i ? i.value() : JsonNode.NULL; // root insert/remove - List treeOps = JsonTreeOperation.of( ops, this ); - //TODO - return this; - } - @Override public final void visit( JsonNodeType type, Consumer visitor ) { if ( type == null || type == getType() ) { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java b/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java deleted file mode 100644 index 9549880..0000000 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTreeOperation.java +++ /dev/null @@ -1,114 +0,0 @@ -package org.hisp.dhis.jsontree; - -import org.hisp.dhis.jsontree.internal.Surly; - -import java.util.List; -import java.util.Map; - -import static java.lang.Integer.parseInt; -import static java.util.Comparator.comparing; -import static org.hisp.dhis.jsontree.JsonNodeOperation.parentPath; - -/** - * {@link JsonTreeOperation}s are used internally to make modifications to a {@link JsonTree}. - *

        - * In contrast to {@link JsonNodeOperation}s they are based on a {@link #target()} {@link JsonNode} and their order - * matters as they have to be applied in order of their {@link #sortIndex()} to cut and paste a new {@link JsonTree} - * together without forcing intermediate representations (which is most of all a performance optimisation.). - *

        - * This means the {@link JsonNodeOperation#path()} has been resolved to a {@link JsonNode} in an actual target - * {@link JsonTree}. The paths are interpreted relative to the target {@link JsonNode} provided in the - * {@link #of(JsonNodeOperation, JsonNode)} method. - * - * @author Jan Bernitt - * @since 1.1 - */ -sealed interface JsonTreeOperation { - - /** - * @return the node to modify or if no such mode exists the parent that gets modified - */ - JsonNode target(); - - /** - * The goal of this index is to bring the operations into an order that allows to copy together a new tree from the - * original tree and nodes brought in by the operations. - * - * @return the index in the underlying JSON input that is (or is near) the start position where a modification takes - * place. Usually this is the start of the modified node but for example if new members or elements are added to an - * object/array these are appended, therefore this points to the end of the parent they are appended to - */ - default int sortIndex() { - return target().startIndex(); - } - - /* - * Object operations - */ - - record RemoveMember(JsonNode target, String name) implements JsonTreeOperation { } - - record ReplaceMember(JsonNode target, String name, JsonNode value) implements JsonTreeOperation { } - - /** - * - * @param target the parent members are added to - * @param values a map of all members to add - */ - record AddMembers(JsonNode target, Map values) implements JsonTreeOperation { - - @Override public int sortIndex() { - return target.endIndex(); - } - } - - /* - * Array operations - */ - - record RemoveElements(JsonNode target, int index) implements JsonTreeOperation { } - - record InsertElements(JsonNode target, int index, List value) implements JsonTreeOperation {} - - record AppendElements(JsonNode target, List values) implements JsonTreeOperation { - - @Override public int sortIndex() { - return target.endIndex(); - } - } - - @Surly - static List of(List ops, JsonNode target) { - return ops.stream() - .flatMap( e -> of(e, target).stream() ) - .sorted(comparing( JsonTreeOperation::sortIndex )) - .toList(); - } - - @Surly - static List of(JsonNodeOperation op, JsonNode target) { - String path = op.path(); - String name = path.substring( path.lastIndexOf( '/' ) +1 ); - if (op instanceof JsonNodeOperation.Remove ) { - JsonNode t = target.getOrDefault( path, null ); - if (t == null) return List.of(); - if (t.getParent().getType() == JsonNodeType.OBJECT) - return List.of(new RemoveMember( t, name )); - return List.of(new RemoveElements( t, parseInt( name ) )); - } - if (op instanceof JsonNodeOperation.Insert insert) { - JsonNode t = target.getOrDefault( path, null ); - JsonNode value = insert.value(); - if (t == null) { - JsonNode parent = target.get( parentPath( path ) ); - boolean isObjectParent = parent.getType() == JsonNodeType.OBJECT; - if (isObjectParent) return List.of(new AddMembers( parent, Map.of(name, value ) )); - return List.of(new InsertElements( parent, parseInt( name ), List.of(value) )); - } - if (t.getType() == JsonNodeType.OBJECT) return List.of(new ReplaceMember( t, name, value )); - return List.of(new AppendElements( t, List.of(value) )); - } - throw new UnsupportedOperationException(op.toString()); - } - -} diff --git a/src/main/java/org/hisp/dhis/jsontree/PathSelector.java b/src/main/java/org/hisp/dhis/jsontree/PathSelector.java deleted file mode 100644 index ca2b894..0000000 --- a/src/main/java/org/hisp/dhis/jsontree/PathSelector.java +++ /dev/null @@ -1,28 +0,0 @@ -package org.hisp.dhis.jsontree; - -import java.util.Map; -import java.util.function.Predicate; -import java.util.stream.Stream; - -/** - * - * @param as - * @param select - * @param followSelected - * @param items - * @param - */ -public record PathSelector(Class as, Predicate select, boolean followSelected, - Map> items) { - - static Stream visit(T root, PathSelector selector ) { - Stream.Builder add = Stream.builder(); - if (selector.select.test( root )) { - selector.items.forEach( (path, pathSelector ) -> { - // is path an array? - // - } ); - } - return add.build(); - } -} diff --git a/src/test/resources/json-patch-tests/spec_tests.json b/src/test/resources/json-patch-tests/spec_tests.json deleted file mode 100644 index c160535..0000000 --- a/src/test/resources/json-patch-tests/spec_tests.json +++ /dev/null @@ -1,233 +0,0 @@ -[ - { - "comment": "4.1. add with missing object", - "doc": { "q": { "bar": 2 } }, - "patch": [ {"op": "add", "path": "/a/b", "value": 1} ], - "error": - "path /a does not exist -- missing objects are not created recursively" - }, - - { - "comment": "A.1. Adding an Object Member", - "doc": { - "foo": "bar" -}, - "patch": [ - { "op": "add", "path": "/baz", "value": "qux" } -], - "expected": { - "baz": "qux", - "foo": "bar" -} - }, - - { - "comment": "A.2. Adding an Array Element", - "doc": { - "foo": [ "bar", "baz" ] -}, - "patch": [ - { "op": "add", "path": "/foo/1", "value": "qux" } -], - "expected": { - "foo": [ "bar", "qux", "baz" ] -} - }, - - { - "comment": "A.3. Removing an Object Member", - "doc": { - "baz": "qux", - "foo": "bar" -}, - "patch": [ - { "op": "remove", "path": "/baz" } -], - "expected": { - "foo": "bar" -} - }, - - { - "comment": "A.4. Removing an Array Element", - "doc": { - "foo": [ "bar", "qux", "baz" ] -}, - "patch": [ - { "op": "remove", "path": "/foo/1" } -], - "expected": { - "foo": [ "bar", "baz" ] -} - }, - - { - "comment": "A.5. Replacing a Value", - "doc": { - "baz": "qux", - "foo": "bar" -}, - "patch": [ - { "op": "replace", "path": "/baz", "value": "boo" } -], - "expected": { - "baz": "boo", - "foo": "bar" -} - }, - - { - "comment": "A.6. Moving a Value", - "doc": { - "foo": { - "bar": "baz", - "waldo": "fred" - }, - "qux": { - "corge": "grault" - } -}, - "patch": [ - { "op": "move", "from": "/foo/waldo", "path": "/qux/thud" } -], - "expected": { - "foo": { - "bar": "baz" - }, - "qux": { - "corge": "grault", - "thud": "fred" - } -} - }, - - { - "comment": "A.7. Moving an Array Element", - "doc": { - "foo": [ "all", "grass", "cows", "eat" ] -}, - "patch": [ - { "op": "move", "from": "/foo/1", "path": "/foo/3" } -], - "expected": { - "foo": [ "all", "cows", "eat", "grass" ] -} - - }, - - { - "comment": "A.8. Testing a Value: Success", - "doc": { - "baz": "qux", - "foo": [ "a", 2, "c" ] -}, - "patch": [ - { "op": "test", "path": "/baz", "value": "qux" }, - { "op": "test", "path": "/foo/1", "value": 2 } -], - "expected": { - "baz": "qux", - "foo": [ "a", 2, "c" ] - } - }, - - { - "comment": "A.9. Testing a Value: Error", - "doc": { - "baz": "qux" -}, - "patch": [ - { "op": "test", "path": "/baz", "value": "bar" } -], - "error": "string not equivalent" - }, - - { - "comment": "A.10. Adding a nested Member Object", - "doc": { - "foo": "bar" -}, - "patch": [ - { "op": "add", "path": "/child", "value": { "grandchild": { } } } -], - "expected": { - "foo": "bar", - "child": { - "grandchild": { - } - } -} - }, - - { - "comment": "A.11. Ignoring Unrecognized Elements", - "doc": { - "foo":"bar" -}, - "patch": [ - { "op": "add", "path": "/baz", "value": "qux", "xyz": 123 } -], - "expected": { - "foo":"bar", - "baz":"qux" -} - }, - - { - "comment": "A.12. Adding to a Non-existent Target", - "doc": { - "foo": "bar" -}, - "patch": [ - { "op": "add", "path": "/baz/bat", "value": "qux" } -], - "error": "add to a non-existent target" - }, - - { - "comment": "A.13 Invalid JSON Patch Document", - "doc": { - "foo": "bar" - }, - "patch": [ - { "op": "add", "path": "/baz", "value": "qux", "op": "remove" } -], - "error": "operation has two 'op' members", - "disabled": true - }, - - { - "comment": "A.14. ~ Escape Ordering", - "doc": { - "/": 9, - "~1": 10 - }, - "patch": [{"op": "test", "path": "/~01", "value": 10}], - "expected": { - "/": 9, - "~1": 10 - } - }, - - { - "comment": "A.15. Comparing Strings and Numbers", - "doc": { - "/": 9, - "~1": 10 - }, - "patch": [{"op": "test", "path": "/~01", "value": "10"}], - "error": "number is not equal to string" - }, - - { - "comment": "A.16. Adding an Array Value", - "doc": { - "foo": ["bar"] - }, - "patch": [{ "op": "add", "path": "/foo/-", "value": ["abc", "def"] }], - "expected": { - "foo": ["bar", ["abc", "def"]] - } - } - -] From 6b21ebf3b47c746d5086d10e06e7c6e45bf166c6 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 27 Jun 2024 12:16:50 +0200 Subject: [PATCH 11/11] chore: drop patch related code for the release --- src/test/resources/json-patch-tests/LICENSE | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 src/test/resources/json-patch-tests/LICENSE diff --git a/src/test/resources/json-patch-tests/LICENSE b/src/test/resources/json-patch-tests/LICENSE deleted file mode 100644 index cf32171..0000000 --- a/src/test/resources/json-patch-tests/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright 2014 The 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 - - http://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. \ No newline at end of file