diff --git a/src/main/java/org/hisp/dhis/jsontree/Collapsed.java b/src/main/java/org/hisp/dhis/jsontree/Collapsed.java new file mode 100644 index 0000000..5f163b7 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/Collapsed.java @@ -0,0 +1,32 @@ +package org.hisp.dhis.jsontree; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marker annotation for {@link java.lang.reflect.RecordComponent}s that are themselves {@link + * Record}s whose components should be treated as if they were directly defined in the parent record + * type when mapping JSON to Java using {@link JsonValue#to(Class)}. + * + *

Collapsing can e.g. be used to access a "flat" JSON object with many properties as a Java + * record which uses inner records to group those properties without the need to mirror that + * grouping in JSON. + * + *

Since records do not allow for inheritance collapsing inner records can be used to compose + * structures that certain groups of properties without the need to duplicate the components. This + * is important as it still allows to write code that operates on one of such groups of properties + * in a way that inheritance would without the need to duplicate the code that can handle data + * having those properties. In contrast to inheritance collapsed composition supports the equivalent + * of multiple-inheritance simply by having multiple inner collapsed record properties. + * + *

Please note that ATM records with generics are not supported simply to keep the implementation + * simple. + * + * @author Jan Bernitt + * @since 1.9 + */ +@Target(ElementType.RECORD_COMPONENT) +@Retention(RetentionPolicy.RUNTIME) +public @interface Collapsed {} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index eac74ae..c59f12f 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -125,19 +125,9 @@ default boolean exists(CharSequence name) { * @since 0.11 (as Stream) */ @TerminalOp(canBeUndefined = true, mustBeObject = true) - default Stream keys() { - if (isUndefined() || isEmpty()) return Stream.empty(); - return node().keys().stream(); - } - - /** - * @return a stream of the map/object values in order of their declaration - * @throws JsonTreeException in case this node does exist but is not an object node - * @since 0.11 - */ - @TerminalOp(canBeUndefined = true, mustBeObject = true) - default Stream values() { - return entries(); + default Streamable.Sized keys() { + if (isUndefined() || isEmpty()) return Streamable.empty(); + return node().keys(); } /** @@ -146,9 +136,9 @@ default Stream values() { * @since 0.11 */ @TerminalOp(canBeUndefined = true, mustBeObject = true) - default Stream entries() { - if (isUndefined() || isEmpty()) return Stream.empty(); - return node().keys().stream().map(this::get); + default Streamable.Sized entries() { + if (isUndefined() || isEmpty()) return Streamable.empty(); + return node().keys().map(this::get); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java b/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java index 7104098..60cc0b7 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java @@ -267,8 +267,8 @@ public static Stream accessAsStream(JsonMixed stream, Type as, JsonAccessors JsonAccessor elements = accessors.accessor(getRawType(elementType)); // auto-box simple values in a 1 element sequence if (!stream.isArray()) return Stream.of(elements.access(stream, as, accessors)); - return stream.stream(Index.AUTO_SKIP) - .map(e -> elements.access(e.as(JsonMixed.class), elementType, accessors)); + return stream.values(Index.AUTO_SKIP) + .map(e -> elements.access(e.as(JsonMixed.class), elementType, accessors)).stream(); } @SuppressWarnings({"java:S1168", "java:S1452"}) @@ -299,6 +299,7 @@ public static Stream accessAsStream(JsonMixed stream, Type as, JsonAccessors * @param components the components in that constructor to pass as args * @param defaults default arguments if the component is undefined in JSON, null of no defaults * are available (taken from a constant with name {@code DEFAULT}). + * @param collapsed true, if the component was annotated with {@link Collapsed} * @param of a static method to construct a record from a single argument (typical {@code * of}-method) * @param ofArgType the generic argument type of the {@link #of()}-method @@ -307,6 +308,7 @@ private record NewRecord( MethodHandle constructor, RecordComponent[] components, Object[] defaults, + boolean[] collapsed, MethodHandle of, Type ofArgType) {} @@ -340,11 +342,12 @@ public static Record accessAsRecord(JsonMixed obj, Type as, JsonAccessors access Object[] args = new Object[components.length]; Object[] defaults = newRecord.defaults; + boolean[] collapsed = newRecord.collapsed; boolean hasDefaults = defaults != null; if (obj.isObject()) { for (int i = 0; i < components.length; i++) { RecordComponent c = components[i]; - JsonMixed cValue = obj.get(c.getName()); + JsonMixed cValue = collapsed[i] ? obj : obj.get(c.getName()); args[i] = hasDefaults && cValue.isUndefined() ? defaults[i] @@ -353,7 +356,7 @@ public static Record accessAsRecord(JsonMixed obj, Type as, JsonAccessors access } else if (obj.isArray()) { for (int i = 0; i < components.length; i++) { RecordComponent c = components[i]; - JsonMixed cValue = obj.get(i); + JsonMixed cValue = collapsed[i] ? obj : obj.get(i); args[i] = hasDefaults && cValue.isUndefined() ? defaults[i] @@ -399,6 +402,9 @@ private static NewRecord createRecordFactory( throw new JsonAccessException( "JSON cannot be mapped to Java record %s, canonical constructor is not accessible" .formatted(type.getSimpleName())); + boolean[] collapsed = new boolean[components.length]; + for (int i = 0; i < components.length; i++) + collapsed[i] = components[i].isAnnotationPresent(Collapsed.class); MethodHandle ofMethod = null; Type ofMethodArgType = null; if (components.length > 1) { @@ -411,7 +417,7 @@ private static NewRecord createRecordFactory( } } } - return new NewRecord(canonical, components, defaults(type), ofMethod, ofMethodArgType); + return new NewRecord(canonical, components, defaults(type), collapsed, ofMethod, ofMethodArgType); } private static MethodHandle constructor( diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonArray.java b/src/main/java/org/hisp/dhis/jsontree/JsonArray.java index f809662..887bfb8 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonArray.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonArray.java @@ -30,6 +30,7 @@ import static org.hisp.dhis.jsontree.JsonNode.Index.AUTO; import static org.hisp.dhis.jsontree.JsonNode.Index.SKIP; +import java.util.Iterator; import java.util.List; import java.util.function.Function; import java.util.stream.DoubleStream; @@ -37,6 +38,7 @@ import java.util.stream.LongStream; import java.util.stream.Stream; import org.hisp.dhis.jsontree.JsonNode.Index; +import org.hisp.dhis.jsontree.internal.NotNull; import org.hisp.dhis.jsontree.internal.TerminalOp; /** @@ -56,38 +58,59 @@ default JsonArray getValue() { return this; // return type override } + /** + * Index access to the array. + * + *

Note that this will neither check index nor element type. + * + * @param index index to access (0 and above) + * @param as assumed type of the element + * @param type of the returned element + * @return element at the given index + */ + E get(int index, Class as); + + @Override + @TerminalOp(canBeUndefined = true, mustBeArray = true) + default @NotNull Iterator iterator() { + return values(AUTO).iterator(); + } + @Override @TerminalOp(canBeUndefined = true, mustBeArray = true) default Stream stream() { - return stream(AUTO); + return values(AUTO).stream(); } /** - * @implNote This utilizes {@link JsonNode#elements(Index)} avoiding map lookups for each element. - * On {@link JsonAbstractArray} level this cannot be done as the node cannot be {@link - * JsonNode#lift(JsonAccessors)} ed to the unknown generic target type. - * @param index the strategy to apply when it comes to node lookup and indexing + * @see #values(Index) * @since 1.9 */ @TerminalOp(canBeUndefined = true, mustBeArray = true) - default Stream stream(JsonNode.Index index) { - JsonNode node = nodeIfExists(); - if (node == null || node.isNull()) return Stream.empty(); - JsonAccessors accessors = getAccessors(); - return node.elements(index).stream().map(n -> n.lift(accessors)); + default Streamable.Sized values() { + return values(SKIP); } /** - * Index access to the array. - * - *

Note that this will neither check index nor element type. - * - * @param index index to access (0 and above) - * @param as assumed type of the element - * @param type of the returned element - * @return element at the given index + * @see #values() + * @since 1.9 */ - E get(int index, Class as); + default Streamable.Sized values(Class to) { + return values().map(e -> e.to(to)); + } + + /** + * @return this arrays values as {@link org.hisp.dhis.jsontree.Streamable.Sized} + * @throws JsonTreeException if this node exist but is not an array or null node + * @since 1.9 + */ + @TerminalOp(canBeUndefined = true, mustBeArray = true) + default Streamable.Sized values(JsonNode.Index index) { + JsonNode node = nodeIfExists(); + if (node == null || node.isNull()) return Streamable.empty(); + JsonAccessors accessors = getAccessors(); + return node.elements(index).map(n -> n.lift(accessors)); + } /** * @return the array elements as a uniform list of {@link String} @@ -97,7 +120,7 @@ default Stream stream(JsonNode.Index index) { default List stringValues() { JsonNode node = nodeIfExists(); if (node == null || node.isNull()) return List.of(); - return node.elements(JsonNode.Index.SKIP).stream() + return node.elements(SKIP) .map(JsonNode::textValue) .map(Text::toString) .toList(); @@ -111,18 +134,7 @@ default List stringValues() { default List booleanValues() { JsonNode node = nodeIfExists(); if (node == null || node.isNull()) return List.of(); - return node.elements(JsonNode.Index.SKIP).stream().map(JsonNode::booleanValue).toList(); - } - - @TerminalOp(canBeUndefined = true, mustBeArray = true) - default List values(Function f) { - JsonNode node = nodeIfExists(); - if (node == null || node.isNull()) return List.of(); - return node.elements(JsonNode.Index.SKIP).stream() - .map(JsonNode::textValue) - .map(Text::toString) - .map(f) - .toList(); + return node.elements(SKIP).map(JsonNode::booleanValue).toList(); } /** @@ -158,30 +170,6 @@ default DoubleStream doubleValues() { return node.elements(SKIP).stream().mapToDouble(JsonNode::doubleValue); } - /** - * Uses {@link #getAccessors()} for type conversion. - * - * @param to element target type - * @return a stream of all array values accessed as the given type in the order defined in JSON - * @since 1.9 - */ - @TerminalOp(canBeUndefined = true, mustBeArray = true) - default Stream streamValues(Class to) { - return stream(SKIP).map(e -> e.to(to)); - } - - /** - * Uses {@link #getAccessors()} for type conversion. - * - * @param to element target type - * @return a list of all array values accessed as the given type in the order defined in JSON - * @since 1.9 - */ - @TerminalOp(canBeUndefined = true, mustBeArray = true) - default List listValues(Class to) { - return streamValues(to).toList(); - } - default JsonMixed get(int index) { return get(index, JsonMixed.class); } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonDiff.java b/src/main/java/org/hisp/dhis/jsontree/JsonDiff.java index c76cc79..1a9a6ad 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonDiff.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonDiff.java @@ -200,7 +200,7 @@ private static void diffObject( JsonObject e, JsonObject a, Mode mode, Consumer add, PropertyInfo p) { if (!p.anyAdditional(mode.objects.anyAdditional)) { // list all extra members - a.keys() + a.keys().stream() .filter(not(e::has)) .forEach(key -> add.accept(new Difference(Type.MORE, e.get(key), a.get(key)))); } @@ -211,7 +211,7 @@ private static void diffObject( } else { // exact order Iterator eKeys = e.keys().iterator(); - Iterator aKeys = a.keys().filter(e::has).iterator(); + Iterator aKeys = a.keys().stream().filter(e::has).iterator(); while (eKeys.hasNext() && aKeys.hasNext()) { Text eKey = eKeys.next(); Text aKey = aKeys.next(); diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonMap.java b/src/main/java/org/hisp/dhis/jsontree/JsonMap.java index 7f55c25..d53627b 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonMap.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonMap.java @@ -51,7 +51,7 @@ public interface JsonMap extends JsonAbstractObject { * @since 0.11 */ default Map toMap(Function toValue) { - return entries().collect(Collectors.toMap(e -> e.getKey().toString(), toValue)); + return entries().stream().collect(Collectors.toMap(e -> e.getKey().toString(), toValue)); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java b/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java index 2774fb9..e053f53 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java @@ -27,8 +27,7 @@ */ @Validation(type = {OBJECT, ARRAY, STRING, NUMBER, INTEGER, BOOLEAN}) @Validation.Ignore -public interface JsonMixed - extends JsonObject, JsonArray, JsonString, JsonNumber, JsonBoolean, JsonInteger { +public interface JsonMixed extends JsonObject, JsonArray, JsonPrimitive { /** * Lift an actual {@link JsonNode} tree to a virtual {@link JsonValue}. diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 1f6075f..67c6876 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -576,7 +576,7 @@ default JsonNode memberIfExists(Text name) throws JsonPathException { * @return this {@link #value()} as a sequence of {@link Entry}s * @throws JsonTreeException if this node is not an object node that could have members */ - default Streamable members() { + default Streamable.Sized members() { throw notA(OBJECT, this, "members()"); } @@ -593,7 +593,7 @@ default Streamable members() { * @throws JsonTreeException if this node is not an object node that could have members * @since 0.11 */ - default Streamable keys() { + default Streamable.Sized keys() { throw notA(OBJECT, this, "keys()"); } @@ -604,7 +604,7 @@ default Streamable keys() { * @throws JsonTreeException if this node is not an object node that could have members * @since 1.2 */ - default Streamable paths() { + default Streamable.Sized paths() { throw notA(OBJECT, this, "paths()"); } @@ -619,7 +619,7 @@ default Streamable paths() { * internally will be reused and returned by the iterator. * @throws JsonTreeException if this node is not an object node that could have members */ - default Streamable members(Index index) { + default Streamable.Sized members(Index index) { throw notA(OBJECT, this, "members(Index)"); } @@ -674,7 +674,7 @@ default JsonNode elementIfExists(int index) throws JsonPathException, JsonTreeEx * @return this {@link #value()} as as {@link Stream} * @throws JsonTreeException if this node is not an array node that could have elements */ - default Streamable elements() { + default Streamable.Sized elements() { throw notA(ARRAY, this, "elements()"); } @@ -689,7 +689,7 @@ default Streamable elements() { * already exist internally will be reused and returned by the iterator. * @throws JsonTreeException if this node is not an array node that could have elements */ - default Streamable elements(Index index) { + default Streamable.Sized elements(Index index) { throw notA(ARRAY, this, "elements(Index)"); } @@ -702,7 +702,7 @@ default Streamable elements(Index index) { * @throws JsonTreeException if this node is not an array node that could have elements * @since 1.9 */ - default Streamable values() { + default Streamable.Sized values() { throw notA(ARRAY, this, "values()"); } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java index d6cee84..ae49a8d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java @@ -35,7 +35,6 @@ import java.util.List; import java.util.Set; import java.util.function.Function; -import java.util.stream.Stream; import org.hisp.dhis.jsontree.JsonNode.Index; import org.hisp.dhis.jsontree.Validation.Rule; import org.hisp.dhis.jsontree.internal.TerminalOp; @@ -96,6 +95,16 @@ static List properties(Class of) { return JsonVirtualTree.properties(of); } + /** + * @param of an object type + * @return a list of the properties in the given object type including those collapsed down from + * {@link Collapsed} inner {@link Record}s + * @since 1.9 + */ + static List collapsedProperties(Class of) { + return JsonVirtualTree.collapsedProperties(of); + } + /** * Access to object fields by name. * @@ -160,9 +169,13 @@ default JsonMultiMap getMultiMap(CharSequence name, Cla return JsonAbstractCollection.asMultiMap(getObject(name), as); } + /** + * @see #entries(Index) + * @since 1.9 + */ @Override @TerminalOp(canBeUndefined = true, mustBeObject = true) - default Stream entries() { + default Streamable.Sized entries() { return entries(AUTO); } @@ -179,10 +192,10 @@ default Stream entries() { * @since 1.9 */ @TerminalOp(canBeUndefined = true, mustBeObject = true) - default Stream entries(JsonNode.Index index) { - if (isUndefined() || isEmpty()) return Stream.empty(); + default Streamable.Sized entries(JsonNode.Index index) { + if (isUndefined() || isEmpty()) return Streamable.empty(); JsonAccessors accessors = getAccessors(); - return node().members(index).stream().map(e -> e.lift(accessors)); + return node().members(index).map(e -> e.lift(accessors)); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java b/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java index 1889cb9..f53c10d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java @@ -38,7 +38,7 @@ */ @Validation(type = {BOOLEAN, NUMBER, STRING}) @Validation.Ignore -public interface JsonPrimitive extends JsonBoolean, JsonNumber, JsonString { +public interface JsonPrimitive extends JsonBoolean, JsonString, JsonInteger { @Override default JsonPrimitive getValue() { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index a10e673..d5676de 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -322,12 +322,12 @@ public Iterator iterator() { } @Override - public Streamable members() { + public Streamable.Sized members() { return members(Index.AUTO); } @Override - public Streamable members(Index op) { + public Streamable.Sized members(Index op) { if (isEmpty()) return Streamable.empty(); return new Streamable.Sized<>() { private final char[] json = tree.json; @@ -383,12 +383,12 @@ public boolean isMember(@NotNull Text name) { } @Override - public Streamable paths() { + public Streamable.Sized paths() { return keysSized(path::chain); } @Override - public Streamable keys() { + public Streamable.Sized keys() { return keysSized(name -> name); } @@ -562,12 +562,12 @@ private JsonNode element(int index, Text segment, boolean orNull) throws JsonPat } @Override - public Streamable elements() { + public Streamable.Sized elements() { return elements(Index.AUTO); } @Override - public Streamable elements(Index op) { + public Streamable.Sized elements(Index op) { if (isEmpty()) return Streamable.empty(); return new Streamable.Sized<>() { private final char[] json = tree.json; @@ -600,7 +600,7 @@ public JsonNode next() { } @Override - public Streamable values() { + public Streamable.Sized values() { if (isEmpty()) return Streamable.empty(); return new Streamable.Sized<>() { private final char[] json = tree.json; diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index f178f14..5997f17 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -394,7 +394,7 @@ private static boolean equivalentTo( if (a.isNumber()) return a.doubleValue() == b.doubleValue(); if (a.isArray()) return a.size() == b.size() && a.indexes().allMatch(i -> eqItems.test(a.get(i), b.get(i))); - return a.size() == b.size() && a.keys().allMatch(key -> eqItems.test(a.get(key), b.get(key))); + return a.size() == b.size() && a.keys().stream().allMatch(key -> eqItems.test(a.get(key), b.get(key))); } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java index bcc16c9..c89eb74 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java @@ -79,6 +79,7 @@ final class JsonVirtualTree implements JsonMixed, Serializable { new JsonVirtualTree(JsonNode.NULL, JsonPath.SELF, JsonAccess.GLOBAL); private static final Map, List> PROPERTIES = new ConcurrentHashMap<>(); + private static final Map, List> COLLAPSED_PROPERTIES = new ConcurrentHashMap<>(); static List properties(Class of) { if (JsonObject.class.isAssignableFrom(of)) { @@ -95,6 +96,10 @@ static List properties(Class of) { "Must be a subtype of JsonObject or Record but was: " + of); } + static List collapsedProperties(Class of) { + return COLLAPSED_PROPERTIES.computeIfAbsent(of, type -> collapsedComponentProperties(of)); + } + static JsonMixed lift(JsonNode node, JsonAccessors accessors) { return new JsonVirtualTree(node.getRoot(), node.path(), node, accessors); } @@ -475,6 +480,26 @@ private void toSignature(Type type, StringBuilder str) { } } + private static List collapsedComponentProperties(Class of) { + List res = new ArrayList<>(); + List properties = componentProperties(of); + for (Property p : properties) { + if (p.source().isAnnotationPresent(Collapsed.class)) { + Type type = p.javaType().getType(); + if (type instanceof Class c && c.isRecord()) { + @SuppressWarnings("unchecked") + Class rType = (Class) type; + res.addAll(collapsedComponentProperties(rType)); + } else { + res.add(p); + } + } else { + res.add(p); + } + } + return List.copyOf(res); + } + private static List componentProperties(Class of) { return Stream.of(of.getRecordComponents()) .map( diff --git a/src/main/java/org/hisp/dhis/jsontree/Streamable.java b/src/main/java/org/hisp/dhis/jsontree/Streamable.java index c2eff69..b0f60b5 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Streamable.java +++ b/src/main/java/org/hisp/dhis/jsontree/Streamable.java @@ -2,10 +2,13 @@ import static java.util.Collections.emptyIterator; +import java.util.ArrayList; import java.util.Iterator; +import java.util.List; import java.util.NoSuchElementException; import java.util.Spliterator; import java.util.function.Consumer; +import java.util.function.Function; import java.util.stream.Stream; import java.util.stream.StreamSupport; import org.hisp.dhis.jsontree.internal.NotNull; @@ -91,6 +94,44 @@ default long estimateSize() { default int characteristics() { return ORDERED | SIZED | NONNULL | IMMUTABLE; } + + default Sized map(Function f) { + Sized self = this; + return new Sized() { + @Override + public long getExactSizeIfKnown() { + return self.getExactSizeIfKnown(); + } + + @Override + public boolean hasNext() { + return self.hasNext(); + } + + @Override + public E next() { + return f.apply(self.next()); + } + }; + } + + /** + * @see Stream#toList() + */ + default List toList() { + int size = (int) getExactSizeIfKnown(); + if (size == 0) return List.of(); + List res = new ArrayList<>(size); + while (hasNext()) res.add(next()); + return res; + } + + /** + * @see Stream#count() + */ + default long count() { + return getExactSizeIfKnown(); + } } Sized EMPTY = new Sized<>() { diff --git a/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java b/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java index 18ccd73..e0615fd 100644 --- a/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java +++ b/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java @@ -31,6 +31,13 @@ public static Number of(char @NotNull [] buffer) { /** * Does not allow whitespace at start/end of the number. * + *

The returned number type is chosen to reflect the input text as close as possible. An + * integer input will result in {@link Integer} or {@link Long} based on numeric range as long as + * the range allows for it based on digit count (not exact numeric range). An input with decimals + * will result in {@link Double} for zero or a {@link TextualNumber} for anything else. Negative + * zero will always return a {@link Double} to preserve the distinction between positive and + * negative zero. + * * @param buffer with content of an integer or decimal number * @param offset start of the number in the buffer * @param length number of characters from offset that represent the number @@ -59,6 +66,8 @@ public static Number of(char @NotNull [] buffer, int offset, int length) { return new TextualNumber(buffer, offset, length); } } + if (isNumericZero(buffer, offset, length)) + return buffer[offset] == '-' ? -0d : 0d; if (!isTextualDecimal(buffer, offset, length) && !Text.of(buffer, offset, length).isSpecialDecimal()) throw nanError(buffer, offset, length, "Is neither an integer nor a decimal number: "); return new TextualNumber(buffer, offset, length); @@ -169,10 +178,12 @@ static boolean isNumericZero(char[] buffer, int offset, int length) { int end = offset + length; int d0 = i; while (i < end && buffer[i] == '0') i++; - if (i >= end) return i > d0; + boolean integerDigits = i > d0; + if (i >= end) return integerDigits; if (buffer[i++] != '.') return false; + d0 = i; while (i < end && buffer[i] == '0') i++; - return i == end; + return i == end && (integerDigits || i > d0); } static boolean isNumericInteger(char[] buffer) { diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 34e6e30..4efc9b7 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -177,6 +177,7 @@ public static Set of(@NotNull Class type) { static Class toJsonType(Class type) { if (type == String.class || type.isEnum()) return JsonString.class; if (type == Character.class || type == char.class) return JsonString.class; + if (type == Class.class) return JsonString.class; if (type == Date.class) return JsonDate.class; if (type == LocalDate.class || type == LocalTime.class || type == LocalDateTime.class) return JsonMixed.class; if (type == Boolean.class || type == boolean.class) return JsonBoolean.class; 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 acb0ba8..83517fb 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -222,12 +222,12 @@ private static PropertyValidations fromItems(AnnotatedElement src) { @NotNull private static PropertyValidations toPropertyValidation(Class type) { - RequiredValidation values = + RequiredValidation requiredness = !type.isPrimitive() ? null : RequiredValidation.PRIMITIVES; StringValidation strings = !type.isEnum() ? null : new StringValidation(anyOfStrings(type), YesNo.AUTO, -1, -1, null); return new PropertyValidations( - NodeType.of(type), PropertyValidations.Strict.DEFAULT, List.of(), values, strings, null, null, null, null); + NodeType.of(type), PropertyValidations.Strict.DEFAULT, List.of(), requiredness, strings, null, null, null, null); } @NotNull 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 1c84cdf..d7f5659 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -196,7 +196,8 @@ private static Validator ofRequired(Text property, PropertyValidations validatio * possible and if required is AUTO */ private static boolean isRequiredImplicitly(PropertyValidations validations) { - return validations.accepted().stream().allMatch(type -> isRequiredImplicitly(validations, type)); + Set accepted = validations.accepted(); + return !accepted.isEmpty() && accepted.stream().allMatch(type -> isRequiredImplicitly(validations, type)); } private static boolean isRequiredImplicitly(PropertyValidations validations, NodeType type) { diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonApiPerfTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonApiPerfTest.java index b600bf6..fd6c319 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonApiPerfTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonApiPerfTest.java @@ -101,12 +101,12 @@ void testJsonValue_doubleValues() { } @Test - void testJsonValue_streamValues() { + void testJsonValue_values() { List accessed = new ArrayList<>(); JsonObject root = JsonMixed.of(JsonNode.of("{\"values\":[1,2,3,4,5]", accessed::add)); assertEquals(List.of(), accessed); - assertEquals(5L, root.getArray("values").streamValues(double.class).count()); + assertEquals(5L, root.getArray("values").values(double.class).count()); assertEquals(List.of(JsonPath.of("values")), accessed, "there should be one access of values"); } @@ -141,12 +141,12 @@ void testJsonNode_elements() { @Test void testJsonArray_stream() { JsonArray arr = JsonMixed.of("[ 1,2 , true , false, \"hello\",{},[]]"); - List nodes = arr.stream(Index.SKIP).map(JsonValue::node).toList(); - List nodes2 = arr.stream(Index.SKIP).map(JsonValue::node).toList(); + List nodes = arr.values(Index.SKIP).map(JsonValue::node).toList(); + List nodes2 = arr.values(Index.SKIP).map(JsonValue::node).toList(); for (int i = 0; i < nodes.size(); i++) assertNotSame(nodes.get(i), nodes2.get(i)); - nodes = arr.stream(Index.ADD).map(JsonValue::node).toList(); - nodes2 = arr.stream(Index.CHECK).map(JsonValue::node).toList(); + nodes = arr.values(Index.ADD).map(JsonValue::node).toList(); + nodes2 = arr.values(Index.CHECK).map(JsonValue::node).toList(); for (int i = 0; i < nodes.size(); i++) assertSame(nodes.get(i), nodes2.get(i)); } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonArrayTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonArrayTest.java index e6f7ed0..507c9ff 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonArrayTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonArrayTest.java @@ -75,16 +75,6 @@ void testForEach_NoArray() { assertThrowsExactly(JsonTreeException.class, () -> value.forEach(e -> fail())); } - @Test - void testValues_Mapped() { - // language=json - String json = - """ - ["a","b","c"]"""; - JsonMixed arr = JsonMixed.of(json); - assertEquals(List.of('a', 'b', 'c'), arr.values(str -> str.charAt(0))); - } - @Test void testGetList_IndexAs() { JsonMixed arr = JsonMixed.of("[[1,2], [3,4]]"); @@ -111,13 +101,9 @@ void testDoubleValues() { } @Test - void testListValues() { - assertEquals(List.of(1d, NaN, 3.1d), Json5.of("[1, NaN, 3.1]").listValues(double.class)); - } - - @Test - void streamValues() { + void testValues_ToClass() { + assertEquals(List.of(1d, NaN, 3.1d), Json5.of("[1, NaN, 3.1]").values(double.class).toList()); assertEquals( - Stream.of(1, 2, 3).toList(), Json5.of("[1, '2', 3.0]").streamValues(int.class).toList()); + List.of(1, 2, 3), Json5.of("[1, '2', 3.0]").values(int.class).toList()); } } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java index 723c7b2..ea122a6 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java @@ -135,10 +135,10 @@ void testViewAsMap_Keys() { } @Test - void testViewAsMap_Values() { + void testViewAsMap_entries() { JsonMap obj = JsonMixed.of("{\"b\":[1],\"c\":[2]}").asMap(JsonArray.class); JsonMap view = obj.project(arr -> arr.getNumber(0)); - assertEquals(List.of(1, 2), view.values().map(JsonNumber::intValue).toList()); + assertEquals(List.of(1, 2), view.entries().map(JsonNumber::intValue).toList()); } @Test @@ -151,16 +151,16 @@ void testKeys_Special() { } @Test - void testValues_Special() { + void testEntries_Special1() { 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()); + assertEquals(List.of(1, 2, 3, 4), map.entries().map(JsonNumber::intValue).toList()); } @Test - void testEntries_Special() { + void testEntries_Special2() { String json = """ {".":1, "{uid}":2, "[6]":3, "x{y}z": 4}"""; diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonNumberTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonNumberTest.java index 7b60afd..dfa0c0c 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonNumberTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonNumberTest.java @@ -1,6 +1,7 @@ package org.hisp.dhis.jsontree; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; @@ -166,6 +167,16 @@ void testFloatValue() { JsonPathException.class, () -> JsonMixed.of("[]").getNumber(0).floatValue()); } + @Test + void testNumber() { + assertEquals(3, JsonMixed.of("3").number()); + assertEquals(-0d, JsonMixed.of("-0").number()); + assertTextualNumberEquals(3.0d, JsonMixed.of("3.0").number()); + assertTextualNumberEquals(3.14d, JsonMixed.of("3.14").number()); + assertEquals(0d, JsonMixed.of("0.0").number()); + assertEquals(-0d, JsonMixed.of("-0.0").number()); + } + @Test void testIrregularNumberPersistence() { JsonMixed big = @@ -186,4 +197,9 @@ void testIrregularNumberPersistence() { }""", root.getDeclaration().toString()); } + + private static void assertTextualNumberEquals(double expected, Number actual) { + assertEquals(expected, actual.doubleValue()); + assertInstanceOf(TextualNumber.class, actual); + } } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java index be8b8e0..f7a9492 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java @@ -19,15 +19,23 @@ */ class JsonValueToTest { - public record ExampleParams( + public record OuterParams( int number, Integer optionalNumber, String string, Set types, List items, - ExampleItem fallback + ExampleItem fallback, + @Collapsed InnerParams inner ) {} + public record InnerParams( + String foo, + @Collapsed InnerInnerParams inner + ) {} + + public record InnerInnerParams(String bar) {} + @Validation(type = NodeType.STRING) public record ExampleItem(String key, double value) { public static ExampleItem of(String value) { @@ -39,14 +47,17 @@ public static ExampleItem of(String value) { @Test void testMinimal() { + InnerParams inner = new InnerParams(null, new InnerInnerParams(null)); assertParamsEquals( - new ExampleParams(10, null, null, null, null, null), Map.of("number", List.of("10"))); + new OuterParams(10, null, null, null, null, null, inner), + Map.of("number", List.of("10"))); } @Test void testEmpty() { + InnerParams inner = new InnerParams(null, new InnerInnerParams(null)); assertParamsEquals( - new ExampleParams(10, null, null, Set.of(), List.of(), null), + new OuterParams(10, null, null, Set.of(), List.of(), null, inner), Map.ofEntries( entry("number", List.of("10")), entry("optionalNumber", List.of()), @@ -59,43 +70,48 @@ void testEmpty() { @Test void testMaximal() { assertParamsEquals( - new ExampleParams( + new OuterParams( 10, 20, "hello", Set.of(JsonNodeType.STRING, JsonNodeType.NULL), List.of(new ExampleItem("a", 1.5), new ExampleItem("b", 2.0)), - new ExampleItem("c", 5)), + new ExampleItem("c", 5), + new InnerParams("foo", new InnerInnerParams("bar"))), Map.ofEntries( entry("number", List.of("10")), entry("optionalNumber", List.of("20")), entry("string", List.of("hello")), entry("types", List.of("STRING", "NULL")), entry("items", List.of("a:1.5", "b:2.0")), - entry("fallback", List.of("c:5")))); + entry("fallback", List.of("c:5")), + entry("foo", List.of("foo")), + entry("bar", List.of("bar")))); } @Test void testSingle() { assertParamsEquals( - new ExampleParams( + new OuterParams( 10, 20, "hello", Set.of(JsonNodeType.STRING), List.of(new ExampleItem("a", 1.5)), - new ExampleItem("c", 5)), + new ExampleItem("c", 5), + new InnerParams("foo2", new InnerInnerParams(null))), Map.ofEntries( entry("number", List.of("10")), entry("optionalNumber", List.of("20")), entry("string", List.of("hello")), entry("types", List.of("STRING")), entry("items", List.of("a:1.5")), - entry("fallback", List.of("c:5")))); + entry("fallback", List.of("c:5")), + entry("foo", List.of("foo2")))); } - private static void assertParamsEquals(ExampleParams expected, Map> actual) { - List properties = JsonObject.properties(ExampleParams.class); + private static void assertParamsEquals(OuterParams expected, Map> actual) { + List properties = JsonObject.collapsedProperties(OuterParams.class); JsonNode object = JsonBuilder.createObject( obj -> { @@ -132,7 +148,7 @@ private static void assertParamsEquals(ExampleParams expected, Map e.rule() == Validation.Rule.REQUIRED)); + } + + public record NotImplicitlyRequired(Class type) {} + + @Test + void testNotImplicitlyRequired() { + Validation.Result result = JsonMixed.of("{}").validate(NotImplicitlyRequired.class, PROBE_ALL); + assertEquals(List.of(), result.errors()); + } } diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java index 0387764..b9f9572 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java @@ -19,7 +19,7 @@ public interface JsonTypeUseExampleA extends JsonObject { @Validator(value = Validation.RegEx.class, params = @Validation(pattern = ".es.*")) String>> getData() { - return getArray("data").values(e -> List.of()); + return getArray("data").values().map(e -> List.of(e.text().toString())).toList(); } }