From 3cec0760e80a762046f6d50139606fd92fb29d21 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Sat, 29 Jun 2024 09:33:49 +0200 Subject: [PATCH 1/4] feat: paths() method to iterate/stream absolute member paths --- .../hisp/dhis/jsontree/JsonAbstractObject.java | 10 ++++++++++ .../java/org/hisp/dhis/jsontree/JsonNode.java | 11 +++++++++++ .../java/org/hisp/dhis/jsontree/JsonPath.java | 6 ++++++ .../java/org/hisp/dhis/jsontree/JsonTree.java | 17 ++++++++++++----- .../org/hisp/dhis/jsontree/JsonObjectTest.java | 11 +++++++++++ 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index c5671c7..f33df59 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -116,6 +116,7 @@ default Stream values() { * @since 0.11 */ default Stream> entries() { + //TODO use members internally to have names as entry key? return keys().map( key -> Map.entry( key, get( key ) ) ); } @@ -130,6 +131,15 @@ default List 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 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 diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index f5def39..2045678 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -402,6 +402,17 @@ default Iterable 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 paths() { + throw new JsonTreeException( getType() + " node has no paths property." ); + } + /** * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). *

diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java index 493efb2..1a4f256 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -4,8 +4,10 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; import static java.lang.Integer.parseInt; +import static java.util.Objects.requireNonNull; import static java.util.stream.Stream.concat; /** @@ -84,6 +86,10 @@ private static String keyOf( String name, boolean forceSegment ) { return forceSegment ? "." + name : name; } + public JsonPath { + requireNonNull( segments ); + } + /** * Extends this path on the right (end) * diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 0b84d16..540c7d5 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -41,7 +41,9 @@ 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; import java.util.function.Predicate; import java.util.stream.StreamSupport; @@ -360,17 +362,22 @@ public Entry next() { }; } + @Override + public Iterable paths() { + return keys(path::extendedWith); + } + @Override public Iterable names() { - return keys(false); + return keys(name -> name); } @Override public Iterable keys() { - return keys(true); + return keys(JsonPath::keyOf); } - private Iterable keys(boolean escape) { + private Iterable keys( Function toKey) { return () -> new Iterator<>() { private final char[] json = tree.json; private final Map nodesByPath = tree.nodesByPath; @@ -382,7 +389,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 ); @@ -395,7 +402,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 ); } }; } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java index 044a259..b5d33b3 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java @@ -59,6 +59,17 @@ 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 testProject() { //language=json From 821dc9d60e489ab3e2c1769e4229aee814e7bd5b Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 25 Jul 2024 21:03:35 +0200 Subject: [PATCH 2/4] chore: restart from upstream --- .../org/hisp/dhis/jsontree/JsonAbstractObject.java | 9 +++++---- .../java/org/hisp/dhis/jsontree/JsonObjectTest.java | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index f33df59..29e4cc5 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -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; @@ -111,13 +110,15 @@ default Stream 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> entries() { - //TODO use members internally to have names as entry key? - 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 ) ) ) ); } /** diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java index b5d33b3..429d6b2 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java @@ -70,6 +70,18 @@ void testPaths_Special() { 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 From b6e54f312a4e24980fbbe9275972919187dcf5ca Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 5 Aug 2024 18:25:20 +0200 Subject: [PATCH 3/4] feat: object properties as data --- .../hisp/dhis/jsontree/JsonAbstractArray.java | 2 +- .../dhis/jsontree/JsonAbstractCollection.java | 8 + .../dhis/jsontree/JsonAbstractObject.java | 2 +- .../org/hisp/dhis/jsontree/JsonMixed.java | 4 +- .../org/hisp/dhis/jsontree/JsonObject.java | 25 +++ .../java/org/hisp/dhis/jsontree/JsonPath.java | 5 - .../org/hisp/dhis/jsontree/JsonValue.java | 18 +- .../hisp/dhis/jsontree/JsonVirtualTree.java | 193 +++++++++++++----- .../jsontree/validation/JsonValidator.java | 7 +- .../jsontree/validation/ObjectValidation.java | 55 ++--- .../jsontree/validation/ObjectValidator.java | 11 +- .../org/hisp/dhis/jsontree/Assertions.java | 6 +- .../jsontree/JsonObjectPropertiesTest.java | 37 ++++ 13 files changed, 261 insertions(+), 112 deletions(-) create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java index 0dad091..2049683 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java @@ -121,7 +121,7 @@ default E first( Predicate test ) { * @throws IllegalArgumentException in case the given schema is not an interface * @since 1.0 */ - default void validateEach(Class> schema, Rule... rules) { + default void validateEach(Class schema, Rule... rules) { forEach( e -> JsonValidator.validate( e, schema, rules ) ); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java index d3cf9c6..34c369e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java @@ -29,6 +29,9 @@ import org.hisp.dhis.jsontree.internal.Surly; +import java.lang.reflect.Method; +import java.util.function.BiPredicate; + import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; import static org.hisp.dhis.jsontree.Validation.NodeType.OBJECT; @@ -176,6 +179,11 @@ public final V as( Class as ) { return viewed.as( as ); } + @Override + public V as( Class as, BiPredicate onCall ) { + return viewed.as( as, onCall ); + } + @Override public final String toString() { return viewed.toString(); diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index 29e4cc5..48d1a23 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -168,7 +168,7 @@ default void forEachValue( Consumer action ) { * @throws IllegalArgumentException in case the given schema is not an interface * @since 0.11 */ - default void validate( Class> schema, Rule... rules ) { + default void validate( Class schema, Rule... rules ) { JsonValidator.validate( this, schema, rules ); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java b/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java index f4c46ee..f6fcaa5 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonMixed.java @@ -1,5 +1,7 @@ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.internal.Surly; + import java.io.Reader; import java.nio.file.Path; @@ -57,7 +59,7 @@ static JsonMixed of( String json ) { * {@code null} default mapping is used * @return root of the virtual tree representing the given JSON input */ - static JsonMixed of( String json, JsonTypedAccessStore store ) { + static JsonMixed of( String json, @Surly JsonTypedAccessStore store ) { return new JsonVirtualTree( json, store ); } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java index 70b0886..67feb3e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java @@ -30,7 +30,10 @@ import org.hisp.dhis.jsontree.Validation.Rule; import org.hisp.dhis.jsontree.validation.JsonValidator; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.AnnotatedType; import java.util.Collection; +import java.util.List; import java.util.function.Function; import static java.util.Arrays.stream; @@ -48,6 +51,28 @@ @Validation.Ignore public interface JsonObject extends JsonAbstractObject { + /** + * An object property based on a default method declared in a type extending {@link JsonObject}. + * + * @param in the {@link JsonObject} type that declared the property + * @param name of the property + * @param type the type the property is resolved to internally when calling {@link #get(String, Class)} + * @param source the underlying method that declared the property + * @param sourceType the return type of the underlying method that declares the property + * + * @since 1.4 + */ + record Property(Class in, String name, Class type, + AnnotatedElement source, AnnotatedType sourceType) {} + + /** + * @return a model of this object in form its properties in no particular order + * @since 1.4 + */ + static List properties(Class of) { + return JsonVirtualTree.properties( of ); + } + /** * Access to object fields by name. *

diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java index 5b2da2d..842dad3 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPath.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPath.java @@ -4,7 +4,6 @@ import java.util.ArrayList; import java.util.List; -import java.util.Objects; import static java.lang.Integer.parseInt; import static java.util.Objects.requireNonNull; @@ -140,10 +139,6 @@ private static String curlyEscapeWithCheck( String name ) { requireNonNull( segments ); } - public JsonPath { - requireNonNull( segments ); - } - /** * Extends this path on the right (end) * diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index ef3826c..1ba03cd 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -31,6 +31,9 @@ import org.hisp.dhis.jsontree.internal.Surly; import java.io.Reader; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; +import java.lang.reflect.Type; import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -110,7 +113,7 @@ static JsonValue of( String json ) { * {@code null} default mapping is used * @return virtual JSON tree root {@link JsonValue} */ - static JsonValue of( String json, JsonTypedAccessStore store ) { + static JsonValue of( String json, @Surly JsonTypedAccessStore store ) { return json == null || "null".equals( json ) ? JsonVirtualTree.NULL : JsonMixed.of( json, store ); } @@ -241,6 +244,19 @@ default boolean isBoolean() { */ T as( Class as ); + /** + * Same as {@link #as(Class)} but with an additional parameter to pass a callback function. This allows to observe + * the API calls for meta-programming. This should not be used in "normal" API usage. + * + * @param as assumed value type for this value + * @param onCall a function that is called before the proxy handles an API call that allows to observe and bypass + * calls (predicate returns false) in which case the result is always {@code null} + * @param value type returned + * @return this object as the provided type, this might mean this object is wrapped as the provided type or + * @since 1.4 + */ + T as( Class as, BiPredicate onCall ); + /** * @return This value as {@link JsonObject} (same as {@code as(JsonObject.class)}) */ diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java index 0479bd5..759db60 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java @@ -39,11 +39,15 @@ import java.lang.reflect.Proxy; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.HashMap; import java.util.Iterator; 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.BiPredicate; import java.util.function.Function; import java.util.stream.Stream; @@ -71,27 +75,36 @@ */ final class JsonVirtualTree implements JsonMixed, Serializable { - public static final JsonMixed NULL = new JsonVirtualTree( JsonNode.NULL, "$", JsonTypedAccess.GLOBAL, null ); + public static final JsonMixed NULL = new JsonVirtualTree( JsonNode.NULL, "$", new Access( JsonTypedAccess.GLOBAL, null) ); + + private static final Map, List> PROPERTIES = new ConcurrentHashMap<>(); + + public static List properties(Class of) { + return PROPERTIES.computeIfAbsent( of, JsonVirtualTree::captureProperties ); + } + + /** + * 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. + */ + private record Access(@Surly JsonTypedAccessStore store, @Maybe ConcurrentMap cache) {} private final @Surly JsonNode root; private final @Surly String path; - private final transient @Surly JsonTypedAccessStore store; - private final transient @Maybe ConcurrentMap accessCache; + private final transient @Surly Access access; public JsonVirtualTree( @Maybe String json, @Surly JsonTypedAccessStore store ) { - this( json == null || json.isEmpty() ? JsonNode.EMPTY_OBJECT : JsonNode.of( json ), "$", store, null ); + this( json == null || json.isEmpty() ? JsonNode.EMPTY_OBJECT : JsonNode.of( json ), "$", new Access( store, null )); } public JsonVirtualTree( @Surly JsonNode root, @Surly JsonTypedAccessStore store ) { - this( root, "$", store, null ); + this( root, "$", new Access( store, null) ); } - private JsonVirtualTree( @Surly JsonNode root, @Surly String path, @Surly JsonTypedAccessStore store, - @Maybe ConcurrentMap accessCache ) { + private JsonVirtualTree( @Surly JsonNode root, @Surly String path, @Surly Access access ) { this.root = root; this.path = path; - this.store = store; - this.accessCache = accessCache; + this.access = access; } @Surly @Override @@ -101,17 +114,19 @@ public String path() { @Surly @Override public JsonTypedAccessStore getAccessStore() { - return store; + return access.store; } @Override public boolean isAccessCached() { - return accessCache != null; + return access.cache != null; } @Override public JsonVirtualTree withAccessCached() { - return isAccessCached() ? this : new JsonVirtualTree( root, path, store, new ConcurrentHashMap<>() ); + return isAccessCached() + ? this + : new JsonVirtualTree( root, path, new Access( access.store, new ConcurrentHashMap<>() ) ); } @Override @@ -143,15 +158,15 @@ private JsonNode value() { @Override public T get( int index, Class as ) { - return asType( as, new JsonVirtualTree( root, path + "[" + index + "]", store, accessCache ) ); + return asType( as, new JsonVirtualTree( root, path + "[" + index + "]", access ) ); } @Override public T get( String name, Class as ) { if ( name.isEmpty() ) return as( as ); boolean isQualified = name.startsWith( "{" ) || name.startsWith( "." ) || name.startsWith( "[" ); - String p = isQualified ? path + name : path + "." + name; - return asType( as, new JsonVirtualTree( root, p, store, accessCache ) ); + String canonicalPath = isQualified ? path + name : path + "." + name; + return asType( as, new JsonVirtualTree( root, canonicalPath, access) ); } @Override @@ -159,9 +174,20 @@ public T as( Class as ) { return asType( as, this ); } + @Override + @SuppressWarnings( "unchecked" ) + public T as( Class as, BiPredicate onCall ) { + return (T) Proxy.newProxyInstance( + Thread.currentThread().getContextClassLoader(), new Class[] { as }, + ( proxy, method, args ) -> { + if (!onCall.test( method, args )) return null; + return onInvoke( proxy, as, this, method, args, true ); + } ); + } + @SuppressWarnings( "unchecked" ) - private T asType( Class as, JsonVirtualTree res ) { - return isExtended( as ) ? createProxy( as, res ) : (T) res; + private T asType( Class as, JsonVirtualTree target ) { + return isJsonMixedSubType( as ) ? createProxy( as, target ) : (T) target; } @Override @@ -264,27 +290,40 @@ public String toString() { } @SuppressWarnings( "unchecked" ) - private E createProxy( Class as, JsonVirtualTree inner ) { + private E createProxy( Class as, JsonVirtualTree target ) { return (E) Proxy.newProxyInstance( Thread.currentThread().getContextClassLoader(), new Class[] { as }, - ( proxy, method, args ) -> { - // are we dealing with a default method in the extending class? - Class declaringClass = method.getDeclaringClass(); - if ( declaringClass == JsonValue.class && "asType".equals( method.getName() ) - && method.getParameterCount() == 0 ) { - return as; - } - if ( isExtended( declaringClass ) ) { - if ( method.isDefault() ) { - // call the default method of the proxied type itself - return callDefaultMethod( proxy, method, declaringClass, args ); - } - // abstract extending interface method? - return callAbstractMethod( inner, method, args ); - } - // call the same method on the wrapped object (assuming it has it) - return callCoreApiMethod( inner, method, args ); - } ); + ( proxy, method, args ) -> onInvoke( proxy, as, target, method, args, false ) ); + } + + /** + * @param proxy instance of the proxy the method was invoked upon + * @param as the type the proxy represents + * @param target the underlying tree that in the end holds the node against which a call potentially is resolved + * @param method the method of the proxy that was called + * @param args the arguments for the method + * @return the result of the method call + * @param type of the proxy + */ + private Object onInvoke( Object proxy, Class as, JsonVirtualTree target, Method method, + Object[] args, boolean alwaysCallDefault ) + throws Throwable { + // are we dealing with a default method in the extending class? + Class declaringClass = method.getDeclaringClass(); + if ( declaringClass == JsonValue.class && "asType".equals( method.getName() ) + && method.getParameterCount() == 0 ) { + return as; + } + if ( isJsonMixedSubType( declaringClass ) || method.isDefault() && alwaysCallDefault ) { + if ( method.isDefault() ) { + // call the default method of the proxied type itself + return callDefaultMethod( proxy, method, declaringClass, args ); + } + // abstract extending interface method? + return callAbstractMethod( target, method, args ); + } + // call the same method on the wrapped object (assuming it has it) + return callCoreApiMethod( target, method, args ); } /** @@ -304,28 +343,26 @@ private static Object callDefaultMethod( Object proxy, Method method, Class d * 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. */ - private Object callAbstractMethod( JsonVirtualTree inner, Method method, Object[] args ) { - JsonObject obj = inner.asObject(); + private Object callAbstractMethod( JsonVirtualTree target, Method method, Object[] args ) { + JsonObject obj = target.asObject(); Class resType = method.getReturnType(); String name = stripGetterPrefix( method ); boolean hasDefault = method.getParameterCount() == 1 && method.getParameterTypes()[0] == resType; if ( obj.get( name ).isUndefined() && hasDefault ) { return args[0]; } - if ( accessCache != null && isCacheable( resType ) ) { - String keyId = inner.path + "." + name + ":" + toSignature( method.getGenericReturnType() ); - return accessCache.computeIfAbsent( keyId, key -> access( method, obj, name ) ); + if ( access.cache != null && isCacheable( resType ) ) { + String keyId = target.path + "." + name + ":" + toSignature( method.getGenericReturnType() ); + return access.cache.computeIfAbsent( keyId, key -> access( method, obj, name ) ); } return access( method, obj, name ); } private Object access( Method method, JsonObject obj, String name ) { Type genericType = method.getGenericReturnType(); - JsonTypedAccessStore accessStore = store == null ? JsonTypedAccess.GLOBAL : store; - JsonGenericTypedAccessor accessor = accessStore.accessor( method.getReturnType() ); - if ( accessor != null ) { - return accessor.access( obj, name, genericType, accessStore ); - } + JsonGenericTypedAccessor accessor = access.store.accessor( method.getReturnType() ); + if ( accessor != null ) + return accessor.access( obj, name, genericType, access.store ); throw new UnsupportedOperationException( "No accessor registered for type: " + genericType ); } @@ -333,17 +370,17 @@ private Object access( Method method, JsonObject obj, String name ) { * 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 inner, Method method, Object[] args ) + private static Object callCoreApiMethod( JsonVirtualTree target, Method method, Object[] args ) throws Throwable { return args == null || args.length == 0 - ? MethodHandles.lookup().unreflect( method ).invokeWithArguments( inner ) - : MethodHandles.lookup().unreflect( method ).bindTo( inner ).invokeWithArguments( args ); + ? 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. *

- * For all other type this is about reaching a balance between memory usage and CPU usage. Simple objects are + * For all other types this is about reaching a balance between memory usage and CPU usage. Simple objects are * recomputed whereas complex objects are not. */ private boolean isCacheable( Class resType ) { @@ -353,7 +390,11 @@ private boolean isCacheable( Class resType ) { && !JsonPrimitive.class.isAssignableFrom( resType ); } - private static boolean isExtended( Class declaringClass ) { + /** + * Logically we check for JsonMixed but to be safe any method implemented by {@linkplain JsonVirtualTree} should be + * considered as "core" and be handled directly + */ + private static boolean isJsonMixedSubType( Class declaringClass ) { return !declaringClass.isAssignableFrom( JsonVirtualTree.class ); } @@ -396,4 +437,54 @@ private void toSignature( Type type, StringBuilder str ) { str.append( '?' ); } } + + private static List captureProperties(Class of) { + Map res = new TreeMap<>(); + propertyMethods(of).forEach( m -> { + @SuppressWarnings( "unchecked" ) + Class in = (Class) m.getDeclaringClass(); + JsonObject obj = JsonMixed.of( "{}" ).as( of, (method, args) -> { + if ( isJsonObjectGetAs( method ) ) { + String name = (String) args[0]; + @SuppressWarnings( "unchecked" ) + Class type = (Class) args[1]; + res.computeIfAbsent( name, n -> new Property( in, n, type, m, m.getAnnotatedReturnType() ) ); + return false; + } + return true; + }); + invokePropertyMethod( obj, m ); + } ); + return List.copyOf( res.values() ); + } + + private static boolean isJsonObjectGetAs( Method method ) { + return "get".equals( method.getName() ) + && method.getParameterCount() == 2 + && method.getDeclaringClass() == JsonObject.class; + } + + private static void invokePropertyMethod(JsonObject obj, Method property) { + try { + MethodHandles.lookup().unreflect( property ).invokeWithArguments( obj ); + } catch ( Throwable e ) { + // ignore + } + } + + private static Stream propertyMethods( Class of ) { + return Stream.of( of.getMethods() ) + .filter( JsonVirtualTree::isJsonObjectSubTypeProperty ); + } + + /** + * @return Only true for methods declared in interfaces extending {@link JsonObject}. All core API methods are + * excluded. + */ + private static boolean isJsonObjectSubTypeProperty(Method m) { + return m.isDefault() + && m.getParameterCount() == 0 + && isJsonMixedSubType( m.getDeclaringClass() ) + && m.getReturnType() != void.class; + } } diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java index 4dd6bda..7428234 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -3,6 +3,7 @@ import org.hisp.dhis.jsontree.JsonAbstractObject; import org.hisp.dhis.jsontree.JsonArray; import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonPathException; import org.hisp.dhis.jsontree.JsonSchemaException; import org.hisp.dhis.jsontree.JsonSchemaException.Info; @@ -22,15 +23,15 @@ */ public final class JsonValidator { - public static void validate( JsonValue value, Class> schema ) { + public static void validate( JsonValue value, Class schema ) { validate( value, schema, Set.of() ); } - public static void validate( JsonValue value, Class> schema, Validation.Rule... rules ) { + public static void validate( JsonValue value, Class schema, Validation.Rule... rules ) { validate( value, schema, Set.of( rules ) ); } - public static void validate( JsonValue value, Class> schema, Set rules ) { + public static void validate( JsonValue value, Class schema, Set rules ) { if (!schema.isInterface()) throw new IllegalArgumentException("Must be an interface bust was: "+schema); if ( !value.exists() ) diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java index b17fd9b..5acd620 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -1,8 +1,6 @@ package org.hisp.dhis.jsontree.validation; -import org.hisp.dhis.jsontree.JsonMixed; -import org.hisp.dhis.jsontree.JsonNode; -import org.hisp.dhis.jsontree.JsonPathException; +import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; @@ -15,11 +13,9 @@ import org.hisp.dhis.jsontree.validation.PropertyValidation.ValueValidation; import java.lang.annotation.Annotation; -import java.lang.invoke.MethodHandles; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.AnnotatedParameterizedType; import java.lang.reflect.AnnotatedType; -import java.lang.reflect.Method; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.math.BigInteger; @@ -36,7 +32,6 @@ import java.util.stream.Stream; import static java.lang.Double.isNaN; -import static java.lang.reflect.Modifier.isAbstract; import static java.util.Comparator.comparing; import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; import static org.hisp.dhis.jsontree.Validation.NodeType.BOOLEAN; @@ -59,7 +54,7 @@ record ObjectValidation( @Surly Map types, @Surly Map properties) { - private static final Map, ObjectValidation> INSTANCES = new ConcurrentHashMap<>(); + private static final Map, ObjectValidation> INSTANCES = new ConcurrentHashMap<>(); private static final Map, PropertyValidation> RECORD_BY_JAVA_TYPE = new ConcurrentSkipListMap<>( comparing( Class::getName ) ); @@ -78,52 +73,30 @@ record ObjectValidation( * string property) */ @Surly - public static ObjectValidation getInstance( Class schema ) { + public static ObjectValidation getInstance( Class schema ) { if ( !schema.isInterface() ) throw new IllegalArgumentException( "Schema must be an interface" ); return INSTANCES.computeIfAbsent( schema, ObjectValidation::createInstance ); } - private static ObjectValidation createInstance( Class schema ) { + private static ObjectValidation createInstance( Class schema ) { Map properties = new HashMap<>(); Map types = new HashMap<>(); - propertyMethods( schema ) - .forEach( m -> { - String property = captureProperty( m, schema ); - if ( property != null ) { - properties.put( property, fromMethod( m ) ); - types.put( property, m.getGenericReturnType() ); - } - } ); + JsonObject.properties( schema ).stream().filter( ObjectValidation::isNotIgnored ).forEach( p -> { + properties.put(p.name(), fromProperty( p )); + types.put( p.name(), p.sourceType().getType() ); + } ); return new ObjectValidation( schema, Map.copyOf( types ), Map.copyOf( properties ) ); } - private static String captureProperty( Method m, Class schema ) { - String[] box = new String[1]; - T value = JsonMixed.of( JsonNode.of( "{}", path -> box[0] = path.substring( 1 ) ) ).as( schema ); - try { - Object res = MethodHandles.lookup().unreflect( m ).invokeWithArguments( value ); - // for virtual nodes force lookup by resolving the underlying node in the actual tree - if ( res instanceof JsonValue node ) node.node(); - return box[0]; - } catch ( JsonPathException e ) { - return box[0]; - } catch ( Throwable ex ) { - return null; - } - } - - private static Stream propertyMethods( Class schema ) { - return Stream.of( schema.getMethods() ) - .filter( m -> m.getDeclaringClass().isInterface() && !m.getDeclaringClass() - .isAnnotationPresent( Validation.Ignore.class ) ) - .filter( m -> m.getReturnType() != void.class && !m.isAnnotationPresent( Validation.Ignore.class ) ) - .filter( m -> m.getParameterCount() == 0 && (m.isDefault()) || isAbstract( m.getModifiers() ) ); + private static boolean isNotIgnored( JsonObject.Property p ) { + return !p.source().isAnnotationPresent( Validation.Ignore.class ) + && !p.in().isAnnotationPresent( Validation.Ignore.class ); } @Maybe - private static PropertyValidation fromMethod( Method src ) { - PropertyValidation onMethod = fromAnnotations( src ); - PropertyValidation onReturnType = fromValueTypeUse( src.getAnnotatedReturnType() ); + private static PropertyValidation fromProperty( JsonObject.Property p ) { + PropertyValidation onMethod = fromAnnotations( p.source() ); + PropertyValidation onReturnType = fromValueTypeUse( p.sourceType() ); if ( onMethod == null ) return onReturnType; if ( onReturnType == null ) return onMethod; return onMethod.overlay( onReturnType ); diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java index 9944991..f0835dc 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -5,6 +5,7 @@ import org.hisp.dhis.jsontree.JsonMap; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonNodeType; +import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Validation.Error; import org.hisp.dhis.jsontree.Validation.NodeType; @@ -77,12 +78,12 @@ public void validate( JsonMixed value, Consumer addError ) { Comparator.comparing( Class::getName ) ); @Surly - public static ObjectValidator getInstance( Class schema ) { + public static ObjectValidator getInstance( Class schema ) { return getInstance( schema, new HashSet<>() ); } @Surly - private static ObjectValidator getInstance( Class schema, Set> currentlyResolved ) { + private static ObjectValidator getInstance( Class schema, Set> currentlyResolved ) { return getInstance( schema, () -> ObjectValidation.getInstance( schema ), currentlyResolved ); } @@ -123,9 +124,9 @@ private static ObjectValidator getInstance( Class schema, @Maybe private static Validator getInstance( java.lang.reflect.Type type, Set> currentlyResolved ) { if ( type instanceof Class cType ) { - if ( JsonAbstractObject.class.isAssignableFrom( cType ) ) { + if ( JsonObject.class.isAssignableFrom( cType ) ) { @SuppressWarnings( "unchecked" ) - Class schema = (Class) cType; + Class schema = (Class) cType; if ( currentlyResolved.contains( schema ) ) return new Lazy( schema, new AtomicReference<>() ); return getInstance( schema, currentlyResolved ); } @@ -658,7 +659,7 @@ private record DependentRequiredCodependent(Map of, AtomicReference instance) implements Validator { + private record Lazy(Class of, AtomicReference instance) implements Validator { @Override public void validate( JsonMixed value, Consumer addError ) { diff --git a/src/test/java/org/hisp/dhis/jsontree/Assertions.java b/src/test/java/org/hisp/dhis/jsontree/Assertions.java index 6089f4d..b0d9163 100644 --- a/src/test/java/org/hisp/dhis/jsontree/Assertions.java +++ b/src/test/java/org/hisp/dhis/jsontree/Assertions.java @@ -13,13 +13,13 @@ public class Assertions { public static Validation.Error assertValidationError( String actualJson, - Class> schema, + Class schema, Validation.Rule expected, Object... args ) { return assertValidationError( JsonMixed.of( actualJson ), schema, expected, args ); } - public static Validation.Error assertValidationError( JsonAbstractObject actual, - Class> schema, + public static Validation.Error assertValidationError( JsonObject actual, + Class schema, Validation.Rule expected, Object... args ) { JsonSchemaException ex = assertThrowsExactly( JsonSchemaException.class, () -> actual.validate( schema ), "expected an error of type " + expected ); diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java new file mode 100644 index 0000000..ee7d247 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java @@ -0,0 +1,37 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.JsonObject.Property; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the extraction of properties provided by {@link JsonObject#properties(Class)} + */ +class JsonObjectPropertiesTest { + + public interface User extends JsonObject { + + @Required + default String name() { + return getString( "name" ).string(); + } + } + + @Test + void test() { + List properties = JsonObject.properties( User.class ); + assertEquals( 1, properties.size()); + Property name = properties.get( 0 ); + assertProperty( "name", JsonString.class, name ); + assertTrue( name.source().isAnnotationPresent( Required.class ) ); + } + + private static void assertProperty(String name, Class type, Property actual) { + assertEquals( name, actual.name() ); + assertEquals( type, actual.type() ); + } +} From 2e4fd41e9b4b33c19dd0bc178fd576c9cfb2ae47 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Tue, 6 Aug 2024 13:39:46 +0200 Subject: [PATCH 4/4] refactor: better names and java vs JSON capturing --- .../dhis/jsontree/JsonAbstractCollection.java | 3 +- .../org/hisp/dhis/jsontree/JsonObject.java | 20 +++-- .../org/hisp/dhis/jsontree/JsonValue.java | 9 ++- .../hisp/dhis/jsontree/JsonVirtualTree.java | 16 ++-- .../jsontree/validation/ObjectValidation.java | 6 +- .../validation/PropertyValidation.java | 3 - .../jsontree/JsonObjectPropertiesTest.java | 76 ++++++++++++++++--- 7 files changed, 94 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java index 34c369e..58e4a85 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractCollection.java @@ -30,6 +30,7 @@ import org.hisp.dhis.jsontree.internal.Surly; import java.lang.reflect.Method; +import java.util.function.BiConsumer; import java.util.function.BiPredicate; import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; @@ -180,7 +181,7 @@ public final V as( Class as ) { } @Override - public V as( Class as, BiPredicate onCall ) { + public V as( Class as, BiConsumer onCall ) { return viewed.as( as, onCall ); } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java index 67feb3e..1bc0cfc 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java @@ -54,18 +54,22 @@ public interface JsonObject extends JsonAbstractObject { /** * An object property based on a default method declared in a type extending {@link JsonObject}. * - * @param in the {@link JsonObject} type that declared the property - * @param name of the property - * @param type the type the property is resolved to internally when calling {@link #get(String, Class)} - * @param source the underlying method that declared the property - * @param sourceType the return type of the underlying method that declares the property - * + * @param in the {@link JsonObject} type that declared the property + * @param jsonName of the property + * @param jsonType the type the property is resolved to internally when calling {@link #get(String, Class)} + * @param javaName the name of the java property accessed that caused the JSON property to be resolved + * @param javaType the return type of the underlying method that declares the property + * @param source the underlying method that declared the property * @since 1.4 */ - record Property(Class in, String name, Class type, - AnnotatedElement source, AnnotatedType sourceType) {} + record Property(Class in, String jsonName, Class jsonType, + String javaName, AnnotatedType javaType, AnnotatedElement source) {} /** + * Note that there can be more than one property with the same {@link Property#javaName()} in case the method it + * reflects accesses more than one member from the JSON object. In such a case each access is a property of the + * accessed {@link Property#jsonName()} with the same {@link Property#javaName()}. + * * @return a model of this object in form its properties in no particular order * @since 1.4 */ diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java index 1ba03cd..7d542eb 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonValue.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonValue.java @@ -38,6 +38,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.BiConsumer; import java.util.function.BiPredicate; import java.util.function.Function; import java.util.function.Predicate; @@ -247,15 +248,17 @@ default boolean isBoolean() { /** * Same as {@link #as(Class)} but with an additional parameter to pass a callback function. This allows to observe * the API calls for meta-programming. This should not be used in "normal" API usage. + *

+ * Not all methods can be observed as some are handled internally without ever going via the proxy. However, in + * contrast to {@link #as(Class)} when using this method any call of a default method is handled via proxy. * * @param as assumed value type for this value - * @param onCall a function that is called before the proxy handles an API call that allows to observe and bypass - * calls (predicate returns false) in which case the result is always {@code null} + * @param onCall a function that is called before the proxy handles an API call that allows to observe calls * @param value type returned * @return this object as the provided type, this might mean this object is wrapped as the provided type or * @since 1.4 */ - T as( Class as, BiPredicate onCall ); + T as( Class as, BiConsumer onCall ); /** * @return This value as {@link JsonObject} (same as {@code as(JsonObject.class)}) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java index 759db60..970e4db 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java @@ -39,7 +39,6 @@ import java.lang.reflect.Proxy; import java.lang.reflect.Type; import java.util.ArrayList; -import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -47,6 +46,7 @@ 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; @@ -176,11 +176,11 @@ public T as( Class as ) { @Override @SuppressWarnings( "unchecked" ) - public T as( Class as, BiPredicate onCall ) { + public T as( Class as, BiConsumer onCall ) { return (T) Proxy.newProxyInstance( Thread.currentThread().getContextClassLoader(), new Class[] { as }, ( proxy, method, args ) -> { - if (!onCall.test( method, args )) return null; + onCall.accept( method, args ); return onInvoke( proxy, as, this, method, args, true ); } ); } @@ -439,7 +439,7 @@ private void toSignature( Type type, StringBuilder str ) { } private static List captureProperties(Class of) { - Map res = new TreeMap<>(); + List res = new ArrayList<>(); propertyMethods(of).forEach( m -> { @SuppressWarnings( "unchecked" ) Class in = (Class) m.getDeclaringClass(); @@ -448,14 +448,12 @@ private static List captureProperties(Class of) String name = (String) args[0]; @SuppressWarnings( "unchecked" ) Class type = (Class) args[1]; - res.computeIfAbsent( name, n -> new Property( in, n, type, m, m.getAnnotatedReturnType() ) ); - return false; + res.add( new Property( in, name, type, m.getName(), m.getAnnotatedReturnType(), m ) ); } - return true; }); - invokePropertyMethod( obj, m ); + invokePropertyMethod( obj, m ); // may add zero, one or more properties via the callback } ); - return List.copyOf( res.values() ); + return List.copyOf( res ); } private static boolean isJsonObjectGetAs( Method method ) { diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java index 5acd620..9ee94a4 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -82,8 +82,8 @@ private static ObjectValidation createInstance( Class sche Map properties = new HashMap<>(); Map types = new HashMap<>(); JsonObject.properties( schema ).stream().filter( ObjectValidation::isNotIgnored ).forEach( p -> { - properties.put(p.name(), fromProperty( p )); - types.put( p.name(), p.sourceType().getType() ); + properties.put(p.jsonName(), fromProperty( p )); + types.put( p.jsonName(), p.javaType().getType() ); } ); return new ObjectValidation( schema, Map.copyOf( types ), Map.copyOf( properties ) ); } @@ -96,7 +96,7 @@ private static boolean isNotIgnored( JsonObject.Property p ) { @Maybe private static PropertyValidation fromProperty( JsonObject.Property p ) { PropertyValidation onMethod = fromAnnotations( p.source() ); - PropertyValidation onReturnType = fromValueTypeUse( p.sourceType() ); + PropertyValidation onReturnType = fromValueTypeUse( p.javaType() ); if ( onMethod == null ) return onReturnType; if ( onReturnType == null ) return onMethod; return onMethod.overlay( onReturnType ); diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java index b6b81fc..ca3703a 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java @@ -34,9 +34,6 @@ record PropertyValidation( @Maybe ArrayValidation arrays, @Maybe ObjectValidation objects, @Maybe PropertyValidation items - //TODO maybe add a Map>> origin, - // which remembers where (annotation or validators) a validation originates from - // but this is difficult to keep accurate with the overlay ) { /** diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java index ee7d247..938444a 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java @@ -3,35 +3,87 @@ import org.hisp.dhis.jsontree.JsonObject.Property; import org.junit.jupiter.api.Test; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedType; import java.util.List; +import java.util.Set; +import static java.util.stream.Collectors.toSet; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; /** - * Tests the extraction of properties provided by {@link JsonObject#properties(Class)} + * Tests the extraction of properties provided by {@link JsonObject#properties(Class)}. + *

+ * The coverage here is very shallow because the feature is used as part of the validation which has plenty of coverage + * for different types, annotations and so on. + * + * @author Jan Bernitt */ class JsonObjectPropertiesTest { + private static final ClassType STRING = new ClassType( String.class ); + public interface User extends JsonObject { - @Required + default String username() { + return getString( "username" ).string(); + } + default String name() { - return getString( "name" ).string(); + return getString( "firstName" ).string() + " " + getString( "lastName" ).string(); } + } @Test - void test() { + void testString() { List properties = JsonObject.properties( User.class ); - assertEquals( 1, properties.size()); - Property name = properties.get( 0 ); - assertProperty( "name", JsonString.class, name ); - assertTrue( name.source().isAnnotationPresent( Required.class ) ); + Property expected = new Property( User.class, "username", JsonString.class, "username", + STRING, null ); + assertPropertyExists( "username", expected, properties ); + } + + @Test + void testString_Multiple() { + List properties = JsonObject.properties( User.class ); + assertEquals( 3, properties.size() ); + assertEquals( Set.of( "username", "firstName", "lastName" ), + properties.stream().map( Property::jsonName ).collect( toSet() ) ); + assertEquals( Set.of( "username", "name" ), properties.stream().map( Property::javaName ).collect( toSet() ) ); + + assertPropertyExists( "firstName", + new Property( User.class, "firstName", JsonString.class, "name", STRING, null ), + properties ); + assertPropertyExists( "lastName", + new Property( User.class, "lastName", JsonString.class, "name", STRING, null ), + properties ); } - private static void assertProperty(String name, Class type, Property actual) { - assertEquals( name, actual.name() ); - assertEquals( type, actual.type() ); + private void assertPropertyExists( String jsonName, Property expected, List actual ) { + Property prop = actual.stream().filter( p -> p.jsonName().equals( jsonName ) ).findFirst() + .orElse( null ); + assertNotNull( prop ); + assertSame( expected.in(), prop.in() ); + assertEquals( expected.jsonName(), prop.jsonName() ); + assertSame( expected.jsonType(), prop.jsonType() ); + assertEquals( expected.javaName(), prop.javaName() ); + assertSame( expected.javaType().getType(), prop.javaType().getType() ); + } + + record ClassType(Class getType) implements AnnotatedType { + + @Override public T getAnnotation( Class aClass ) { + return null; + } + + @Override public Annotation[] getAnnotations() { + return new Annotation[0]; + } + + @Override public Annotation[] getDeclaredAnnotations() { + return new Annotation[0]; + } } }