Skip to content

Commit 7c569d5

Browse files
authored
JsonPath as concept (#59)
* 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
1 parent 525e0eb commit 7c569d5

27 files changed

+1623
-267
lines changed

CHANGELOG.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,39 @@
11
# ChangeLog
22

3-
## [Unreleased] v1.1
3+
## v1.1 - Bulk Modification APIs - [Unreleased]
4+
5+
> [!Note]
6+
> ### Major Features
7+
> * **Added**: [JSON Patch](https://jsonpatch.com/) support; `JsonValue#patch` (`JsonPatch`, `JsonPointer`)
8+
> * **Added**: bulk modification API: `JsonNode#patch` + `JsonNodeOperation`
9+
> * **Added**: `@Validation#acceptNull()`, `null` value satisfies required property
10+
11+
> [!Tip]
12+
> ### Minor API Improvements
13+
> * **Added**: JSON value test for same information `JsonValue#equivalentTo`
14+
> * **Added**: JSON value test for same definition (ignoring formatting) `JsonValue#identicalTo`
15+
> * **Added**: `JsonAbstractObject#exists(String)` test if object member exists
16+
> * **Changed**: `JsonNode#equals` and `JsonNode#hashCode` are now based on the json input
17+
18+
19+
> [!Warning]
20+
> ### Breaking Changes
21+
> * **Changed**: `JsonNode#getPath` returns a `JsonPath` (`String` before)
22+
> * **Changed**: `JsonNode#keys` returns paths with escaping when needed
23+
24+
> [!Caution]
25+
> ### Bugfixes
26+
27+
28+
## v1.0 Matured APIs - January 2024
29+
Unfortunately no detailed changelog was maintained prior to version 1.0.
30+
31+
The following is a recollection from memory on major improvements in versions
32+
close the 1.0 release.
33+
34+
> [!Note]
35+
> ### Major Features
36+
> * **Added**: [JSON Schema Validation](https://json-schema.org/) support;
37+
> `JsonAbstractObject#validate` and `JsonAbstractArray#validateEach` +
38+
> `@Validation` and `@Required`
39+
>

src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,25 @@ default boolean isUndefined( String name ) {
7676
}
7777

7878
/**
79+
* Test if the object property is defined which includes being defined JSON {@code null}.
80+
*
81+
* @param name name of the object member
82+
* @return true if this object has a member of the provided name
83+
* @since 1.1
84+
*/
85+
default boolean exists(String name) {
86+
return get(name).exists();
87+
}
88+
89+
/**
90+
* Note that keys may differ from the member names as defined in the JSON document in case that their literal
91+
* interpretation would have clashed with key syntax. In that case the object member name is "escaped" so that using
92+
* the returned key with {@link #get(String)} will return the value. Use {@link #names()} to receive the literal
93+
* object member names as defined in the document.
94+
*
7995
* @return The keys of this map.
8096
* @throws JsonTreeException in case this node does exist but is not an object node
97+
* @see #names()
8198
* @since 0.11 (as Stream)
8299
*/
83100
default Stream<String> keys() {
@@ -103,15 +120,14 @@ default Stream<Map.Entry<String, E>> entries() {
103120
}
104121

105122
/**
106-
* Lists JSON object property names in order of declaration.
123+
* Lists raw JSON object member names in order of declaration.
107124
*
108-
* @return The list of property names in the order they were defined.
109-
* @throws JsonTreeException in case this value is not an JSON object
125+
* @return The list of object member names in the order they were defined.
126+
* @throws JsonTreeException in case this node does exist but is not an object node
127+
* @see #keys()
110128
*/
111129
default List<String> names() {
112-
List<String> names = new ArrayList<>();
113-
keys().forEach( names::add );
114-
return names;
130+
return isUndefined() || isEmpty() ? List.of() : stream( node().names().spliterator(), false ).toList();
115131
}
116132

117133
/**

src/main/java/org/hisp/dhis/jsontree/JsonNode.java

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
import java.nio.file.Files;
3939
import java.nio.file.Path;
4040
import java.util.Iterator;
41+
import java.util.List;
4142
import java.util.Map.Entry;
4243
import java.util.Optional;
4344
import java.util.Set;
@@ -124,7 +125,7 @@ static JsonNode of( String json ) {
124125
* @since 0.10
125126
*/
126127
static JsonNode ofNonStandard( String json ) {
127-
return JsonTree.ofNonStandard( json ).get( "$" );
128+
return JsonTree.ofNonStandard( json ).get( JsonPath.ROOT );
128129
}
129130

130131
/**
@@ -136,7 +137,7 @@ static JsonNode ofNonStandard( String json ) {
136137
* @since 0.10
137138
*/
138139
static JsonNode of( String json, GetListener onGet ) {
139-
return JsonTree.of( json, onGet ).get( "$" );
140+
return JsonTree.of( json, onGet ).get( JsonPath.ROOT );
140141
}
141142

142143
/**
@@ -215,7 +216,7 @@ static JsonNode of( Reader json, GetListener onGet ) {
215216
*/
216217
default JsonValue lift( JsonTypedAccessStore store ) {
217218
JsonVirtualTree root = new JsonVirtualTree( getRoot(), store );
218-
return isRoot() ? root : root.get( getPath() );
219+
return isRoot() ? root : root.get( getPath().toString() );
219220
}
220221

221222
/**
@@ -224,7 +225,7 @@ default JsonValue lift( JsonTypedAccessStore store ) {
224225
*/
225226
@Surly
226227
default JsonNode getParent() {
227-
return isRoot() ? this : getRoot().get( parentPath( getPath() ) );
228+
return isRoot() ? this : getRoot().get( getPath().dropLastSegment().toString() );
228229
}
229230

230231
/**
@@ -236,15 +237,44 @@ default JsonNode getParent() {
236237
* @throws JsonPathException when no such node exists in the subtree of this node
237238
*/
238239
@Surly
239-
default JsonNode get( String path )
240+
default JsonNode get(@Surly String path )
240241
throws JsonPathException {
241242
if ( path.isEmpty() ) return this;
242243
if ( "$".equals( path ) ) return getRoot();
243244
if ( path.startsWith( "$" ) ) return getRoot().get( path.substring( 1 ) );
245+
if (!path.startsWith( "{" ) && !path.startsWith( "[" ) && !path.startsWith( "." ))
246+
path = "."+path;
247+
return get( JsonPath.of( path ) );
248+
}
249+
250+
/**
251+
*
252+
* @param path a path understood relative to this node's {@link #getPath()}
253+
* @return the node at the given path
254+
* @since 1.1
255+
*/
256+
@Surly
257+
default JsonNode get(@Surly JsonPath path) {
244258
throw new JsonPathException( path,
245259
format( "This is a leaf node of type %s that does not have any children at path: %s", getType(), path ) );
246260
}
247261

262+
/**
263+
* Access node by path with default.
264+
*
265+
* @param path a simple or nested path relative to this node
266+
* @param orDefault value to return in no node at the given path exist in this subtree
267+
* @return the node at path or the provided default if no such node exists
268+
* @since 1.1
269+
*/
270+
default JsonNode getOrDefault( String path, JsonNode orDefault ) {
271+
try {
272+
return get( path );
273+
} catch ( JsonPathException ex ) {
274+
return orDefault;
275+
}
276+
}
277+
248278
/**
249279
* Size of an array of number of object members.
250280
* <p>
@@ -345,6 +375,9 @@ default JsonNode member( String name )
345375
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
346376
* <p>
347377
* The members are iterated in order of declaration in the underlying document.
378+
* <p>
379+
* In contrast to {@link #keys()} the entries in this method will always have the literal property as their {@link Entry#getKey()}.
380+
* This means also they are not fully safe to be used for {@link #get(String)}.
348381
*
349382
* @return this {@link #value()} as a sequence of {@link Entry}
350383
* @throws JsonTreeException if this node is not an object node that could have members
@@ -369,6 +402,19 @@ default Iterable<String> keys() {
369402
throw new JsonTreeException( getType() + " node has no keys property." );
370403
}
371404

405+
/**
406+
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
407+
* <p>
408+
* The names are iterated in order of declaration in the underlying document.
409+
*
410+
* @return the raw property names of this object node
411+
* @throws JsonTreeException if this node is not an object node that could have members
412+
* @since 1.1
413+
*/
414+
default Iterable<String> names() {
415+
throw new JsonTreeException( getType() + " node has no names property." );
416+
}
417+
372418
/**
373419
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
374420
* <p>
@@ -499,8 +545,9 @@ default int count( JsonNodeType type ) {
499545

500546
/**
501547
* @return path within the overall content this node represents
548+
* @since 1.1 (with {@link JsonPath} type)
502549
*/
503-
String getPath();
550+
JsonPath getPath();
504551

505552
/**
506553
* @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
748795
format( "`%s` only allowed for %s but was: %s", operation, expected, actual ) );
749796
}
750797

751-
static String parentPath( String path ) {
752-
if ( path.endsWith( "]" ) ) {
753-
return path.substring( 0, path.lastIndexOf( '[' ) );
754-
}
755-
int end = path.lastIndexOf( '.' );
756-
return end < 0 ? "" : path.substring( 0, end );
757-
}
758798
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package org.hisp.dhis.jsontree;
2+
3+
import org.hisp.dhis.jsontree.JsonBuilder.JsonArrayBuilder;
4+
5+
import java.util.HashMap;
6+
import java.util.HashSet;
7+
import java.util.LinkedHashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.Set;
11+
import java.util.function.Consumer;
12+
import java.util.function.Function;
13+
14+
import static java.util.stream.Collectors.toMap;
15+
import static org.hisp.dhis.jsontree.JsonBuilder.createArray;
16+
import static org.hisp.dhis.jsontree.JsonNodeType.OBJECT;
17+
import static org.hisp.dhis.jsontree.JsonPatchException.clash;
18+
19+
/**
20+
* {@linkplain JsonNodeOperation}s are used to make bulk modifications using {@link JsonNode#patch(List)}.
21+
* <p>
22+
* {@linkplain JsonNodeOperation} is a path based operation that is not yet "bound" to target.
23+
* <p>
24+
* The order of operations made into a set does not matter. Any order has the same outcome when applied to the same
25+
* target.
26+
*
27+
* @author Jan Bernitt
28+
* @since 1.1
29+
*/
30+
sealed public interface JsonNodeOperation {
31+
32+
static String parentPath( String path ) {
33+
//TODO move callers to JsonPath
34+
return JsonPath.of( path ).dropLastSegment().toString();
35+
}
36+
37+
/**
38+
* @return the target of the operation
39+
*/
40+
String path();
41+
42+
/**
43+
* @return true when this operation targets an array index
44+
*/
45+
default boolean isArrayOp() {
46+
return path().endsWith( "]" );
47+
}
48+
49+
/**
50+
* @return true when this is an {@link Insert} operation
51+
*/
52+
default boolean isRemove() {
53+
return this instanceof Remove;
54+
}
55+
56+
/**
57+
* @param path relative path to remove
58+
*/
59+
record Remove(String path) implements JsonNodeOperation {}
60+
61+
/**
62+
* <h4>Insert into Arrays</h4>
63+
* In an array the value is inserted before the existing value at the path index. That means the current value at
64+
* the path index will be after the inserted value in the updated tree.
65+
* <p>
66+
* <h4>Merge</h4>
67+
* <ul>
68+
* <li>object + object = add all properties of inserted object to target object</li>
69+
* <li>array + array = insert all elements of inserted array at target index into the target array</li>
70+
* <li>array + primitive = append inserted element to target array</li>
71+
* <li>primitive + primitive = create array with current value and inserted value</li>
72+
* <li>* + object = trying to merge an object value into a non object target is an error</li>
73+
* </ul>
74+
*
75+
* @param path relative path to the target property, this either is the root, an object member or an array index or
76+
* range
77+
* @param value the new value
78+
* @param merge when true, insert the value's items not the value itself
79+
*/
80+
record Insert(String path, JsonNode value, boolean merge) implements JsonNodeOperation {
81+
public Insert(String path, JsonNode value) { this(path, value, false); }
82+
}
83+
84+
/**
85+
* As each target path may only occur once a set of operations may need folding inserts for arrays. This means each
86+
* operation that wants to insert at the same index in the same target array is merged into a single operation
87+
* inserting all the values in the order they occur in the #ops parameter.
88+
*
89+
* @param ops a set of ops that may contain multiple inserts targeting the same array index
90+
* @return a list of operations where the clashing array inserts have been merged by concatenating the inserted
91+
* elements
92+
* @throws JsonPathException if the ops is found to contain other operations clashing on same path (that are not
93+
* array inserts)
94+
*/
95+
static List<JsonNodeOperation> mergeArrayInserts(List<JsonNodeOperation> ops) {
96+
if (ops.stream().filter( JsonNodeOperation::isArrayOp ).count() < 2) return ops;
97+
return List.copyOf( ops.stream()
98+
.collect( toMap(JsonNodeOperation::path, Function.identity(), (op1, op2) -> {
99+
if (!op1.isArrayOp() || op1.isRemove() || op2.isRemove() )
100+
throw JsonPatchException.clash( ops, op1, op2 );
101+
JsonNode merged = createArray( arr -> {
102+
Consumer<JsonNodeOperation> add = op -> {
103+
Insert insert = (Insert) op;
104+
if ( insert.merge() ) {
105+
arr.addElements( insert.value().elements(), JsonArrayBuilder::addElement );
106+
} else {
107+
arr.addElement( insert.value() );
108+
}
109+
};
110+
add.accept( op1 );
111+
add.accept( op2 );
112+
} );
113+
return new Insert( op1.path(), merged, true );
114+
}, LinkedHashMap::new ) ).values());
115+
}
116+
117+
/**
118+
* @param ops set of patch operations
119+
* @implNote array merge inserts don't need special handling as it is irrelevant how many elements are inserted at
120+
* the target index as each operation is independent and uniquely targets an insert position in the target array in
121+
* its state before any change
122+
*/
123+
static void checkPatch( List<JsonNodeOperation> ops ) {
124+
if (ops.size() < 2) return;
125+
Map<String, JsonNodeOperation> opsByPath = new HashMap<>();
126+
Set<String> parents = new HashSet<>();
127+
for ( JsonNodeOperation op : ops ) {
128+
String path = op.path();
129+
if (op instanceof Insert insert && insert.merge && insert.value.getType() == OBJECT) {
130+
insert.value.keys().forEach( p -> checkPatchPath( ops, op, path+"."+p, opsByPath, parents ) );
131+
checkPatchParents( ops, op, path, opsByPath, parents );
132+
} else {
133+
checkPatchPath( ops, op, path, opsByPath, parents );
134+
checkPatchParents( ops, op, parentPath( path ), opsByPath, parents );
135+
}
136+
}
137+
}
138+
139+
private static void checkPatchPath( List<JsonNodeOperation> ops, JsonNodeOperation op, String path,
140+
Map<String, JsonNodeOperation> opsByPath, Set<String> parents ) {
141+
if ( opsByPath.containsKey( path ) ) throw clash( ops, opsByPath.get( path ), op );
142+
if ( parents.contains( path ) ) throw clash( ops, op, null );
143+
opsByPath.put( path, op );
144+
}
145+
146+
private static void checkPatchParents( List<JsonNodeOperation> ops, JsonNodeOperation op, String path,
147+
Map<String, JsonNodeOperation> opsByPath, Set<String> parents ) {
148+
while ( !path.isEmpty() ) {
149+
if ( opsByPath.containsKey( path ) ) throw clash( ops, opsByPath.get( path ), op );
150+
parents.add( path );
151+
path = parentPath( path );
152+
}
153+
}
154+
}

0 commit comments

Comments
 (0)