Skip to content

Commit

Permalink
fix: entries() use names as keys - name to key escape rules (#62)
Browse files Browse the repository at this point in the history
* feat: paths() method to iterate/stream absolute member paths

* fix: map/object entries must use names as keys

* fix: path name to key special case handling
  • Loading branch information
jbee authored Jul 8, 2024
1 parent a846b23 commit a9869b4
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 19 deletions.
17 changes: 14 additions & 3 deletions src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import org.hisp.dhis.jsontree.Validation.Rule;
import org.hisp.dhis.jsontree.validation.JsonValidator;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -111,12 +110,15 @@ default Stream<E> values() {
}

/**
* @return a stream of map/object entries in order of their declaration
* @return a stream of map/object entries in order of their declaration. the entry keys are the raw {@link #names()}
* as given in the original JSON document (not the {@link #keys()})
* @throws JsonTreeException in case this node does exist but is not an object node
* @since 0.11
*/
default Stream<Map.Entry<String, E>> entries() {
return keys().map( key -> Map.entry( key, get( key ) ) );
if ( isUndefined() || isEmpty() ) return Stream.empty();
return stream( node().names().spliterator(), false ).map(
name -> Map.entry( name, get( JsonPath.keyOf( name ) ) ) );
}

/**
Expand All @@ -130,6 +132,15 @@ default List<String> names() {
return isUndefined() || isEmpty() ? List.of() : stream( node().names().spliterator(), false ).toList();
}

/**
* @return a stream of the absolute paths of the map/object members in oder of their declaration
* @throws JsonTreeException in case this node does exist but is not an object node
* @since 1.2
*/
default Stream<JsonPath> paths() {
return isUndefined() || isEmpty() ? Stream.empty() : stream( node().paths().spliterator(), false );
}

/**
* @param action call with each entry in the map/object in order of their declaration
* @throws JsonTreeException in case this node does exist but is not an object node
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/org/hisp/dhis/jsontree/JsonNode.java
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,17 @@ 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}).
*
* @return the absolute paths of the members of this object in order of declaration
* @throws JsonTreeException if this node is not an object node that could have members
* @since 1.2
*/
default Iterable<JsonPath> paths() {
throw new JsonTreeException( getType() + " node has no paths property." );
}

/**
* OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}).
* <p>
Expand Down
63 changes: 59 additions & 4 deletions src/main/java/org/hisp/dhis/jsontree/JsonPath.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.List;

import static java.lang.Integer.parseInt;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Stream.concat;

/**
Expand Down Expand Up @@ -35,6 +36,8 @@
*/
public record JsonPath(List<String> segments) {

private static final System.Logger log = System.getLogger( JsonPath.class.getName() );

/**
* A path pointing to the root or self
*/
Expand Down Expand Up @@ -79,9 +82,61 @@ public static String keyOf( String name ) {
* @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;
boolean hasCurly = name.indexOf( '{' ) >= 0;
boolean hasSquare = name.indexOf( '[' ) >= 0;
boolean hasDot = name.indexOf( '.' ) >= 0;
// default case: no special characters in name
if (!hasCurly && !hasSquare && !hasDot) return forceSegment ? "." + name : name;
// common special case: has a dot (and possibly square) => needs curly escape
if ( !hasCurly && hasDot ) return curlyEscapeWithCheck( name );
// common special case: has a square but no curly or dot => only needs escaping when open + close square
if ( !hasCurly ) return hasInnerSquareSegment( name ) ? "{"+name+"}" : "." + name;
// edge special case: [...] but only opens at the start => dot works
if ( !hasDot && name.charAt( 0 ) == '[' && name.indexOf( '[', 1 ) < 0 ) return "."+name;
// edge special case: {...} but only opens at the start => dot works
if ( !hasDot && name.charAt( 0 ) == '{' && name.indexOf( '{', 1 ) < 0 ) return "."+name;
// special case: has curly open but no valid curly close => plain or dot works
if (indexOfInnerCurlySegmentEnd( name ) < 1) return name.charAt( 0 ) == '{' ? "."+name : name;
return curlyEscapeWithCheck( name );
}

private static boolean hasInnerSquareSegment(String name) {
int i = name.indexOf( '[', 1 );
while ( i >= 0 ) {
if (isSquareSegmentOpen( name, i )) return true;
i = name.indexOf( '[', i+1 );
}
return false;
}

/**
* Searches for the end since possibly a curly escape is used and a valid inner curly end would be misunderstood.
*/
private static int indexOfInnerCurlySegmentEnd(String name) {
int i = name.indexOf( '}', 1 );
while ( i >= 0 ) {
if (isCurlySegmentClose( name, i )) return i;
i = name.indexOf( '}', i+1 );
}
return -1;
}

private static String curlyEscapeWithCheck( String name ) {
int end = indexOfInnerCurlySegmentEnd( name );
if (end > 0) {
// a } at the very end is ok since escaping that again {...} makes it an invalid end
// so then effectively there is no valid on in the escaped name
if (end < name.length()-1) {
log.log( System.Logger.Level.WARNING,
"Path segment escape required but not supported for name `%s`, character at %d will be misunderstood as segment end".formatted(
name, end ) );
}
}
return "{"+name+"}";
}

public JsonPath {
requireNonNull( segments );
}

/**
Expand Down Expand Up @@ -235,7 +290,7 @@ private static List<String> splitIntoSegments( String path )
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
i++; // if it is not a dot the first char after the . is never a start of next segment
while ( i < len && !isDotSegmentClose( path, i ) ) i++;
}
} else if ( isSquareSegmentOpen( path, i ) ) {
Expand Down
23 changes: 13 additions & 10 deletions src/main/java/org/hisp/dhis/jsontree/JsonTree.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,21 +27,19 @@
*/
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;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.IntConsumer;
import java.util.function.Predicate;
import java.util.stream.StreamSupport;
Expand Down Expand Up @@ -352,25 +350,30 @@ public Entry<String, JsonNode> next() {
} else if ( member.endIndex() < startIndexVal ) {
// duplicate keys case: just skip the duplicate
startIndex = expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndexVal ), '}' );
return new SimpleEntry<>( name, member );
return Map.entry( name, member );
}
startIndex = expectCommaSeparatorOrEnd( json, member.endIndex(), '}' );
return new SimpleEntry<>( name, member );
return Map.entry( name, member );
}
};
}

@Override
public Iterable<JsonPath> paths() {
return keys(path::extendedWith);
}

@Override
public Iterable<String> names() {
return keys(false);
return keys(name -> name);
}

@Override
public Iterable<String> keys() {
return keys(true);
return keys(JsonPath::keyOf);
}

private Iterable<String> keys(boolean escape) {
private <E> Iterable<E> keys( Function<String, E> toKey) {
return () -> new Iterator<>() {
private final char[] json = tree.json;
private final Map<JsonPath, JsonNode> nodesByPath = tree.nodesByPath;
Expand All @@ -382,7 +385,7 @@ public boolean hasNext() {
}

@Override
public String next() {
public E next() {
if ( !hasNext() )
throw new NoSuchElementException( "next() called without checking hasNext()" );
LazyJsonString.Span property = LazyJsonString.parseString( json, startIndex );
Expand All @@ -395,7 +398,7 @@ public String next() {
startIndex = member == null || member.endIndex() < startIndex // (duplicates)
? expectCommaSeparatorOrEnd( json, skipNodeAutodetect( json, startIndex ), '}' )
: expectCommaSeparatorOrEnd( json, member.endIndex(), '}' );
return escape ? JsonPath.keyOf( name ) : name;
return toKey.apply( name );
}
};
}
Expand Down
4 changes: 2 additions & 2 deletions src/test/java/org/hisp/dhis/jsontree/JsonMapTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,8 @@ void testEntries_Special() {
String json = """
{".":1, "{uid}":2, "[6]":3, "x{y}z": 4}""";
JsonMap<JsonNumber> map = JsonMixed.of( json ).asMap( JsonNumber.class );
assertEquals( List.of( entry( "{.}", 1 ), entry( ".{uid}", 2 ),
entry( ".[6]", 3 ), entry( "x{y}z", 4 ) ),
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() );
}
}
23 changes: 23 additions & 0 deletions src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,29 @@ void testNames_Special() {
assertEquals( List.of( ".", "{uid}", "[0]" ), value.names() );
}

@Test
void testPaths_Special() {
//language=json
String json = """
{"root": {".":1,"{uid}":2,"[0]": 3,"normal":4}}""";
JsonObject value = JsonMixed.of( json ).getObject( "root" );
assertEquals( List.of( JsonPath.of( ".root{.}" ), JsonPath.of( ".root.{uid}" ), JsonPath.of( ".root.[0]" ),
JsonPath.of( ".root.normal" ) ),
value.paths().toList() );
}

@Test
void testPaths_OpenAPI() {
//language=json
String json = """
{"paths": {"/api/dataElements/{uid:[a-zA-Z0-9]{11}}": {"get": {"id": "opx"}, "delete": {"id":"opy"}}}}""";
JsonObject paths = JsonMixed.of( json ).getObject( "paths" );
assertEquals( List.of("/api/dataElements/{uid:[a-zA-Z0-9]{11}}"), paths.names() );
JsonObject ops = paths.getObject( JsonPath.keyOf( "/api/dataElements/{uid:[a-zA-Z0-9]{11}}" ) );
assertEquals( List.of("get", "delete"), ops.keys().toList() );
assertEquals( "opy", ops.getObject( "delete" ).getString( "id" ).string() );
}

@Test
void testProject() {
//language=json
Expand Down

0 comments on commit a9869b4

Please sign in to comment.