diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index d4be266..efe8758 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -27,6 +27,7 @@ */ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.internal.Maybe; import org.hisp.dhis.jsontree.internal.Surly; import java.io.IOException; @@ -250,42 +251,56 @@ default JsonNode getParent() { * @throws JsonPathException when no such node exists in the subtree of this node */ @Surly - default JsonNode get(@Surly String path ) - throws JsonPathException { + 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; + char c0 = path.charAt( 0 ); + if ( c0 == '$' ) return getRoot().get( path.substring( 1 ) ); + if ( c0 != '{' && c0 != '[' && c0 != '.' ) path = "."+path; return get( JsonPath.of( path ) ); } /** + * Access the node at the given path in the subtree of this node. * + * @param path a simple or nested path relative to this node. A path starting with {@code $} is relative to the root + * node of this node, in other words it is an absolute path + * @return the node at the given path or {@code null} if no such node exists + * @throws JsonPathException when the provided path is malformed + * @since 1.5 + */ + @Maybe + default JsonNode getOrNull(@Surly String path ) throws JsonPathException { + if ( path.isEmpty() ) return this; + if ( "$".equals( path ) ) return getRoot(); + char c0 = path.charAt( 0 ); + if ( c0 == '$' ) return getRoot().getOrNull( path.substring( 1 ) ); + if ( c0 != '{' && c0 != '[' && c0 != '.' ) path = "."+path; + return getOrNull( JsonPath.of( path ) ); + } + + /** * @param path a path understood relative to this node's {@link #getPath()} * @return the node at the given path + * @throws JsonPathException when no such node exists in the subtree of this node * @since 1.1 */ @Surly - default JsonNode get(@Surly JsonPath path) { + default JsonNode get(@Surly JsonPath path) throws JsonPathException { 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 + * @param path a path understood relative to this node's {@link #getPath()} + * @return the node at the given path or {@code null} if no such node exists + * @throws JsonPathException when the provided path is malformed + * @since 1.5 */ - default JsonNode getOrDefault( String path, JsonNode orDefault ) { - try { - return get( path ); - } catch ( JsonPathException ex ) { - return orDefault; - } + @Maybe + default JsonNode getOrNull(@Surly JsonPath path) throws JsonPathException { + throw new JsonPathException( path, + format( "This is a leaf node of type %s that does not have any children at path: %s", getType(), path ) ); } /** @@ -332,7 +347,7 @@ default boolean isRoot() { */ default boolean isMember( String name ) { try { - return member( name ) != null; + return memberOrNull( name ) != null; } catch ( JsonPathException ex ) { return false; } @@ -347,7 +362,7 @@ default boolean isMember( String name ) { */ default boolean isElement( int index ) { try { - return element( index ) != null; + return elementOrNull( index ) != null; } catch ( JsonPathException ex ) { return false; } @@ -379,11 +394,27 @@ default boolean isElement( int index ) { * @throws JsonPathException when no such member exists * @throws JsonTreeException if this node is not an object node that could have members */ + @Surly default JsonNode member( String name ) throws JsonPathException { throw new JsonTreeException( getType() + " node has no member property: " + name ); } + /** + * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). + * + * @param name name of the member to access + * @return the member with the given name or {@code null} if no such member exists + * @throws JsonPathException when the path is malformed + * @throws JsonTreeException if this node is not an object node that could have members + * @since 1.5 + */ + @Maybe + default JsonNode memberOrNull( String name ) + throws JsonPathException { + throw new JsonTreeException( getType() + " node has no member property: " + name ); + } + /** * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). *

@@ -463,11 +494,27 @@ default Iterator> members( boolean cacheNodes ) { * @throws JsonPathException when no such element exists * @throws JsonTreeException if this node is not an array node that could have elements */ + @Surly default JsonNode element( int index ) throws JsonPathException { throw new JsonTreeException( getType() + " node has no element property for index: " + index ); } + /** + * OBS! Only defined when this node is of type {@link JsonNodeType#ARRAY}). + * + * @param index index of the element to access + * @return the node at the given array index or {@code null} if no such element exists + * @throws JsonPathException when the index is negative (invalid) + * @throws JsonTreeException if this node is not an array node that could have elements + * @since 1.5 + */ + @Maybe + default JsonNode elementOrNull( int index ) + throws JsonPathException { + throw new JsonTreeException( getType() + " node has no element property for index: " + index ); + } + /** * OBS! Only defined when this node is of type {@link JsonNodeType#ARRAY}). *

diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 6c80d42..6f00cf2 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -38,7 +38,6 @@ import java.util.Map.Entry; import java.util.NoSuchElementException; import java.util.Optional; -import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.IntConsumer; @@ -257,6 +256,11 @@ public JsonNode get(@Surly JsonPath path ) { return tree.get( this.path.extendedWith( path ) ); } + @Maybe @Override + public JsonNode getOrNull( @Surly JsonPath path ) { + return tree.getOrNull( this.path.extendedWith( path ) ); + } + @Override public Iterable> members() { return requireNonNull( value() ); @@ -270,8 +274,8 @@ public boolean isEmpty() { @Override public int size() { - if (isEmpty()) return 0; if (size != null) return size; + if (isEmpty()) return 0; size = (int) StreamSupport.stream( spliterator(), false ).count(); return size; } @@ -297,30 +301,46 @@ Iterable> parseValue() { return this; } + @Surly @Override + public JsonNode member( String name ) throws JsonPathException { + return requireNonNull( member( name, () -> { + JsonPath memberPath = path.extendedWith( name ); + throw new JsonPathException( memberPath, + format( "Path `%s` does not exist, object `%s` does not have a property `%s`", memberPath, path, + name ) ); + } ) ); + } + @Override - public JsonNode member( String name ) - throws JsonPathException { - JsonPath propertyPath = path.extendedWith( name ); - JsonNode member = tree.nodesByPath.get( propertyPath ); - if ( member != null ) { - return member; + public JsonNode memberOrNull( String name ) throws JsonPathException { + return member( name, () -> {} ); + } + + public JsonNode member( String name, Runnable noSuchElement ) throws JsonPathException { + JsonPath memberPath = path.extendedWith( name ); + JsonNode member = tree.nodesByPath.get( memberPath ); + if ( member != null ) return member; + if (size != null) { + // if size is known all members are in the tree map + // so a miss means there is no such member + noSuchElement.run(); + return null; } char[] json = tree.json; int index = skipWhitespace( json, expectChar( json, start, '{' ) ); while ( index < json.length && json[index] != '}' ) { LazyJsonString.Span property = LazyJsonString.parseString( json, index ); - index = expectColonSeparator( json, property.endIndex() ); + index = expectColon( json, property.endIndex() ); if ( name.equals( property.value() ) ) { int mStart = index; - return tree.nodesByPath.computeIfAbsent( propertyPath, - key -> tree.autoDetect( key, mStart ) ); + return tree.nodesByPath.computeIfAbsent( memberPath, key -> tree.autoDetect( key, mStart ) ); } else { index = skipNodeAutodetect( json, index ); - index = expectCommaSeparatorOrEnd( json, index, '}' ); + index = expectCommaOrEnd( json, index, '}' ); } } - throw new JsonPathException( propertyPath, - format( "Path `%s` does not exist, object `%s` does not have a property `%s`", propertyPath, path, name ) ); + noSuchElement.run(); + return null; } @Override @@ -328,11 +348,15 @@ public Iterator> members( boolean cacheNodes ) { return new Iterator<>() { private final char[] json = tree.json; private final Map nodesByPath = tree.nodesByPath; + private int startIndex = skipWhitespace( json, expectChar( json, start, '{' ) ); + private int n = 0; @Override public boolean hasNext() { - return startIndex < json.length && json[startIndex] != '}'; + boolean hasNext = startIndex < json.length && json[startIndex] != '}'; + if (!hasNext && cacheNodes) size = n; + return hasNext; } @Override @@ -342,7 +366,7 @@ public Entry next() { LazyJsonString.Span property = LazyJsonString.parseString( json, startIndex ); String name = property.value(); JsonPath propertyPath = path.extendedWith( name ); - int startIndexVal = expectColonSeparator( json, property.endIndex() ); + int startIndexVal = expectColon( json, property.endIndex() ); JsonNode member = cacheNodes ? nodesByPath.computeIfAbsent( propertyPath, key -> tree.autoDetect( key, startIndexVal ) ) : nodesByPath.get( propertyPath ); @@ -350,10 +374,11 @@ public Entry next() { member = tree.autoDetect( propertyPath, startIndexVal ); } else if ( member.endIndex() < startIndexVal ) { // duplicate keys case: just skip the duplicate - startIndex = expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndexVal ), '}' ); + startIndex = expectCommaOrEnd( json, skipNodeAutodetect( json, startIndexVal ), '}' ); return Map.entry( name, member ); } - startIndex = expectCommaSeparatorOrEnd( json, member.endIndex(), '}' ); + n++; + startIndex = expectCommaOrEnd( json, member.endIndex(), '}' ); return Map.entry( name, member ); } }; @@ -394,11 +419,11 @@ public E next() { // advance to next member or end... JsonPath propertyPath = path.extendedWith( name ); JsonNode member = nodesByPath.get( propertyPath ); - startIndex = expectColonSeparator( json, property.endIndex() ); // move after : + startIndex = expectColon( json, property.endIndex() ); // move after : // move after value startIndex = member == null || member.endIndex() < startIndex // (duplicates) - ? expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndex ), '}' ) - : expectCommaSeparatorOrEnd( json, member.endIndex(), '}' ); + ? expectCommaOrEnd( json, skipNodeAutodetect( json, startIndex ), '}' ) + : expectCommaOrEnd( json, member.endIndex(), '}' ); return toKey.apply( name ); } }; @@ -423,6 +448,11 @@ public JsonNode get( @Surly JsonPath path ) { return tree.get( this.path.extendedWith( path ) ); } + @Maybe @Override + public JsonNode getOrNull( @Surly JsonPath path ) { + return tree.getOrNull( this.path.extendedWith( path ) ); + } + @Override public JsonNodeType getType() { return JsonNodeType.ARRAY; @@ -441,8 +471,8 @@ public boolean isEmpty() { @Override public int size() { - if (isEmpty()) return 0; if (size != null) return size; + if (isEmpty()) return 0; size = (int) StreamSupport.stream( spliterator(), false ).count(); return size; } @@ -466,29 +496,68 @@ Iterable parseValue() { return this; } + @Surly @Override + public JsonNode element( int index ) { + return requireNonNull( element( index, length -> { + JsonPath elementPath = path.extendedWith( index ); + throw new JsonPathException( elementPath, + format( "Path `%s` does not exist, array `%s` has only `%d` elements.", + elementPath, getPath(), length ) ); + } ) ); + } + @Override - public JsonNode element( int index ) + public JsonNode elementOrNull(int index) { + return element( index, length -> {} ); + } + + private JsonNode element( int index, IntConsumer indexOutOfBounds ) throws JsonPathException { - if ( index < 0 ) { + if ( index < 0 ) throw new JsonPathException( path, format( "Path `%s` does not exist, array index is negative: %d", path, index ) ); + if (size != null && index >= size) { + // early exit for a miss + indexOutOfBounds.accept( size ); + return null; } + JsonPath elementPath = path.extendedWith( index ); + JsonNode e = tree.nodesByPath.get( elementPath ); + if (e != null) return e; char[] json = tree.json; - 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.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, JsonPath path ) { - throw new JsonPathException( path, - format( "Path `%s` does not exist, array `%s` has only `%d` elements.", - path, parent.getPath(), length ) ); + if (index == 0) { + int i = skipWhitespace( json, expectChar( json, start, '[' ) ); + if (json[i] == ']') { + indexOutOfBounds.accept( 0 ); + return null; + } + return tree.nodesByPath.computeIfAbsent( elementPath, p -> tree.autoDetect( p, i ) ); + } + // maybe the element before it exists? (iteration in a counter loop) + JsonNode predecessor = tree.nodesByPath.get( path.extendedWith( index - 1) ); + if (predecessor != null) { + int i = skipWhitespace( json, expectCommaOrEnd( json, predecessor.endIndex(), ']' )); + if (json[i] == ']') { + indexOutOfBounds.accept( index - 1 ); + return null; + } + return tree.nodesByPath.computeIfAbsent( elementPath, p -> tree.autoDetect( p, i ) ); + } + // go there from the start of the array + int i = skipWhitespace( json, expectChar( json, start, '[' ) ); + int elementsToSkip = index; + while ( elementsToSkip > 0 && i < json.length && json[i] != ']' ) { + i = skipWhitespace( json, i ); + i = skipNodeAutodetect( json, i ); + i = expectCommaOrEnd( json, i, ']' ); + elementsToSkip--; + } + if ( json[i] == ']' ) { + indexOutOfBounds.accept(index - elementsToSkip ); + return null; + } + int eStart = i; + return tree.nodesByPath.computeIfAbsent( elementPath, p -> tree.autoDetect( p, eStart )); } @Override @@ -502,41 +571,29 @@ public Iterator elements( boolean cacheNodes ) { @Override public boolean hasNext() { - return startIndex < json.length && json[startIndex] != ']'; + boolean hasNext = startIndex < json.length && json[startIndex] != ']'; + if (!hasNext && cacheNodes) size = n; + return hasNext; } @Override public JsonNode next() { if ( !hasNext() ) throw new NoSuchElementException( "next() called without checking hasNext()" ); - JsonPath ePath = path.extendedWith( n ); + JsonPath elementPath = path.extendedWith( n ); JsonNode e = cacheNodes - ? nodesByPath.computeIfAbsent( ePath, + ? nodesByPath.computeIfAbsent( elementPath, key -> tree.autoDetect( key, startIndex ) ) - : nodesByPath.get( ePath ); + : nodesByPath.get( elementPath ); if ( e == null ) { - e = tree.autoDetect( ePath, startIndex ); + e = tree.autoDetect( elementPath, startIndex ); } n++; - startIndex = expectCommaSeparatorOrEnd( json, e.endIndex(), ']' ); + startIndex = expectCommaOrEnd( json, e.endIndex(), ']' ); return e; } }; } - - private static int skipToElement( int n, char[] json, int index, IntConsumer onEndOfArray ) { - int elementsToSkip = n; - while ( elementsToSkip > 0 && index < json.length && json[index] != ']' ) { - index = skipWhitespace( json, index ); - index = skipNodeAutodetect( json, index ); - index = expectCommaSeparatorOrEnd( json, index, ']' ); - elementsToSkip--; - } - if ( json[index] == ']' ) { - onEndOfArray.accept( n - elementsToSkip ); - } - return index; - } } private static final class LazyJsonNumber extends LazyJsonNode { @@ -680,6 +737,14 @@ public String toString() { * @throws JsonFormatException when this document contains malformed JSON that confuses the parser */ JsonNode get( JsonPath path ) { + return get( path, false ); + } + + JsonNode getOrNull( JsonPath path ) { + return get( path, true ); + } + + private JsonNode get( JsonPath path, boolean orNull ) { if ( nodesByPath.isEmpty() ) nodesByPath.put( JsonPath.ROOT, autoDetect( JsonPath.ROOT, skipWhitespace( json, 0 ) ) ); if ( onGet != null && !path.isEmpty() ) onGet.accept( path.toString() ); @@ -689,16 +754,16 @@ JsonNode get( JsonPath path ) { // find by finding the closest already indexed parent and navigate down from there... JsonNode parent = getClosestIndexedParent( path, nodesByPath ); JsonPath pathToGo = path.shortenedBy( parent.getPath() ); - while ( !pathToGo.isEmpty() ) { // meaning: are we at the target node? (self) + while (parent != null && !pathToGo.isEmpty() ) { // meaning: are we at the target node? (self) if ( pathToGo.startsWithArray() ) { checkNodeIs( parent, JsonNodeType.ARRAY, path ); int index = pathToGo.arrayIndexAtStart(); - parent = parent.element( index ); + parent = orNull ? parent.elementOrNull( index ) : parent.element( index ); pathToGo = pathToGo.dropFirstSegment(); } else if ( pathToGo.startsWithObject() ) { checkNodeIs( parent, JsonNodeType.OBJECT, path ); String property = pathToGo.objectMemberAtStart(); - parent = parent.member( property ); + parent = orNull ? parent.memberOrNull( property ) : parent.member( property ); pathToGo = pathToGo.dropFirstSegment(); } else { throw new JsonPathException( path, format( "Malformed path %s at %s.", path, pathToGo ) ); @@ -716,27 +781,16 @@ private JsonNode autoDetect( JsonPath path, int atIndex ) { throw new JsonFormatException( json, atIndex, "a JSON value but found EOI" ); } char c = json[atIndex]; - switch ( c ) { - case '{' -> { - return new LazyJsonObject( this, path, atIndex ); - } - case '[' -> { - return new LazyJsonArray( this, path, atIndex ); - } - case '"' -> { - return new LazyJsonString( this, path, atIndex ); - } - case 't', 'f' -> { - return new LazyJsonBoolean( this, path, atIndex ); - } - case 'n' -> { - return new LazyJsonNull( this, path, atIndex ); - } - case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { - return new LazyJsonNumber( this, path, atIndex ); - } + return switch ( c ) { + case '{' -> new LazyJsonObject( this, path, atIndex ); + case '[' -> new LazyJsonArray( this, path, atIndex ); + case '"' -> new LazyJsonString( this, path, atIndex ); + case 't', 'f' -> new LazyJsonBoolean( this, path, atIndex ); + case 'n' -> new LazyJsonNull( this, path, atIndex ); + case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> + new LazyJsonNumber( this, path, atIndex ); default -> throw new JsonFormatException( json, atIndex, "start of a JSON value but found: `" + c + "`" ); - } + }; } private static void checkNodeIs( JsonNode parent, JsonNodeType expected, JsonPath path ) { @@ -802,9 +856,9 @@ static int skipObject( char[] json, int fromIndex ) { index = skipWhitespace( json, index ); while ( index < json.length && json[index] != '}' ) { index = skipString( json, index ); - index = expectColonSeparator( json, index ); + index = expectColon( json, index ); index = skipNodeAutodetect( json, index ); - index = expectCommaSeparatorOrEnd( json, index, '}' ); + index = expectCommaOrEnd( json, index, '}' ); } return expectChar( json, index, '}' ); } @@ -815,23 +869,21 @@ static int skipArray( char[] json, int fromIndex ) { index = skipWhitespace( json, index ); while ( index < json.length && json[index] != ']' ) { index = skipNodeAutodetect( json, index ); - index = expectCommaSeparatorOrEnd( json, index, ']' ); + index = expectCommaOrEnd( json, index, ']' ); } return expectChar( json, index, ']' ); } - private static int expectColonSeparator( char[] json, int index ) { + private static int expectColon( char[] json, int index ) { return skipWhitespace( json, expectChar( json, skipWhitespace( json, index ), ':' ) ); } - private static int expectCommaSeparatorOrEnd( char[] json, int index, char end ) { + private static int expectCommaOrEnd( char[] json, int index, char end ) { index = skipWhitespace( json, index ); - if ( json[index] == ',' ) { + if ( json[index] == ',' ) return skipWhitespace( json, index + 1 ); - } - if ( json[index] != end ) { - return expectChar( json, index, end ); // cause fail - } + if ( json[index] != end ) + return expectChar( json, index, end ); // causes fail return index; // found end, return index pointing to it } @@ -885,11 +937,17 @@ private static int skipNumber( char[] json, int fromIndex ) { } private static int skipWhitespace( char[] json, int fromIndex ) { - return skipWhile( json, fromIndex, JsonTree::isWhitespace ); + int index = fromIndex; + while ( index < json.length && isWhitespace( json[index] ) ) + index++; + return index; } private static int skipDigits( char[] json, int fromIndex ) { - return skipWhile( json, fromIndex, JsonTree::isDigit ); + int index = fromIndex; + while ( index < json.length && isDigit( json[index] ) ) + index++; + return index; } /** @@ -911,14 +969,6 @@ private static boolean isEscapableLetter( char c ) { || c == 'u'; } - private static int skipWhile( char[] json, int fromIndex, CharPredicate whileTrue ) { - int index = fromIndex; - while ( index < json.length && whileTrue.test( json[index] ) ) { - index++; - } - return index; - } - private static int skipChar( char[] json, int index, char c ) { return index < json.length && json[index] == c ? index + 1 : index; } @@ -944,22 +994,18 @@ private static int expectChars( char[] json, int index, CharSequence expected ) } private static int expectChar( char[] json, int index, CharPredicate expected ) { - if ( index >= json.length ) { + if ( index >= json.length ) throw new JsonFormatException( "Expected character but reached EOI: " + getEndSection( json, index ) ); - } - if ( !expected.test( json[index] ) ) { + if ( !expected.test( json[index] ) ) throw new JsonFormatException( json, index, '~' ); - } return index + 1; } private static int expectChar( char[] json, int index, char expected ) { - if ( index >= json.length ) { + if ( index >= json.length ) throw new JsonFormatException( "Expected " + expected + " but reach EOI: " + getEndSection( json, index ) ); - } - if ( json[index] != expected ) { + if ( json[index] != expected ) throw new JsonFormatException( json, index, expected ); - } return index + 1; } @@ -1005,11 +1051,11 @@ static int adjustObject( char[] json, int fromIndex ) { index = skipWhitespace( json, index ); while ( index < json.length && json[index] != '}' ) { index = adjustString( json, index ); - index = expectColonSeparator( json, index ); + index = expectColon( json, index ); index = adjustNodeAutodetect( json, index ); // blank dangling , if ( json[index] == ',' && json[index + 1] == '}' ) json[index++] = ' '; - index = expectCommaSeparatorOrEnd( json, index, '}' ); + index = expectCommaOrEnd( json, index, '}' ); } return expectChar( json, index, '}' ); } @@ -1023,7 +1069,7 @@ static int adjustArray( char[] json, int fromIndex ) { // blank dangling , if ( json[index] == ',' && json[index + 1] == ']' ) json[index++] = ' '; - index = expectCommaSeparatorOrEnd( json, index, ']' ); + index = expectCommaOrEnd( json, index, ']' ); } return expectChar( json, index, ']' ); } @@ -1033,7 +1079,8 @@ static int adjustString( char[] json, int fromIndex ) { if ( json[index] == '"' ) return skipString( json, fromIndex ); index = expectChar( json, index, '\'' ); json[index - 1] = '"'; - index = skipWhile( json, index, c -> c != '\'' ); + while ( index < json.length && json[index] != '\'') + index++; index = expectChar( json, index, '\'' ); json[index - 1] = '"'; return index; diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java index 970e4db..c3bd1af 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java @@ -32,6 +32,7 @@ import org.hisp.dhis.jsontree.internal.Surly; import java.io.Serializable; +import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Method; @@ -43,11 +44,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.BiConsumer; -import java.util.function.BiPredicate; import java.util.function.Function; import java.util.stream.Stream; @@ -83,6 +82,25 @@ public static List properties(Class of) { return PROPERTIES.computeIfAbsent( of, JsonVirtualTree::captureProperties ); } + /** + * {@link MethodHandle} cache for zero-args default methods as usually used by the properties declared in + * {@link JsonObject} sub-types. Each subtype has its on map with the method name as key as that is already unique. + */ + private static final ClassValue> PROPERTY_MH_CACHE = new ClassValue<>() { + @Override + protected Map computeValue( Class declaringClass ) { + return new ConcurrentHashMap<>(); + } + }; + + /** + * {@link MethodHandle} cache used for any method that does not match conditions for {@link #PROPERTY_MH_CACHE}. + *

+ * {@link MethodHandle}s are cached as a performance optimisation, in particular because during the MH resolve + * exceptions might be thrown and caught internally which has shown to be costly compared to a cache lookup. + */ + private static final Map OTHER_MH_CACHE = new ConcurrentHashMap<>(); + /** * The access support is shared by all values that are derived from the same initial virtual tree. * Therefore, it makes sense to group them in a single object to reduce the fields needed for each node. @@ -134,9 +152,10 @@ public Class asType() { return JsonMixed.class; } - private T value( JsonNodeType expected, Function get, Function orElse ) { + private T value( JsonNodeType expected, Function get, T orElse ) { try { - JsonNode node = root.get( path ); + JsonNode node = root.getOrNull( path ); + if (node == null) return orElse; JsonNodeType actualType = node.getType(); if ( actualType == JsonNodeType.NULL ) { return null; @@ -148,7 +167,7 @@ private T value( JsonNodeType expected, Function get, Function< } return get.apply( node ); } catch ( JsonPathException ex ) { - return orElse.apply( ex ); + return orElse; } } @@ -236,7 +255,7 @@ private List arrayList( Class elementType ) { res.add( (T) value ); } return res; - }, ex -> List.of() ); + }, List.of() ); } @Override @@ -246,23 +265,23 @@ public int size() { @Override public Boolean bool() { - return (Boolean) value( JsonNodeType.BOOLEAN, JsonNode::value, ex -> null ); + return (Boolean) value( JsonNodeType.BOOLEAN, JsonNode::value, null ); } @Override public Number number() { - return (Number) value( JsonNodeType.NUMBER, JsonNode::value, ex -> null ); + return (Number) value( JsonNodeType.NUMBER, JsonNode::value, null ); } @Override public String string() { - return (String) value( JsonNodeType.STRING, JsonNode::value, ex -> null ); + return (String) value( JsonNodeType.STRING, JsonNode::value, null ); } @Override public boolean exists() { try { - return root.get( path ) != null; + return root.getOrNull( path ) != null; } catch ( JsonPathException ex ) { return false; } @@ -314,10 +333,11 @@ private Object onInvoke( Object proxy, Class as, JsonVi && method.getParameterCount() == 0 ) { return as; } - if ( isJsonMixedSubType( declaringClass ) || method.isDefault() && alwaysCallDefault ) { - if ( method.isDefault() ) { + boolean isDefault = method.isDefault(); + if ( isJsonMixedSubType( declaringClass ) || isDefault && alwaysCallDefault ) { + if ( isDefault ) { // call the default method of the proxied type itself - return callDefaultMethod( proxy, method, declaringClass, args ); + return callDefaultMethod( proxy, method, args ); } // abstract extending interface method? return callAbstractMethod( target, method, args ); @@ -330,15 +350,52 @@ private Object onInvoke( Object proxy, Class as, JsonVi * Any default methods implemented by an extension of the {@link JsonValue} class tree is run by calling the default * as defined in the interface. This is sadly not as straight forward as it might sound. */ - private static Object callDefaultMethod( Object proxy, Method method, Class declaringClass, Object[] args ) + private static Object callDefaultMethod( Object proxy, Method method, Object[] args ) throws Throwable { - return MethodHandles.lookup() - .findSpecial( declaringClass, method.getName(), - MethodType.methodType( method.getReturnType(), method.getParameterTypes() ), - declaringClass ) + if (method.getParameterCount() == 0) + return PROPERTY_MH_CACHE.get( method.getDeclaringClass() ) + .computeIfAbsent( method.getName(), name -> getDefaultMethodHandle( method ) ) + .bindTo( proxy ).invoke(); + return OTHER_MH_CACHE.computeIfAbsent( method, JsonVirtualTree::getDefaultMethodHandle ) .bindTo( proxy ).invokeWithArguments( args ); } + /** + * All methods by the core API of the general JSON tree represented as {@link JsonValue}s (and the general + * subclasses) are implemented by the {@link JsonVirtualTree} wrapper, so they can be called directly. + */ + private static Object callCoreApiMethod( JsonVirtualTree target, Method method, Object[] args ) + throws Throwable { + if (args == null || args.length == 0) + return OTHER_MH_CACHE.computeIfAbsent( method, JsonVirtualTree::getCoreApiMethodHandle ) + .bindTo( target ).invoke(); + if (method.isDefault()) + return OTHER_MH_CACHE.computeIfAbsent( method, JsonVirtualTree::getDefaultMethodHandle ) + .bindTo( target ).invokeWithArguments( args ); + return OTHER_MH_CACHE.computeIfAbsent(method, JsonVirtualTree::getCoreApiMethodHandle ) + .bindTo( target ).invokeWithArguments( args ); + } + + private static MethodHandle getCoreApiMethodHandle( Method m ) { + try { + return MethodHandles.lookup().unreflect( m ); + } catch ( IllegalAccessException e ) { + throw new RuntimeException( e ); + } + } + + private static MethodHandle getDefaultMethodHandle( Method method ) { + try { + Class declaringClass = method.getDeclaringClass(); + return MethodHandles.lookup() + .findSpecial( declaringClass, method.getName(), + MethodType.methodType( method.getReturnType(), method.getParameterTypes() ), + declaringClass ); + } catch ( Exception ex ) { + throw new RuntimeException(ex); + } + } + /** * Abstract interface methods are "implemented" by deriving an {@link JsonGenericTypedAccessor} from the method's * return type and have the accessor extract the value by using the underlying {@link JsonValue} API. @@ -366,17 +423,6 @@ private Object access( Method method, JsonObject obj, String name ) { throw new UnsupportedOperationException( "No accessor registered for type: " + genericType ); } - /** - * All methods by the core API of the general JSON tree represented as {@link JsonValue}s (and the general - * subclasses) are implemented by the {@link JsonVirtualTree} wrapper, so they can be called directly. - */ - private static Object callCoreApiMethod( JsonVirtualTree target, Method method, Object[] args ) - throws Throwable { - return args == null || args.length == 0 - ? MethodHandles.lookup().unreflect( method ).invokeWithArguments( target ) - : MethodHandles.lookup().unreflect( method ).bindTo( target ).invokeWithArguments( args ); - } - /** * This is twofold: Concepts like {@link Stream} and {@link Iterator} should not be cached to work correctly. *

diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonNodeTest.java index 40992f2..a9de7bd 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.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrowsExactly; @@ -55,6 +56,15 @@ void testGet_String() { "This is a leaf node of type STRING that does not have any children at path: .foo" ); } + @Test + void testGetOrNull() { + assertNull( JsonNode.of( "{}" ).getOrNull( "foo" ) ); + assertNull( JsonNode.of( "[]" ).getOrNull( "[1]" ) ); + assertThrowsExactly( JsonPathException.class, () -> JsonNode.of( "true" ).getOrNull( "foo" ) ); + assertThrowsExactly( JsonPathException.class, () -> JsonNode.of( "1" ).getOrNull( "foo" ) ); + assertThrowsExactly( JsonPathException.class, () -> JsonNode.of( "\"x\"" ).getOrNull( "foo" ) ); + } + @Test void testGet_Number() { assertGetThrowsJsonPathException( "42",