Skip to content

JsonPath as concept #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jun 27, 2024
38 changes: 37 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`
>
28 changes: 22 additions & 6 deletions src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> keys() {
Expand All @@ -103,15 +120,14 @@ default Stream<Map.Entry<String, E>> 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<String> names() {
List<String> names = new ArrayList<>();
keys().forEach( names::add );
return names;
return isUndefined() || isEmpty() ? List.of() : stream( node().names().spliterator(), false ).toList();
}

/**
Expand Down
66 changes: 53 additions & 13 deletions src/main/java/org/hisp/dhis/jsontree/JsonNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 );
}

/**
Expand All @@ -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 );
}

/**
Expand Down Expand Up @@ -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() );
}

/**
Expand All @@ -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() );
}

/**
Expand All @@ -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.
* <p>
Expand Down Expand Up @@ -345,6 +375,9 @@ default JsonNode member( String name )
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
* <p>
* The members are iterated in order of declaration in the underlying document.
* <p>
* 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
Expand All @@ -369,6 +402,19 @@ default Iterable<String> keys() {
throw new JsonTreeException( getType() + " node has no keys property." );
}

/**
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
* <p>
* 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<String> names() {
throw new JsonTreeException( getType() + " node has no names property." );
}

/**
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
* <p>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 );
}
}
154 changes: 154 additions & 0 deletions src/main/java/org/hisp/dhis/jsontree/JsonNodeOperation.java
Original file line number Diff line number Diff line change
@@ -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)}.
* <p>
* {@linkplain JsonNodeOperation} is a path based operation that is not yet "bound" to target.
* <p>
* 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 {}

/**
* <h4>Insert into Arrays</h4>
* 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.
* <p>
* <h4>Merge</h4>
* <ul>
* <li>object + object = add all properties of inserted object to target object</li>
* <li>array + array = insert all elements of inserted array at target index into the target array</li>
* <li>array + primitive = append inserted element to target array</li>
* <li>primitive + primitive = create array with current value and inserted value</li>
* <li>* + object = trying to merge an object value into a non object target is an error</li>
* </ul>
*
* @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<JsonNodeOperation> mergeArrayInserts(List<JsonNodeOperation> 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<JsonNodeOperation> 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<JsonNodeOperation> ops ) {
if (ops.size() < 2) return;
Map<String, JsonNodeOperation> opsByPath = new HashMap<>();
Set<String> 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<JsonNodeOperation> ops, JsonNodeOperation op, String path,
Map<String, JsonNodeOperation> opsByPath, Set<String> 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<JsonNodeOperation> ops, JsonNodeOperation op, String path,
Map<String, JsonNodeOperation> opsByPath, Set<String> parents ) {
while ( !path.isEmpty() ) {
if ( opsByPath.containsKey( path ) ) throw clash( ops, opsByPath.get( path ), op );
parents.add( path );
path = parentPath( path );
}
}
}
Loading