From 46abf3760f634b30fe9c79db4c65988b858d1067 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 10 Feb 2025 13:32:27 +0100 Subject: [PATCH 1/2] feat: JSON diff function with modes --- .../java/org/hisp/dhis/jsontree/JsonDiff.java | 380 ++++++++++++++++++ .../org/hisp/dhis/jsontree/JsonValue.java | 28 ++ .../org/hisp/dhis/jsontree/JsonDiffTest.java | 215 ++++++++++ .../jsontree/JsonObjectPropertiesTest.java | 37 ++ 4 files changed, 660 insertions(+) create mode 100644 src/main/java/org/hisp/dhis/jsontree/JsonDiff.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonDiffTest.java diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonDiff.java b/src/main/java/org/hisp/dhis/jsontree/JsonDiff.java new file mode 100644 index 0000000..42237f7 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonDiff.java @@ -0,0 +1,380 @@ +package org.hisp.dhis.jsontree; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedParameterizedType; +import java.lang.reflect.AnnotatedType; +import java.lang.reflect.ParameterizedType; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentSkipListMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.stream.IntStream; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE_USE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static java.util.Comparator.comparing; +import static java.util.function.Predicate.not; + +/** + * Computes the differences between two JSON values. + * + * @author Jan Bernitt + * @since 1.7 + */ +public record JsonDiff(JsonValue expected, JsonValue actual, List differences) { + + /** Is there more JSON, missing JSON, the JSON is out of order or are the simple values wrong? */ + public enum Type { + /** There is something missing in the actual JSON that was expected */ + LESS, + /** There is something additional in the actual JSON that isn't expected */ + MORE, + /** Array elements or object members are in different order in the actual and expected */ + SORT, + /** The simple value of the leaf node in the actual JSON is not equal to the value expected */ + NEQ + } + + public record Difference(Type type, JsonValue inExpected, JsonValue inActual) { + + @Override + public String toString() { + return JsonDiff.format(this); + } + } + + /** + * When annotating a property method of a JSON array value the elements in the array do not have + * to be in the same order as in the "expected" array. + * + *

When annotating a property method of a JSON object value the members in the object do not + * have to be in the same order as in the "expected" object. + */ + @Target({METHOD, TYPE_USE}) + @Retention(RUNTIME) + public @interface AnyOrder { + boolean value() default true; + } + + /** + * When annotating a property method of a JSON array value that may be extra elements in the + * actual JSON array. If order is checked these would all need to be at the end, if any order is + * allowed they can be in-between the expected values. + * + *

When annotating a property method of a JSON object value there may be extra members in the + * actual JSON object. If order is checked these would all need to be at the end, if any order is + * allowed they can be in-between the expected members. + */ + @Target({METHOD, TYPE_USE}) + @Retention(RUNTIME) + public @interface AnyAdditional { + boolean value() default true; + } + + /** + * @param anyOrder allow any order of array elements or object members when comparing + * @param anyAdditional allow any additional array elements or object members in the actual value + * when comparing + */ + public record Strictness(boolean anyOrder, boolean anyAdditional) {} + + public enum Equivalence { + NUMERIC, + TEXTUAL + } + + /** + * How to compare expected and actual JSON values when making a diff. + * + * @param arrays default {@link Strictness} for arrays + * @param objects default {@link Strictness} for objects + */ + public record Mode(Strictness arrays, Strictness objects, Equivalence numbers) { + public static final Mode DEFAULT = + new Mode(new Strictness(false, false), new Strictness(true, false), Equivalence.NUMERIC); + public static final Mode STRICT = + new Mode(new Strictness(false, false), new Strictness(false, false), Equivalence.TEXTUAL); + public static final Mode LENIENT = + new Mode(new Strictness(true, true), new Strictness(true, true), Equivalence.NUMERIC); + + public Mode anyOrder() { + return anyOrder(true); + } + + public Mode anyOrder(boolean anyOrder) { + return new Mode( + new Strictness(anyOrder, arrays.anyAdditional), + new Strictness(anyOrder, objects.anyAdditional), + numbers); + } + + public Mode anyAdditional() { + return anyAdditional(true); + } + + public Mode anyAdditional(boolean anyAdditional) { + return new Mode( + new Strictness(arrays.anyOrder, anyAdditional), + new Strictness(objects.anyOrder, anyAdditional), + numbers); + } + + public Mode arrays(Strictness arrays) { + return new Mode(arrays, objects, numbers); + } + + public Mode objects(Strictness objects) { + return new Mode(arrays, objects, numbers); + } + + public Mode numbers(Equivalence numbers) { + return new Mode(arrays, objects, numbers); + } + } + + static JsonDiff of(JsonValue e, JsonValue a, Mode mode) { + List differences = new ArrayList<>(); + if (!e.node().isRoot()) e = e.node().extract().lift(e.getAccessStore()); + if (!a.node().isRoot()) a = a.node().extract().lift(a.getAccessStore()).as(a.asType()); + diff(e, a, mode, differences::add, getRootInfo(a.asType())); + return new JsonDiff(e, a, List.copyOf(differences)); + } + + public static String format(Difference d) { + JsonValue a = d.inExpected; + JsonValue b = d.inActual; + JsonPath aPath = a.exists() ? a.node().getPath() : b.node().getPath(); + JsonPath bPath = b.exists() ? b.node().getPath() : a.node().getPath(); + String aJson = a.exists() ? a.toJson() : "?"; + String bJson = b.exists() ? b.toJson() : "?"; + String type = + switch (d.type()) { + case NEQ -> "!="; + case MORE -> "++"; + case LESS -> "--"; + case SORT -> ">>"; + }; + if (aPath.equals(bPath)) return "%s $%s: %s <> %s".formatted(type, aPath, aJson, bJson); + return "%s $%s/$%s: %s <> %s".formatted(type, aPath, bPath, aJson, bJson); + } + + private static void diff( + JsonValue e, JsonValue a, Mode mode, Consumer add, PropertyInfo p) { + if (!a.exists()) { + add.accept(new Difference(Type.LESS, e, a)); + return; + } + if (e.type() != a.type()) { + add.accept(new Difference(Type.NEQ, e, a)); + return; + } + switch (e.type()) { + case BOOLEAN, STRING, NULL -> diffValue(e, a, add); + case NUMBER -> diffNumber(e.as(JsonNumber.class), a.as(JsonNumber.class), mode, add); + case ARRAY -> diffArray(e.as(JsonArray.class), a.as(JsonArray.class), mode, add, p); + case OBJECT -> diffObject(e.asObject(), a.asObject(), mode, add, p); + } + } + + private static void diffValue(JsonValue e, JsonValue a, Consumer add) { + if (!e.toJson().equals(a.toJson())) add.accept(new Difference(Type.NEQ, e, a)); + } + + private static void diffNumber(JsonNumber e, JsonNumber a, Mode mode, Consumer add) { + if (mode.numbers == Equivalence.TEXTUAL) { + diffValue(e, a, add); + } else if (e.doubleValue() != a.doubleValue()) add.accept(new Difference(Type.NEQ, e, a)); + } + + private static void diffObject( + JsonObject e, JsonObject a, Mode mode, Consumer add, PropertyInfo p) { + if (!p.anyAdditional(mode.objects.anyAdditional) && e.size() != a.size()) { + // list all extra members + a.keys() + .filter(not(e::has)) + .forEach(key -> add.accept(new Difference(Type.MORE, e.get(key), a.get(key)))); + } + // handle all members in a + if (p.anyOrder(mode.objects.anyOrder)) { + // any order + e.keys().forEach(key -> diff(e.get(key), a.get(key), mode, add, p.property(key))); + } else { + // exact order + Iterator eKeys = e.keys().iterator(); + Iterator aKeys = a.keys().filter(e::has).iterator(); + while (eKeys.hasNext() && aKeys.hasNext()) { + String eKey = eKeys.next(); + String aKey = aKeys.next(); + if (eKey.equals(aKey)) { + diff(e.get(eKey), a.get(aKey), mode, add, p.property(eKey)); + } else { + add.accept(new Difference(Type.SORT, e.get(eKey), a.get(aKey))); + } + } + } + } + + private static void diffArray( + JsonArray e, JsonArray a, Mode mode, Consumer add, PropertyInfo p) { + int eN = e.size(); + int aN = a.size(); + if (eN < aN && !p.anyAdditional(mode.arrays.anyAdditional)) { + // list all extra elements + IntStream.range(eN, aN) + .forEach(i -> add.accept(new Difference(Type.MORE, e.get(i), a.get(i)))); + } + PropertyInfo elements = p.elements(); + if (p.anyOrder(mode.arrays.anyOrder)) { + // any order + BitSet different = new BitSet(eN); + for (int i = 0; i < eN; i++) { + int index = i; + diff(e.get(i), a.get(i), mode, d -> different.set(index), elements); + } + if (different.isEmpty()) return; + // try to find an equal value else-where + for (int i0 : different.stream().toArray()) { // needs a copy to loop & modify! + JsonValue elem = e.get(i0); + int sameIndex = + different.stream() + .filter(i -> i != i0 && noDiff(elem, a.get(i), mode, elements)) + .findFirst() + .orElse(-1); + if (sameIndex >= 0) { + different.clear(sameIndex); + } else { + JsonValue inActual = a.get(i0); + Type type = inActual.exists() ? Type.NEQ : Type.LESS; + add.accept(new Difference(type, elem, inActual)); + } + } + } else { + // exact order + for (int i = 0; i < eN; i++) diff(e.get(i), a.get(i), mode, add, elements); + } + } + + private static boolean noDiff(JsonValue e, JsonValue a, Mode mode, PropertyInfo p) { + AtomicBoolean diff = new AtomicBoolean(true); + diff(e, a, mode, d -> diff.set(false), p); + return diff.get(); + } + + /* + Type and annotation knowledge being extracted + */ + + /** For each type this captures the information given by annotations. */ + private record PropertyInfo( + Boolean anyOrder, + Boolean anyAdditional, + // the properties of an object + Map properties, + // the elements of an array or the values of a map object + PropertyInfo values) { + + static final PropertyInfo NONE = new PropertyInfo((Boolean) null, null, Map.of(), null); + + static PropertyInfo of( + AnnotatedElement source, + Boolean anyOrder, + Boolean anyAdditional, + Map properties, + PropertyInfo values) { + AnyOrder order = source.getAnnotation(AnyOrder.class); + AnyAdditional additional = source.getAnnotation(AnyAdditional.class); + if (order == null + && additional == null + && anyOrder == null + && anyAdditional == null + && properties.isEmpty() + && values == NONE) return NONE; + if (order != null) anyOrder = order.value(); + if (additional != null) anyAdditional = additional.value(); + return new PropertyInfo(anyOrder, anyAdditional, properties, values); + } + + PropertyInfo elements() { + return values == null ? NONE : values; // an array + } + + PropertyInfo property(String key) { + if (values != null && values != NONE) return values; // a map + return properties.getOrDefault(key, NONE); // a object + } + + boolean anyOrder(boolean defaultValue) { + return anyOrder != null ? anyOrder : defaultValue; + } + + boolean anyAdditional(boolean defaultValue) { + return anyAdditional != null ? anyAdditional : defaultValue; + } + } + + private static final Map, Map> INFO = + new ConcurrentSkipListMap<>(comparing(Class::getName)); + + private static PropertyInfo getRootInfo(Class type) { + if (JsonObject.class.isAssignableFrom(type)) { + @SuppressWarnings("unchecked") + Class objType = (Class) type; + return new PropertyInfo((Boolean) null, null, getProperties(objType), PropertyInfo.NONE); + } + return PropertyInfo.NONE; + } + + private static Map getProperties(Class type) { + return INFO.computeIfAbsent(type, JsonDiff::findProperties); + } + + private static Map findProperties(Class type) { + List properties = JsonObject.properties(type); + if (properties.isEmpty()) return Map.of(); + Map res = new HashMap<>(); + for (JsonObject.Property p : properties) { + PropertyInfo info = propertyOf(p.javaType()); + if (info != PropertyInfo.NONE) res.put(p.jsonName(), info); + } + return Map.copyOf(res); + } + + private static PropertyInfo propertyOf(AnnotatedType type) { + java.lang.reflect.Type t = type.getType(); + if (t instanceof Class raw) { + if (JsonObject.class.isAssignableFrom(raw)) { + @SuppressWarnings("unchecked") + Map properties = getProperties((Class) raw); + return PropertyInfo.of(type, null, null, properties, PropertyInfo.NONE); + } + if (JsonArray.class.isAssignableFrom(raw)) + return PropertyInfo.of(type, null, null, Map.of(), PropertyInfo.NONE); + } else if (type instanceof AnnotatedParameterizedType pt) { + Class raw = (Class) ((ParameterizedType) pt.getType()).getRawType(); + AnnotatedType eType = pt.getAnnotatedActualTypeArguments()[0]; + if (JsonList.class.isAssignableFrom(raw)) + return PropertyInfo.of(type, null, null, Map.of(), propertyOf(eType)); + if (List.class.isAssignableFrom(raw)) + return PropertyInfo.of(type, false, null, Map.of(), propertyOf(eType)); + if (Set.class.isAssignableFrom(raw)) + return PropertyInfo.of(type, true, null, Map.of(), propertyOf(eType)); + if (JsonMap.class.isAssignableFrom(raw)) + return PropertyInfo.of(type, true, true, Map.of(), propertyOf(eType)); + if (JsonMultiMap.class.isAssignableFrom(raw)) + return PropertyInfo.of(type, true, true, Map.of(), propertyOf(eType)); + if (Map.class.isAssignableFrom(raw)) + return PropertyInfo.of( + type, true, true, Map.of(), propertyOf(pt.getAnnotatedActualTypeArguments()[1])); + } + return PropertyInfo.NONE; + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index 7d542eb..41e53bc 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -27,6 +27,7 @@ */ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.JsonDiff.Mode; import org.hisp.dhis.jsontree.internal.Maybe; import org.hisp.dhis.jsontree.internal.Surly; @@ -368,6 +369,33 @@ private static boolean equivalentTo(JsonValue a, JsonValue b, BiPredicate compare.test( ao.get( key ), bo.get( key ) ) ); } + /** + * Compare this value (expected) with the given value (actual) using {@link Mode#DEFAULT}. + * + * @since 1.7 + * @param with the JSON to compare this JSON value with. To benefit from annotation specific + * handling the value must be "cast" to the root object type using {@link #as(Class)} prior to + * calling this method + * @return the differences + */ + default JsonDiff diff(JsonValue with) { + return diff(with, Mode.DEFAULT); + } + + /** + * Compare this value (expected) with the given value (actual) using the provided mode. + * + * @since 1.7 + * @param with the JSON to compare this JSON value with. To benefit from annotation specific + * handling the value must be "cast" to the root object type using {@link #as(Class)} prior to + * calling this method + * @param mode of how strict to make the comparison + * @return the differences + */ + default JsonDiff diff(JsonValue with, Mode mode) { + return JsonDiff.of(this, with, mode); + } + /** * 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/JsonDiffTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonDiffTest.java new file mode 100644 index 0000000..29141f7 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonDiffTest.java @@ -0,0 +1,215 @@ +package org.hisp.dhis.jsontree; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hisp.dhis.jsontree.JsonDiff.Mode.DEFAULT; +import static org.hisp.dhis.jsontree.JsonDiff.Mode.LENIENT; +import static org.hisp.dhis.jsontree.JsonDiff.Mode.STRICT; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JsonDiffTest { + + @Test + void testNull() { + assertNoDiff("null", "null"); + assertDiff("null", "[]", "!= $: null <> []"); + assertDiff("null", "1", "!= $: null <> 1"); + } + + @Test + void testNumber() { + assertNoDiff("12", "12"); + assertNoDiff("4", "4"); + assertDiff("7", "4", "!= $: 7 <> 4"); + } + + @Test + void testNumber_Mode() { + assertNoDiff("4", "4", STRICT); + assertDiff("4", "4.0", STRICT, "!= $: 4 <> 4.0"); + assertNoDiff("13.5", "13.500", LENIENT); + } + + @Test + void testBoolean() { + assertNoDiff("true", "true"); + assertNoDiff("false", "false"); + assertDiff("false", "true", "!= $: false <> true"); + assertDiff("true", "false", "!= $: true <> false"); + } + + @Test + void testString() { + assertNoDiff(Json.of("hello"), Json.of("hello")); + assertNoDiff(Json.of("hey, ho"), Json.of("hey, ho")); + assertDiff(Json.of("hello"), Json.of("world"), "!= $: \"hello\" <> \"world\""); + } + + @Test + void testArray() { + assertNoDiff("[]", "[]"); + assertNoDiff("[ [ ] ]", "[[]]"); + assertNoDiff("[true]", "[true]"); + assertNoDiff("[ true, 42 ]", "[true, 42]"); + assertNoDiff("[ 42, true ]", "[true, 42]", STRICT.anyOrder()); + assertNoDiff("[ \"a\", 42, true ]", "[true, \"a\", 42]", STRICT.anyOrder()); + assertDiff("[1,2,3]", "[1,5,3]", "!= $[1]: 2 <> 5"); + assertNoDiff("[1,2,3]", "[1,3,2]", LENIENT); + assertNoDiff("[1,2,3]", "[1,3,2,5]", LENIENT); + assertDiff("[1,2,3]", "[1,3,2,5]", STRICT.anyOrder(), "++ $[3]: ? <> 5"); + assertDiff("[ true, 42, 678 ]", "[true, 42]", "-- $[2]: 678 <> ?"); + assertDiff("[ true, 42 ]", "[true, 42, 678]", "++ $[2]: ? <> 678"); + } + + @Test + void testObject() { + assertNoDiff("{}", "{}"); + assertNoDiff("{}", "{ }"); + assertNoDiff("{\"x\": {} }", "{ \"x\":{ }}"); + assertNoDiff("{}", "{ \"x\":{ }}", LENIENT); + assertNoDiff("{\"a\": 1, \"b\":2}", "{ \"b\": 2, \"c\": 42, \"a\": 1}", LENIENT); + assertDiff( + "{ \"b\": 2, \"c\": 42, \"a\": 1}", "{\"a\": 1, \"b\":2}", LENIENT, "-- $.c: 42 <> ?"); + assertDiff("{\"a\":[1,2,3]}", "{\"a\":[1,5,3]}", "!= $.a[1]: 2 <> 5"); + } + + private interface JsonAnyAnnotationObject extends JsonObject { + + @JsonDiff.AnyOrder + default JsonList versions() { + return getList("versions", JsonNumber.class); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + default JsonList<@JsonDiff.AnyOrder JsonList> numbers() { + return (JsonList) getList("numbers", JsonList.class); + } + + @JsonDiff.AnyOrder + @JsonDiff.AnyAdditional + default JsonObject sub() { + return getObject("sub"); + } + + @JsonDiff.AnyAdditional + default JsonArray indexes() { + return getArray("indexes"); + } + } + + @Test + void testAnyAdditional_Array() { + assertNoDiff( + JsonValue.of("{\"sub\":{}}"), + JsonValue.of("{\"sub\":{\"some\": 1}}").as(JsonAnyAnnotationObject.class)); + assertNoDiff( + JsonValue.of("{\"sub\":{\"a\": 2, \"c\": 7}}"), + JsonValue.of("{\"sub\":{\"x\": 1, \"c\": 7, \"a\":2}}").as(JsonAnyAnnotationObject.class)); + } + + @Test + void testAnyOrder_Object() { + assertNoDiff( + JsonValue.of("{\"versions\":[1,2,3]}"), + JsonValue.of("{\"versions\":[2,3,1]}").as(JsonAnyAnnotationObject.class)); + } + + @Test + void testAnyOrder_JsonList() { + assertNoDiff( + JsonValue.of("{\"versions\":[1,2,3]}"), + JsonValue.of("{\"versions\":[2,3,1]}").as(JsonAnyAnnotationObject.class)); + } + + @Test + void testAnyOrder_JsonListList() { + assertNoDiff( + JsonValue.of("{\"numbers\":[[1],[1,2],[1,2,3]]}"), + JsonValue.of("{\"numbers\":[[1], [2,1],[3,1,2]]}").as(JsonAnyAnnotationObject.class)); + } + + @Test + void testAnyOrder_JsonListList2() { + assertDiff( + JsonValue.of("{\"numbers\":[[1],[1,2],[1,2,3]]}"), + JsonValue.of("{\"numbers\":[[2,1], [1],[3,1,2]]}").as(JsonAnyAnnotationObject.class), + "++ $.numbers[0][1]: ? <> 1", + "!= $.numbers[0][0]: 1 <> 2", + "-- $.numbers[1][1]: 2 <> ?"); + } + + private interface JsonTypeDefaultsObject extends JsonObject { + + default Set numbers() { + return Set.copyOf(getArray("numbers").numberValues()); + } + + default Map ages() { + return getMap("ages", JsonNumber.class).toMap(JsonNumber::number); + } + } + + @Test + void testAnyOrder_Set() { + assertNoDiff( + JsonValue.of("{\"numbers\":[1,2,3]}"), + JsonValue.of("{\"numbers\":[2,3,1]}").as(JsonTypeDefaultsObject.class)); + } + + @Test + void testAnyOrder_Map() { + assertNoDiff( + JsonValue.of("{\"ages\":{\"0-15\":1, \"16-30\": 5}}"), + JsonValue.of("{\"ages\":{\"16-30\":5, \"0-15\": 1}}").as(JsonTypeDefaultsObject.class)); + } + + @Test + void testAnyAdditional_Map() { + assertNoDiff( + JsonValue.of("{\"ages\":{\"0-15\":1, \"16-30\": 5}}"), + JsonValue.of("{\"ages\":{\"16-30\":5, \"0-15\": 1, \"31+\": 4}}") + .as(JsonTypeDefaultsObject.class)); + } + + private static void assertDiff(String expected, String actual, String... differences) { + assertDiff(expected, actual, DEFAULT, differences); + } + + private static void assertDiff( + String expected, String actual, JsonDiff.Mode mode, String... differences) { + assertDiff(JsonValue.of(expected), JsonValue.of(actual), mode, differences); + } + + private static void assertDiff(JsonValue expected, JsonValue actual, String... differences) { + assertDiff(expected, actual, DEFAULT, differences); + } + + private static void assertDiff( + JsonValue expected, JsonValue actual, JsonDiff.Mode mode, String... differences) { + assertEquals( + List.of(differences), + expected.diff(actual, mode).differences().stream() + .map(JsonDiff.Difference::toString) + .toList()); + } + + private static void assertNoDiff(String expected, String actual) { + assertNoDiff(JsonValue.of(expected), JsonValue.of(actual)); + } + + private static void assertNoDiff(String expected, String actual, JsonDiff.Mode mode) { + assertNoDiff(JsonValue.of(expected), JsonValue.of(actual), mode); + } + + private static void assertNoDiff(JsonValue expected, JsonValue actual) { + assertNoDiff(expected, actual, STRICT); + } + + private static void assertNoDiff(JsonValue expected, JsonValue actual, JsonDiff.Mode mode) { + assertEquals(List.of(), expected.diff(actual, mode).differences()); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java index 938444a..081835a 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java @@ -61,6 +61,43 @@ void testString_Multiple() { properties ); } + private interface Recursive extends JsonObject { + + default JsonList others() { + return getList("others", Recursive.class ); + } + + default Recursive direct() { + return get( "direct", Recursive.class ); + } + } + + @Test + void testRecursiveDataStructure() { + List properties = JsonObject.properties( Recursive.class ); + assertEquals( 2, properties.size() ); + } + + private interface CyclicA extends JsonObject { + + default CyclicB sub() { + return get("sub", CyclicB.class); + } + } + + private interface CyclicB extends JsonObject { + + default CyclicA parent() { + return get( "parent", CyclicA.class ); + } + } + + @Test + void testCyclicDataStructure() { + List properties = JsonObject.properties( CyclicA.class ); + assertEquals( 1, properties.size() ); + } + private void assertPropertyExists( String jsonName, Property expected, List actual ) { Property prop = actual.stream().filter( p -> p.jsonName().equals( jsonName ) ).findFirst() .orElse( null ); From 3ac8a6cf1558ccfac8d41e3ac9f25bb47b7a4f62 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 10 Feb 2025 15:18:01 +0100 Subject: [PATCH 2/2] chore: docs and more tests --- README.md | 48 +++++++++++++++++++ .../org/hisp/dhis/jsontree/JsonDiffTest.java | 9 ++++ 2 files changed, 57 insertions(+) diff --git a/README.md b/README.md index 68250c5..0bf1034 100644 --- a/README.md +++ b/README.md @@ -214,3 +214,51 @@ JsonAddress a = obj.asA(JsonAddress.class); // throws exception, no < 1 ``` Both `isA` and `asA` can also be limited to a given set of validation `Rule` types. + + +## Comparing JSON values (diff) +When asserting that the JSON returned by a service contains +the expected information a usual challenge is that the +comparison cannot rely on a specifics such as + +* the whitespace (formatting) +* the order of properties in objects +* the order of elements in an array that is a "set" +* the presence of additional object properties +* the exact way numbers are formatted + +To make such comparisons easy the `diff` method can be used +to find the differences between two `JsonValue` instances. +When computing the difference the comparison can be configured +using the `JsonDiff.Mode`. + +```java +JsonValue expected = JsonValue.of( "[1,2,3]" ); +JsonValue actual = JsonValue.of( "[1,3,2]" ); + +JsonDiff diff1 = expected.diff( actual ); // is different +JsonDiff diff2 = expected.diff( actual, JsonDiff.Mode.LENIENT); // same +``` + +To adjust individual properties of `JsonObject` subtypes the `default` +methods of its properties can be annotated with `@AnyOrder` and +`@AnyAdditional` to opt into a lenient handling. + +```java +interface MyObject extends JsonObject { + + @JsonDiff.AnyOrder + default JsonArray tags() { + return getArray( "tags" ); + } +} +``` +The used `Mode` is now overridden for the annotated property: +```java +MyObject expected = JsonValue.of( """ + {"tags": ["hello", "intro"]}""" ).as( MyObject.clas ); +MyObject actual = JsonValue.of( """ + {"tags": ["intro", "hello"]}""" ).as( MyObject.clas ); + +JsonDiff diff1 = expected.diff( actual ); // same +``` \ No newline at end of file diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonDiffTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonDiffTest.java index 29141f7..21999d6 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonDiffTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonDiffTest.java @@ -125,6 +125,15 @@ void testAnyOrder_JsonList() { JsonValue.of("{\"versions\":[2,3,1]}").as(JsonAnyAnnotationObject.class)); } + @Test + void testAnyOrder_JsonListSubObject() { + // checks that the "typing" as JsonAnyAnnotationObject also works when the value + // that is compared is only a part of another JSON value + assertNoDiff( + JsonValue.of("{\"versions\":[1,2,3]}"), + JsonMixed.of("[{\"versions\":[2,3,1]}]").get(0).as(JsonAnyAnnotationObject.class)); + } + @Test void testAnyOrder_JsonListList() { assertNoDiff(