diff --git a/CHANGELOG.md b/CHANGELOG.md index 23c833b..833bb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ # ChangeLog -## [Unreleased] v1.1 +## v1.1 - Bulk Modification APIs - [Unreleased] + +> [!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 + +> [!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 + + +> [!Warning] +> ### Breaking Changes +> * **Changed**: `JsonNode#getPath` returns a `JsonPath` (`String` before) +> * **Changed**: `JsonNode#keys` returns paths with escaping when needed + +> [!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/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index d3a1c3a..c5671c7 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -76,8 +76,25 @@ default boolean isUndefined( String name ) { } /** + * 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(); + } + + /** + * 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 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 + * @see #names() * @since 0.11 (as Stream) */ default Stream keys() { @@ -103,15 +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() { - List names = new ArrayList<>(); - keys().forEach( names::add ); - return names; + 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 5c89d0f..f5def39 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; @@ -124,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 ); } /** @@ -136,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 ); } /** @@ -215,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() ); } /** @@ -224,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() ); } /** @@ -236,15 +237,44 @@ 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 ) ); } + /** + * 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. *

@@ -345,6 +375,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 @@ -369,6 +402,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}). *

@@ -499,8 +545,9 @@ default int count( JsonNodeType type ) { /** * @return path within the overall content this node represents + * @since 1.1 (with {@link JsonPath} type) */ - String getPath(); + JsonPath getPath(); /** * @return the plain JSON of this node as defined in the overall content @@ -748,11 +795,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 ); - } } 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..e299784 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java @@ -0,0 +1,154 @@ +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 { + + static String parentPath( String path ) { + //TODO move callers to JsonPath + return JsonPath.of( path ).dropLastSegment().toString(); + } + + /** + * @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, 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 = parentPath( path ); + } + } +} 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..a7ae8ae --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPatchException.java @@ -0,0 +1,37 @@ +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 patch operation fails. + * + * @author Jan Bernitt + * @since 1.1 + */ +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/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java new file mode 100644 index 0000000..493efb2 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -0,0 +1,317 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.internal.Surly; + +import java.util.ArrayList; +import java.util.List; + +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 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 + */ + 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. + * + * @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 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) + * + * @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 extendedWith( List.of(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 extendedWith( List.of("[" + index + "]")); + } + + private JsonPath extendedWith( List subSegments ) { + return subSegments.isEmpty() + ? this + : new JsonPath( concat( segments.stream(), subSegments.stream() ).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 ? ROOT : 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 ? ROOT : 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 ) ); + } + + @Surly + public String objectMemberAtStart() { + 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 ); + 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 ) { + if (!(obj instanceof JsonPath other)) return false; + return segments.equals( other.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; + } + // make immutable + return List.copyOf( 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/JsonPointer.java b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java new file mode 100644 index 0000000..0152e53 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java @@ -0,0 +1,61 @@ +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; + +/** + * 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() { + if (value.isEmpty()) return List.of(); + 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() { + if (value.isEmpty()) return ""; + 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 + public String toString() { + 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/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/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index f81afad..0b84d16 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -27,13 +27,16 @@ */ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.JsonNodeOperation.Insert; 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.Arrays; import java.util.HashMap; import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.NoSuchElementException; @@ -72,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 ) { @@ -99,15 +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; - //TODO remember the index in parent array/object to improve size() performance? - - LazyJsonNode( JsonTree tree, String path, int start ) { + LazyJsonNode( JsonTree tree, JsonPath path, int start ) { this.tree = tree; this.path = path; this.start = start; @@ -115,11 +116,11 @@ private abstract static class LazyJsonNode implements JsonNode { @Override public final JsonNode getRoot() { - return tree.get( "$" ); + return tree.get( JsonPath.ROOT ); } @Override - public final String getPath() { + public final JsonPath getPath() { return path; } @@ -216,6 +217,17 @@ 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()} */ @@ -227,7 +239,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; } @@ -306,22 +313,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 @@ -335,13 +342,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 ), '}' ); @@ -353,11 +360,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 @@ -372,14 +388,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; } }; } @@ -389,7 +405,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 ); } @@ -399,11 +415,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 @@ -457,18 +470,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 ) ); @@ -478,7 +491,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; @@ -492,7 +505,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 ) ) @@ -524,7 +537,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 ); } @@ -550,7 +563,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 ); } @@ -613,7 +626,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 ); } @@ -632,7 +645,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 ); } @@ -662,36 +675,27 @@ 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( "", autoDetect( "", skipWhitespace( json, 0 ) ) ); - if ( path.startsWith( "$" ) ) { - path = path.substring( 1 ); - } - if ( onGet != null && !path.isEmpty() ) onGet.accept( path ); + 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 ) { + if ( node != null ) return node; - } + // find by finding the closest already indexed parent and navigate down from there... JsonNode parent = getClosestIndexedParent( path, nodesByPath ); - String pathToGo = path.substring( parent.getPath().length() ); - while ( !pathToGo.isEmpty() ) { - if ( pathToGo.startsWith( "[" ) ) { + JsonPath pathToGo = path.shortenedBy( parent.getPath() ); + while ( !pathToGo.isEmpty() ) { // meaning: are we at the target node? (self) + if ( pathToGo.startsWithArray() ) { checkNodeIs( parent, JsonNodeType.ARRAY, path ); - int index = parseInt( pathToGo.substring( 1, pathToGo.indexOf( ']' ) ) ); + int index = pathToGo.arrayIndexAtStart(); parent = parent.element( index ); - pathToGo = pathToGo.substring( pathToGo.indexOf( ']' ) + 1 ); - } else if ( pathToGo.startsWith( "." ) ) { + pathToGo = pathToGo.dropFirstSegment(); + } else if ( pathToGo.startsWithObject() ) { checkNodeIs( parent, JsonNodeType.OBJECT, path ); - String property = getHeadProperty( pathToGo ); + String property = pathToGo.objectMemberAtStart(); 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( '}' ) ); - 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 ) ); } @@ -699,16 +703,7 @@ JsonNode get( String path ) { return parent; } - 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( String path, int atIndex ) { + private JsonNode autoDetect( JsonPath path, int atIndex ) { JsonNode node = nodesByPath.get( path ); if ( node != null ) { return node; @@ -740,7 +735,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, @@ -761,8 +756,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/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/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 7d5217c..ef3826c 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -33,12 +33,16 @@ 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}. + * 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: *

    @@ -61,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 @@ -295,6 +300,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 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. + * + * @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; + // names 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. *

      @@ -376,4 +430,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/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..9944991 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -27,9 +27,11 @@ 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; +import java.util.regex.Pattern; import java.util.stream.Stream; import static java.lang.Double.isNaN; @@ -162,7 +164,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 ); } @@ -194,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 @@ -228,8 +233,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 +249,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 +265,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 +362,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 ) ); } @@ -415,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 ) ); } } @@ -574,19 +589,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 +620,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 +646,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/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 new file mode 100644 index 0000000..8200d26 --- /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..40992f2 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,28 +44,33 @@ */ class JsonNodeTest { + @Test + void testEquals() { + assertEquals( JsonNode.of( "1" ), JsonNode.of( "1" ) ); + } + @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 @@ -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", @@ -95,7 +109,8 @@ void testGet_Array() { @Test void testGet_Array_NoValueAtPath() { - assertGetThrowsJsonPathException( "[1,2]", "a", "Malformed path a at a." ); + 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." ); assertGetThrowsJsonPathException( "[[1,2],[]]", "[0].a", @@ -194,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/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 new file mode 100644 index 0000000..d62379b --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonPathTest.java @@ -0,0 +1,272 @@ +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.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. + * + * @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.ROOT::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_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()); + } + + 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..0394994 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() ); } @@ -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`", 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 ) ); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/example/JsonPatchTest.java b/src/test/java/org/hisp/dhis/jsontree/example/JsonPatchTest.java deleted file mode 100644 index dfa8ea5..0000000 --- a/src/test/java/org/hisp/dhis/jsontree/example/JsonPatchTest.java +++ /dev/null @@ -1,125 +0,0 @@ -package org.hisp.dhis.jsontree.example; - -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. - */ -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(); - } - } - - @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/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 );