Skip to content

Commit

Permalink
JsonPath as concept (#59)
Browse files Browse the repository at this point in the history
* feat: json-patch support

* Resolves #52 - JsonValue equality checks

* feat: json-patch validation

* feat: acceptNull to declare null satisfies required validation

* feat: more patch (incomplete)

* feat: JsonPath API and special key handling

* fix: names() gives access to the raw object member names

* chore: javadoc, tweaks and tests

* chore: remove patch (not ready for release)

* chore: drop patch related code for the release

* chore: drop patch related code for the release
  • Loading branch information
jbee authored Jun 27, 2024
1 parent 525e0eb commit 7c569d5
Show file tree
Hide file tree
Showing 27 changed files with 1,623 additions and 267 deletions.
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

0 comments on commit 7c569d5

Please sign in to comment.