From f361cc7ba10ff2edc1324e5b2ccd5c4532de6b23 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 26 Mar 2026 12:08:45 +0100 Subject: [PATCH 01/20] chore: add Javadoc to JsonSelectable API --- .../hisp/dhis/jsontree/JsonSelectable.java | 59 +++++++++++++++++-- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonSelectable.java b/src/main/java/org/hisp/dhis/jsontree/JsonSelectable.java index b4853f6..dd01986 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonSelectable.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonSelectable.java @@ -11,9 +11,10 @@ import org.hisp.dhis.jsontree.JsonSelector.Matches; /** - * The API in {@link JsonNode} that is about selecting {@link JsonNode}s based on {@link JsonSelector}s. - *

- * Just extracted from main {@link JsonNode} interface to group and organize the code a bit. + * The API in {@link JsonNode} that is about selecting {@link JsonNode}s based on {@link + * JsonSelector}s. + * + *

Just extracted from main {@link JsonNode} interface to group and organize the code a bit. * * @since 1.9 */ @@ -31,10 +32,22 @@ public interface JsonSelectable { */ void query(JsonSelector selector, Matches matches); + /** + * @implNote JDK {@link Stream}s are convenient but always prefer one of the other methods for + * better performance + * @return Queries the entire (sub)tree and return a stream of matches + */ default Stream query(JsonSelector selector) { return query(selector, Integer.MAX_VALUE); } + /** + * @implNote JDK {@link Stream}s are convenient but always prefer one of the other methods for + * better performance + * @param limit maximum number of matches before ending the search + * @return Queries the entire (sub)tree and return a stream of matches up to the given limit of + * matches + */ default Stream query(JsonSelector selector, int limit) { final class Streamer implements Matches { @@ -57,12 +70,21 @@ public void accept(T match) { return res.stream.build(); } + /** + * @return Queries the entire (sub)tree and return the number of matches found + */ default int queryCount(JsonSelector selector) { return queryCount(selector, Integer.MAX_VALUE); } + /** + * @param limit maximum number of matches before ending the search + * @return Queries the entire (sub)tree and return the number of matches up to the given limit of + * matches + */ default int queryCount(JsonSelector selector, int limit) { - final class Counter implements Matches { int count; + final class Counter implements Matches { + int count; @Override public boolean satisfied() { @@ -79,19 +101,40 @@ public void accept(T match) { return c.count; } + /** + * @return Queries the entire (sub)tree and ends the search returning true, as soon as at least + * one match is found + */ default boolean queryExists(JsonSelector selector) { return queryCount(selector, 1) > 0; } + /** + * @return Queries the entire (sub)tree and ends the search returning the first match, as soon as + * it is found + */ default Optional queryFirst(JsonSelector selector) { return query(selector, 1).findFirst(); } + /** + * Same as {@link #queryReduce(JsonSelector, int, Function, Object, BinaryOperator)} but without + * limit so all matches will be aggregated + */ default V queryReduce( JsonSelector selector, Function extract, V init, BinaryOperator reduce) { return queryReduce(selector, Integer.MAX_VALUE, extract, init, reduce); } + /** + * @param limit maximum number of matches before ending the search + * @param extract a function to extract the value from a node that is aggregated (reduced) + * @param init the initial value of the aggregation + * @param reduce the reduction (aggregation) function used to combine values extracted from + * matches (right hand side operator) with the aggregation so far (left hand side operator) + * @return Queries the entire (sub)tree and aggregates the values extracted from matches to a + * single aggregate value (up to the given limit of matches) matches + */ default V queryReduce( JsonSelector selector, int limit, Function extract, V init, BinaryOperator reduce) { final class Reducer implements Matches { @@ -114,9 +157,15 @@ public void accept(T match) { return res.value; } + /** + * @param n number of highest scoring matches to return + * @param score scoring function (higher is better/kept) + * @return the best scoring matches (up to given limit of n) in order from the highest score to + * the lowest score + */ default Stream queryTop(int n, JsonSelector selector, ToIntFunction score) { record Score(R value, int score) {} - final PriorityQueue> heap = new PriorityQueue<>(n+1, comparingInt(Score::score)); + final PriorityQueue> heap = new PriorityQueue<>(n + 1, comparingInt(Score::score)); query( selector, match -> { From 91b9438a7cae33334f6eb4bbeca320f01052714a Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 26 Mar 2026 14:18:38 +0100 Subject: [PATCH 02/20] feat: schema validation modes --- .../hisp/dhis/jsontree/JsonAbstractArray.java | 13 ++++- .../dhis/jsontree/JsonAbstractObject.java | 17 +++++- .../org/hisp/dhis/jsontree/JsonObject.java | 15 +++-- .../dhis/jsontree/JsonSchemaException.java | 14 ++--- .../org/hisp/dhis/jsontree/Validation.java | 39 ++++++++++++- .../jsontree/validation/JsonValidator.java | 56 +++++++++++++------ .../org/hisp/dhis/jsontree/Assertions.java | 2 +- .../JsonValidationRequiredTest.java | 4 +- 8 files changed, 117 insertions(+), 43 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java index 2d9247c..493eb95 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractArray.java @@ -128,13 +128,22 @@ default E first(Predicate test) { /** * @param schema the schema to validate all elements of this array against + * @param mode check, fail fast or give a breakdown of all issues? * @param rules optional set of {@link Rule}s to check, empty includes all + * @return A stream with errors for each element of this array if {@link Validation.Mode} is + * probing * @throws JsonSchemaException in case this value does not match the given schema * @throws UnsupportedOperationException in case the given schema is not an interface * @since 1.0 */ @TerminalOp(mustBeArray = true) - default void validateEach(Class schema, Rule... rules) { - forEach(e -> JsonValidator.validate(e, schema, rules)); + default Stream validateEach( + Class schema, Validation.Mode mode, Rule... rules) { + if (mode == Validation.Mode.PROBE) { + return stream().map(e -> JsonValidator.validate(e, schema, mode, rules)); + } else { + forEach(e -> JsonValidator.validate(e, schema, mode, rules)); + return Stream.empty(); + } } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index 0025f56..12e1c25 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -197,15 +197,26 @@ default void forEachValue(Consumer action) { keys().forEach(name -> action.accept(get(name))); } + /** + * @see #validate(Class, Validation.Mode, Rule...) + * @since 0.11 + **/ + default void validate(Class schema, Rule... rules) { + validate(schema, Validation.Mode.FAIL_ALL, rules); + } + /** * @param schema the schema to validate against + * @param mode check, fail fast or give a breakdown of all issues? * @param rules optional set of {@link Rule}s to check, empty includes all + * @return Validation result if {@link Validation.Mode} is probing (or if + * there were no errors) * @throws JsonSchemaException in case this value does not match the given schema * @throws IllegalArgumentException in case the given schema is not an interface - * @since 0.11 + * @since 1.9 */ @TerminalOp(canBeUndefined = true, mustBeObject = true) - default void validate(Class schema, Rule... rules) { - JsonValidator.validate(this, schema, rules); + default Validation.Result validate(Class schema, Validation.Mode mode, Rule... rules) { + return JsonValidator.validate(this, schema, mode, rules); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java index 58a45a8..09ed6c9 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java @@ -180,19 +180,18 @@ default Stream entries(JsonNode.Index index) { /** * Uses the JSON schema validation to check whether this object conforms to the provided type * - * @param schema a subtype of {@link JsonObject} or {@link Record} to check agsinst + * @param schema a subtype of {@link JsonObject} or {@link Record} to check against * @param rules optional set of {@link Rule}s to check, empty includes all * @return true if this is an object is valid against the provided schema * @since 0.11 (in this form with rules parameter) */ @TerminalOp default boolean isA(Class schema, Rule... rules) { - try { - JsonValidator.validate(this, schema, rules); - return true; - } catch (JsonPathException | JsonTreeException | JsonSchemaException ex) { - return false; - } + if (!exists() || !isObject()) return false; + // Note that this uses PROBE_ALL as that avoid throwing exceptions entirely + Validation.Result result = + JsonValidator.validate(this, schema, Validation.Mode.PROBE_ALL, rules); + return result.errors().isEmpty(); } /** @@ -212,7 +211,7 @@ default boolean isA(Class schema, Rule... rules) { default T asA(Class schema, Rule... rules) throws JsonPathException, JsonTreeException, JsonSchemaException { T obj = as(schema); - JsonValidator.validate(obj, schema, rules); + JsonValidator.validate(obj, schema, Validation.Mode.FAIL, rules); return obj; } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonSchemaException.java b/src/main/java/org/hisp/dhis/jsontree/JsonSchemaException.java index 274054a..e60f372 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonSchemaException.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonSchemaException.java @@ -7,17 +7,15 @@ /** Thrown when an JSON input does not match its JSON schema description. */ public final class JsonSchemaException extends IllegalArgumentException { - private final transient Info info; + private final transient Validation.Result result; - public record Info(JsonValue value, Class schema, List errors) {} - - public JsonSchemaException(String message, Info info) { - super(message + toString(info.errors())); - this.info = info; + public JsonSchemaException(String message, Validation.Result result) { + super(message + toString(result.errors())); + this.result = result; } - public Info getInfo() { - return info; + public Validation.Result getResult() { + return result; } private static String toString(List errors) { diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 3587220..2267a71 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -1,11 +1,11 @@ package org.hisp.dhis.jsontree; -import java.io.Serializable; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.List; +import java.util.Set; import java.util.function.Consumer; import org.hisp.dhis.jsontree.internal.CheckNull; import org.hisp.dhis.jsontree.internal.NotNull; @@ -57,6 +57,30 @@ @Retention(RetentionPolicy.RUNTIME) public @interface Validation { + /** + * @since 1.9 + */ + enum Mode { + /** + * Return the {@link Result} (fail fast) but don't throw + */ + PROBE, + + PROBE_ALL, + /** + * Throw on first error found + */ + FAIL, + /** + * Find all errors and then throw + */ + FAIL_ALL; + + public boolean isFailFast() { + return this == PROBE || this == FAIL; + } + } + enum YesNo { NO, YES, @@ -145,8 +169,16 @@ interface Validator { void validate(JsonMixed value, Consumer addError); } - record Error(Rule rule, JsonPath path, JsonValue value, String template, List args) - implements Serializable { + /** + * @param value the value that was validated + * @param schema the schema validated against + * @param rules the rules included in the validation + * @param errors list of errors (if any were found) + * @since 1.9 + */ + record Result(JsonValue value, Class schema, Set rules, List errors) {} + + record Error(Rule rule, JsonPath path, JsonValue value, String template, List args) { public static Error of(Rule rule, JsonValue value, String template, Object... args) { return new Error(rule, value.path(), value, template, List.of(args)); @@ -423,4 +455,5 @@ public String toString() { * @since 1.1 */ YesNo acceptNull() default YesNo.AUTO; + } 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 de058b8..f390560 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -1,14 +1,17 @@ package org.hisp.dhis.jsontree.validation; import java.util.ArrayList; +import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; + import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonPath; import org.hisp.dhis.jsontree.JsonPathException; import org.hisp.dhis.jsontree.JsonSchemaException; -import org.hisp.dhis.jsontree.JsonSchemaException.Info; +import org.hisp.dhis.jsontree.Validation.Result; import org.hisp.dhis.jsontree.JsonTreeException; import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Validation; @@ -20,15 +23,13 @@ */ public final class JsonValidator { - public static void validate(JsonValue value, Class schema) { - validate(value, schema, Set.of()); - } - - public static void validate(JsonValue value, Class schema, Validation.Rule... rules) { - validate(value, schema, Set.of(rules)); + public static Result validate(JsonValue value, Class schema, Validation.Mode mode, Validation.Rule[] rules) { + Set set = + rules.length == 0 ? EnumSet.allOf(Validation.Rule.class) : EnumSet.of(rules[0], rules); + return validate(value, schema, mode, set); } - public static void validate(JsonValue value, Class schema, Set rules) { + private static Result validate(JsonValue value, Class schema, Validation.Mode mode, Set rules) { if (!value.exists()) throw new JsonPathException( value.path(), @@ -42,21 +43,44 @@ public static void validate(JsonValue value, Class schema, Set errors = new ArrayList<>(); + boolean failFast = mode.isFailFast(); + List errors = failFast ? List.of() : new ArrayList<>(); + Consumer addError = + error -> { + if (rules.contains(error.rule())) { + if (!failFast) { + errors.add(error); + } else + throw new JsonSchemaException( + "fail fast", new Result(value, schema, rules, List.of(error))); + } + }; - // TODO add a fail-fast (1st error) mode // TODO strict types vs convertable types mode + if (mode == Validation.Mode.PROBE) { + try { + validate(value, validator, addError); + return new Result(value, schema, rules, List.of()); + } catch (JsonSchemaException ex) { + return ex.getResult(); + } + } else { + validate(value, validator, addError); + Result result = new Result(value, schema, rules, errors); + if (mode != Validation.Mode.PROBE_ALL) + if (!errors.isEmpty()) + throw new JsonSchemaException("%d errors".formatted(errors.size()), result); + return result; + } + } + private static void validate(JsonValue value, ObjectValidator validator, Consumer addError) { for (Map.Entry e : validator.properties().entrySet()) { JsonMixed property = value.asObject().get(e.getKey(), JsonMixed.class); - e.getValue().validate(property, errors::add); + e.getValue().validate(property, addError); } - if (!rules.isEmpty()) errors = errors.stream().filter(e -> rules.contains(e.rule())).toList(); - if (!errors.isEmpty()) - throw new JsonSchemaException( - "%d errors".formatted(errors.size()), new Info(value, schema, errors)); } // TODO a publicly accessible way to get the JSON Schema validation description (JSON) for a diff --git a/src/test/java/org/hisp/dhis/jsontree/Assertions.java b/src/test/java/org/hisp/dhis/jsontree/Assertions.java index 33e98bb..667a8a9 100644 --- a/src/test/java/org/hisp/dhis/jsontree/Assertions.java +++ b/src/test/java/org/hisp/dhis/jsontree/Assertions.java @@ -22,7 +22,7 @@ public static Validation.Error assertValidationError( JsonSchemaException.class, () -> actual.validate(schema), "expected an error of type " + expected); - List errors = ex.getInfo().errors(); + List errors = ex.getResult().errors(); assertEquals( 1, errors.size(), diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java index 8279bf0..05379bb 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java @@ -46,6 +46,7 @@ import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Rule; +import org.hisp.dhis.jsontree.internal.Language; import org.junit.jupiter.api.Test; /** @@ -102,7 +103,7 @@ default JsonMixed value() { void testIsA() { assertTrue(Json5.of("{'bar':'x'}").isA(JsonFoo.class)); JsonMixed val = Json5.of("{'key':'x', 'value': 1}"); - JsonEntry e = val.as(JsonEntry.class); + assertDoesNotThrow(() -> val.as(JsonEntry.class)); assertTrue(val.isA(JsonEntry.class)); JsonMixed both = Json5.of("{'key':'x', 'value': 1, 'bar':'y'}"); assertTrue(both.isA(JsonFoo.class)); @@ -211,7 +212,6 @@ void testAsObject_NotAnObjectRecursive() { String json = """ {"a": [], "b":{"bar":""}}"""; - JsonMixed obj = JsonMixed.of(json); assertValidationError(json, JsonRoot.class, Rule.TYPE, Set.of(NodeType.OBJECT), NodeType.ARRAY); } From d7dd4507bc60064766b9927a13bae9be23b8ded1 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 30 Mar 2026 12:02:20 +0200 Subject: [PATCH 03/20] feat: input expressions FTW --- INPUT_EXPRESSIONS.md | 290 +++++++++++ .../hisp/dhis/jsontree/InputExpression.java | 450 ++++++++++++++++++ .../org/hisp/dhis/jsontree/JsonAccess.java | 2 +- .../java/org/hisp/dhis/jsontree/JsonNode.java | 24 +- .../org/hisp/dhis/jsontree/JsonSelector.java | 10 +- .../java/org/hisp/dhis/jsontree/JsonTree.java | 6 +- .../java/org/hisp/dhis/jsontree/Text.java | 13 + .../org/hisp/dhis/jsontree/TextBuilder.java | 61 ++- .../org/hisp/dhis/jsontree/Validation.java | 6 + .../jsontree/validation/JsonValidator.java | 1 - .../dhis/jsontree/InputExpressionTest.java | 164 +++++++ 11 files changed, 1001 insertions(+), 26 deletions(-) create mode 100644 INPUT_EXPRESSIONS.md create mode 100644 src/main/java/org/hisp/dhis/jsontree/InputExpression.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md new file mode 100644 index 0000000..dadb3f4 --- /dev/null +++ b/INPUT_EXPRESSIONS.md @@ -0,0 +1,290 @@ +# Input Expressions (InpEx) + +An Input Expression is a pattern matching expression language +designed for checking short, "simple" values such as +numbers, identifiers, dates, codes or URLs. + +The language is **linear‑time**, strict left ro right matching +and straight forward to implement **allocation‑free**. +It focuses on readability, simplicity, predictability and safety. + +Input expression always look for "what you want" (whitelist-y), and so it has + +- no OR +- no "not in" character sets +- no backtracking + +--- + +## Overview + +A pattern is a sequence of *units*. Each unit is either: + +- a **literal character** (except special characters `# @ [ ] | ? * + ~ ( )`), +- a **character set** (mnemonic like `#` for digit, `@` for name‑character, etc.), +- a **set of character sets** `[...]` (to match 1 input character), +- a **sequence of sets** `|...|` (to match 1 input character for each set in the sequence), +- a **repeat** applied to a unit (e.g., `?{unit}`, `+{unit}`, `3{unit}`), +- a **scan** (non‑greedy skip) `~{unit}` or `~~{unit1}{unit2}`, +- a **group** `(...)` used to create a compound unit. + +Everything else is a literal character. + +Most notably this includes (almost) all punctuation marks which is handy, +as these tend to occur in the type of values this wants to match. + +Note that the lack of an OR construct is mitigated in the implementation `Pattern` +by considering a list of multiple patterns to be alternative formats for the value. +For example, a floating point number can be difficult with optional digits before +and after decimal point. This simply becomes 2 patterns for each case. + +--- + +## Syntax Summary + +| Syntax | Type | Description | +|---------------------|----------------|----------------------------------------------------------------------------------------------| +| `d` | _set_ | any digit (`0`‑`9`) (alias) | +| `u` | _set_ | any uppercase letter (`A`‑`Z`) | +| `l` | _set_ | any lowercase letter (`a`‑`z`) | +| `c` | _set_ | any letter (`A`‑`Z`, `a`‑`z`) | +| `x` | _set_ | any hexadecimal digit (`A`‑`F`, `a`‑`f`, `0`‑`9`) | +| `a` | _set_ | any alphanumeric (`A`‑`Z`, `a`‑`z`, `0`‑`9`) | +| `s` | _set_ | sign (`+` or `-`) | +| `#` | _set_*, _unit_ | any digit (`0`‑`9`) | +| `@` | _set_*, _unit_ | any *name* character (`A`‑`Z`, `a`‑`z`, `0`‑`9`, `_`, `-`) | +| `[{set1}{set2}…]` | _unit_ | matches a single character that belongs to any of the listed sets | +| `\|{set1}{set2}…\|` | _unit_ | matches a sequence where each character belongs to the corresponding set (length must match) | +| `?{unit}` | _unit_ | _unit_ occurs 0 or 1 time (optional) | +| `*{unit}` | _unit_ | _unit_ occurs 0 or more times (greedy) | +| `+{unit}` | _unit_ | _unit_ occurs 1 or more times (greedy) | +| `9{unit}` | _unit_ | _unit_ occurs exactly n times (n = 1‑9, 9 in this example) | +| `9?{unit}` | _unit_ | _unit_ occurs 0‑n times (n = 1‑9, 9 in this example) | +| `~{unit}` | _unit_ | skip forward (non‑greedy) until the _unit_ matches (the _unit_ is consumed) | +| `~~{unit1}{unit2}` | _unit_ | repeat _unit1_ zero or more times, then _unit2_ (non‑greedy) – “repeat until” | +| `(…)` | _unit_ | groups a sequence of units so that repeats/scans apply to the whole group | +| `` | _unit_ | any other character matches itself literally | + +`#` and `@` have their set semantics in `|…|` but are literally in `[…]`. + +--- + +## Building Blocks + +### Literals +Any character that is **not** a special symbol matches itself exactly. +Special symbols are: `# @ [ ] | ? * + ~ ( )` and digits when they appear as repeat counts. + +**"Escaping"** + +There is no escaping syntax, but most of the special characters can be matched literally, +by placing them inside a `|…|` or `[…]`. + +Naturally `|` must be "escaped" in `[|]` and `[` and `]` in `|[|`/ `|]|`. +`#` and `@` are only _sets_ in `|…|` so they must be "escaped" in `[#]` / `[@]` + +--- + +### Character Sets + +//TODO for each environment make a table for character (ranges) and what they mean instead + +Input expressions build on pre-defined named character sets. + +| Code | Set Description | Characters | +|------|------------------------------------------------------------|----------------------------------| +| `s` | sign | `+` `-` | +| `b` | binary digit | `0`,`1` | +| `d` | digit | `0`‑`9` | +| `u` | uppercase letter | `A`‑`Z` | +| `l` | lowercase letter | `a`‑`z` | +| `c` | letter (upper or lower) | `A`‑`Z` `a`‑`z` | +| `a` | alphanumeric (letter or digit) | `A`‑`Z` `a`‑`z` `0`‑`9` | +| `x` | hexadecimal digit | `0`‑`9` `A`‑`F` `a`‑`f` | +| `i` | identifier (letter, digit, underscore, hyphen) | `A`‑`Z` `a`‑`z` `0`‑`9` `_` `-` | +| `#` | digit (alias for `d`; in `\|…\|` and outside `[…]`) | `0`‑`9` | +| `@` | identifier (alias for `i`; in `\|…\|` and outside `[…]`) | `A`‑`Z` `a`‑`z` `0`‑`9` `_` `-` | +| a-z* | lower letters not listed above are reserved for future use | - | +| A-Z | matches the given character case-insensitive | A => `A`,`a`; B => `B`, `b`, ... | +| ... | any other character is matched literally | + => `+`, é => `é`, ... | + +Larger sets can be composed using `[…]`; for example, `[ud]` are upper case letters and digits, +`[d.]` are digits and decimal points. + +In `[…]` to match `]` literally it must be the first character. +In `|…|` `|` is always the end of the sequence. +To match it literally the sequence is interrupted and `[|]` is used to match the bar. + +--- + +### Set‑of‑Sets: `[…]` + +`[ set1 set2 … ]` matches a **single** character that belongs to **any** of the listed sets. + +**Example:** +`[#u]` matches a digit **or** an uppercase letter. + +Sets can be given as mnemonics (`#`, `@`, `u`, …) or as literal characters (which match exactly that character). +If you need to match a literal `]`, put it inside the brackets: `[]]` matches `]`. + +--- + +### Sequence of Sets: `|…|` + +`|{set1}{set2}…|` matches a **sequence** of characters where the **first** character must belong to `{set1}`, +the second to `{set2}`, etc. The length of the sequence is fixed to the number of sets inside the bars. +Note that this also implies that a set of length n always matches input of length n. + +**Example:** +`|dd|` matches exactly two digits (e.g., `12`). +`|ua|` matches an uppercase letter followed by an alphanumeric character. + +If a set is given as a **digit** (`0`‑`9`), it behaves as a *numeric limit* when placed consecutively with other digits. +See **Numeric Sequences** below. + +Literal characters inside `|…|` match themselves exactly. +If you need to match a literal `|`, place it inside a set: `[|]`. + +--- + +### Repeats + +A repeat applies to the *immediately following unit*. + +The unit can be: + +* a single mnemonic set `#` or `@`, +* a set‑of‑sets `[…]`, +* a sequence `|…|`, +* a group `(…)`, +* or a literal character. + +| Repeat syntax | Meaning | +|---------------|---------------------------------------| +| `?{unit}` | 0 or 1 times (optional) | +| `*{unit}` | 0 or more times (greedy) | +| `+{unit}` | 1 or more times (greedy) | +| `n{unit}` | exactly n times (n = 1‑9) | +| `n?{unit}` | 0 to n times (n = 1‑9) | + +**Examples:** +`3#` matches exactly three digits. +`*|ab|` matches zero or more repetitions of the two‑character sequence `ab`. +`2?@` matches at most two name characters (zero, one, or two). +`+?` is not allowed – the `?` after a repeat changes the meaning to “0‑n”; +the repeat itself does not have a separate non‑greedy modifier. + +**Greediness:** Repeats are *greedy*: they match as many repetitions as possible (up to the given max) +until the input no longer matches. Unlike RegEx there is no connection to what follows the repeated unit. + +**Zero‑length units:** A unit should never be of zero length, but if a repeat makes zero progress +(but is considered a match nonetheless) the repetition will end as if no further occurrence was found. + +Repeating something up to n-times where n is > 9 must be done by repeating the repeated pattern, +for example `5#5#` to do 10 repetitions of `#`. + +--- + +### Scans (Non‑greedy Skip) + +Scans let you skip forward in the input until a certain unit matches. + +- `~{unit}` – skip any number of characters (including none) until `{unit}` matches. The `{unit}` itself is consumed. +- `~~{unit1}{unit2}` – match `{unit1}` zero or more times (non‑greedy), then match `{unit2}`. + This is the non-greedy equivalent of `(*{unit1}){unit2}`, it ensures that `{unit1}` is repeated only as needed to find `{unit2}`. It is useful for “repeat until” patterns. + +Both forms are **non‑greedy**: the minimal number of characters are skipped / repeated to satisfy the match. + +**Examples:** +`~#` skips to the first digit and consumes it. +`~~(ab)(xy)` matches zero or more `ab` until `xy` is found (e.g., `ababxy`). + +--- + +### Groups: `(…)` + +Parentheses group a sequence of units into a single unit. This allows you to apply repeats or scans to a composite pattern. + +**Example:** +`+(ab)` matches one or more repetitions of the literal two‑character sequence `ab`. +`~(##)` skips until two digits appear consecutively. + +Groups **do not nest**. The notation is intentionally flat to keep implementation simple. +If you need nested grouping, you can often restructure the pattern using separate units. + +--- + +## Numeric Sequences + +Inside a sequence `|…|`, consecutive digits represent a **numeric limit** rather than individual digit sets. + +For example: +`|12|` does **not** mean “digit 1 followed by digit 2”. Instead, it means “a two‑digit number whose numeric value is ≤ 12”. +`|123|` means “a three‑digit number ≤ 123”. + +This works only when digits appear consecutively with no other sets between them. +The length of the digit sequence determines how many digits the input must have, +and the numeric comparison is performed after parsing the input digits. + +If you want individual digit limits, separate the digits: +`|1||2|` means a digit ≤ 1 followed by a digit ≤ 2. + +**Implementation note:** Numeric comparison is performed using the entire sequence of digits; +leading zeros are allowed and count toward the number length. +Numeric comparison is restricted to Java `long` range (not including the largest negative number). +Any numeric sequence given exceeding this range will simply overflow and behave accordingly +with a compromised numerical comparison. + +--- + +## Performance and Safety + +Input Expressions are designed to be **safe** and **fast**: + +- Matching runs in **linear time** relative to the pattern length and input length. +- No recursion or backtracking that could cause exponential blow‑up. +- No dynamic memory allocation during matching (the pattern is processed as a `char[]` and input as a `CharSequence`). +- The implementation guards against infinite loops when a repeat would match zero characters repeatedly. + +These properties make it suitable for validating user input in contexts where resource usage must be predictable. + +--- + +## Examples + +| Pattern | Matches | Does not match | +|--------------|------------------------------------|------------------------------------------| +| `3#` | `123`, `000` | `12` (too few), `1234` (too many) | +| `*#` | `` (empty), `1`, `12`, `123`, … | any non‑digit | +| `+#` | `1`, `12`, `123`, … | `` (empty) | +| `[#u]` | `5`, `A`, `Z` | `a` (lowercase), `*` | +| `u#` | `A1`, `B2` | `1A` (wrong order) | | +| `12` | `02`, `12` | `13` (too high), `123` (too many digits) | +| `~#` | `abc1` → matches `1` (skips `abc`) | `abc` (no digit) | +| `~~(ab)(xy)` | `xy`, `abxy`, `ababxy` | `axy` (if not `xy` it must match `ab`) | +| `+@` | `abc`, `a_b`, `x-9` | `a.b` (dot not allowed) | +| `2?#` | ``, `1`, `12` | `123` (too many) | +| `3?@` | ``, `a`, `ab`, `abc` | `abcd` (too many) | + +Common simple value inputs and their patterns: + +| Value | Input Expression Pattern | Examples | Description | +|----------------------|-------------------------------------------------------------------------------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------| +| Integer | `?\|s\|+#` or `?[+-]+#` or `?[s]+#` | `42`, `-7`, `+123`, `0` | Optional sign, one or more digits. | +| Decimal | `?[+-]+#?(.*#)` + `?[+-]*#.+#` | `0.5`, `-3.`, `+.7`, `123.456`, `.5` | At least one digit before or after the decimal point; includes integers. | +| +Scientific Notation | add `?([E]?[+-]+#)` | `1.23e-4`, `-0.5E+2`, `3.0e0` | Mantissa with at least one digit after decimal, followed by `e` or `E`, optional sign, and exponent digits. | +| ISO Date | `\|2099-12-31\|` | `2023-12-25`, `1900-01-01` | Four‑digit year (0‑2099), two‑digit month (0‑12), two‑digit day (0‑31). No calendar validation. | +| Time (24h) | `\|23:59\|`, `\|23:59:59\|` or flexible `\|23:59\|?(\|:59\|)` | `14:30`, `08:05:22` | Two‑digit hour (00‑23), minute (00‑59), and optional seconds (00‑59). | +| UUID | `8[x]-4[x]-4[x]-4[x]-8[x]4[x]` or `\|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\| ` | `123e4567-e89b-12d3-a456-426614174000` | Standard 36‑character UUID: 8‑4‑4‑4‑12 hex digits, separated by hyphens. | +| US Phone | `?(\|(###)\|) 3#-4#` | `(123) 456-7890`, `456-7890` | Area code in parentheses, optional; then three digits, hyphen, four digits. | +| IPv4 Address | `\|255.255.255.255\|` (3 digits each only) `#2?#.#2?#.#2?#.#2#` (1-3 digits) | `192.168.1.1`, `0.0.0.0`, `255.255.255.255` | Four numbers 0‑255 separated by dots. Each `0-255` matches 3 digits numerically 0-255. | +| MAC Address | `\|xx-xx-xx-xx-xx-xx\|` or `2[x]:2[x]:2[x]:2[x]:2[x]:2[x]` | `00:1A:2B:3C:4D:5E`, `00-1A-2B-3C-4D-5E` | Six groups of two hex digits, separated by colon or hyphen. | +| Currency (USD) | `$?-#+*(,3#)?(.2#)` | `$1,234.56`, `$0.99`, `$-12.34` | Dollar sign, optional sign, digits, optional comma, optional decimal part. | +| US Zip Code | `5#?(-4#)` | `12345`, `12345-6789` | Five digits, optionally hyphen and four digits. | + +Note in the example of a decimal numbers that allow decimal point without digits before or after +this is solved using 2 patterns (+) and matching that either of them matches. + +--- + +This specification describes the pattern language as implemented in the `Pattern` class of the [DHIS2 JSON Tree library](https://github.com/dhis2/json-tree). diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java new file mode 100644 index 0000000..3924499 --- /dev/null +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -0,0 +1,450 @@ +package org.hisp.dhis.jsontree; + +import java.util.List; +import java.util.stream.Stream; + +import static java.lang.Integer.MAX_VALUE; + +/** + * Input patterns are specifically designed to match short sequences where the relevant features + * to match are in the ASCII range. Typical examples are numbers, like dates or telephone numbers + * as well as alphanumeric values, like UUIDs, codes and other identifiers. + * + *

Performance and Safety

+ * A key aspect of the design is that matching is allocation-free and guaranteed to be linear time. + * Mostly that is linear to the pattern length. In case of open-ended repeats and scans this is + * linear to the input length. This makes it fairly safe to run against user input without risking + * that matching causes excessive resource usage. + * + *

Syntax

+ * See {@code INPUT_EXPRESSIONS.md} in the repository root for details + * + * @author Jan Bernitt + * @since 1.9 + */ +public record InputExpression(List patterns) { + + public static InputExpression of(String... patterns) { + return new InputExpression(Stream.of(patterns).map(Pattern::of).toList()); + } + + /** + * @param input the sequence to check + * @return the format that matches the input, or null if none matches + */ + public Pattern match(CharSequence input) { + for (Pattern pattern : patterns) if (pattern.matches(input)) return pattern; + return null; + } + + public static boolean matches(String pattern, CharSequence input) { + return matches(pattern.toCharArray(), input); + } + + public static boolean matches(char[] pattern, CharSequence input) { + return match(pattern, 0, pattern.length, input, 0) == input.length(); + } + + public static int match(char[] pattern, int offset, int end, CharSequence input, int pos) { + int i = offset; + int len = input.length(); + while (i < end && pos >= 0) { + int iCode = i; + char opcode = pattern[i++]; + switch (opcode) { + case '#': if (!isDigit(input.charAt(pos++))) return -1; break; + case '@': if (!isIdentifier(input.charAt(pos++))) return -1; break; + case '[': { + if (!matchSet(pattern, i, input.charAt(pos++))) return -1; + i = skipTo(']', pattern, i) + 1; + } + break; + case '|': { + if (!matchSequence(pattern, i, input, pos)) return -1; + i = skipTo('|', pattern, i) + 1; + pos += i - iCode - 2; // 1:1 length relation: set char => 1 input char + } + break; + case '?', '*', '+', '1','2','3','4','5','6','7','8','9': { + pos = matchRepeat(pattern, iCode, input, pos); + if (pos < 0) return -1; + if (pattern[i] == '?') i++; + i = skipUnit(pattern, i); + } + break; + case '~': { + pos = matchScan(pattern, i, input, pos); + if (pos < 0) return -1; + i = + pattern[i] == '~' + ? skipUnit(pattern, skipUnit(pattern, i + 1)) + : skipUnit(pattern, i); + } + break; + case '{': { + pos = matchNumericBounds(pattern, i, input, pos); + if (pos < 0) return -1; + i = skipUnit(pattern, i-1); + } + break; + case '(', ')': break; // these are NOOPs here, just mark block start/end for repeat/scan + default: + if (opcode != input.charAt(pos++)) return -1; + } + } + // ) at the end is special as it would be a NOOP + if (pos >= len && i + 1 == end && pattern[i] == ')') return pos; + return i == end ? pos : -1; + } + + public static int matchScan(char[] pattern, int offset, CharSequence input, int pos) { + int len = input.length(); + if (pattern[offset] == '~') { + //~~{unit}{unit} + int end1 = skipUnit(pattern, offset+1); + int end2 = skipUnit(pattern, end1); + while (pos < len) { + int pos2 = match(pattern, end1, end2, input, pos); + if (pos2 >= 0) return pos2; + pos2 = match(pattern, offset+1, end1, input, pos); + if (pos2 < 0) return -1; + pos = pos2; + } + return -1; + } + // ~{unit} + int end = skipUnit(pattern, offset); + while (pos < len) { + int pos2 = match(pattern, offset, end, input, pos); + if (pos2 >= 0) return pos2; + pos++; + } + return -1; + } + + public static int matchRepeat(char[] pattern, int offset, CharSequence input, int pos) { + int i = offset; + char opcode = pattern[i++]; + int repMin = switch (opcode) { + case '?', '*' -> 0; + case '+' -> 1; + default -> opcode - '0'; + }; + int repMax= switch (opcode) { + case '?' -> 1; + case '*', '+' -> MAX_VALUE; + default -> opcode - '0'; + }; + if (pattern[i] == '?') { + repMin = 0; + i++; + } + int end = skipUnit(pattern, i); + int len = input.length(); + // mandatory occurrences + for (int r = 0; r < repMin; r++) { + pos = match(pattern, i, end, input, pos); + if (pos < 0) return -1; //mismatch + if (pos >= len && r < repMin-1) return -1; // too little input + } + if (pos >= len) return pos; + // optional occurrences + for (int r = repMin; r < repMax; r++) { + int pos2 = match(pattern, i, end, input, pos); + if (pos2 < 0 || pos2 == pos) return pos; // no progress or mismatch => done + pos = pos2; // made some progress + if (pos >= len) return pos; // input exhausted; stop trying further repeats + } + return pos; // max-rep done successful + } + + private static int matchNumericBounds(char[] pattern, int offset, CharSequence input, int pos) { + int end = skipTo('}', pattern, offset); + int offset2 = end+1; + int end2 = skipUnit(pattern, offset2); + int pos2 = match(pattern, offset2, end2, input, pos); + if (pos2 < 0) return -1; + long min = 0; + long max = 0; + int i = offset; + while (i < pattern.length && isDigit(pattern[i])) { + max *= 10; + max += pattern[i++] - '0'; + } + if (i < pattern.length && pattern[i] != '}') { + min = max; + max = 0; + i++; + while (i < pattern.length && isDigit(pattern[i])) { + max *= 10; + max += pattern[i++] - '0'; + } + } + long val = 0; + i = pos; + while (i < pos2) { + val *= 10; + val += input.charAt(i++) - '0'; + } + if (val >= min && (max== 0 || val <= max)) return pos2; + return -1; + } + + + + /** + *
+   *   |...|
+   * 
+ */ + private static boolean matchSequence(char[] pattern, int offset, CharSequence input, int pos) { + int i = offset; + int len = input.length(); + while (i < pattern.length && pos < len && (i == offset || pattern[i] != '|')) { + char in = input.charAt(pos++); + char opcode = pattern[i++]; + switch (opcode) { + case 'b': if (!isBinary(in)) return false; break; + case 'd', '#': if (!isDigit(in)) return false; break; + case 'i': if (!isIdentifier(in)) return false; break; + case 'u': if (!isUpperLetter(in)) return false; break; + case 'l': if (!isLowerLetter(in)) return false; break; + case 'c': if (!isLetter(in)) return false; break; + case 'a': if (!isAlphanumeric(in)) return false; break; + case 'x': if (!isHexadecimal(in)) return false; break; + case 's': if (!isSign(in)) return false; break; + case '?': break; // any character, always fine + default: + if (isUpperLetter(opcode)) { + if (opcode != (in & ~0b10_0000)) return false; break; + } else if (isDigit(opcode)) { + if (!isDigit(in)) return false; // easy case + int pos0 = pos-1; + pos = matchNumericSequence(pattern, i-1, input, pos0); + if (pos < 0) return false; + i += pos - pos0 - 1; + } else if (isLowerLetter(opcode)) { + throw reserved(opcode); + } else { + // everything else is taken literally + if (opcode != in) return false; break; + } + } + } + return true; + } + + private static int matchNumericSequence(char[] pattern, int offset, CharSequence input, int pos) { + int i = offset; + long max = pattern[i++] - '0'; + long val = input.charAt(pos++) - '0'; + int len = input.length(); + while (i < pattern.length && pattern[i] != '|' && isDigit(pattern[i]) && pos < len) { + char d = input.charAt(pos++); + if (!isDigit(d)) return -1; + max *= 10; + max += pattern[i++] - '0'; + val *= 10; + val += d - '0'; + } + if (i > pattern.length) return -1; + if (pattern[i] != '|' && isDigit(pattern[i])) return -1; + return val <= max && val >= 0 ? pos : -1; + } + + + /** + *
+   *   [...]
+   * 
+ */ + private static boolean matchSet(char[] pattern, int offset, char in) { + int i = offset; + while (i < pattern.length && (i == offset || pattern[i] != ']')) { + char opcode = pattern[i++]; + switch (opcode) { + case 'b': if (isBinary(in)) return true; break; + case 'd': if (isDigit(in)) return true; break; + case 'i': if (isIdentifier(in)) return true; break; + case 'u': if (isUpperLetter(in)) return true; break; + case 'l': if (isLowerLetter(in)) return true; break; + case 'c': if (isLetter(in)) return true; break; + case 'a': if (isAlphanumeric(in)) return true; break; + case 'x': if (isHexadecimal(in)) return true; break; + case 's': if (isSign(in)) return true; break; + default: + if (isUpperLetter(opcode)) { + if (opcode == (in & ~0b10_0000)) return true; + } else if (isDigit(opcode)) { + if (isDigit(in, opcode)) return true; + } else if (isLowerLetter(opcode)) { + throw reserved(opcode); + } + else { + // everything else is taken literally + if (opcode == in) return true; + } + } + } + return false; + } + + private static UnsupportedOperationException reserved(char opcode) { + return new UnsupportedOperationException("Lower case letter %s is reserved for future use as named set.".formatted(opcode)); + } + + private static boolean isSign(char c) { + return c == '+' || c == '-'; + } + + private static boolean isBinary(char c) { + return c == '0' || c == '1'; + } + + private static boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + private static boolean isDigit(char c, char upperDigit) { + return c >= '0' && c <= upperDigit; + } + + private static boolean isLetter(char c) { + return isLowerLetter(c) || isUpperLetter(c); + } + + private static boolean isLowerLetter(char c) { + return c >= 'a' && c <= 'z'; + } + + private static boolean isUpperLetter(char c) { + return c >= 'A' && c <= 'Z'; + } + + private static boolean isAlphanumeric(char c) { + return isLetter(c) || isDigit(c); + } + + private static boolean isHexadecimal(char c) { + return isDigit(c) || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'; + } + + private static boolean isIdentifier(char c) { + return isAlphanumeric(c) || c == '_' || c == '-'; + } + + private static int skipTo(char c, char[] pattern, int offset) { + int len = pattern.length; + while (offset < len && pattern[offset] != c) offset++; + return offset; + } + + private static int skipUnit(char[] pattern, int offset) { + return switch (pattern[offset]) { + // +2 (+1 for opening, +1 for first character inside) + case '|' -> skipTo('|', pattern, offset + 2) + 1; + case '[' -> skipTo(']', pattern, offset + 2) + 1; + case '(' -> skipTo(')', pattern, offset + 2) + 1; + case '{' -> skipUnit(pattern, skipTo('}', pattern, offset + 2) + 1); + default -> offset + 1; + }; + } + + /* + Format (find length bounds) + */ + + /** + * A pattern is one alternative within a multi-pattern-{@link InputExpression}. + * + * @param pattern the pattern to match + * @param minLength the minimum input length required to match the pattern, -1 if not fixed + * @param maxLength the maximum input length possible to match the pattern, -1 if not fixed + * @implNote Using length bounds is purely a performance optimisation that makes use of the + * fact the Input Expressions often have a clear length bounds. + */ + public record Pattern(Text pattern, int minLength, int maxLength) { + + public static Pattern of(CharSequence pattern) { + Text p = Text.of(pattern); + return new Pattern(p, length(p, 0, p.length(), false), length(p, 0, p.length(), true)); + } + + public boolean matches(CharSequence input) { + int length = input.length(); + if (minLength >= 0 && length < minLength) return false; + if (maxLength >= 0 && length > maxLength) return false; + return pattern.matches(input); + } + + private static int length(Text pattern, int offset, int end, boolean max) { + int len = 0; + int i = offset; + while (i < end) { + char opcode = pattern.charAt(i++); + switch (opcode) { + case '#', '@': len++; break; + case '[': len++; i = pattern.indexOf(']', i + 1) + 1; break; + case '|': { + int i2 = pattern.indexOf('|', i + 1) + 1; + len += (i2 - i - 1); + i = i2; + } + break; + case '?', '*', '+': { + if (max && (opcode == '*' || opcode == '+')) return -1; // open end + int end2 = skipUnit(pattern, i); + if (max || opcode == '+') { // ? max or + min => 1 times + int len2 = length(pattern, i, end2, max); + if (len2 < 0) return -1; + len += len2; + } + i = end2; + } + break; + case '~': { + if (max) return -1; // open end + if (pattern.charAt(i) == '~') i = skipUnit(pattern, i+1); + int end2 = skipUnit(pattern, i); + int len2 = length(pattern, i, end2, false); + if (len2 < 0) return -1; + len += len2; + i = end2; + } + break; + case '1','2','3','4','5','6','7','8','9': { + boolean zeroOrMore = pattern.charAt(i) == '?'; + if (zeroOrMore) i++; // skip ? + int end2 = skipUnit(pattern, i); + if (max || !zeroOrMore) { + int len2 = length(pattern, i, end2, max); + if (len2 < 0) return -1; + len += (len2 * (opcode - '0')); + } + i = end2; + // for min + zeroOrMore we do nothing here + // then ? will be handled by ? case above + } + break; + case '{': + i = pattern.indexOf('}', i) + 1; break; + case '(', ')': break; // no impact here + default: len++; + } + } + return len; + } + + private static int skipUnit(Text pattern, int offset) { + return switch (pattern.charAt(offset)) { + // +2 (+1 for opening, +1 for first character inside) + case '|' -> pattern.indexOf('|', offset + 2) + 1; + case '[' -> pattern.indexOf(']', offset + 2) + 1; + case '(' -> pattern.indexOf(')', offset + 2) + 1; + case '{' -> skipUnit(pattern, pattern.indexOf('}', offset + 2) + 1); + default -> offset + 1; + }; + } + } + +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java b/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java index 5d9860f..0708e73 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java @@ -266,7 +266,7 @@ public static Stream accessAsStream(JsonMixed stream, Type as, JsonAccessors JsonAccessor elements = accessors.accessor(getRawType(elementType)); // auto-box simple values in a 1 element sequence if (!stream.isArray()) return Stream.of(elements.access(stream, as, accessors)); - return stream.stream(Index.SKIP) + return stream.stream(Index.AUTO_SKIP) .map(e -> elements.access(e.as(JsonMixed.class), elementType, accessors)); } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index 8da80a8..e20beb3 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -115,7 +115,29 @@ enum Index { * multiple methods on a {@link JsonValue} representing the {@link JsonNode} will have the * node remembered internally after the first lookup. */ - AUTO + AUTO, + + /** + * When the fallback set when constructing the root, for example via {@link + * JsonNode#of(CharSequence, GetListener, Index)} is set to {@link #AUTO} and an operation uses + * {@code AUTO_SKIP} this will {@link #SKIP}, otherwise it will use the fallback set. + * + *

This is a good default in tooling build on top of the {@link JsonNode} API. For example in + * pre-processing access like validation, diffing and such. This allows the author creating the + * root note to be control while giving the preference to {@link #SKIP}. + */ + AUTO_SKIP; + + /** + * Called on the {@link Index} on the operation level. + * + * @param treeLevel the {@link Index} setting at the tree level + * @return the effective {@link Index} strategy to use + */ + public Index resolve(Index treeLevel) { + if (this == AUTO_SKIP) return treeLevel == AUTO ? SKIP : treeLevel; + return this == AUTO ? treeLevel : this; + } } /** diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonSelector.java b/src/main/java/org/hisp/dhis/jsontree/JsonSelector.java index a7f4b31..886c71d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonSelector.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonSelector.java @@ -1,7 +1,7 @@ package org.hisp.dhis.jsontree; import static java.lang.Integer.parseInt; -import static org.hisp.dhis.jsontree.JsonNode.Index.SKIP; +import static org.hisp.dhis.jsontree.JsonNode.Index.AUTO_SKIP; import java.util.List; import java.util.function.Consumer; @@ -246,10 +246,10 @@ public void match(JsonNode node, Matches matches) { private void matchChildren(JsonNode node, Matches matches) { switch (node.type()) { case ARRAY -> { - for (JsonNode e : node.elements(SKIP)) match(e, matches); + for (JsonNode e : node.elements(AUTO_SKIP)) match(e, matches); } case OBJECT -> { - for (JsonNode e : node.members(SKIP)) match(e, matches); + for (JsonNode e : node.members(AUTO_SKIP)) match(e, matches); } } } @@ -331,9 +331,9 @@ private record AnyMatcher() implements Matcher { public void match(JsonNode node, JsonSelector next, Matches matches) { JsonNodeType type = node.type(); if (type == JsonNodeType.OBJECT) { - for (JsonNode e : node.members(SKIP)) next.match(e, matches); + for (JsonNode e : node.members(AUTO_SKIP)) next.match(e, matches); } else if (type == JsonNodeType.ARRAY) { - for (JsonNode e : node.elements(SKIP)) next.match(e, matches); + for (JsonNode e : node.elements(AUTO_SKIP)) next.match(e, matches); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 0f623f4..1338920 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -346,7 +346,7 @@ private Iterator membersIterator(Index op) { if (isEmpty()) return emptyIterator(); return new Iterator<>() { private final char[] json = tree.json; - private final Index index = op == Index.AUTO ? tree.auto : op; + private final Index index = op.resolve(tree.auto); private int offset = skipWhitespace(json, expectChar(json, start, '{')); private int n = 0; @@ -593,7 +593,7 @@ public Streamable values() { private Iterator elementsIterator(Index op) { return new Iterator<>() { private final char[] json = tree.json; - private final Index index = op == Index.AUTO ? tree.auto : op; + private final Index index = op.resolve(tree.auto); private int offset = skipWhitespace(json, expectChar(json, start, '[')); private int n = 0; @@ -832,7 +832,7 @@ private static JsonNode getClosestIndexedParent( private JsonNode autoDetect(JsonPath path, int offset, JsonNode.Index index) { return switch (index) { - case SKIP -> autoDetectNoIndexLookup(path, offset); + case SKIP, AUTO_SKIP -> autoDetectNoIndexLookup(path, offset); case CHECK -> autoDetect(path, offset); case AUTO -> isPrimitiveNode(json, offset) diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java index 91c0360..09a1758 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Text.java +++ b/src/main/java/org/hisp/dhis/jsontree/Text.java @@ -188,6 +188,14 @@ default boolean regionMatches(int startIndex, CharSequence sample, int offset, i return true; } + default boolean matches(CharSequence input) { + return matches(input, 0, input.length()); + } + + default boolean matches(CharSequence input, int offset, int len) { + return InputExpression.match(toCharArray(), 0, length(), input, offset) == offset + len; + } + @Override default @NotNull Text subSequence(int start, int end) { checkSubSequence(start, end, length()); @@ -428,6 +436,11 @@ public Number parseNumber() { return TextualNumber.of(buffer, offset, length); } + @Override + public boolean matches(CharSequence input, int offset, int len) { + return InputExpression.match(buffer, offset, length, input, offset) == offset+len; + } + @Override public boolean equals(Object obj) { if (this == obj) return true; diff --git a/src/main/java/org/hisp/dhis/jsontree/TextBuilder.java b/src/main/java/org/hisp/dhis/jsontree/TextBuilder.java index 6a0966a..5cb177f 100644 --- a/src/main/java/org/hisp/dhis/jsontree/TextBuilder.java +++ b/src/main/java/org/hisp/dhis/jsontree/TextBuilder.java @@ -249,25 +249,56 @@ static void appendLong(long value, char[] buffer, int offset, int length) { static int characterCount(int value) { if (value == 0) return 1; - boolean overflow = value == Integer.MIN_VALUE; - int rest = overflow ? value - 1 : abs(value); - int n = 0; - while (rest > 0) { - n++; - rest /= 10; - } - return value < 0 ? n+1 : n; + int n = digitCount(value); + return value < 0 ? n + 1 : n; } static int characterCount(long value) { if (value == 0L) return 1; - boolean overflow = value == Long.MIN_VALUE; - long rest = overflow ? value - 1L : abs(value); - int n = 0; - while (rest > 0) { - n++; - rest /= 10; - } + int n = digitCount(value); return value < 0 ? n+1 : n; } + + private static int digitCount(int n) { + if (n < 0) { + if (n == Integer.MIN_VALUE) return 10; + n = -n; + } + if (n < 10) return 1; + if (n < 100) return 2; + if (n < 1000) return 3; + if (n < 10000) return 4; + if (n < 100000) return 5; + if (n < 1000000) return 6; + if (n < 10000000) return 7; + if (n < 100000000) return 8; + if (n < 1000000000) return 9; + return 10; + } + + private static int digitCount(long n) { + if (n < 0) { + if (n == Long.MIN_VALUE) return 19; + n = -n; + } + if (n < 10L) return 1; + if (n < 100L) return 2; + if (n < 1000L) return 3; + if (n < 10000L) return 4; + if (n < 100000L) return 5; + if (n < 1000000L) return 6; + if (n < 10000000L) return 7; + if (n < 100000000L) return 8; + if (n < 1000000000L) return 9; + if (n < 10000000000L) return 10; + if (n < 100000000000L) return 11; + if (n < 1000000000000L) return 12; + if (n < 10000000000000L) return 13; + if (n < 100000000000000L) return 14; + if (n < 1000000000000000L) return 15; + if (n < 10000000000000000L) return 16; + if (n < 100000000000000000L) return 17; + if (n < 1000000000000000000L) return 18; + return 19; + } } diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 2267a71..66454f7 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -220,6 +220,10 @@ public String toString() { */ NodeType[] type() default {}; + //TODO implement => NO => rather have a in Validation + // NodeType[] typeFrom() default {}; + // which sets all the JSON inputs we allow as long as they convert + /** * A property marked as varargs will allow {@link NodeType#ARRAY} to occur, each element then is * validated against the present simple value validations. @@ -290,6 +294,8 @@ public String toString() { * @return string value must match the given regex pattern */ String pattern() default ""; + //TODO change to String[] pattern() default {}; + // which always are Input expressions, to use RegEx a @Validator can be used (included in lib; move code to that for RegEx) // formats are mostly like named patterns, // instead of repeating the pattern the name is given 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 f390560..72e0c30 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -58,7 +58,6 @@ private static Result validate(JsonValue value, Class schema, Validation.Mode } }; - // TODO strict types vs convertable types mode if (mode == Validation.Mode.PROBE) { try { validate(value, validator, addError); diff --git a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java new file mode 100644 index 0000000..2eb331e --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -0,0 +1,164 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.InputExpression.Pattern; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InputExpressionTest { + + @Test + void testLiterally() { + assertMatches("Hello", "Hello"); + assertDoesNotMatch("Hello", "Hello, World"); + } + + @Test + void testRepeat_DigitsHash() { + assertMatches("+#", "123"); + assertDoesNotMatch("+#", "1A"); + } + + @Test + void testRepeat_Sequence() { + assertMatches("3|ddul|", "12Ab34Bc56Cd"); + assertMatches("3?|ddul|", "12Ab", "12Ab12Ab", "12Ab12Ab12Ab"); + assertDoesNotMatch("3|ddul|", "12ab34Bc56Cd", "12Ab34Bc", "12Ab"); + assertDoesNotMatch("3?|ddul|", "12Ab12Ab12Ab12Ab"); + } + + @Test + void testRepeat_SequenceBlock() { + assertMatches("3(|ddul|)", "12Ab34Bc56Cd"); + } + + @Test + void testRepeat_ZeroTimesMatchesEndOfInput() { + assertMatches("#?#", "1", "12"); + } + + @Test + void testScan() { + assertMatches("He~o", "Hello", "Hero", "Heo"); + assertEquals(new Pattern(Text.of("He~o"), 3, -1), Pattern.of("He~o")); + } + + @Test + void testScanIf() { + assertMatches("He~~lo", "Hello","Helo","Helllo","Heo"); + assertDoesNotMatch("He~~lo", "Hemo", "Hero"); + assertEquals(new Pattern(Text.of("He~~lo"), 3, -1), Pattern.of("He~~lo")); + } + + @Test + void testScanIf_URL() { + String pattern = "/api~~(/+@)(/gist)"; + assertMatches(pattern, "/api/gist", "/api/foo/gist", "/api/foo/bar/gist"); + assertDoesNotMatch(pattern, "/api/foo","/api/gist/foo"); + assertEquals(new Pattern(Text.of(pattern), 9, -1), Pattern.of(pattern)); + } + + @Test + void testSequenceNumeric() { + assertMatches("|2050|", "2022"); + assertDoesNotMatch("|2050|", "2061"); + assertMatches("|2059|", "0001","2059"); + assertDoesNotMatch("|2059|", "2060"); + } + + @Test + void testPattern_Dates() { + assertMatches("|2050-12-31|", "2020-01-01", "2020-00-00"); + assertMatches("|2050/12/31|", "2020/01/01"); + assertMatches("|2050.12.31|", "2020.01.01"); + assertMatches("|2050|[-./]|12|[-./]|31|", "2020.01.01","2020-01-01", "2020/01/01"); + + assertDoesNotMatch("|2050-12-31|", "2020-01-32", "2020-13-01", "2051-01-01"); + } + + @Test + void testPattern_DatesNumericBound() { + // YYYY/MM/DD + String pattern = "{1900-}|2050|/{1-}|12|/{1-}|31|"; + assertMatches(pattern, "2020/01/01"); + assertDoesNotMatch(pattern, "2020/00/00"); + // Weekly with 1-2 week digits + pattern = "{1900-}|2050|W{1-53}(#?#)"; + assertMatches(pattern, "2020W1", "2020W53"); + assertDoesNotMatch(pattern, "2020W0", "2020W54"); + } + + @Test + void testPattern_LongNumbers() { + assertMatches("|9223372036854775807|", "9223372036854775807"); + assertDoesNotMatch("|9223372036854775807|", "9223372036854775808"); + assertMatches("|-9223372036854775807|", "-9223372036854775807"); + } + + @Test + void testPattern_DHIS2() { + InputExpression expr = InputExpression.of( + "{1900-}|2050|", + "{1900-}|2050|{1-}|12|", + "{1900-}|2050|-{1-}|12|", + "{1900-}|2050|{1-}|12|{1-}|31|", + "{1900-}|2050|-{1-}|12|-{1-}|31|", + "{1900-}|2050|W{1-53}(#?#)", + "{1900-}|2050|WedW{1-53}(#?#)", + "{1900-}|2050|SatW{1-53}(#?#)", + "{1900-}|2050|SunW{1-53}(#?#)", + "{1900-}|2050|BiW{1-27}(#?#)", + "{1900-}|2050|{1-}|06|B", + "{1900-}|2050|Q{1-}|4|", + "{1900-}|2050|NovQ{1-}|4|", + "{1900-}|2050|S{1-}|2|", + "{1900-}|2050|NovS{1-}|2|", + "{1900-}|2050|AprilS{1-}|2|", + "{1900-}|2050|April", + "{1900-}|2050|July", + "{1900-}|2050|Sep", + "{1900-}|2050|Oct", + "{1900-}|2050|Nov" + ); + assertMatches(expr, "2022", "198011", "1980-11", "19770528", "1977-05-28"); + assertMatches(expr,"2025W51", "1989W3", "2044Q1", "2033BiW22"); + // pattern issue + assertDoesNotMatch(expr, "20221", "2022-1", "1980113", "1980-1-13"); + // numeric out of bounds + assertDoesNotMatch(expr, "2051", "1899", "1980100", "198013", "1980-14", "1977-00-28"); + } + + private static void assertMatches(String pattern, String...inputs) { + InputExpression expr = InputExpression.of(pattern); + for (String input : inputs) { + assertTrue(InputExpression.matches(pattern, input), () -> "%s should match %s".formatted(pattern, input)); + assertNotNull(expr.match(input)); + } + } + + private static void assertDoesNotMatch(String pattern, String...inputs) { + InputExpression expr = InputExpression.of(pattern); + for (String input : inputs) { + assertFalse(InputExpression.matches(pattern, input), () -> "%s should NOT match %s".formatted(pattern, input)); + assertNull(expr.match(input)); + } + } + + private static void assertMatches(InputExpression expr, String...inputs) { + for (String input : inputs) { + Pattern matching = expr.match(input); + assertNotNull(matching, () -> "Pattern should match %s".formatted(input)); + } + } + + private static void assertDoesNotMatch(InputExpression expr, String...inputs) { + for (String input : inputs) { + Pattern matching = expr.match(input); + assertNull(matching, () -> "Pattern should NOT match %s".formatted(input)); + } + } +} From 2631dab554b55116d4ca7c6e7971ba2014ea7edf Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 30 Mar 2026 13:55:50 +0200 Subject: [PATCH 04/20] chore: updated spec --- INPUT_EXPRESSIONS.md | 295 +++++++----------- .../hisp/dhis/jsontree/InputExpression.java | 2 +- .../dhis/jsontree/InputExpressionTest.java | 7 +- 3 files changed, 115 insertions(+), 189 deletions(-) diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md index dadb3f4..4ec5e51 100644 --- a/INPUT_EXPRESSIONS.md +++ b/INPUT_EXPRESSIONS.md @@ -1,4 +1,4 @@ -# Input Expressions (InpEx) +# Input Expressions An Input Expression is a pattern matching expression language designed for checking short, "simple" values such as @@ -8,7 +8,7 @@ The language is **linear‑time**, strict left ro right matching and straight forward to implement **allocation‑free**. It focuses on readability, simplicity, predictability and safety. -Input expression always look for "what you want" (whitelist-y), and so it has +An Input Expression always look for "what you want" (whitelist-y), and so it has - no OR - no "not in" character sets @@ -18,80 +18,81 @@ Input expression always look for "what you want" (whitelist-y), and so it has ## Overview -A pattern is a sequence of *units*. Each unit is either: +A pattern is given using the following building blocks: -- a **literal character** (except special characters `# @ [ ] | ? * + ~ ( )`), -- a **character set** (mnemonic like `#` for digit, `@` for name‑character, etc.), -- a **set of character sets** `[...]` (to match 1 input character), -- a **sequence of sets** `|...|` (to match 1 input character for each set in the sequence), -- a **repeat** applied to a unit (e.g., `?{unit}`, `+{unit}`, `3{unit}`), -- a **scan** (non‑greedy skip) `~{unit}` or `~~{unit1}{unit2}`, +- a **character set** `[...]` (match 1 input character against a set of possible characters), +- a **sequence of sets** `|...|` (match a sequence of input characters against a sequence of sets of characters), +- a **repeat** `?`(0-1), `*`(0-*), `+`(1-*) greedy repeat the subsequence unit, +- a **scan** `~` (skip-until) or `~~` (skip-match-until) non‑greedy skip until match for terminal unit, - a **group** `(...)` used to create a compound unit. -Everything else is a literal character. +Everything else is a literal character. +Which characters are literal and which are codes is detailed below for each mode. -Most notably this includes (almost) all punctuation marks which is handy, +Most notably literal characters include (almost) all punctuation marks which is handy, as these tend to occur in the type of values this wants to match. -Note that the lack of an OR construct is mitigated in the implementation `Pattern` -by considering a list of multiple patterns to be alternative formats for the value. -For example, a floating point number can be difficult with optional digits before -and after decimal point. This simply becomes 2 patterns for each case. +> [Note] +> Note that the lack of an OR construct is mitigated in the implementation `InputExpression` +> by considering a list of multiple `Pattern`s to be alternative formats for the value. +> For example, a floating point number where digits most only be present before +> or after the decimal point is given by 1 pattern for a number having digits before +> and 1 pattern having digits after the decimal point. --- +## Syntax + +Interpretation occurs in 3 modes: + +- main mode: Initial mode, or in other words mode outside of character sets `[…]` and sequences `|…|` +- character set mode: inside `[…]` +- sequence mode: inside `|…|` + +> [Note] +> Note that apart from having modes the language does not allow nesting. +> This means within any of `(…)`, `[…]` and `|…|` these no further grouping is possible. +> This is a limitation chosen for simplicity of implementation. Implementation can and may choose to +> allow nesting of groups to increase expressiveness. Such an extension would not change the semantics +> of the language, just make it harder to skip a unit/block as nesting must be considered. + +### Main-Mode + +| Code | Description | Characters | +|---------|-------------------------------------------------------------------------------------------------------|---------------------------------| +| `#` | digit (shorthand for `[d]` or `[#]` | `0`‑`9` | +| `@` | identifier (letter, digit, underscore, hyphen; = `[i]`) | `A`‑`Z` `a`‑`z` `0`‑`9` `_` `-` | +| `[…]` | matches a single character that belongs to any of the listed sets | | +| `\|…\|` | matches a sequence where each character belongs to the corresponding set | | +| `?…` | subsequent unit occurs 0 or 1 time (optional) | | +| `*…` | subsequent unit occurs 0 or more times (**greedy**) | | +| `+…` | subsequent unit occurs 1 or more times (**greedy**) | | +| 1-`9…` | subsequent unit occurs exactly 9 times (digit is max repetition) | | +| 1-`9?…` | subsequent unit occurs 0 to 9 times (digit is max repetition) | | +| `~…` | skip forward (**non‑greedy**) until a match for subsequent unit is found | | +| `~~……` | repeat directly subsequent unit zero or more times (**non‑greedy**), to find unit directly after that | | +| `(…)` | groups a sequence of codes so that repeat and scan apply to the whole group | | +| `{…}…` | adds a numeric bound to the unit following the bound `{…}` | | +| ... | any other character is matched literally | + => `+`, é => `é`, ... | + +- **Escaping:** There is no escaping notation or code; to match op-code characters literally use a `|…|` or `[…]` +- **Unit:** Repeats and scans apply to a "unit", which is a `(…)`, `[…]` or `|…|` block or any other character individually (be it an op-code or literal match) +- Parentheses group a sequence of codes into a single unit. Its sole function is to apply repeats or scans to a composite pattern. -## Syntax Summary - -| Syntax | Type | Description | -|---------------------|----------------|----------------------------------------------------------------------------------------------| -| `d` | _set_ | any digit (`0`‑`9`) (alias) | -| `u` | _set_ | any uppercase letter (`A`‑`Z`) | -| `l` | _set_ | any lowercase letter (`a`‑`z`) | -| `c` | _set_ | any letter (`A`‑`Z`, `a`‑`z`) | -| `x` | _set_ | any hexadecimal digit (`A`‑`F`, `a`‑`f`, `0`‑`9`) | -| `a` | _set_ | any alphanumeric (`A`‑`Z`, `a`‑`z`, `0`‑`9`) | -| `s` | _set_ | sign (`+` or `-`) | -| `#` | _set_*, _unit_ | any digit (`0`‑`9`) | -| `@` | _set_*, _unit_ | any *name* character (`A`‑`Z`, `a`‑`z`, `0`‑`9`, `_`, `-`) | -| `[{set1}{set2}…]` | _unit_ | matches a single character that belongs to any of the listed sets | -| `\|{set1}{set2}…\|` | _unit_ | matches a sequence where each character belongs to the corresponding set (length must match) | -| `?{unit}` | _unit_ | _unit_ occurs 0 or 1 time (optional) | -| `*{unit}` | _unit_ | _unit_ occurs 0 or more times (greedy) | -| `+{unit}` | _unit_ | _unit_ occurs 1 or more times (greedy) | -| `9{unit}` | _unit_ | _unit_ occurs exactly n times (n = 1‑9, 9 in this example) | -| `9?{unit}` | _unit_ | _unit_ occurs 0‑n times (n = 1‑9, 9 in this example) | -| `~{unit}` | _unit_ | skip forward (non‑greedy) until the _unit_ matches (the _unit_ is consumed) | -| `~~{unit1}{unit2}` | _unit_ | repeat _unit1_ zero or more times, then _unit2_ (non‑greedy) – “repeat until” | -| `(…)` | _unit_ | groups a sequence of units so that repeats/scans apply to the whole group | -| `` | _unit_ | any other character matches itself literally | - -`#` and `@` have their set semantics in `|…|` but are literally in `[…]`. - ---- - -## Building Blocks - -### Literals -Any character that is **not** a special symbol matches itself exactly. -Special symbols are: `# @ [ ] | ? * + ~ ( )` and digits when they appear as repeat counts. - -**"Escaping"** - -There is no escaping syntax, but most of the special characters can be matched literally, -by placing them inside a `|…|` or `[…]`. - -Naturally `|` must be "escaped" in `[|]` and `[` and `]` in `|[|`/ `|]|`. -`#` and `@` are only _sets_ in `|…|` so they must be "escaped" in `[#]` / `[@]` +**Examples:** +- `3#` matches exactly three digits. +- `*|ab|` matches zero or more repetitions of the two‑character sequence `ab`. +- `2?@` matches at most two identifier characters. +- `~#` skips to the first digit and consumes it. +- `~~(foo)(bar)` matches zero or more `ab` until `xy` is found (e.g., `foofoobar`). --- -### Character Sets -//TODO for each environment make a table for character (ranges) and what they mean instead +### Character Sets: `[…]`-Mode -Input expressions build on pre-defined named character sets. +`[{code1}{code2}…]` matches a **single** character that belongs to **any** of the listed sets. -| Code | Set Description | Characters | +| Code | Description | Characters | |------|------------------------------------------------------------|----------------------------------| | `s` | sign | `+` `-` | | `b` | binary digit | `0`,`1` | @@ -102,138 +103,62 @@ Input expressions build on pre-defined named character sets. | `a` | alphanumeric (letter or digit) | `A`‑`Z` `a`‑`z` `0`‑`9` | | `x` | hexadecimal digit | `0`‑`9` `A`‑`F` `a`‑`f` | | `i` | identifier (letter, digit, underscore, hyphen) | `A`‑`Z` `a`‑`z` `0`‑`9` `_` `-` | -| `#` | digit (alias for `d`; in `\|…\|` and outside `[…]`) | `0`‑`9` | -| `@` | identifier (alias for `i`; in `\|…\|` and outside `[…]`) | `A`‑`Z` `a`‑`z` `0`‑`9` `_` `-` | | a-z* | lower letters not listed above are reserved for future use | - | | A-Z | matches the given character case-insensitive | A => `A`,`a`; B => `B`, `b`, ... | +| 0-9 | literally 0-9 (just to clarify) | `0`..`9` | | ... | any other character is matched literally | + => `+`, é => `é`, ... | -Larger sets can be composed using `[…]`; for example, `[ud]` are upper case letters and digits, -`[d.]` are digits and decimal points. - -In `[…]` to match `]` literally it must be the first character. -In `|…|` `|` is always the end of the sequence. -To match it literally the sequence is interrupted and `[|]` is used to match the bar. +- Listing multiple sets composes a larger set; for example, `[u&]` => upper case letters and `&` +- To match `]` literally it must be the first character in the set, e.g. `[]uld]` +- The semantics of all **letter** codes are shared with `|…|` sequences --- -### Set‑of‑Sets: `[…]` - -`[ set1 set2 … ]` matches a **single** character that belongs to **any** of the listed sets. - -**Example:** -`[#u]` matches a digit **or** an uppercase letter. - -Sets can be given as mnemonics (`#`, `@`, `u`, …) or as literal characters (which match exactly that character). -If you need to match a literal `]`, put it inside the brackets: `[]]` matches `]`. - ---- - -### Sequence of Sets: `|…|` - -`|{set1}{set2}…|` matches a **sequence** of characters where the **first** character must belong to `{set1}`, -the second to `{set2}`, etc. The length of the sequence is fixed to the number of sets inside the bars. -Note that this also implies that a set of length n always matches input of length n. - -**Example:** -`|dd|` matches exactly two digits (e.g., `12`). -`|ua|` matches an uppercase letter followed by an alphanumeric character. - -If a set is given as a **digit** (`0`‑`9`), it behaves as a *numeric limit* when placed consecutively with other digits. -See **Numeric Sequences** below. - -Literal characters inside `|…|` match themselves exactly. -If you need to match a literal `|`, place it inside a set: `[|]`. - ---- - -### Repeats - -A repeat applies to the *immediately following unit*. - -The unit can be: - -* a single mnemonic set `#` or `@`, -* a set‑of‑sets `[…]`, -* a sequence `|…|`, -* a group `(…)`, -* or a literal character. - -| Repeat syntax | Meaning | -|---------------|---------------------------------------| -| `?{unit}` | 0 or 1 times (optional) | -| `*{unit}` | 0 or more times (greedy) | -| `+{unit}` | 1 or more times (greedy) | -| `n{unit}` | exactly n times (n = 1‑9) | -| `n?{unit}` | 0 to n times (n = 1‑9) | - -**Examples:** -`3#` matches exactly three digits. -`*|ab|` matches zero or more repetitions of the two‑character sequence `ab`. -`2?@` matches at most two name characters (zero, one, or two). -`+?` is not allowed – the `?` after a repeat changes the meaning to “0‑n”; -the repeat itself does not have a separate non‑greedy modifier. - -**Greediness:** Repeats are *greedy*: they match as many repetitions as possible (up to the given max) -until the input no longer matches. Unlike RegEx there is no connection to what follows the repeated unit. - -**Zero‑length units:** A unit should never be of zero length, but if a repeat makes zero progress -(but is considered a match nonetheless) the repetition will end as if no further occurrence was found. - -Repeating something up to n-times where n is > 9 must be done by repeating the repeated pattern, -for example `5#5#` to do 10 repetitions of `#`. - ---- +### Sequence of Sets: `|…|`-Mode -### Scans (Non‑greedy Skip) +`|{code1}{code2}…|` matches a **sequence** of characters where the **first** character must belong to +the character set given by `{code1}`, the second belong to set given by `{code2}`, etc. -Scans let you skip forward in the input until a certain unit matches. +Note that a sequence always has a 1:1 length relation to the input it matches. -- `~{unit}` – skip any number of characters (including none) until `{unit}` matches. The `{unit}` itself is consumed. -- `~~{unit1}{unit2}` – match `{unit1}` zero or more times (non‑greedy), then match `{unit2}`. - This is the non-greedy equivalent of `(*{unit1}){unit2}`, it ensures that `{unit1}` is repeated only as needed to find `{unit2}`. It is useful for “repeat until” patterns. - -Both forms are **non‑greedy**: the minimal number of characters are skipped / repeated to satisfy the match. - -**Examples:** -`~#` skips to the first digit and consumes it. -`~~(ab)(xy)` matches zero or more `ab` until `xy` is found (e.g., `ababxy`). - ---- - -### Groups: `(…)` - -Parentheses group a sequence of units into a single unit. This allows you to apply repeats or scans to a composite pattern. - -**Example:** -`+(ab)` matches one or more repetitions of the literal two‑character sequence `ab`. -`~(##)` skips until two digits appear consecutively. - -Groups **do not nest**. The notation is intentionally flat to keep implementation simple. -If you need nested grouping, you can often restructure the pattern using separate units. - ---- - -## Numeric Sequences - -Inside a sequence `|…|`, consecutive digits represent a **numeric limit** rather than individual digit sets. - -For example: -`|12|` does **not** mean “digit 1 followed by digit 2”. Instead, it means “a two‑digit number whose numeric value is ≤ 12”. -`|123|` means “a three‑digit number ≤ 123”. - -This works only when digits appear consecutively with no other sets between them. -The length of the digit sequence determines how many digits the input must have, -and the numeric comparison is performed after parsing the input digits. - -If you want individual digit limits, separate the digits: -`|1||2|` means a digit ≤ 1 followed by a digit ≤ 2. +| Code | Description | Characters | +|------|------------------------------------------------------------|----------------------------------| +| `s` | sign | `+` `-` | +| `b` | binary digit | `0`,`1` | +| `d` | digit | `0`‑`9` | +| `u` | uppercase letter | `A`‑`Z` | +| `l` | lowercase letter | `a`‑`z` | +| `c` | letter (upper or lower) | `A`‑`Z` `a`‑`z` | +| `a` | alphanumeric (letter or digit) | `A`‑`Z` `a`‑`z` `0`‑`9` | +| `x` | hexadecimal digit | `0`‑`9` `A`‑`F` `a`‑`f` | +| `i` | identifier (letter, digit, underscore, hyphen) | `A`‑`Z` `a`‑`z` `0`‑`9` `_` `-` | +| a-z* | lower letters not listed above are reserved for future use | - | +| `?` | any character (unicode BMP range) | U+0000 to U+FFFF | +| `#` | digit (alias for `d`) | `0`‑`9` | +| `@` | literally @ (just to clarify) | `@` | +| A-Z | matches the given character case-insensitive | A => `A`,`a`; B => `B`, `b`, ... | +| `0` | literally 0 (just to clarify) | `0` | +| 1-9* | the digit is the numerically largest digit | e.g. `3` => `0`-`3` | +| ... | any other character is matched literally | + => `+`, é => `é`, ... | -**Implementation note:** Numeric comparison is performed using the entire sequence of digits; -leading zeros are allowed and count toward the number length. -Numeric comparison is restricted to Java `long` range (not including the largest negative number). -Any numeric sequence given exceeding this range will simply overflow and behave accordingly -with a compromised numerical comparison. +- A `|…|` sequence must not be empty. +- The semantics of all **letter** codes are shared with `[…]` sets +- To match `|` literally the sequence is interrupted and `[|]` is used to match the bar. +- Multiple **digits** (`0`‑`9`) in a row, match not each digit being in bounds but the entire sequence of digits +being in bounds. For example, `|234|` is not 0-2 followed by 0-3 followed by 0-4 but a 3-digit number 0-234. +- **Digit sequences:** consecutive digits represent a **numeric upper bound** rather than individual digit bound. +- To match digits with individual upper bounds, separate the digits in multiple `|…|`. + +**Examples** +- `|dd|` matches exactly two digits (e.g., `12`). +- `|ua|` matches an uppercase letter followed by an alphanumeric character. +- `|123|` matches a three‑digit number ≤ 123. +- `|1||2|` matches a digit ≤ 1 followed by a digit ≤ 2. + +> [Note] +> **Implementation note:** Numeric comparison is restricted to Java `long` range (not including the largest negative number). +> Any numeric sequence given exceeding this range will simply overflow and behave accordingly +> with a compromised numerical comparison. --- @@ -243,7 +168,7 @@ Input Expressions are designed to be **safe** and **fast**: - Matching runs in **linear time** relative to the pattern length and input length. - No recursion or backtracking that could cause exponential blow‑up. -- No dynamic memory allocation during matching (the pattern is processed as a `char[]` and input as a `CharSequence`). +- No dynamic heap memory allocation during matching - The implementation guards against infinite loops when a repeat would match zero characters repeatedly. These properties make it suitable for validating user input in contexts where resource usage must be predictable. @@ -284,7 +209,3 @@ Common simple value inputs and their patterns: Note in the example of a decimal numbers that allow decimal point without digits before or after this is solved using 2 patterns (+) and matching that either of them matches. - ---- - -This specification describes the pattern language as implemented in the `Pattern` class of the [DHIS2 JSON Tree library](https://github.com/dhis2/json-tree). diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index 3924499..f54a055 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -351,7 +351,7 @@ private static int skipUnit(char[] pattern, int offset) { } /* - Format (find length bounds) + Pattern (find length bounds) */ /** diff --git a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java index 2eb331e..dfc45a5 100644 --- a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -9,6 +9,11 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * Tests for the {@link InputExpression} pattern matching implementation. + * + * @author Jan Bernitt + */ class InputExpressionTest { @Test @@ -100,7 +105,7 @@ void testPattern_LongNumbers() { } @Test - void testPattern_DHIS2() { + void testPattern_DHIS2Periods() { InputExpression expr = InputExpression.of( "{1900-}|2050|", "{1900-}|2050|{1-}|12|", From 7d3e7d712ec04b368c7df18e28077f0be842dfb0 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 30 Mar 2026 13:57:53 +0200 Subject: [PATCH 05/20] chore: fixed note markup --- INPUT_EXPRESSIONS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md index 4ec5e51..113700e 100644 --- a/INPUT_EXPRESSIONS.md +++ b/INPUT_EXPRESSIONS.md @@ -32,7 +32,7 @@ Which characters are literal and which are codes is detailed below for each mode Most notably literal characters include (almost) all punctuation marks which is handy, as these tend to occur in the type of values this wants to match. -> [Note] +> [!NOTE] > Note that the lack of an OR construct is mitigated in the implementation `InputExpression` > by considering a list of multiple `Pattern`s to be alternative formats for the value. > For example, a floating point number where digits most only be present before @@ -48,7 +48,7 @@ Interpretation occurs in 3 modes: - character set mode: inside `[…]` - sequence mode: inside `|…|` -> [Note] +> [!NOTE] > Note that apart from having modes the language does not allow nesting. > This means within any of `(…)`, `[…]` and `|…|` these no further grouping is possible. > This is a limitation chosen for simplicity of implementation. Implementation can and may choose to @@ -155,7 +155,7 @@ being in bounds. For example, `|234|` is not 0-2 followed by 0-3 followed by 0-4 - `|123|` matches a three‑digit number ≤ 123. - `|1||2|` matches a digit ≤ 1 followed by a digit ≤ 2. -> [Note] +> [!NOTE] > **Implementation note:** Numeric comparison is restricted to Java `long` range (not including the largest negative number). > Any numeric sequence given exceeding this range will simply overflow and behave accordingly > with a compromised numerical comparison. From 3cc7bdc01dc23bf8e0bdbbd6906310251f0d632a Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 30 Mar 2026 14:40:32 +0200 Subject: [PATCH 06/20] chore: details of numeric bounds --- INPUT_EXPRESSIONS.md | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md index 113700e..ba6a82e 100644 --- a/INPUT_EXPRESSIONS.md +++ b/INPUT_EXPRESSIONS.md @@ -8,7 +8,7 @@ The language is **linear‑time**, strict left ro right matching and straight forward to implement **allocation‑free**. It focuses on readability, simplicity, predictability and safety. -An Input Expression always look for "what you want" (whitelist-y), and so it has +An Input Expression always looks for "what you want" (whitelist-y), and so it has - no OR - no "not in" character sets @@ -35,9 +35,9 @@ as these tend to occur in the type of values this wants to match. > [!NOTE] > Note that the lack of an OR construct is mitigated in the implementation `InputExpression` > by considering a list of multiple `Pattern`s to be alternative formats for the value. -> For example, a floating point number where digits most only be present before -> or after the decimal point is given by 1 pattern for a number having digits before -> and 1 pattern having digits after the decimal point. +> For example, a floating point number where digits must only be present before +> or after the decimal point is given by 1 pattern for a number enforcing digits before +> and 1 pattern enforcing digits after the decimal point. --- ## Syntax @@ -85,6 +85,10 @@ Interpretation occurs in 3 modes: - `~#` skips to the first digit and consumes it. - `~~(foo)(bar)` matches zero or more `ab` until `xy` is found (e.g., `foofoobar`). +> [!TIP] +> Repetitions in Input Expressions are written in the order we describe a pattern in natural language; +> for example, "two digits followed by 3 upper case letters" is `2#3[u]` (`2` digits `#`, `3` upper case letters `[u]`). + --- @@ -160,14 +164,36 @@ being in bounds. For example, `|234|` is not 0-2 followed by 0-3 followed by 0-4 > Any numeric sequence given exceeding this range will simply overflow and behave accordingly > with a compromised numerical comparison. +### Numeric Bounds `{…}` +Any unit can be preceded by a numeric bounds check. The check applies to the input section that was matched by +subsequent unit. Numeric bounds don't themselves consume input, they just add a constraint to the matched input. + +`{n}` adds an upper bound of `n` (n being any integer number). +`{n-m}` adds a range bound for the input to be between n and m (n and m being any integer number). +The upper bound can be open `{n-}`. This also allows to combine the bound with a `|…|` which might +already enforce an upper bound. + +**Examples** + +An IPv4 address can be `|255.255.255.255|`. This works giving each part 0-255 range, but mandates each part to have +3 digits. To match `0.0.0.0` as valid, the pattern can be adjusted to `{255}(#2?#).{255}(#2?#).{255}(#2?#).{255}(#2?#)`. + +A simple date pattern may be `|2099-12-31|` which is fine except it does allow 0 for month and day, +and the year can be any 4-digit number until year 2099. +A refined pattern could be `{1900-}|2099|-{1-}|12|-{1-}|31|`. +Now year is within 1900-2099, month between 1 and 12 and day between 1 and 31. +If in addition single digit days and month should be permitted the pattern could be refined to +`{1900-}|2099|-{1-12}(#?#)-{1-31}(#?#)`. If in addition a 2 digit year is accepted the pattern becomes +`{1900-2099}(2#2?#)-{1-12}(#?#)-{1-31}(#?#)`. --- ## Performance and Safety Input Expressions are designed to be **safe** and **fast**: -- Matching runs in **linear time** relative to the pattern length and input length. -- No recursion or backtracking that could cause exponential blow‑up. +- Matching runs in **linear time** relative to the pattern length and/or input length. +- No backtracking that could cause exponential blow‑up. +- No recursion could cause stack overflow - No dynamic heap memory allocation during matching - The implementation guards against infinite loops when a repeat would match zero characters repeatedly. From 2cbd01ba75cb11cd331c69293b9342df92d969c6 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 30 Mar 2026 15:32:08 +0200 Subject: [PATCH 07/20] refactor: pattern validation uses InputExpression, RegEx via @Validator --- INPUT_EXPRESSIONS.md | 33 ++++++++++--------- .../hisp/dhis/jsontree/InputExpression.java | 7 ++++ .../org/hisp/dhis/jsontree/JsonPointer.java | 3 +- .../java/org/hisp/dhis/jsontree/Text.java | 13 ++++++++ .../org/hisp/dhis/jsontree/Validation.java | 31 ++++++++++++++--- .../jsontree/validation/ObjectValidation.java | 9 +++-- .../jsontree/validation/ObjectValidator.java | 16 +++++---- .../validation/PropertyValidation.java | 14 ++++---- .../dhis/jsontree/InputExpressionTest.java | 2 +- .../JsonValidationMiscTypeUseTest.java | 7 +++- ...va => JsonValidationRegExPatternTest.java} | 5 +-- 11 files changed, 100 insertions(+), 40 deletions(-) rename src/test/java/org/hisp/dhis/jsontree/validation/{JsonValidationPatternTest.java => JsonValidationRegExPatternTest.java} (91%) diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md index ba6a82e..a207aa4 100644 --- a/INPUT_EXPRESSIONS.md +++ b/INPUT_EXPRESSIONS.md @@ -142,7 +142,7 @@ Note that a sequence always has a 1:1 length relation to the input it matches. | `@` | literally @ (just to clarify) | `@` | | A-Z | matches the given character case-insensitive | A => `A`,`a`; B => `B`, `b`, ... | | `0` | literally 0 (just to clarify) | `0` | -| 1-9* | the digit is the numerically largest digit | e.g. `3` => `0`-`3` | +| 1-9* | the digit is the numerically largest permitted digit | e.g. `3` => `0`-`3` | | ... | any other character is matched literally | + => `+`, é => `é`, ... | - A `|…|` sequence must not be empty. @@ -185,19 +185,6 @@ Now year is within 1900-2099, month between 1 and 12 and day between 1 and 31. If in addition single digit days and month should be permitted the pattern could be refined to `{1900-}|2099|-{1-12}(#?#)-{1-31}(#?#)`. If in addition a 2 digit year is accepted the pattern becomes `{1900-2099}(2#2?#)-{1-12}(#?#)-{1-31}(#?#)`. ---- - -## Performance and Safety - -Input Expressions are designed to be **safe** and **fast**: - -- Matching runs in **linear time** relative to the pattern length and/or input length. -- No backtracking that could cause exponential blow‑up. -- No recursion could cause stack overflow -- No dynamic heap memory allocation during matching -- The implementation guards against infinite loops when a repeat would match zero characters repeatedly. - -These properties make it suitable for validating user input in contexts where resource usage must be predictable. --- @@ -221,7 +208,7 @@ Common simple value inputs and their patterns: | Value | Input Expression Pattern | Examples | Description | |----------------------|-------------------------------------------------------------------------------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------| -| Integer | `?\|s\|+#` or `?[+-]+#` or `?[s]+#` | `42`, `-7`, `+123`, `0` | Optional sign, one or more digits. | +| Integer | `?\|s\|+#` or `?[+-]+#` or `?[s]+#` (better for Java `int`: `?[+-]9?##`) | `42`, `-7`, `+123`, `0` | Optional sign, one or more digits. | | Decimal | `?[+-]+#?(.*#)` + `?[+-]*#.+#` | `0.5`, `-3.`, `+.7`, `123.456`, `.5` | At least one digit before or after the decimal point; includes integers. | | +Scientific Notation | add `?([E]?[+-]+#)` | `1.23e-4`, `-0.5E+2`, `3.0e0` | Mantissa with at least one digit after decimal, followed by `e` or `E`, optional sign, and exponent digits. | | ISO Date | `\|2099-12-31\|` | `2023-12-25`, `1900-01-01` | Four‑digit year (0‑2099), two‑digit month (0‑12), two‑digit day (0‑31). No calendar validation. | @@ -235,3 +222,19 @@ Common simple value inputs and their patterns: Note in the example of a decimal numbers that allow decimal point without digits before or after this is solved using 2 patterns (+) and matching that either of them matches. + +--- + +## Performance and Safety + +Input Expressions are designed to be **safe** and **fast**: + +- Matching runs in **linear time** relative to the pattern length and/or input length. +- Maliciously large inputs most often can be rejected in constant time O(1) purely based on length. +- No backtracking that could cause exponential blow‑up. +- No recursion could cause stack overflow. +- No dynamic heap memory allocation during matching. +- No infinite loops due to repeating zero character matches. + +These properties make it suitable for validating user input in contexts where resource usage must be predictable. + diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index f54a055..af01b22 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -447,4 +447,11 @@ private static int skipUnit(Text pattern, int offset) { } } + /* + To RegEx equivalent + */ + + public String toRegExEquivalent() { + return ""; //TODO + } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java index 1d0d801..1024f5c 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPointer.java @@ -12,7 +12,8 @@ * @since 1.1 * @param value a pointer expression */ -@Validation(type = STRING, pattern = "(/((~[01])|([^/~]))*)*") +@Validation(type = STRING) +@Validator(value = Validation.RegEx.class, params = @Validation( pattern = "(/((~[01])|([^/~]))*)*")) public record JsonPointer(@NotNull String value) { public JsonPointer { diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java index 09a1758..f735090 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Text.java +++ b/src/main/java/org/hisp/dhis/jsontree/Text.java @@ -188,10 +188,23 @@ default boolean regionMatches(int startIndex, CharSequence sample, int offset, i return true; } + /** + * @param input an input string + * @return if this text as pattern matches the entirety of the given input + */ default boolean matches(CharSequence input) { return matches(input, 0, input.length()); } + /** + * Match an input sub-sequence against this text as pattern. + * @see InputExpression + * + * @param input an input string + * @param offset the first character to match + * @param len length from the first character to match + * @return if this text as pattern matches the subsequence of the given input + */ default boolean matches(CharSequence input, int offset, int len) { return InputExpression.match(toCharArray(), 0, length(), input, offset) == offset + len; } diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 66454f7..a6958de 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -7,6 +7,8 @@ import java.util.List; import java.util.Set; import java.util.function.Consumer; +import java.util.regex.Pattern; + import org.hisp.dhis.jsontree.internal.CheckNull; import org.hisp.dhis.jsontree.internal.NotNull; @@ -291,11 +293,11 @@ public String toString() { /** * If multiple annotations are present all given patterns must match. * - * @return string value must match the given regex pattern + * @see RegEx + * + * @return string value must match the given {@link InputExpression} pattern */ - String pattern() default ""; - //TODO change to String[] pattern() default {}; - // which always are Input expressions, to use RegEx a @Validator can be used (included in lib; move code to that for RegEx) + String[] pattern() default {}; // formats are mostly like named patterns, // instead of repeating the pattern the name is given @@ -462,4 +464,25 @@ public String toString() { */ YesNo acceptNull() default YesNo.AUTO; + /** + * A {@link Validator} that uses RegEx patterns. + * Use via @{@link org.hisp.dhis.jsontree.Validator}. + */ + record RegEx(java.util.regex.Pattern regex) implements Validator { + + public RegEx(Validation params) { + // avoid exception during bootstrapping fail => simply is ineffective + this(params.pattern().length == 0 ? null : Pattern.compile(params.pattern()[0])); + } + + @Override + public void validate(JsonMixed value, Consumer addError) { + if (regex == null) return; // params had no pattern + if (!value.isSimple() || value.isUndefined()) return; + CharSequence actual = value.text(); + if (!regex.matcher(actual).matches()) + addError.accept( + Error.of(Rule.PATTERN, value, "must match %s but was: %s", regex.pattern(), actual)); + } + } } 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 538b1e9..2916551 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -33,6 +33,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListMap; import java.util.stream.Stream; + +import org.hisp.dhis.jsontree.InputExpression; import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Text; @@ -217,7 +219,7 @@ private static PropertyValidation toPropertyValidation(Class type) { ValueValidation values = !type.isPrimitive() ? null : new ValueValidation(YES, Set.of(), AUTO, Set.of(), List.of()); StringValidation strings = - !type.isEnum() ? null : new StringValidation(anyOfStrings(type), AUTO, -1, -1, ""); + !type.isEnum() ? null : new StringValidation(anyOfStrings(type), AUTO, -1, -1, null); return new PropertyValidation(anyOfTypes(type), values, strings, null, null, null, null); } @@ -270,14 +272,15 @@ private static StringValidation toStringValidation(@NotNull Validation src) { if (src.enumeration() == Enum.class && src.minLength() < 0 && src.maxLength() < 0 - && src.pattern().isEmpty() + && src.pattern().length == 0 && src.caseInsensitive().isAuto() && !isAutoUnquotedJsonStrings(src.oneOfValues())) return null; Set anyOfStrings = anyOfStrings(src.enumeration()); if (anyOfStrings.isEmpty() && isAutoUnquotedJsonStrings(src.oneOfValues())) anyOfStrings = Set.of(src.oneOfValues()); + InputExpression pattern = src.pattern().length == 0 ? null : InputExpression.of(src.pattern()); return new StringValidation( - anyOfStrings, src.caseInsensitive(), src.minLength(), src.maxLength(), src.pattern()); + anyOfStrings, src.caseInsensitive(), src.minLength(), src.maxLength(), pattern); } private static Set anyOfStrings(@NotNull Class type) { 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 6d3a251..5b1e711 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -28,6 +28,8 @@ import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Stream; + +import org.hisp.dhis.jsontree.InputExpression; import org.hisp.dhis.jsontree.JsonList; import org.hisp.dhis.jsontree.JsonMap; import org.hisp.dhis.jsontree.JsonMixed; @@ -196,9 +198,9 @@ private static Validator create(@CheckNull PropertyValidation.StringValidation s : new EnumAnyString(strings.anyOfStrings(), strings.caseInsensitive().isYes()), strings.minLength() <= 0 ? null : new MinLength(strings.minLength()), strings.maxLength() <= 1 ? null : new MaxLength(strings.maxLength()), - strings.pattern().isEmpty() + strings.pattern() == null ? null - : new Pattern(java.util.regex.Pattern.compile(strings.pattern()))); + : new Pattern(strings.pattern(), strings.pattern().toRegExEquivalent())); } @CheckNull @@ -488,15 +490,15 @@ public void validate(JsonMixed value, Consumer addError) { } } - private record Pattern(java.util.regex.Pattern regex) implements Validator { + private record Pattern(InputExpression expr, String regExEquivalent) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isString()) return; - String actual = value.string(); - if (!regex.matcher(actual).matches()) + if (!value.isSimple() || value.isUndefined()) return; + Text actual = value.text(); + if (expr.match(actual) == null) addError.accept( - Error.of(Rule.PATTERN, value, "must match %s but was: %s", regex.pattern(), actual)); + Error.of(Rule.PATTERN, value, "must match %s but was: %s", regExEquivalent, actual)); } } 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 8c19b7f..aa23c44 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java @@ -8,6 +8,8 @@ import java.util.List; import java.util.Set; import java.util.stream.Stream; + +import org.hisp.dhis.jsontree.InputExpression; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Validator; import org.hisp.dhis.jsontree.Validation.YesNo; @@ -133,14 +135,14 @@ ValueValidation overlay(@CheckNull ValueValidation with) { * @param caseInsensitive test {@link #anyOfStrings} with {@link String#equalsIgnoreCase(String)} * @param minLength minimum length for the JSON string, negative is off * @param maxLength maximum length for the JSON string, negative is off - * @param pattern JSON string must match the provided pattern, empty string is off + * @param pattern JSON string must match the provided pattern, null is off */ record StringValidation( @NotNull Set anyOfStrings, YesNo caseInsensitive, int minLength, int maxLength, - @NotNull String pattern) { + @CheckNull InputExpression pattern) { StringValidation overlay(@CheckNull StringValidation with) { return with == null ? this @@ -149,7 +151,7 @@ StringValidation overlay(@CheckNull StringValidation with) { overlayY(caseInsensitive, with.caseInsensitive), overlayI(minLength, with.minLength), overlayI(maxLength, with.maxLength), - overlayS(pattern, with.pattern)); + overlayO(pattern, with.pattern)); } } @@ -231,9 +233,9 @@ private static YesNo overlayY(YesNo a, YesNo b) { return b; } - private static String overlayS(String a, String b) { - if (!b.isEmpty()) return b; - if (!a.isEmpty()) return a; + private static T overlayO(T a, T b) { + if (b != null) return b; + if (a != null) return a; return b; } diff --git a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java index dfc45a5..211fa4f 100644 --- a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -133,7 +133,7 @@ void testPattern_DHIS2Periods() { assertMatches(expr,"2025W51", "1989W3", "2044Q1", "2033BiW22"); // pattern issue assertDoesNotMatch(expr, "20221", "2022-1", "1980113", "1980-1-13"); - // numeric out of bounds + // numerically out of bounds assertDoesNotMatch(expr, "2051", "1899", "1980100", "198013", "1980-14", "1977-00-28"); } diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java index ddff01e..0387764 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscTypeUseTest.java @@ -6,6 +6,7 @@ import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.Rule; +import org.hisp.dhis.jsontree.Validator; import org.junit.jupiter.api.Test; class JsonValidationMiscTypeUseTest { @@ -13,7 +14,11 @@ class JsonValidationMiscTypeUseTest { public interface JsonTypeUseExampleA extends JsonObject { @Validation(minItems = 1) - default List<@Validation(maxItems = 2) List<@Validation(pattern = ".es.*") String>> getData() { + default List< + @Validation(maxItems = 2) List< + @Validator(value = Validation.RegEx.class, params = @Validation(pattern = ".es.*")) + String>> + getData() { return getArray("data").values(e -> List.of()); } } diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRegExPatternTest.java similarity index 91% rename from src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java rename to src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRegExPatternTest.java index 75b3cbf..8ff5b26 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRegExPatternTest.java @@ -9,6 +9,7 @@ import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Rule; +import org.hisp.dhis.jsontree.Validator; import org.junit.jupiter.api.Test; /** @@ -16,11 +17,11 @@ * * @author Jan Bernitt */ -class JsonValidationPatternTest { +class JsonValidationRegExPatternTest { public interface JsonPatternExampleA extends JsonObject { - @Validation(pattern = "[0-9]{1,4}[A-Z]?") + @Validator(value = Validation.RegEx.class, params = @Validation(pattern = "[0-9]{1,4}[A-Z]?")) default String no() { return getString("no").string(); } From a4f3fbfe10b7ce5159ec8da640f258203a9981eb Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Wed, 8 Apr 2026 10:22:33 +0200 Subject: [PATCH 08/20] chore: validator cleanup --- INPUT_EXPRESSIONS.md | 24 +- .../hisp/dhis/jsontree/InputExpression.java | 60 +++-- .../java/org/hisp/dhis/jsontree/JsonNode.java | 26 +- .../org/hisp/dhis/jsontree/JsonString.java | 18 +- .../jsontree/validation/ObjectValidation.java | 68 ++--- .../jsontree/validation/ObjectValidator.java | 234 +++++++++++------- ...lidation.java => PropertyValidations.java} | 27 +- .../org/hisp/dhis/jsontree/Assertions.java | 1 + .../dhis/jsontree/InputExpressionTest.java | 22 ++ .../validation/JsonValidationEnumTest.java | 1 + .../validation/JsonValidationMaximumTest.java | 4 +- .../validation/JsonValidationMinimumTest.java | 8 +- .../JsonValidationMiscDeepGraphTest.java | 8 +- 13 files changed, 298 insertions(+), 203 deletions(-) rename src/main/java/org/hisp/dhis/jsontree/validation/{PropertyValidation.java => PropertyValidations.java} (91%) diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md index a207aa4..dde71bc 100644 --- a/INPUT_EXPRESSIONS.md +++ b/INPUT_EXPRESSIONS.md @@ -206,19 +206,19 @@ If in addition single digit days and month should be permitted the pattern could Common simple value inputs and their patterns: -| Value | Input Expression Pattern | Examples | Description | -|----------------------|-------------------------------------------------------------------------------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------| -| Integer | `?\|s\|+#` or `?[+-]+#` or `?[s]+#` (better for Java `int`: `?[+-]9?##`) | `42`, `-7`, `+123`, `0` | Optional sign, one or more digits. | -| Decimal | `?[+-]+#?(.*#)` + `?[+-]*#.+#` | `0.5`, `-3.`, `+.7`, `123.456`, `.5` | At least one digit before or after the decimal point; includes integers. | -| +Scientific Notation | add `?([E]?[+-]+#)` | `1.23e-4`, `-0.5E+2`, `3.0e0` | Mantissa with at least one digit after decimal, followed by `e` or `E`, optional sign, and exponent digits. | -| ISO Date | `\|2099-12-31\|` | `2023-12-25`, `1900-01-01` | Four‑digit year (0‑2099), two‑digit month (0‑12), two‑digit day (0‑31). No calendar validation. | -| Time (24h) | `\|23:59\|`, `\|23:59:59\|` or flexible `\|23:59\|?(\|:59\|)` | `14:30`, `08:05:22` | Two‑digit hour (00‑23), minute (00‑59), and optional seconds (00‑59). | +| Value | Input Expression Pattern | Examples | Description | +|----------------------|------------------------------------------------------------------------------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------| +| Integer | `?\|s\|+#` or `?[+-]+#` or `?[s]+#` (better for Java `int`: `?[+-]#9?#`) | `42`, `-7`, `+123`, `0` | Optional sign, one or more digits. | +| Decimal | `?[+-]+#?(.*#)` + `?[+-]*#.+#` | `0.5`, `-3.`, `+.7`, `123.456`, `.5` | At least one digit before or after the decimal point; includes integers. | +| +Scientific Notation | add `?([E]?[+-]+#)` | `1.23e-4`, `-0.5E+2`, `3.0e0` | Mantissa with at least one digit after decimal, followed by `e` or `E`, optional sign, and exponent digits. | +| ISO Date | `\|2099-12-31\|` | `2023-12-25`, `1900-01-01` | Four‑digit year (0‑2099), two‑digit month (0‑12), two‑digit day (0‑31). No calendar validation. | +| Time (24h) | `\|23:59\|`, `\|23:59:59\|` or flexible `\|23:59\|?(\|:59\|)` | `14:30`, `08:05:22` | Two‑digit hour (00‑23), minute (00‑59), and optional seconds (00‑59). | | UUID | `8[x]-4[x]-4[x]-4[x]-8[x]4[x]` or `\|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\| ` | `123e4567-e89b-12d3-a456-426614174000` | Standard 36‑character UUID: 8‑4‑4‑4‑12 hex digits, separated by hyphens. | -| US Phone | `?(\|(###)\|) 3#-4#` | `(123) 456-7890`, `456-7890` | Area code in parentheses, optional; then three digits, hyphen, four digits. | -| IPv4 Address | `\|255.255.255.255\|` (3 digits each only) `#2?#.#2?#.#2?#.#2#` (1-3 digits) | `192.168.1.1`, `0.0.0.0`, `255.255.255.255` | Four numbers 0‑255 separated by dots. Each `0-255` matches 3 digits numerically 0-255. | -| MAC Address | `\|xx-xx-xx-xx-xx-xx\|` or `2[x]:2[x]:2[x]:2[x]:2[x]:2[x]` | `00:1A:2B:3C:4D:5E`, `00-1A-2B-3C-4D-5E` | Six groups of two hex digits, separated by colon or hyphen. | -| Currency (USD) | `$?-#+*(,3#)?(.2#)` | `$1,234.56`, `$0.99`, `$-12.34` | Dollar sign, optional sign, digits, optional comma, optional decimal part. | -| US Zip Code | `5#?(-4#)` | `12345`, `12345-6789` | Five digits, optionally hyphen and four digits. | +| US Phone | `?(\|(###)\|) 3#-4#` | `(123) 456-7890`, `456-7890` | Area code in parentheses, optional; then three digits, hyphen, four digits. | +| IPv4 Address | `\|255.255.255.255\|` (3 digits each only) `#2?#.#2?#.#2?#.#2#` (1-3 digits) | `192.168.1.1`, `0.0.0.0`, `255.255.255.255` | Four numbers 0‑255 separated by dots. Each `0-255` matches 3 digits numerically 0-255. | +| MAC Address | `\|xx-xx-xx-xx-xx-xx\|` or `2[x]:2[x]:2[x]:2[x]:2[x]:2[x]` | `00:1A:2B:3C:4D:5E`, `00-1A-2B-3C-4D-5E` | Six groups of two hex digits, separated by colon or hyphen. | +| Currency (USD) | `$?-#+*(,3#)?(.2#)` | `$1,234.56`, `$0.99`, `$-12.34` | Dollar sign, optional sign, digits, optional comma, optional decimal part. | +| US Zip Code | `5#?(-4#)` | `12345`, `12345-6789` | Five digits, optionally hyphen and four digits. | Note in the example of a decimal numbers that allow decimal point without digits before or after this is solved using 2 patterns (+) and matching that either of them matches. diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index af01b22..b6d83ee 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -6,17 +6,19 @@ import static java.lang.Integer.MAX_VALUE; /** - * Input patterns are specifically designed to match short sequences where the relevant features - * to match are in the ASCII range. Typical examples are numbers, like dates or telephone numbers - * as well as alphanumeric values, like UUIDs, codes and other identifiers. + * Input patterns are specifically designed to match short sequences where the relevant features to + * match are in the ASCII range. Typical examples are numbers, like dates or telephone numbers as + * well as alphanumeric values, like UUIDs, codes and other identifiers. * *

Performance and Safety

+ * * A key aspect of the design is that matching is allocation-free and guaranteed to be linear time. - * Mostly that is linear to the pattern length. In case of open-ended repeats and scans this is - * linear to the input length. This makes it fairly safe to run against user input without risking - * that matching causes excessive resource usage. + * Mostly that is linear to the pattern length. In case of open-ended repeats and scans this can be + * linear to the input length as a worse case scenario. This makes it fairly safe to run against + * user input without risking that matching causes excessive resource usage. * *

Syntax

+ * * See {@code INPUT_EXPRESSIONS.md} in the repository root for details * * @author Jan Bernitt @@ -48,21 +50,27 @@ public static boolean matches(char[] pattern, CharSequence input) { public static int match(char[] pattern, int offset, int end, CharSequence input, int pos) { int i = offset; int len = input.length(); + // note that pos < len cannot be tested in the while condition + // as there might be pattern left that can match nothing with nothing left while (i < end && pos >= 0) { int iCode = i; char opcode = pattern[i++]; switch (opcode) { - case '#': if (!isDigit(input.charAt(pos++))) return -1; break; - case '@': if (!isIdentifier(input.charAt(pos++))) return -1; break; + case '#': if (pos >= len || !isDigit(input.charAt(pos++))) return -1; break; + case '@': if (pos >= len || !isIdentifier(input.charAt(pos++))) return -1; break; case '[': { - if (!matchSet(pattern, i, input.charAt(pos++))) return -1; + if (pos >= len || !matchSet(pattern, i, input.charAt(pos++))) return -1; + // optimisation: jumped here as unit, return directly + if (iCode == offset && offset > 0) return pos; i = skipTo(']', pattern, i) + 1; } break; case '|': { - if (!matchSequence(pattern, i, input, pos)) return -1; + pos = matchSequence(pattern, i, input, pos); + if (pos < 0) return -1; + // optimisation: jumped here as unit, return directly + if (iCode == offset && offset > 0) return pos; i = skipTo('|', pattern, i) + 1; - pos += i - iCode - 2; // 1:1 length relation: set char => 1 input char } break; case '?', '*', '+', '1','2','3','4','5','6','7','8','9': { @@ -197,41 +205,41 @@ private static int matchNumericBounds(char[] pattern, int offset, CharSequence i * |...| * */ - private static boolean matchSequence(char[] pattern, int offset, CharSequence input, int pos) { + private static int matchSequence(char[] pattern, int offset, CharSequence input, int pos) { int i = offset; int len = input.length(); while (i < pattern.length && pos < len && (i == offset || pattern[i] != '|')) { char in = input.charAt(pos++); char opcode = pattern[i++]; switch (opcode) { - case 'b': if (!isBinary(in)) return false; break; - case 'd', '#': if (!isDigit(in)) return false; break; - case 'i': if (!isIdentifier(in)) return false; break; - case 'u': if (!isUpperLetter(in)) return false; break; - case 'l': if (!isLowerLetter(in)) return false; break; - case 'c': if (!isLetter(in)) return false; break; - case 'a': if (!isAlphanumeric(in)) return false; break; - case 'x': if (!isHexadecimal(in)) return false; break; - case 's': if (!isSign(in)) return false; break; + case 'b': if (!isBinary(in)) return -1; break; + case 'd', '#': if (!isDigit(in)) return -1; break; + case 'i': if (!isIdentifier(in)) return -1; break; + case 'u': if (!isUpperLetter(in)) return -1; break; + case 'l': if (!isLowerLetter(in)) return -1; break; + case 'c': if (!isLetter(in)) return -1; break; + case 'a': if (!isAlphanumeric(in)) return -1; break; + case 'x': if (!isHexadecimal(in)) return -1; break; + case 's': if (!isSign(in)) return -1; break; case '?': break; // any character, always fine default: if (isUpperLetter(opcode)) { - if (opcode != (in & ~0b10_0000)) return false; break; + if (opcode != (in & ~0b10_0000)) return -1; break; } else if (isDigit(opcode)) { - if (!isDigit(in)) return false; // easy case + if (!isDigit(in)) return -1; // easy case int pos0 = pos-1; pos = matchNumericSequence(pattern, i-1, input, pos0); - if (pos < 0) return false; + if (pos < 0) return -1; i += pos - pos0 - 1; } else if (isLowerLetter(opcode)) { throw reserved(opcode); } else { // everything else is taken literally - if (opcode != in) return false; break; + if (opcode != in) return -1; break; } } } - return true; + return pos; } private static int matchNumericSequence(char[] pattern, int offset, CharSequence input, int pos) { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java index e20beb3..1f6075f 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNode.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java @@ -372,19 +372,6 @@ default JsonNode getIfExists(@NotNull JsonPath subPath) throws JsonTreeException throw notAnObject(subPath, this, "getIfExists(JsonPath)"); } - /** - * Size of an array or number of object members. - * - *

This is preferable to calling {@link #value()} or {@link #members()} or {@link #elements()} - * when size is only property of interest as it might have better performance. - * - * @return number of elements in an array or number of fields in an object, otherwise undefined - * @throws JsonTreeException when this node in neither an array nor an object - */ - default int size() { - throw notAContainer(this, "size()"); - } - /** * @param subPath relative path to check * @return true, only if a node exists that the given path relative to this node (this includes a @@ -406,6 +393,19 @@ default boolean isEmpty() { throw notAContainer(this, "isEmpty()"); } + /** + * Size of an array or number of object members. + * + *

This is preferable to calling {@link #value()} or {@link #members()} or {@link #elements()} + * when size is only property of interest as it might have better performance. + * + * @return number of elements in an array or number of fields in an object, otherwise undefined + * @throws JsonTreeException when this node in neither an array nor an object + */ + default int size() { + throw notAContainer(this, "size()"); + } + /** * OBS! Only defined when this node is of type {@link JsonNodeType#OBJECT}). * diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonString.java b/src/main/java/org/hisp/dhis/jsontree/JsonString.java index 299290f..e60e893 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonString.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonString.java @@ -55,7 +55,7 @@ default JsonString getValue() { /** * @return the text of the string node, {@code null} when this property is undefined or defined as * JSON {@code null}. - * @throws JsonTreeException in case this node exists but is not a string node (or null) + * @throws JsonTreeException in case this node exist but does not support {@link JsonNode#textValue()} * @since 1.9 */ @TerminalOp(canBeUndefined = true) @@ -64,9 +64,21 @@ default Text text() { return node == null || node.isNull() ? null : node.textValue(); } + /** + * + * @return length of the text of this node if it exists, or -1 if it does not exists + * @throws JsonTreeException in case this node exist but does not support {@link JsonNode#textValue()} + * @since 1.9 + */ + @TerminalOp(canBeUndefined = true) + default int length() { + JsonNode node = nodeIfExists(); + return node == null ? -1 : node.textValue().length(); + } + /** * @return the first character of the text or null if it is empty or undefined - * @throws JsonTreeException in case this node exists but is not a string node (or null) + * @throws JsonTreeException in case this node exist but does not support {@link JsonNode#textValue()} * @since 1.9 */ @TerminalOp(canBeUndefined = true) @@ -86,7 +98,7 @@ default char charValue() { /** * @return string value of the property or {@code null} when this property is undefined or defined * as JSON {@code null}. - * @throws JsonTreeException in case this node exists but is not a string node (or null) + * @throws JsonTreeException in case this node exist but does not support {@link JsonNode#textValue()} */ @TerminalOp(canBeUndefined = true) default String string() { 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 2916551..0f64eaf 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -43,13 +43,13 @@ import org.hisp.dhis.jsontree.Validator; import org.hisp.dhis.jsontree.internal.CheckNull; import org.hisp.dhis.jsontree.internal.NotNull; -import org.hisp.dhis.jsontree.validation.PropertyValidation.ArrayValidation; -import org.hisp.dhis.jsontree.validation.PropertyValidation.NumberValidation; -import org.hisp.dhis.jsontree.validation.PropertyValidation.StringValidation; -import org.hisp.dhis.jsontree.validation.PropertyValidation.ValueValidation; +import org.hisp.dhis.jsontree.validation.PropertyValidations.ArrayValidation; +import org.hisp.dhis.jsontree.validation.PropertyValidations.NumberValidation; +import org.hisp.dhis.jsontree.validation.PropertyValidations.StringValidation; +import org.hisp.dhis.jsontree.validation.PropertyValidations.ValueValidation; /** - * Analysis types and annotations to extract a {@link PropertyValidation} model description. + * Analysis types and annotations to extract a {@link PropertyValidations} model description. * * @author Jan Bernitt * @since 0.11 @@ -60,7 +60,7 @@ record ObjectValidation( @NotNull Class schema, @NotNull Map types, - @NotNull Map properties) { + @NotNull Map properties) { /** Cache for all validation applied to a particular object schema. */ private static final Map, ObjectValidation> INSTANCES = new ConcurrentHashMap<>(); @@ -69,7 +69,7 @@ record ObjectValidation( * A cache for the validations set for any use (mapping target) of a particular Java type from an * annotation put on the type declaration itself. */ - private static final Map, PropertyValidation> TYPE_DECLARATION_VALIDATIONS = + private static final Map, PropertyValidations> TYPE_DECLARATION_VALIDATIONS = new ConcurrentSkipListMap<>(comparing(Class::getName)); /** @@ -77,7 +77,7 @@ record ObjectValidation( * meta-annotations which they aggregate. The map caches the validation set for a particular * meta-annotation type. */ - private static final Map, PropertyValidation> + private static final Map, PropertyValidations> META_TYPE_BY_VALIDATIONS = new ConcurrentHashMap<>(); /** @@ -97,7 +97,7 @@ public static ObjectValidation of(Class schema) { private static ObjectValidation createInstance( Class schema, List properties) { - Map validations = new HashMap<>(); + Map validations = new HashMap<>(); Map types = new HashMap<>(); properties.stream() .filter(ObjectValidation::isNotIgnored) @@ -115,9 +115,9 @@ private static boolean isNotIgnored(JsonObject.Property p) { } @CheckNull - private static PropertyValidation fromProperty(JsonObject.Property p) { - PropertyValidation onMethod = fromAnnotations(p.source()); - PropertyValidation onReturnType = fromValueTypeUse(p.javaType()); + private static PropertyValidations fromProperty(JsonObject.Property p) { + PropertyValidations onMethod = fromAnnotations(p.source()); + PropertyValidations onReturnType = fromValueTypeUse(p.javaType()); if (onMethod == null) return onReturnType; if (onReturnType == null) return onMethod; return onMethod.overlay(onReturnType); @@ -128,7 +128,7 @@ private static PropertyValidation fromProperty(JsonObject.Property p) { * @return validation based on the Java value type (this includes annotations on the class type) */ @CheckNull - private static PropertyValidation fromValueTypeUse(AnnotatedType src) { + private static PropertyValidations fromValueTypeUse(AnnotatedType src) { Type type = src.getType(); if (type instanceof Class simpleType) return fromValueTypeDeclaration(simpleType).overlay(fromAnnotations(src)); @@ -136,7 +136,7 @@ private static PropertyValidation fromValueTypeUse(AnnotatedType src) { if (!(src instanceof AnnotatedParameterizedType pt)) return null; Type rt = ((ParameterizedType) pt.getType()).getRawType(); Class rawType = (Class) rt; - PropertyValidation base = fromValueTypeDeclaration(rawType).overlay(fromAnnotations(src)); + PropertyValidations base = fromValueTypeDeclaration(rawType).overlay(fromAnnotations(src)); AnnotatedType[] typeArguments = pt.getAnnotatedActualTypeArguments(); if (typeArguments.length == 1) return base.withItems(fromValueTypeUse(typeArguments[0])); if (Map.class.isAssignableFrom(rawType)) { @@ -147,12 +147,12 @@ private static PropertyValidation fromValueTypeUse(AnnotatedType src) { } @NotNull - private static PropertyValidation fromValueTypeDeclaration(@NotNull Class type) { + private static PropertyValidations fromValueTypeDeclaration(@NotNull Class type) { return TYPE_DECLARATION_VALIDATIONS.computeIfAbsent( type, t -> { - PropertyValidation declared = fromAnnotations(t); - PropertyValidation inferred = declared != null ? declared : toPropertyValidation(t); + PropertyValidations declared = fromAnnotations(t); + PropertyValidations inferred = declared != null ? declared : toPropertyValidation(t); if (Object[].class.isAssignableFrom(t)) return inferred.withItems(fromValueTypeDeclaration(t.getComponentType())); return inferred; @@ -160,15 +160,15 @@ private static PropertyValidation fromValueTypeDeclaration(@NotNull Class typ } @CheckNull - private static PropertyValidation fromAnnotations(AnnotatedElement src) { - PropertyValidation meta = fromMetaAnnotations(src); + private static PropertyValidations fromAnnotations(AnnotatedElement src) { + PropertyValidations meta = fromMetaAnnotations(src); Validation validation = getValidationAnnotation(src); - PropertyValidation main = validation == null ? null : toPropertyValidation(validation); + PropertyValidations main = validation == null ? null : toPropertyValidation(validation); List validators = toValidators(src); - PropertyValidation items = fromItems(src); - PropertyValidation base = meta == null ? main : meta.overlay(main); + PropertyValidations items = fromItems(src); + PropertyValidations base = meta == null ? main : meta.overlay(main); if (base == null && items == null && validators.isEmpty()) return null; - if (base == null) base = new PropertyValidation(Set.of(), null, null, null, null, null, null); + if (base == null) base = new PropertyValidations(Set.of(), null, null, null, null, null, null); return base.withCustoms(validators).withItems(items); } @@ -185,19 +185,19 @@ private static Validation getValidationAnnotation(AnnotatedElement src) { } @CheckNull - private static PropertyValidation fromMetaAnnotations(AnnotatedElement src) { + private static PropertyValidations fromMetaAnnotations(AnnotatedElement src) { Annotation[] candidates = src.getAnnotations(); if (candidates.length == 0) return null; return Stream.of(candidates) .sorted(comparing(a -> a.annotationType().getSimpleName())) .map(ObjectValidation::fromMetaAnnotation) .filter(Objects::nonNull) - .reduce(PropertyValidation::overlay) + .reduce(PropertyValidations::overlay) .orElse(null); } @CheckNull - private static PropertyValidation fromMetaAnnotation(Annotation a) { + private static PropertyValidations fromMetaAnnotation(Annotation a) { Class type = a.annotationType(); if (!type.isAnnotationPresent(Validation.class)) return null; return META_TYPE_BY_VALIDATIONS.computeIfAbsent( @@ -209,24 +209,24 @@ private static PropertyValidation fromMetaAnnotation(Annotation a) { } @CheckNull - private static PropertyValidation fromItems(AnnotatedElement src) { + private static PropertyValidations fromItems(AnnotatedElement src) { if (!src.isAnnotationPresent(Validation.Items.class)) return null; return toPropertyValidation(src.getAnnotation(Validation.Items.class).value()); } @NotNull - private static PropertyValidation toPropertyValidation(Class type) { + private static PropertyValidations toPropertyValidation(Class type) { ValueValidation values = !type.isPrimitive() ? null : new ValueValidation(YES, Set.of(), AUTO, Set.of(), List.of()); StringValidation strings = !type.isEnum() ? null : new StringValidation(anyOfStrings(type), AUTO, -1, -1, null); - return new PropertyValidation(anyOfTypes(type), values, strings, null, null, null, null); + return new PropertyValidations(anyOfTypes(type), values, strings, null, null, null, null); } @NotNull - private static PropertyValidation toPropertyValidation(@NotNull Validation src) { - PropertyValidation res = - new PropertyValidation( + private static PropertyValidations toPropertyValidation(@NotNull Validation src) { + PropertyValidations res = + new PropertyValidations( anyOfTypes(src), toValueValidation(src), toStringValidation(src), @@ -311,9 +311,9 @@ private static ArrayValidation toArrayValidation(@NotNull Validation src) { } @CheckNull - private static PropertyValidation.ObjectValidation toObjectValidation(@NotNull Validation src) { + private static PropertyValidations.ObjectValidation toObjectValidation(@NotNull Validation src) { if (src.minProperties() < 0 && src.maxProperties() < 0) return null; - return new PropertyValidation.ObjectValidation(src.minProperties(), src.maxProperties()); + return new PropertyValidations.ObjectValidation(src.minProperties(), src.maxProperties()); } @NotNull 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 5b1e711..eee5384 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -83,10 +83,10 @@ public static ObjectValidator of(Class schema) { } private static ObjectValidator of(Class schema, Set> currentlyResolved) { - return getInstance(schema, () -> ObjectValidation.of(schema), currentlyResolved); + return ofObject(schema, () -> ObjectValidation.of(schema), currentlyResolved); } - private static ObjectValidator getInstance( + private static ObjectValidator ofObject( Class schema, Supplier analyse, Set> currentlyResolved) { return BY_SCHEMA_TYPE.computeIfAbsent( schema, @@ -94,16 +94,16 @@ private static ObjectValidator getInstance( currentlyResolved.add(type); Map res = new TreeMap<>(); ObjectValidation objectValidation = analyse.get(); - Map properties = objectValidation.properties(); + Map properties = objectValidation.properties(); properties.forEach( - (property, validation) -> { - Validator propValidator = create(validation, property); + (property, validations) -> { + Validator propValidator = of(property, validations); Validator propTypeValidator = - getInstance(objectValidation.types().get(property), currentlyResolved); + ofJavaType(objectValidation.types().get(property), currentlyResolved); Validator validator = If.of(propValidator, propTypeValidator); if (validator != null) res.put(JsonPath.of(property), validator); }); - Validator dependentRequired = createDependentRequired(properties); + Validator dependentRequired = ofDependentRequired(properties); if (dependentRequired != null) res.put(JsonPath.SELF, dependentRequired); return new ObjectValidator(type, Map.copyOf(res)); }); @@ -118,7 +118,7 @@ private static ObjectValidator getInstance( * superinterfaces. */ @CheckNull - private static Validator getInstance( + private static Validator ofJavaType( java.lang.reflect.Type type, Set> currentlyResolved) { if (type instanceof Class schema) { if (JsonObject.class.isAssignableFrom(schema) || Record.class.isAssignableFrom(schema)) { @@ -128,16 +128,16 @@ private static Validator getInstance( } else if (type instanceof ParameterizedType pt) { Class rawType = (Class) pt.getRawType(); if (JsonMap.class.isAssignableFrom(rawType)) - return Items.of(getInstance(pt.getActualTypeArguments()[0], currentlyResolved)); + return Items.of(ofJavaType(pt.getActualTypeArguments()[0], currentlyResolved)); if (JsonList.class.isAssignableFrom(rawType)) - return Items.of(getInstance(pt.getActualTypeArguments()[0], currentlyResolved)); + return Items.of(ofJavaType(pt.getActualTypeArguments()[0], currentlyResolved)); } return null; } @CheckNull - private static Validator create(PropertyValidation node, Text property) { - Set anyOf = node.anyOfTypes(); + private static Validator of(Text property, PropertyValidations validations) { + Set anyOf = validations.anyOfTypes(); Map byType = new EnumMap<>(JsonNodeType.class); BiConsumer add = @@ -145,22 +145,21 @@ private static Validator create(PropertyValidation node, Text property) { if (validator != null) byType.put(type, validator); }; Predicate has = type -> anyOf.isEmpty() || anyOf.contains(type); - if (has.test(STRING)) add.accept(JsonNodeType.STRING, create(node.strings())); - if (has.test(NUMBER)) add.accept(JsonNodeType.NUMBER, create(node.numbers())); - if (has.test(INTEGER)) add.accept(JsonNodeType.NUMBER, create(node.numbers())); - if (has.test(ARRAY)) add.accept(JsonNodeType.ARRAY, create(node.arrays())); - if (has.test(OBJECT)) add.accept(JsonNodeType.OBJECT, create(node.objects())); + if (has.test(STRING)) add.accept(JsonNodeType.STRING, ofString(validations.strings())); + if (has.test(NUMBER) || has.test(INTEGER)) add.accept(JsonNodeType.NUMBER, ofNumber(validations.numbers())); + if (has.test(ARRAY)) add.accept(JsonNodeType.ARRAY, ofArray(validations.arrays())); + if (has.test(OBJECT)) add.accept(JsonNodeType.OBJECT, ofObject(validations.objects())); Validator type = anyOf.isEmpty() ? null : new Type(anyOf); - Validator anyType = create(node.values()); + Validator anyType = ofGeneric(validations.values()); Validator typeDependent = byType.isEmpty() ? null : new TypeDependent(byType); - Validator items = node.items() == null ? null : Items.of(create(node.items(), property)); + Validator items = validations.items() == null ? null : Items.of(of(property, validations.items())); Validator whenDefined = If.of(type, If.of(anyType, If.of(typeDependent, items))); - PropertyValidation.ValueValidation values = node.values(); + PropertyValidations.ValueValidation values = validations.values(); boolean isRequiredYes = values != null && values.required().isYes(); boolean isRequiredAuto = - (values == null || values.required().isAuto()) && isRequiredImplicitly(node, anyOf); + (values == null || values.required().isAuto()) && isRequiredImplicitly(validations, anyOf); boolean isAllowNull = values != null && values.allowNull().isYes(); Validator required = !isRequiredYes && !isRequiredAuto ? null : new Required(property, isAllowNull); @@ -171,31 +170,35 @@ private static Validator create(PropertyValidation node, Text property) { * minItems, minLength, minProperties implicitly means this is required if there is only one type * possible and if required is AUTO */ - private static boolean isRequiredImplicitly(PropertyValidation node, Set anyOf) { - return anyOf.size() == 1 - && (anyOf.contains(STRING) && node.strings() != null && node.strings().minLength() > 0 - || anyOf.contains(ARRAY) && node.arrays() != null && node.arrays().minItems() > 0 - || anyOf.contains(OBJECT) - && node.objects() != null - && node.objects().minProperties() > 0); + private static boolean isRequiredImplicitly(PropertyValidations validations, Set types) { + return types.stream().allMatch(type -> isRequiredImplicitly(validations, type)); + } + + private static boolean isRequiredImplicitly(PropertyValidations validations, NodeType type) { + return switch (type) { + case STRING -> validations.strings() != null && validations.strings().minLength() > 0; + case ARRAY -> validations.arrays() != null && validations.arrays().minItems() > 0; + case OBJECT -> validations.objects() != null && validations.objects().minProperties() > 0; + default -> false; + }; } @CheckNull - private static Validator create(@CheckNull PropertyValidation.ValueValidation values) { + private static Validator ofGeneric(@CheckNull PropertyValidations.ValueValidation values) { return values == null ? null : All.of( - values.anyOfJsons().isEmpty() ? null : new EnumAnyJson(values.anyOfJsons()), + values.anyOfJsons().isEmpty() + ? null + : new EnumJson(Set.copyOf(values.anyOfJsons().stream().map(JsonMixed::of).toList())), values.customs().isEmpty() ? null : new All(values.customs())); } @CheckNull - private static Validator create(@CheckNull PropertyValidation.StringValidation strings) { + private static Validator ofString(@CheckNull PropertyValidations.StringValidation strings) { if (strings == null) return null; return All.of( - strings.anyOfStrings().isEmpty() - ? null - : new EnumAnyString(strings.anyOfStrings(), strings.caseInsensitive().isYes()), + ofStringEnum(strings), strings.minLength() <= 0 ? null : new MinLength(strings.minLength()), strings.maxLength() <= 1 ? null : new MaxLength(strings.maxLength()), strings.pattern() == null @@ -203,44 +206,56 @@ private static Validator create(@CheckNull PropertyValidation.StringValidation s : new Pattern(strings.pattern(), strings.pattern().toRegExEquivalent())); } + private static Validator ofStringEnum(@NotNull PropertyValidations.StringValidation strings) { + Set constants = strings.anyOfStrings(); + if (constants.isEmpty()) return null; + boolean caseInsensitive = strings.caseInsensitive().isYes(); + if (!caseInsensitive) return EnumCaseSensitive.of(constants); + return new EnumCaseInsensitive(constants); + } + @CheckNull - private static Validator create(@CheckNull PropertyValidation.NumberValidation numbers) { - return numbers == null - ? null - : All.of( - isNaN(numbers.minimum()) ? null : new Minimum(numbers.minimum()), - isNaN(numbers.maximum()) ? null : new Maximum(numbers.maximum()), - isNaN(numbers.exclusiveMinimum()) - ? null - : new ExclusiveMinimum(numbers.exclusiveMinimum()), - isNaN(numbers.exclusiveMaximum()) - ? null - : new ExclusiveMaximum(numbers.exclusiveMaximum()), - isNaN(numbers.multipleOf()) ? null : new MultipleOf(numbers.multipleOf())); + private static Validator ofNumber(@CheckNull PropertyValidations.NumberValidation numbers) { + if (numbers == null) return null; + double min = numbers.minimum(); + double max = numbers.maximum(); + double minEx = numbers.exclusiveMinimum(); + double maxEx = numbers.exclusiveMaximum(); + return All.of( + isNaN(min) ? null : isIntRange(min) ? new MinimumInt((int) min) : new Minimum(min), + isNaN(max) ? null : isIntRange(max) ? new MaximumInt((int) max) : new Maximum(max), + isNaN(minEx) ? null : new ExclusiveMinimum(minEx), + isNaN(maxEx) ? null : new ExclusiveMaximum(maxEx), + isNaN(numbers.multipleOf()) ? null : new MultipleOf(numbers.multipleOf())); + } + + private static boolean isIntRange(double limit) { + if (limit % 1d != 0d) return false; + return limit < 0 ? limit >= Integer.MIN_VALUE : limit <= Integer.MAX_VALUE; } @CheckNull - private static Validator create(@CheckNull PropertyValidation.ArrayValidation arrays) { + private static Validator ofArray(@CheckNull PropertyValidations.ArrayValidation arrays) { return arrays == null ? null : All.of( - arrays.minItems() <= 0 ? null : new MinItems(arrays.minItems()), - arrays.maxItems() <= 1 ? null : new MaxItems(arrays.maxItems()), - !arrays.uniqueItems().isYes() ? null : new UniqueItems()); + arrays.minItems() <= 0 ? null : new MinItems(arrays.minItems()), + arrays.maxItems() <= 1 ? null : new MaxItems(arrays.maxItems()), + !arrays.uniqueItems().isYes() ? null : new UniqueItems()); } @CheckNull - private static Validator create(@CheckNull PropertyValidation.ObjectValidation objects) { + private static Validator ofObject(@CheckNull PropertyValidations.ObjectValidation objects) { return objects == null ? null : All.of( - objects.minProperties() <= 0 ? null : new MinProperties((objects.minProperties())), - objects.maxProperties() <= 1 ? null : new MaxProperties((objects.maxProperties()))); + objects.minProperties() <= 0 ? null : new MinProperties((objects.minProperties())), + objects.maxProperties() <= 1 ? null : new MaxProperties((objects.maxProperties()))); } @CheckNull - private static Validator createDependentRequired( - @NotNull Map properties) { + private static Validator ofDependentRequired( + @NotNull Map properties) { if (properties.isEmpty()) return null; if (properties.values().stream() .allMatch(p -> p.values() == null || p.values().dependentRequired().isEmpty())) return null; @@ -248,7 +263,7 @@ private static Validator createDependentRequired( Map> isMissing = new HashMap<>(); properties.forEach( (name, validation) -> { - PropertyValidation.ValueValidation values = validation.values(); + PropertyValidations.ValueValidation values = validation.values(); isMissing.put(name, isMissing(values)); if (values != null && !values.dependentRequired().isEmpty()) { values @@ -314,7 +329,7 @@ private static Validator createDependentRequired( } @NotNull - private static BiPredicate isMissing(PropertyValidation.ValueValidation values) { + private static BiPredicate isMissing(PropertyValidations.ValueValidation values) { if (values == null || !values.allowNull().isYes()) return JsonMixed::isUndefined; BiPredicate test = JsonMixed::exists; return test.negate(); @@ -388,11 +403,11 @@ public void validate(JsonMixed value, Consumer addError) { } } - private record TypeDependent(Map anyOf) implements Validator { + private record TypeDependent(Map byType) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - Validator forType = anyOf.get(value.type()); + Validator forType = byType.get(value.type()); if (forType != null) forType.validate(value, addError); } } @@ -427,17 +442,16 @@ public void validate(JsonMixed value, Consumer addError) { /** * The value must be one of the provided JSON strings * - * @param constants each a valid JSON string + * @param constants expected JSON values */ - private record EnumAnyJson(Set constants) implements Validator { + private record EnumJson(Set constants) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (value.isUndefined()) return; - String json = value.toMinimizedJson(); - if (!constants.contains(json)) - addError.accept( - Error.of(Rule.ENUM, value, "must be one of %s but was: %s", constants, json)); + for (JsonMixed c : constants) + if (c.equivalentTo(value)) return; + addError.accept( + Error.of(Rule.ENUM, value, "must be one of %s but was: %s", constants, value)); } } @@ -445,22 +459,47 @@ public void validate(JsonMixed value, Consumer addError) { string values */ - private record EnumAnyString(Set constants, boolean caseInsensitive) - implements Validator { + private record EnumCaseSensitive(Set constants) implements Validator { + + static EnumCaseSensitive of(Set constants) { + return new EnumCaseSensitive(Set.copyOf(constants.stream().map(Text::of).toList())); + } + + @Override + public void validate(JsonMixed value, Consumer addError) { + Text actual = value.text(); + if (!constants.contains(actual)) + addError.accept( + Error.of( + Rule.ENUM, + value, + "must be one of %s but was: %s", + constants, + actual)); + } + } + + private record EnumCaseInsensitive(InputExpression expr, Set constants) implements Validator { + + EnumCaseInsensitive(Set constants) { + this( + InputExpression.of( + constants.stream() + .map(String::toUpperCase) + .map("|%s|"::formatted) + .toArray(String[]::new)), + constants); + } @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isString()) return; - String actual = value.string(); - if (!constants.contains(actual) && !caseInsensitive - || caseInsensitive && constants.stream().noneMatch(name -> name.equalsIgnoreCase(actual))) + Text actual = value.text(); + if (expr.match(actual) == null) addError.accept( Error.of( Rule.ENUM, value, - "must be one of %s" - + (caseInsensitive ? " (case insensitive)" : "") - + " but was: %s", + "must be one of %s (case insensitive) but was: %s", constants, actual)); } @@ -470,8 +509,7 @@ private record MinLength(int limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isString()) return; - int actual = value.string().length(); + int actual = value.length(); if (actual < limit) addError.accept( Error.of(Rule.MIN_LENGTH, value, "length must be >= %d but was: %d", limit, actual)); @@ -482,8 +520,7 @@ private record MaxLength(int limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isString()) return; - int actual = value.string().length(); + int actual = value.length(); if (actual > limit) addError.accept( Error.of(Rule.MAX_LENGTH, value, "length must be <= %d but was: %d", limit, actual)); @@ -494,7 +531,6 @@ private record Pattern(InputExpression expr, String regExEquivalent) implements @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isSimple() || value.isUndefined()) return; Text actual = value.text(); if (expr.match(actual) == null) addError.accept( @@ -506,23 +542,40 @@ public void validate(JsonMixed value, Consumer addError) { number values */ + private record MinimumInt(int limit) implements Validator { + + @Override + public void validate(JsonMixed value, Consumer addError) { + int actual = value.intValue(); + if (actual < limit) + addError.accept(Error.of(Rule.MINIMUM, value, "must be >= %d but was: %d", limit, actual)); + } + } + private record Minimum(double limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isNumber()) return; - double actual = value.number().doubleValue(); + double actual = value.doubleValue(); if (actual < limit) addError.accept(Error.of(Rule.MINIMUM, value, "must be >= %f but was: %f", limit, actual)); } } + private record MaximumInt(int limit) implements Validator { + @Override + public void validate(JsonMixed value, Consumer addError) { + int actual = value.intValue(); + if (actual > limit) + addError.accept(Error.of(Rule.MAXIMUM, value, "must be <= %d but was: %d", limit, actual)); + } + } + private record Maximum(double limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isNumber()) return; - double actual = value.number().doubleValue(); + double actual = value.doubleValue(); if (actual > limit) addError.accept(Error.of(Rule.MAXIMUM, value, "must be <= %f but was: %f", limit, actual)); } @@ -532,8 +585,7 @@ private record ExclusiveMinimum(double limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isNumber()) return; - double actual = value.number().doubleValue(); + double actual = value.doubleValue(); if (actual <= limit) addError.accept( Error.of(Rule.EXCLUSIVE_MINIMUM, value, "must be > %f but was: %f", limit, actual)); @@ -544,8 +596,7 @@ private record ExclusiveMaximum(double limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isNumber()) return; - double actual = value.number().doubleValue(); + double actual = value.doubleValue(); if (actual >= limit) addError.accept( Error.of(Rule.EXCLUSIVE_MAXIMUM, value, "must be < %f but was: %f", limit, actual)); @@ -556,8 +607,7 @@ private record MultipleOf(double n) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isNumber()) return; - double actual = value.number().doubleValue(); + double actual = value.doubleValue(); if (actual % n > 0d) addError.accept( Error.of(Rule.MULTIPLE_OF, value, "must be a multiple of %f but was: %f", n, actual)); @@ -683,7 +733,7 @@ public void validate(JsonMixed value, Consumer addError) { boolean equalsNotMet = !equals.isEmpty() && equals.entrySet().stream() - .anyMatch(e -> !e.getValue().equals(value.getString(e.getKey()).string())); + .anyMatch(e -> !e.getValue().equals(value.getString(e.getKey()).string())); if (presentNotMet || absentNotMet || equalsNotMet) return; if (!dependents.isEmpty() && dependents.stream().anyMatch(p -> isMissing.get(p).test(value, p))) { diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java similarity index 91% rename from src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java rename to src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java index aa23c44..aa2ba4c 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -17,7 +17,8 @@ import org.hisp.dhis.jsontree.internal.NotNull; /** - * A declarative model or description of what validation rules to check. + * A declarative model or description of what validation rules to check for a single property within + * an object. * * @param anyOfTypes the node types allowed/expected * @param values general validations that apply to any node type @@ -27,14 +28,14 @@ * @param objects validations that apply to object nodes * @param items validations that apply to array elements or object member values (map use) */ -record PropertyValidation( +record PropertyValidations( @NotNull Set anyOfTypes, @CheckNull ValueValidation values, - @CheckNull StringValidation strings, + @CheckNull StringValidation strings, // TODO should these run for non-strings? @CheckNull NumberValidation numbers, @CheckNull ArrayValidation arrays, @CheckNull ObjectValidation objects, - @CheckNull PropertyValidation items) { + @CheckNull PropertyValidations items) { /** * Layers the provided validations on top of this. This means they take precedence unless they are @@ -45,9 +46,9 @@ record PropertyValidation( * parameter and this node validations acting as fallback when a validation is defined off */ @NotNull - PropertyValidation overlay(@CheckNull PropertyValidation with) { + PropertyValidations overlay(@CheckNull PropertyValidations with) { if (with == null) return this; - return new PropertyValidation( + return new PropertyValidations( overlayC(anyOfTypes, with.anyOfTypes), values == null ? with.values : values.overlay(with.values), strings == null ? with.strings : strings.overlay(with.strings), @@ -58,13 +59,13 @@ PropertyValidation overlay(@CheckNull PropertyValidation with) { } @NotNull - PropertyValidation withItems(@CheckNull PropertyValidation items) { + PropertyValidations withItems(@CheckNull PropertyValidations items) { if (items == null && this.items == null) return this; - return new PropertyValidation(anyOfTypes, values, strings, numbers, arrays, objects, items); + return new PropertyValidations(anyOfTypes, values, strings, numbers, arrays, objects, items); } @NotNull - PropertyValidation withCustoms(@NotNull List validators) { + PropertyValidations withCustoms(@NotNull List validators) { if (validators.isEmpty() && (values == null || values.customs.isEmpty())) return this; ValueValidation newValues = values == null @@ -75,11 +76,11 @@ PropertyValidation withCustoms(@NotNull List validators) { values.allowNull, values.anyOfJsons, validators); - return new PropertyValidation(anyOfTypes, newValues, strings, numbers, arrays, objects, items); + return new PropertyValidations(anyOfTypes, newValues, strings, numbers, arrays, objects, items); } @NotNull - public PropertyValidation varargs() { + public PropertyValidations varargs() { Set anyOfTypes = new HashSet<>(anyOfTypes()); anyOfTypes.add(NodeType.ARRAY); ArrayValidation arrays = this.arrays; @@ -87,14 +88,14 @@ public PropertyValidation varargs() { arrays = this.arrays == null ? new ArrayValidation(1, -1, YesNo.AUTO) : this.arrays.required(); } - return new PropertyValidation( + return new PropertyValidations( Set.copyOf(anyOfTypes), values, strings, numbers, arrays, objects, - new PropertyValidation(anyOfTypes(), values, strings, numbers, null, objects, items)); + new PropertyValidations(anyOfTypes(), values, strings, numbers, null, objects, items)); } /** diff --git a/src/test/java/org/hisp/dhis/jsontree/Assertions.java b/src/test/java/org/hisp/dhis/jsontree/Assertions.java index 667a8a9..a1f3fc3 100644 --- a/src/test/java/org/hisp/dhis/jsontree/Assertions.java +++ b/src/test/java/org/hisp/dhis/jsontree/Assertions.java @@ -41,6 +41,7 @@ private static List textToString(List args) { } private static Object textToString(Object arg) { + if (arg instanceof JsonValue v) return v.toJson(); if (arg instanceof Text t) return t.toString(); if (arg instanceof Set s) return s.stream().map(Assertions::textToString).collect(Collectors.toSet()); diff --git a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java index 211fa4f..daa5ee5 100644 --- a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -3,6 +3,8 @@ import org.hisp.dhis.jsontree.InputExpression.Pattern; 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.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -104,6 +106,26 @@ void testPattern_LongNumbers() { assertMatches("|-9223372036854775807|", "-9223372036854775807"); } + @Test + void testPattern_ExamplesDecimal() { + InputExpression expr = + InputExpression.of("?[+-]+#?(.*#)?([E]?[+-]+#)", "?[+-]*#.+#?([E]?[+-]+#)"); + assertMatches(expr, ".5", "4.", "1.1", "+345.234", "-23.56e-12", "+.5e1"); + } + + @Test + void testPattern_ExamplesInteger() { + // different variants of how to match for an integer + List patterns = + List.of("?|s|+#", "?[+-]+#", "?[s]+#", "?[+-]#9?#", "?[+-]{2147483647}(#9?#)"); + for (String pattern : patterns) + assertMatches(pattern, "1", "0", "-2", "00001", "+999", "-2147483647"); + } + + //TODO confirm that groups can be nested as long as the inner group appear last in the outer group + // for example: ~(foo+(bar)) + // because if the ) are mistaken it has the same effect + @Test void testPattern_DHIS2Periods() { InputExpression expr = InputExpression.of( diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java index 7bddbdf..020b1aa 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.lang.annotation.ElementType; +import java.util.List; import java.util.Set; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java index fa6363c..7ab131a 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java @@ -70,8 +70,8 @@ void testMaximum_TooLarge() { {"age":101}""", JsonMaximumExampleA.class, Rule.MAXIMUM, - 100d, - 101d); + 100, + 101); assertValidationError( """ {"height":250.7}""", diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java index 7c576ce..ea68abc 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java @@ -70,15 +70,15 @@ void testMinimum_TooSmall() { {"age":-1}""", JsonMinimumExampleA.class, Rule.MINIMUM, - 0d, - -1d); + 0, + -1); assertValidationError( """ {"height":19}""", JsonMinimumExampleB.class, Rule.MINIMUM, - 20d, - 19d); + 20, + 19); } @Test diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java index bddd495..f616394 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java @@ -139,8 +139,8 @@ void testDeep_Error_PagerSizeZero() { {"pager": {"size": 0, "page": 0}, "entries":[]}""", JsonPage.class, Rule.MINIMUM, - 1d, - 0d); + 1, + 0); assertEquals(".pager.size", error.path().toString()); } @@ -152,8 +152,8 @@ void testDeep_Error_PagerPageNegative() { {"pager": {"size": 20, "page": -1}, "entries":[]}""", JsonPage.class, Rule.MINIMUM, - 0d, - -1d); + 0, + -1); assertEquals(".pager.page", error.path().toString()); } From 1c24d03d352cde5036ff58c3200d314178c21976 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 9 Apr 2026 14:29:27 +0200 Subject: [PATCH 09/20] feat: strict + non-strict validation --- .../java/org/hisp/dhis/jsontree/Text.java | 4 + .../org/hisp/dhis/jsontree/Validation.java | 46 ++++- .../jsontree/validation/ObjectValidation.java | 33 +++- .../jsontree/validation/ObjectValidator.java | 179 ++++++++++++------ .../validation/PropertyValidations.java | 34 +++- .../validation/JsonValidationMinimumTest.java | 2 + .../JsonValidationMiscDeepGraphTest.java | 1 + 7 files changed, 225 insertions(+), 74 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java index f735090..143e3a8 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Text.java +++ b/src/main/java/org/hisp/dhis/jsontree/Text.java @@ -260,6 +260,10 @@ default boolean isTextualDecimal() { return TextualNumber.isTextualDecimal(toCharArray()); } + default boolean isSpecialDecimal() { + return contentEquals("NaN") || contentEquals("Infinite") || contentEquals("-Infinite"); + } + /** * In contrast to the JDK method this only accepts valid inputs. * diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index a6958de..29b70d1 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -222,9 +222,41 @@ public String toString() { */ NodeType[] type() default {}; - //TODO implement => NO => rather have a in Validation - // NodeType[] typeFrom() default {}; - // which sets all the JSON inputs we allow as long as they convert + /** + * @return The strictness used when checking {@link #type()}s. By default, conversion is non-strict for best interoperability + */ + Strict strictness() default @Strict(); + + @Retention(RetentionPolicy.RUNTIME) + @interface Strict { + + /** + * @return true, to set all AUTO to YES + */ + boolean value() default false; + + /** + * Non-strict JSON strings of "true"/"false" are accepted as JSON booleans + * + * @return When YES, do not accept boolean given as JSON string + */ + YesNo booleans() default YesNo.AUTO; + + /** + * Non-strict JSON number and boolean are accepted as JSON string (of a corresponding value) + * + * @return When YES, do not accept JSON number or boolean + */ + YesNo strings() default YesNo.AUTO; + + /** + * Non-strict JSON string that are numerical are accepted as numbers. Where numbers map to Java + * doubles the number literals of NaN, Infinite and -Infinite are accepted as well. + * + * @return When YES, do not accept JSON string as number + */ + YesNo numbers() default YesNo.AUTO; + } /** * A property marked as varargs will allow {@link NodeType#ARRAY} to occur, each element then is @@ -432,12 +464,12 @@ public String toString() { YesNo required() default YesNo.AUTO; /** - * To describe which properties are in a dependency relation with each other properties can be - * assigned group names. One or more members of a group have the role of a trigger while the + * To describe which properties are in a dependency relation with each other properties are + * assigned to group names. One or more members of a group have the role of a trigger while the * others are the ones that are required depending on the trigger. This property defines the * groups for the annotated property and its role using suffixes as described below. * - *

A trigger uses the suffix {@code !} to trigger when present, {@code ?} to trigger when + *

A property marked with the suffix {@code !} triggers when present, a property marked with {@code ?} triggers when * absent. * *

Multiple triggers in a group always combine with AND logic (all need to present/absent). For @@ -446,7 +478,7 @@ public String toString() { * {@code ?} triggers both conditions must be met to trigger. * *

If none of the properties in a group is marked any of the properties makes all others in the - * group required. + * group required (all group properties are co-dependent). * *

In addition, a property that is dependent required (not a trigger) can use the {@code ^} * suffix if it is mutual exclusive to all other required properties that are marked equally. 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 0f64eaf..5a343a2 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -40,6 +40,7 @@ import org.hisp.dhis.jsontree.Text; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; +import org.hisp.dhis.jsontree.Validation.YesNo; import org.hisp.dhis.jsontree.Validator; import org.hisp.dhis.jsontree.internal.CheckNull; import org.hisp.dhis.jsontree.internal.NotNull; @@ -168,7 +169,17 @@ private static PropertyValidations fromAnnotations(AnnotatedElement src) { PropertyValidations items = fromItems(src); PropertyValidations base = meta == null ? main : meta.overlay(main); if (base == null && items == null && validators.isEmpty()) return null; - if (base == null) base = new PropertyValidations(Set.of(), null, null, null, null, null, null); + if (base == null) + base = + new PropertyValidations( + Set.of(), + PropertyValidations.Strict.DEFAULT, + null, + null, + null, + null, + null, + null); return base.withCustoms(validators).withItems(items); } @@ -217,10 +228,11 @@ private static PropertyValidations fromItems(AnnotatedElement src) { @NotNull private static PropertyValidations toPropertyValidation(Class type) { ValueValidation values = - !type.isPrimitive() ? null : new ValueValidation(YES, Set.of(), AUTO, Set.of(), List.of()); + !type.isPrimitive() ? null : new ValueValidation(YES, Set.of(), YesNo.AUTO, Set.of(), List.of()); StringValidation strings = - !type.isEnum() ? null : new StringValidation(anyOfStrings(type), AUTO, -1, -1, null); - return new PropertyValidations(anyOfTypes(type), values, strings, null, null, null, null); + !type.isEnum() ? null : new StringValidation(anyOfStrings(type), YesNo.AUTO, -1, -1, null); + return new PropertyValidations( + anyOfTypes(type), PropertyValidations.Strict.DEFAULT, values, strings, null, null, null, null); } @NotNull @@ -228,6 +240,7 @@ private static PropertyValidations toPropertyValidation(@NotNull Validation src) PropertyValidations res = new PropertyValidations( anyOfTypes(src), + toStrict(src.strictness()), toValueValidation(src), toStringValidation(src), toNumberValidation(src), @@ -237,6 +250,18 @@ private static PropertyValidations toPropertyValidation(@NotNull Validation src) return src.varargs().isYes() ? res.varargs() : res; } + private static PropertyValidations.Strict toStrict(Validation.Strict src) { + YesNo booleans = src.booleans(); + YesNo strings = src.strings(); + YesNo numbers = src.numbers(); + if (src.value()) { + if (booleans == AUTO) booleans = YES; + if (strings == AUTO) strings = YES; + if (numbers == AUTO) numbers = YES; + } + return new PropertyValidations.Strict(booleans, strings, numbers); + } + @CheckNull private static ValueValidation toValueValidation(@NotNull Validation src) { boolean oneOfValuesEmpty = 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 eee5384..cbeefba 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -3,6 +3,7 @@ import static java.lang.Double.isNaN; import static java.util.stream.Collectors.toMap; import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; +import static org.hisp.dhis.jsontree.Validation.NodeType.BOOLEAN; import static org.hisp.dhis.jsontree.Validation.NodeType.INTEGER; import static org.hisp.dhis.jsontree.Validation.NodeType.NULL; import static org.hisp.dhis.jsontree.Validation.NodeType.NUMBER; @@ -13,6 +14,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.EnumMap; +import java.util.EnumSet; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -25,7 +27,6 @@ import java.util.function.BiConsumer; import java.util.function.BiPredicate; import java.util.function.Consumer; -import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Stream; @@ -100,7 +101,7 @@ private static ObjectValidator ofObject( Validator propValidator = of(property, validations); Validator propTypeValidator = ofJavaType(objectValidation.types().get(property), currentlyResolved); - Validator validator = If.of(propValidator, propTypeValidator); + Validator validator = Chain.of(propValidator, propTypeValidator); if (validator != null) res.put(JsonPath.of(property), validator); }); Validator dependentRequired = ofDependentRequired(properties); @@ -137,41 +138,71 @@ private static Validator ofJavaType( @CheckNull private static Validator of(Text property, PropertyValidations validations) { - Set anyOf = validations.anyOfTypes(); - Map byType = new EnumMap<>(JsonNodeType.class); BiConsumer add = (type, validator) -> { if (validator != null) byType.put(type, validator); }; - Predicate has = type -> anyOf.isEmpty() || anyOf.contains(type); - if (has.test(STRING)) add.accept(JsonNodeType.STRING, ofString(validations.strings())); - if (has.test(NUMBER) || has.test(INTEGER)) add.accept(JsonNodeType.NUMBER, ofNumber(validations.numbers())); - if (has.test(ARRAY)) add.accept(JsonNodeType.ARRAY, ofArray(validations.arrays())); - if (has.test(OBJECT)) add.accept(JsonNodeType.OBJECT, ofObject(validations.objects())); - Validator type = anyOf.isEmpty() ? null : new Type(anyOf); + Set types = validations.types(); + if (types.isEmpty()) types = EnumSet.allOf(NodeType.class); + boolean acceptBooleans = types.contains(BOOLEAN); + boolean acceptStrings = types.contains(STRING); + boolean acceptNumbers = types.contains(NUMBER) || types.contains(INTEGER); + + Validator strings = ofString(validations.strings()); + Validator numbers = ofNumber(validations.numbers()); + if (acceptStrings) add.accept(JsonNodeType.STRING, strings); + if (acceptNumbers) add.accept(JsonNodeType.NUMBER, numbers); + if (types.contains(ARRAY)) add.accept(JsonNodeType.ARRAY, ofArray(validations.arrays())); + if (types.contains(OBJECT)) add.accept(JsonNodeType.OBJECT, ofObject(validations.objects())); + + // AUTO type conversions + PropertyValidations.Strict strictness = validations.strictness(); + boolean strictStrings = strictness.strings().isYes(); + boolean strictBooleans = strictness.booleans().isYes(); + boolean strictNumbers = strictness.numbers().isYes(); + if (!strictStrings && acceptStrings && strings != null) { + // accept string given as number => string constraints apply to number + if (!acceptNumbers) add.accept(JsonNodeType.NUMBER, strings); + // accept string given as boolean => string constraints apply to boolean + if (!acceptBooleans) add.accept(JsonNodeType.BOOLEAN, strings); + } + if (!strictNumbers && acceptNumbers && numbers != null && !acceptStrings) { + // accept number given as string => number constraints apply to string + add.accept(JsonNodeType.STRING, numbers); + } + + Validator type = null; + if (!validations.types().isEmpty()) { + type = + new Type( + EnumSet.copyOf(validations.types()), strictBooleans, strictStrings, strictNumbers); + } Validator anyType = ofGeneric(validations.values()); Validator typeDependent = byType.isEmpty() ? null : new TypeDependent(byType); Validator items = validations.items() == null ? null : Items.of(of(property, validations.items())); - Validator whenDefined = If.of(type, If.of(anyType, If.of(typeDependent, items))); + Validator whenDefined = Chain.of(type, anyType, typeDependent, items); + + Validator required = ofRequired(property, validations); + return Chain.of(required, whenDefined); + } + private static Validator ofRequired(Text property, PropertyValidations validations) { PropertyValidations.ValueValidation values = validations.values(); boolean isRequiredYes = values != null && values.required().isYes(); boolean isRequiredAuto = - (values == null || values.required().isAuto()) && isRequiredImplicitly(validations, anyOf); + (values == null || values.required().isAuto()) && isRequiredImplicitly(validations); boolean isAllowNull = values != null && values.allowNull().isYes(); - Validator required = - !isRequiredYes && !isRequiredAuto ? null : new Required(property, isAllowNull); - return If.of(required, whenDefined); + return !isRequiredYes && !isRequiredAuto ? null : new Required(property, isAllowNull); } /** * minItems, minLength, minProperties implicitly means this is required if there is only one type * possible and if required is AUTO */ - private static boolean isRequiredImplicitly(PropertyValidations validations, Set types) { - return types.stream().allMatch(type -> isRequiredImplicitly(validations, type)); + private static boolean isRequiredImplicitly(PropertyValidations validations) { + return validations.types().stream().allMatch(type -> isRequiredImplicitly(validations, type)); } private static boolean isRequiredImplicitly(PropertyValidations validations, NodeType type) { @@ -260,11 +291,11 @@ private static Validator ofDependentRequired( if (properties.values().stream() .allMatch(p -> p.values() == null || p.values().dependentRequired().isEmpty())) return null; Map> groupPropertyRole = new HashMap<>(); - Map> isMissing = new HashMap<>(); + Map> notExists = new HashMap<>(); properties.forEach( (name, validation) -> { PropertyValidations.ValueValidation values = validation.values(); - isMissing.put(name, isMissing(values)); + notExists.put(name, notExists(values)); if (values != null && !values.dependentRequired().isEmpty()) { values .dependentRequired() @@ -285,7 +316,7 @@ private static Validator ofDependentRequired( if (members.values().stream().noneMatch(ObjectValidator::isDependentRequiredRole)) { all.add( new DependentRequiredCodependent( - Map.copyOf(isMissing), Set.copyOf(members.keySet()))); + Map.copyOf(notExists), Set.copyOf(members.keySet()))); } else { Set> memberEntries = members.entrySet(); List present = @@ -308,16 +339,16 @@ private static Validator ofDependentRequired( .filter(e -> e.getValue().endsWith("^")) .map(Map.Entry::getKey) .toList(); - Map equals = + Map equals = memberEntries.stream() .filter(e -> e.getValue().contains("=")) .collect( toMap( Map.Entry::getKey, - e -> e.getValue().substring(e.getValue().indexOf('=') + 1))); + e -> Text.of(e.getValue().substring(e.getValue().indexOf('=') + 1)))); all.add( new DependentRequired( - Map.copyOf(isMissing), + Map.copyOf(notExists), Set.copyOf(present), Set.copyOf(absent), Map.copyOf(equals), @@ -329,7 +360,7 @@ private static Validator ofDependentRequired( } @NotNull - private static BiPredicate isMissing(PropertyValidations.ValueValidation values) { + private static BiPredicate notExists(PropertyValidations.ValueValidation values) { if (values == null || !values.allowNull().isYes()) return JsonMixed::isUndefined; BiPredicate test = JsonMixed::exists; return test.negate(); @@ -369,13 +400,21 @@ public void validate(JsonMixed value, Consumer addError) { } /** Runs the {@link #dependent()} only if the {@link #independent()} is successful */ - private record If(Validator independent, Validator dependent) implements Validator { + private record Chain(Validator independent, Validator dependent) implements Validator { @CheckNull static Validator of(@CheckNull Validator independent, @CheckNull Validator dependent) { if (dependent == null) return independent; if (independent == null) return dependent; - return new If(independent, dependent); + return new Chain(independent, dependent); + } + + static Validator of(Validator... validators) { + if (validators.length == 0) return null; + Validator chain = validators[0]; + for (int i = 1; i < validators.length; i++) + chain = of(chain, validators[i]); + return chain; } @Override @@ -391,15 +430,44 @@ public void validate(JsonMixed value, Consumer addError) { } } - private record Type(Set anyOf) implements Validator { + private record Type( + EnumSet accepted, + boolean strictBooleans, + boolean strictStrings, + boolean strictNumbers) + implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { NodeType actual = NodeType.of(value.type()); - if (actual == NULL || anyOf.contains(actual)) return; - if (actual == NUMBER && anyOf.contains(INTEGER) && value.isInteger()) return; + if (actual == NULL || accepted.contains(actual)) return; + boolean acceptIntegers = accepted.contains(INTEGER); + if (actual == NUMBER && acceptIntegers && value.isInteger()) return; + // non-strict cases: + // easy: accept string given as number or boolean + if ((actual == BOOLEAN || actual == NUMBER) && !strictStrings && accepted.contains(STRING)) + return; + // harder: numbers or booleans given as string + if (actual == STRING) { + boolean acceptBooleansAsString = !strictBooleans && accepted.contains(BOOLEAN); + boolean acceptNumbers = accepted.contains(NUMBER); + boolean acceptNumbersAsString = !strictNumbers && (acceptNumbers || acceptIntegers); + // avoid accessing text() if both booleans and numbers are strict + if (acceptBooleansAsString || acceptNumbersAsString) { + Text text = value.text(); + // accept boolean given as string + if (acceptBooleansAsString && (text.contentEquals("true") || text.contentEquals("false"))) + return; + // accept number given as string + if (!strictNumbers + && acceptNumbers + && (text.isTextualDecimal() || text.isSpecialDecimal())) return; + if (!strictNumbers && acceptIntegers && text.isTextualInteger()) return; + } + } + addError.accept( - Error.of(Rule.TYPE, value, "must have any of %s type but was: %s", anyOf, actual)); + Error.of(Rule.TYPE, value, "must have any of %s type but was: %s", accepted, actual)); } } @@ -707,6 +775,7 @@ public void validate(JsonMixed value, Consumer addError) { /** * The dependent required validator. * + * @param notExists a predicate for each dependent property on how to check of they do not exist * @param present a set of properties that all need to be present to trigger * @param absent a set of properties that all need to be absent to trigger * @param equals a map from property to value that all need to match the current value to trigger @@ -715,35 +784,35 @@ public void validate(JsonMixed value, Consumer addError) { * mutual exclusive */ private record DependentRequired( - Map> isMissing, + Map> notExists, Set present, Set absent, - Map equals, + Map equals, Set dependents, Set exclusiveDependent) implements Validator { @Override - public void validate(JsonMixed value, Consumer addError) { - if (!value.isObject()) return; + public void validate(JsonMixed obj, Consumer addError) { + if (!obj.isObject()) return; boolean presentNotMet = - !present.isEmpty() && present.stream().anyMatch(p -> isMissing.get(p).test(value, p)); + !present.isEmpty() && present.stream().anyMatch(p -> notExists.get(p).test(obj, p)); boolean absentNotMet = - !absent.isEmpty() && absent.stream().anyMatch(p -> !isMissing.get(p).test(value, p)); + !absent.isEmpty() && absent.stream().anyMatch(p -> !notExists.get(p).test(obj, p)); boolean equalsNotMet = !equals.isEmpty() && equals.entrySet().stream() - .anyMatch(e -> !e.getValue().equals(value.getString(e.getKey()).string())); + .anyMatch(e -> !e.getValue().contentEquals(obj.getString(e.getKey()).text())); if (presentNotMet || absentNotMet || equalsNotMet) return; if (!dependents.isEmpty() - && dependents.stream().anyMatch(p -> isMissing.get(p).test(value, p))) { + && dependents.stream().anyMatch(p -> notExists.get(p).test(obj, p))) { Set missing = - Set.copyOf(dependents.stream().filter(p -> isMissing.get(p).test(value, p)).toList()); + Set.copyOf(dependents.stream().filter(p -> notExists.get(p).test(obj, p)).toList()); if (!equals.isEmpty()) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object with %s requires all of %s, missing: %s", equals, dependents, @@ -752,7 +821,7 @@ public void validate(JsonMixed value, Consumer addError) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object without any of %s requires all of %s, missing: %s", absent, dependents, @@ -761,7 +830,7 @@ public void validate(JsonMixed value, Consumer addError) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object with any of %s requires all of %s, missing: %s", present, dependents, @@ -770,7 +839,7 @@ public void validate(JsonMixed value, Consumer addError) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object with any of %s or without any of %s requires all of %s, missing: %s", present, absent, @@ -781,13 +850,13 @@ public void validate(JsonMixed value, Consumer addError) { if (!exclusiveDependent.isEmpty()) { Set defined = Set.copyOf( - exclusiveDependent.stream().filter(p -> !isMissing.get(p).test(value, p)).toList()); + exclusiveDependent.stream().filter(p -> !notExists.get(p).test(obj, p)).toList()); if (defined.size() == 1) return; // it is exclusively defined => OK if (!equals.isEmpty()) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object with %s requires one but only one of %s, but has: %s", equals, exclusiveDependent, @@ -796,7 +865,7 @@ public void validate(JsonMixed value, Consumer addError) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object requires one but only one of %s, but has: %s", exclusiveDependent, defined)); @@ -804,7 +873,7 @@ public void validate(JsonMixed value, Consumer addError) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object without any of %s requires one but only one of %s, but has: %s", absent, exclusiveDependent, @@ -813,7 +882,7 @@ public void validate(JsonMixed value, Consumer addError) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object with any of %s requires one but only one of %s, but has: %s", present, exclusiveDependent, @@ -822,7 +891,7 @@ public void validate(JsonMixed value, Consumer addError) { addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, - value, + obj, "object with any of %s or without any of %s requires one but only one of %s, but has: %s", present, absent, @@ -833,15 +902,19 @@ public void validate(JsonMixed value, Consumer addError) { } } + /** + * @param notExists a predicate for each codependent property on how to check of they do not exist + * @param codependent the set of codependent properties (names) + */ private record DependentRequiredCodependent( - Map> isMissing, Set codependent) + Map> notExists, Set codependent) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { if (!value.isObject()) return; - if (codependent.stream().anyMatch(p -> isMissing.get(p).test(value, p)) - && codependent.stream().anyMatch(p -> !isMissing.get(p).test(value, p))) + if (codependent.stream().anyMatch(p -> notExists.get(p).test(value, p)) + && codependent.stream().anyMatch(p -> !notExists.get(p).test(value, p))) addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, @@ -849,7 +922,7 @@ public void validate(JsonMixed value, Consumer addError) { "object with any of %1$s all of %1$s are required, missing: %s", codependent, Set.copyOf( - codependent.stream().filter(p -> isMissing.get(p).test(value, p)).toList()))); + codependent.stream().filter(p -> notExists.get(p).test(value, p)).toList()))); } } diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java index aa2ba4c..54c4d17 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -20,7 +20,7 @@ * A declarative model or description of what validation rules to check for a single property within * an object. * - * @param anyOfTypes the node types allowed/expected + * @param types the node types accepted/expected * @param values general validations that apply to any node type * @param strings validations that apply to string nodes * @param numbers validations that apply to number nodes @@ -29,14 +29,26 @@ * @param items validations that apply to array elements or object member values (map use) */ record PropertyValidations( - @NotNull Set anyOfTypes, + @NotNull Set types, + @NotNull Strict strictness, @CheckNull ValueValidation values, - @CheckNull StringValidation strings, // TODO should these run for non-strings? + @CheckNull StringValidation strings, @CheckNull NumberValidation numbers, @CheckNull ArrayValidation arrays, @CheckNull ObjectValidation objects, @CheckNull PropertyValidations items) { + record Strict(YesNo booleans, YesNo strings, YesNo numbers) { + public static final Strict DEFAULT = new Strict(YesNo.AUTO, YesNo.AUTO, YesNo.AUTO); + + Strict overlay(Strict with) { + return new Strict( + overlayY(booleans, with.booleans), + overlayY(strings, with.strings), + overlayY(numbers, with.numbers)); + } + } + /** * Layers the provided validations on top of this. This means they take precedence unless they are * defined off. @@ -49,7 +61,8 @@ record PropertyValidations( PropertyValidations overlay(@CheckNull PropertyValidations with) { if (with == null) return this; return new PropertyValidations( - overlayC(anyOfTypes, with.anyOfTypes), + overlayC(types, with.types), + strictness.overlay(with.strictness), values == null ? with.values : values.overlay(with.values), strings == null ? with.strings : strings.overlay(with.strings), numbers == null ? with.numbers : numbers.overlay(with.numbers), @@ -61,7 +74,7 @@ PropertyValidations overlay(@CheckNull PropertyValidations with) { @NotNull PropertyValidations withItems(@CheckNull PropertyValidations items) { if (items == null && this.items == null) return this; - return new PropertyValidations(anyOfTypes, values, strings, numbers, arrays, objects, items); + return new PropertyValidations(types, strictness, values, strings, numbers, arrays, objects, items); } @NotNull @@ -76,26 +89,27 @@ PropertyValidations withCustoms(@NotNull List validators) { values.allowNull, values.anyOfJsons, validators); - return new PropertyValidations(anyOfTypes, newValues, strings, numbers, arrays, objects, items); + return new PropertyValidations(types, strictness, newValues, strings, numbers, arrays, objects, items); } @NotNull public PropertyValidations varargs() { - Set anyOfTypes = new HashSet<>(anyOfTypes()); - anyOfTypes.add(NodeType.ARRAY); + Set newTypes = new HashSet<>(types()); + newTypes.add(NodeType.ARRAY); ArrayValidation arrays = this.arrays; if (values != null && values.required.isYes()) { arrays = this.arrays == null ? new ArrayValidation(1, -1, YesNo.AUTO) : this.arrays.required(); } return new PropertyValidations( - Set.copyOf(anyOfTypes), + Set.copyOf(newTypes), + strictness, values, strings, numbers, arrays, objects, - new PropertyValidations(anyOfTypes(), values, strings, numbers, null, objects, items)); + new PropertyValidations(types(), strictness, values, strings, numbers, null, objects, items)); } /** diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java index ea68abc..7d013fd 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java @@ -1,6 +1,7 @@ package org.hisp.dhis.jsontree.validation; import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.hisp.dhis.jsontree.Validation.YesNo.YES; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.util.Set; @@ -9,6 +10,7 @@ import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Rule; +import org.hisp.dhis.jsontree.Validation.Strict; import org.junit.jupiter.api.Test; /** diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java index f616394..39ab001 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java @@ -15,6 +15,7 @@ import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Rule; +import org.hisp.dhis.jsontree.Validation.Strict; import org.junit.jupiter.api.Test; /** From 76b27f72cc4970a243d6e0e77c9eb79e0244009e Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Thu, 9 Apr 2026 17:07:45 +0200 Subject: [PATCH 10/20] chore: reorganized fundamental validation/validator composition --- .../java/org/hisp/dhis/jsontree/Text.java | 3 + .../org/hisp/dhis/jsontree/Validation.java | 25 +++++++- .../jsontree/validation/JsonValidator.java | 3 +- .../jsontree/validation/ObjectValidation.java | 37 ++++++----- .../jsontree/validation/ObjectValidator.java | 64 +++++-------------- .../validation/PropertyValidations.java | 56 +++++++--------- .../JsonValidationRequiredTest.java | 4 +- 7 files changed, 84 insertions(+), 108 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java index 143e3a8..ef7453b 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Text.java +++ b/src/main/java/org/hisp/dhis/jsontree/Text.java @@ -260,6 +260,9 @@ default boolean isTextualDecimal() { return TextualNumber.isTextualDecimal(toCharArray()); } + /** + * @return true, if this text is exactly "NaN", "Infinite" or "-Infinite" (Java double special number literals) + */ default boolean isSpecialDecimal() { return contentEquals("NaN") || contentEquals("Infinite") || contentEquals("-Infinite"); } diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 29b70d1..815a2c7 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -236,22 +236,26 @@ public String toString() { boolean value() default false; /** - * Non-strict JSON strings of "true"/"false" are accepted as JSON booleans + * Non-strict: The JSON strings "true" and "false" are accepted as JSON booleans * * @return When YES, do not accept boolean given as JSON string */ YesNo booleans() default YesNo.AUTO; /** - * Non-strict JSON number and boolean are accepted as JSON string (of a corresponding value) + * Non-strict: A JSON number or JSON boolean are accepted as JSON string (as their text value) + * if and only if no string-specific or custom validations rules are present. For restricted strings + * it is always assumed that only a JSON string can match the restrictions. * * @return When YES, do not accept JSON number or boolean */ YesNo strings() default YesNo.AUTO; /** - * Non-strict JSON string that are numerical are accepted as numbers. Where numbers map to Java + * Non-strict: A JSON string that is numerical is accepted as number. Where numbers map to Java * doubles the number literals of NaN, Infinite and -Infinite are accepted as well. + * In such a case number specific restrictions do apply to the string as well. This handling only + * occurs if JSON string wasn't already an accepted type. * * @return When YES, do not accept JSON string as number */ @@ -517,4 +521,19 @@ public void validate(JsonMixed value, Consumer addError) { Error.of(Rule.PATTERN, value, "must match %s but was: %s", regex.pattern(), actual)); } } + + /** + * The value must be one of the provided JSON strings + * + * @param constants expected JSON values + */ + record EnumJson(Set constants) implements Validator { + + @Override + public void validate(JsonMixed value, Consumer addError) { + for (JsonMixed c : constants) if (c.equivalentTo(value)) return; + addError.accept( + Error.of(Rule.ENUM, value, "must be one of %s but was: %s", constants, value)); + } + } } 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 72e0c30..4b0d631 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -6,16 +6,15 @@ import java.util.Map; import java.util.Set; import java.util.function.Consumer; - import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonPath; import org.hisp.dhis.jsontree.JsonPathException; import org.hisp.dhis.jsontree.JsonSchemaException; -import org.hisp.dhis.jsontree.Validation.Result; import org.hisp.dhis.jsontree.JsonTreeException; import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.Error; +import org.hisp.dhis.jsontree.Validation.Result; /** * @author Jan Bernitt 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 5a343a2..642c77d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -33,10 +33,9 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListMap; import java.util.stream.Stream; - import org.hisp.dhis.jsontree.InputExpression; +import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; -import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Text; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; @@ -46,8 +45,8 @@ import org.hisp.dhis.jsontree.internal.NotNull; import org.hisp.dhis.jsontree.validation.PropertyValidations.ArrayValidation; import org.hisp.dhis.jsontree.validation.PropertyValidations.NumberValidation; +import org.hisp.dhis.jsontree.validation.PropertyValidations.RequiredValidation; import org.hisp.dhis.jsontree.validation.PropertyValidations.StringValidation; -import org.hisp.dhis.jsontree.validation.PropertyValidations.ValueValidation; /** * Analysis types and annotations to extract a {@link PropertyValidations} model description. @@ -174,7 +173,7 @@ private static PropertyValidations fromAnnotations(AnnotatedElement src) { new PropertyValidations( Set.of(), PropertyValidations.Strict.DEFAULT, - null, + List.of(), null, null, null, null, @@ -227,12 +226,12 @@ private static PropertyValidations fromItems(AnnotatedElement src) { @NotNull private static PropertyValidations toPropertyValidation(Class type) { - ValueValidation values = - !type.isPrimitive() ? null : new ValueValidation(YES, Set.of(), YesNo.AUTO, Set.of(), List.of()); + RequiredValidation values = + !type.isPrimitive() ? null : new RequiredValidation(YES, Set.of(), YesNo.AUTO); StringValidation strings = !type.isEnum() ? null : new StringValidation(anyOfStrings(type), YesNo.AUTO, -1, -1, null); return new PropertyValidations( - anyOfTypes(type), PropertyValidations.Strict.DEFAULT, values, strings, null, null, null, null); + anyOfTypes(type), PropertyValidations.Strict.DEFAULT, List.of(), values, strings, null, null, null, null); } @NotNull @@ -241,7 +240,8 @@ private static PropertyValidations toPropertyValidation(@NotNull Validation src) new PropertyValidations( anyOfTypes(src), toStrict(src.strictness()), - toValueValidation(src), + toCustoms(src), + toRequiredValidation(src), toStringValidation(src), toNumberValidation(src), toArrayValidation(src), @@ -250,6 +250,13 @@ private static PropertyValidations toPropertyValidation(@NotNull Validation src) return src.varargs().isYes() ? res.varargs() : res; } + private static List toCustoms(@NotNull Validation src) { + if (src.oneOfValues().length == 0 || isAutoUnquotedJsonStrings(src.oneOfValues())) return List.of(); + return List.of( + new Validation.EnumJson( + Set.copyOf(Stream.of(src.oneOfValues()).map(JsonMixed::of).toList()))); + } + private static PropertyValidations.Strict toStrict(Validation.Strict src) { YesNo booleans = src.booleans(); YesNo strings = src.strings(); @@ -263,21 +270,13 @@ private static PropertyValidations.Strict toStrict(Validation.Strict src) { } @CheckNull - private static ValueValidation toValueValidation(@NotNull Validation src) { - boolean oneOfValuesEmpty = - src.oneOfValues().length == 0 || isAutoUnquotedJsonStrings(src.oneOfValues()); + private static PropertyValidations.RequiredValidation toRequiredValidation(@NotNull Validation src) { boolean dependentRequiresEmpty = src.dependentRequired().length == 0; if (src.required().isAuto() - && oneOfValuesEmpty && dependentRequiresEmpty && src.acceptNull().isAuto()) return null; - Set oneOfValues = - oneOfValuesEmpty - ? Set.of() - : Set.copyOf( - Stream.of(src.oneOfValues()).map(e -> JsonValue.of(e).toMinimizedJson()).toList()); - return new ValueValidation( - src.required(), Set.of(src.dependentRequired()), src.acceptNull(), oneOfValues, List.of()); + return new RequiredValidation( + src.required(), Set.of(src.dependentRequired()), src.acceptNull()); } private static boolean isAutoUnquotedJsonStrings(String[] values) { 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 cbeefba..780b227 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -29,7 +29,6 @@ import java.util.function.Consumer; import java.util.function.Supplier; import java.util.stream.Stream; - import org.hisp.dhis.jsontree.InputExpression; import org.hisp.dhis.jsontree.JsonList; import org.hisp.dhis.jsontree.JsonMap; @@ -146,12 +145,12 @@ private static Validator of(Text property, PropertyValidations validations) { Set types = validations.types(); if (types.isEmpty()) types = EnumSet.allOf(NodeType.class); - boolean acceptBooleans = types.contains(BOOLEAN); boolean acceptStrings = types.contains(STRING); boolean acceptNumbers = types.contains(NUMBER) || types.contains(INTEGER); Validator strings = ofString(validations.strings()); Validator numbers = ofNumber(validations.numbers()); + Validator customs = All.of(validations.customs().stream()); if (acceptStrings) add.accept(JsonNodeType.STRING, strings); if (acceptNumbers) add.accept(JsonNodeType.NUMBER, numbers); if (types.contains(ARRAY)) add.accept(JsonNodeType.ARRAY, ofArray(validations.arrays())); @@ -159,15 +158,9 @@ private static Validator of(Text property, PropertyValidations validations) { // AUTO type conversions PropertyValidations.Strict strictness = validations.strictness(); - boolean strictStrings = strictness.strings().isYes(); + boolean strictStrings = strings != null || customs != null || strictness.strings().isYes(); boolean strictBooleans = strictness.booleans().isYes(); boolean strictNumbers = strictness.numbers().isYes(); - if (!strictStrings && acceptStrings && strings != null) { - // accept string given as number => string constraints apply to number - if (!acceptNumbers) add.accept(JsonNodeType.NUMBER, strings); - // accept string given as boolean => string constraints apply to boolean - if (!acceptBooleans) add.accept(JsonNodeType.BOOLEAN, strings); - } if (!strictNumbers && acceptNumbers && numbers != null && !acceptStrings) { // accept number given as string => number constraints apply to string add.accept(JsonNodeType.STRING, numbers); @@ -179,21 +172,18 @@ private static Validator of(Text property, PropertyValidations validations) { new Type( EnumSet.copyOf(validations.types()), strictBooleans, strictStrings, strictNumbers); } - Validator anyType = ofGeneric(validations.values()); - Validator typeDependent = byType.isEmpty() ? null : new TypeDependent(byType); + Validator typeSpecific = byType.isEmpty() ? null : new TypeDependent(byType); Validator items = validations.items() == null ? null : Items.of(of(property, validations.items())); - Validator whenDefined = Chain.of(type, anyType, typeDependent, items); - Validator required = ofRequired(property, validations); - return Chain.of(required, whenDefined); + return Chain.of(required, type, customs, typeSpecific, items); } private static Validator ofRequired(Text property, PropertyValidations validations) { - PropertyValidations.ValueValidation values = validations.values(); - boolean isRequiredYes = values != null && values.required().isYes(); + PropertyValidations.RequiredValidation requiredness = validations.requiredness(); + boolean isRequiredYes = requiredness != null && requiredness.required().isYes(); boolean isRequiredAuto = - (values == null || values.required().isAuto()) && isRequiredImplicitly(validations); - boolean isAllowNull = values != null && values.allowNull().isYes(); + (requiredness == null || requiredness.required().isAuto()) && isRequiredImplicitly(validations); + boolean isAllowNull = requiredness != null && requiredness.allowNull().isYes(); return !isRequiredYes && !isRequiredAuto ? null : new Required(property, isAllowNull); } @@ -214,17 +204,6 @@ private static boolean isRequiredImplicitly(PropertyValidations validations, Nod }; } - @CheckNull - private static Validator ofGeneric(@CheckNull PropertyValidations.ValueValidation values) { - return values == null - ? null - : All.of( - values.anyOfJsons().isEmpty() - ? null - : new EnumJson(Set.copyOf(values.anyOfJsons().stream().map(JsonMixed::of).toList())), - values.customs().isEmpty() ? null : new All(values.customs())); - } - @CheckNull private static Validator ofString(@CheckNull PropertyValidations.StringValidation strings) { if (strings == null) return null; @@ -289,12 +268,12 @@ private static Validator ofDependentRequired( @NotNull Map properties) { if (properties.isEmpty()) return null; if (properties.values().stream() - .allMatch(p -> p.values() == null || p.values().dependentRequired().isEmpty())) return null; + .allMatch(p -> p.requiredness() == null || p.requiredness().dependentRequired().isEmpty())) return null; Map> groupPropertyRole = new HashMap<>(); Map> notExists = new HashMap<>(); properties.forEach( (name, validation) -> { - PropertyValidations.ValueValidation values = validation.values(); + PropertyValidations.RequiredValidation values = validation.requiredness(); notExists.put(name, notExists(values)); if (values != null && !values.dependentRequired().isEmpty()) { values @@ -360,7 +339,7 @@ private static Validator ofDependentRequired( } @NotNull - private static BiPredicate notExists(PropertyValidations.ValueValidation values) { + private static BiPredicate notExists(PropertyValidations.RequiredValidation values) { if (values == null || !values.allowNull().isYes()) return JsonMixed::isUndefined; BiPredicate test = JsonMixed::exists; return test.negate(); @@ -378,8 +357,11 @@ private record All(List validators) implements Validator { @CheckNull static Validator of(Validator... validators) { + return of(Stream.of(validators)); + } + static Validator of(Stream validators) { List actual = - Stream.of(validators) + validators .filter(Objects::nonNull) .mapMulti( (Validator v, Consumer pipe) -> { @@ -507,22 +489,6 @@ public void validate(JsonMixed value, Consumer addError) { } } - /** - * The value must be one of the provided JSON strings - * - * @param constants expected JSON values - */ - private record EnumJson(Set constants) implements Validator { - - @Override - public void validate(JsonMixed value, Consumer addError) { - for (JsonMixed c : constants) - if (c.equivalentTo(value)) return; - addError.accept( - Error.of(Rule.ENUM, value, "must be one of %s but was: %s", constants, value)); - } - } - /* string values */ diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java index 54c4d17..b2b0dfd 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -21,7 +21,9 @@ * an object. * * @param types the node types accepted/expected - * @param values general validations that apply to any node type + * @param customs a validator defined by class is used (custom or user defined validators), empty + * list is off + * @param requiredness validations about the property being required or not (presence) * @param strings validations that apply to string nodes * @param numbers validations that apply to number nodes * @param arrays validations that apply to array nodes @@ -31,7 +33,8 @@ record PropertyValidations( @NotNull Set types, @NotNull Strict strictness, - @CheckNull ValueValidation values, + @NotNull List customs, + @CheckNull RequiredValidation requiredness, @CheckNull StringValidation strings, @CheckNull NumberValidation numbers, @CheckNull ArrayValidation arrays, @@ -63,7 +66,8 @@ PropertyValidations overlay(@CheckNull PropertyValidations with) { return new PropertyValidations( overlayC(types, with.types), strictness.overlay(with.strictness), - values == null ? with.values : values.overlay(with.values), + overlayEachClassAtMostOnce(customs, with.customs), + requiredness == null ? with.requiredness : requiredness.overlay(with.requiredness), strings == null ? with.strings : strings.overlay(with.strings), numbers == null ? with.numbers : numbers.overlay(with.numbers), arrays == null ? with.arrays : arrays.overlay(with.arrays), @@ -74,22 +78,15 @@ PropertyValidations overlay(@CheckNull PropertyValidations with) { @NotNull PropertyValidations withItems(@CheckNull PropertyValidations items) { if (items == null && this.items == null) return this; - return new PropertyValidations(types, strictness, values, strings, numbers, arrays, objects, items); + return new PropertyValidations( + types, strictness, customs, requiredness, strings, numbers, arrays, objects, items); } @NotNull PropertyValidations withCustoms(@NotNull List validators) { - if (validators.isEmpty() && (values == null || values.customs.isEmpty())) return this; - ValueValidation newValues = - values == null - ? new ValueValidation(YesNo.AUTO, Set.of(), YesNo.AUTO, Set.of(), validators) - : new ValueValidation( - values.required, - values.dependentRequired, - values.allowNull, - values.anyOfJsons, - validators); - return new PropertyValidations(types, strictness, newValues, strings, numbers, arrays, objects, items); + List merged = overlayEachClassAtMostOnce(customs, validators); + return new PropertyValidations( + types, strictness, merged, requiredness, strings, numbers, arrays, objects, items); } @NotNull @@ -97,19 +94,21 @@ public PropertyValidations varargs() { Set newTypes = new HashSet<>(types()); newTypes.add(NodeType.ARRAY); ArrayValidation arrays = this.arrays; - if (values != null && values.required.isYes()) { + if (requiredness != null && requiredness.required.isYes()) { arrays = this.arrays == null ? new ArrayValidation(1, -1, YesNo.AUTO) : this.arrays.required(); } return new PropertyValidations( Set.copyOf(newTypes), strictness, - values, + customs, + requiredness, strings, numbers, arrays, objects, - new PropertyValidations(types(), strictness, values, strings, numbers, null, objects, items)); + new PropertyValidations( + types(), strictness, customs, requiredness, strings, numbers, null, objects, items)); } /** @@ -120,26 +119,17 @@ public PropertyValidations varargs() { * @param dependentRequired the groups this property is a member of for dependent requires * @param allowNull when {@link YesNo#YES} a JSON {@code null} value satisfies being {@link * #required()} or {@link #dependentRequired()} - * @param anyOfJsons the JSON value must be one of the provided JSON values, empty set is off - * @param customs a validator defined by class is used (custom or user defined validators), empty - * list is off */ - record ValueValidation( - @NotNull YesNo required, - @NotNull Set dependentRequired, - @NotNull YesNo allowNull, - @NotNull Set anyOfJsons, - @NotNull List customs) { + record RequiredValidation( + @NotNull YesNo required, @NotNull Set dependentRequired, @NotNull YesNo allowNull) { - ValueValidation overlay(@CheckNull ValueValidation with) { + RequiredValidation overlay(@CheckNull PropertyValidations.RequiredValidation with) { return with == null ? this - : new ValueValidation( + : new RequiredValidation( overlayY(required, with.required), overlayC(dependentRequired, with.dependentRequired), - overlayY(allowNull, with.allowNull), - overlayC(anyOfJsons, with.anyOfJsons), - overlayAdditive(customs, with.customs)); + overlayY(allowNull, with.allowNull)); } } @@ -272,7 +262,7 @@ private static > C overlayC(C a, C b) { return b; } - private static List overlayAdditive(List a, List b) { + private static List overlayEachClassAtMostOnce(List a, List b) { if (b.isEmpty()) return a; if (a.isEmpty()) return b; Set> bs = b.stream().map(Object::getClass).collect(toSet()); diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java index 05379bb..0197edf 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java @@ -118,8 +118,8 @@ void testIsA_MissingMember() { @Test void testIsA_WrongNodeType() { - assertFalse(Json5.of("{'bar':true}").isA(JsonFoo.class)); - assertFalse(Json5.of("{'key':'x', 'value': '1'}").isA(JsonEntry.class)); + assertFalse(Json5.of("{'bar':[]}").isA(JsonFoo.class)); + assertFalse(Json5.of("{'key':'x', 'value': []}").isA(JsonEntry.class)); } @Test From f8855473a44121094a8c08cec32942079a52c4fb Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Tue, 14 Apr 2026 10:36:05 +0200 Subject: [PATCH 11/20] feat: InputExpression to RegEx - more tests --- INPUT_EXPRESSIONS.md | 24 +- .../hisp/dhis/jsontree/InputExpression.java | 205 ++++++++++++++++-- .../org/hisp/dhis/jsontree/JsonString.java | 3 - .../org/hisp/dhis/jsontree/Validation.java | 26 +-- .../jsontree/validation/ObjectValidator.java | 22 +- .../validation/PropertyValidations.java | 14 +- .../dhis/jsontree/InputExpressionTest.java | 150 +++++++++++-- 7 files changed, 357 insertions(+), 87 deletions(-) diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md index dde71bc..ced27fa 100644 --- a/INPUT_EXPRESSIONS.md +++ b/INPUT_EXPRESSIONS.md @@ -206,19 +206,19 @@ If in addition single digit days and month should be permitted the pattern could Common simple value inputs and their patterns: -| Value | Input Expression Pattern | Examples | Description | -|----------------------|------------------------------------------------------------------------------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------| -| Integer | `?\|s\|+#` or `?[+-]+#` or `?[s]+#` (better for Java `int`: `?[+-]#9?#`) | `42`, `-7`, `+123`, `0` | Optional sign, one or more digits. | -| Decimal | `?[+-]+#?(.*#)` + `?[+-]*#.+#` | `0.5`, `-3.`, `+.7`, `123.456`, `.5` | At least one digit before or after the decimal point; includes integers. | -| +Scientific Notation | add `?([E]?[+-]+#)` | `1.23e-4`, `-0.5E+2`, `3.0e0` | Mantissa with at least one digit after decimal, followed by `e` or `E`, optional sign, and exponent digits. | -| ISO Date | `\|2099-12-31\|` | `2023-12-25`, `1900-01-01` | Four‑digit year (0‑2099), two‑digit month (0‑12), two‑digit day (0‑31). No calendar validation. | -| Time (24h) | `\|23:59\|`, `\|23:59:59\|` or flexible `\|23:59\|?(\|:59\|)` | `14:30`, `08:05:22` | Two‑digit hour (00‑23), minute (00‑59), and optional seconds (00‑59). | +| Value | Input Expression Pattern | Examples | Description | +|----------------------|-------------------------------------------------------------------------------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------| +| Integer | `?\|s\|+#` or `?[+-]+#` or `?[s]+#` (better for Java `int`: `?[+-]#9?#`) | `42`, `-7`, `+123`, `0` | Optional sign, one or more digits. | +| Decimal | `?[+-]+#?(.*#)` + `?[+-]*#.+#` | `0.5`, `-3.`, `+.7`, `123.456`, `.5` | At least one digit before or after the decimal point; includes integers. | +| +Scientific Notation | add `?([E]?[+-]+#)` | `1.23e-4`, `-0.5E+2`, `3.0e0` | Mantissa with at least one digit after decimal, followed by `e` or `E`, optional sign, and exponent digits. | +| ISO Date | `\|2099-12-31\|` | `2023-12-25`, `1900-01-01` | Four‑digit year (0‑2099), two‑digit month (0‑12), two‑digit day (0‑31). No calendar validation. | +| Time (24h) | `\|23:59\|`, `\|23:59:59\|` or flexible `\|23:59\|?(\|:59\|)` | `14:30`, `08:05:22` | Two‑digit hour (00‑23), minute (00‑59), and optional seconds (00‑59). | | UUID | `8[x]-4[x]-4[x]-4[x]-8[x]4[x]` or `\|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\| ` | `123e4567-e89b-12d3-a456-426614174000` | Standard 36‑character UUID: 8‑4‑4‑4‑12 hex digits, separated by hyphens. | -| US Phone | `?(\|(###)\|) 3#-4#` | `(123) 456-7890`, `456-7890` | Area code in parentheses, optional; then three digits, hyphen, four digits. | -| IPv4 Address | `\|255.255.255.255\|` (3 digits each only) `#2?#.#2?#.#2?#.#2#` (1-3 digits) | `192.168.1.1`, `0.0.0.0`, `255.255.255.255` | Four numbers 0‑255 separated by dots. Each `0-255` matches 3 digits numerically 0-255. | -| MAC Address | `\|xx-xx-xx-xx-xx-xx\|` or `2[x]:2[x]:2[x]:2[x]:2[x]:2[x]` | `00:1A:2B:3C:4D:5E`, `00-1A-2B-3C-4D-5E` | Six groups of two hex digits, separated by colon or hyphen. | -| Currency (USD) | `$?-#+*(,3#)?(.2#)` | `$1,234.56`, `$0.99`, `$-12.34` | Dollar sign, optional sign, digits, optional comma, optional decimal part. | -| US Zip Code | `5#?(-4#)` | `12345`, `12345-6789` | Five digits, optionally hyphen and four digits. | +| US Phone | `?(\|(###)\|) 3#-4#` | `(123) 456-7890`, `456-7890` | Area code in parentheses, optional; then three digits, hyphen, four digits. | +| IPv4 Address | `\|255.255.255.255\|` (3 digits each only) `#2?#.#2?#.#2?#.#2?#` (1-3 digits) | `192.168.1.1`, `0.0.0.0`, `255.255.255.255` | Four numbers 0‑255 separated by dots. Each `0-255` matches 3 digits numerically 0-255. | +| MAC Address | `\|xx-xx-xx-xx-xx-xx\|` or `2[x]:2[x]:2[x]:2[x]:2[x]:2[x]` | `00:1A:2B:3C:4D:5E`, `00-1A-2B-3C-4D-5E` | Six groups of two hex digits, separated by colon or hyphen. | +| Currency (USD) | `$?-+#*(,3#)?(.2#)` | `$1,234.56`, `$0.99`, `$-12.34` | Dollar sign, optional sign, digits, optional comma, optional decimal part. | +| US Zip Code | `5#?(-4#)` | `12345`, `12345-6789` | Five digits, optionally hyphen and four digits. | Note in the example of a decimal numbers that allow decimal point without digits before or after this is solved using 2 patterns (+) and matching that either of them matches. diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index b6d83ee..4e64725 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.stream.Stream; +import static java.lang.Character.toLowerCase; import static java.lang.Integer.MAX_VALUE; /** @@ -358,6 +359,17 @@ private static int skipUnit(char[] pattern, int offset) { }; } + private static int skipUnit(Text pattern, int offset) { + return switch (pattern.charAt(offset)) { + // +2 (+1 for opening, +1 for first character inside) + case '|' -> pattern.indexOf('|', offset + 2) + 1; + case '[' -> pattern.indexOf(']', offset + 2) + 1; + case '(' -> pattern.indexOf(')', offset + 2) + 1; + case '{' -> skipUnit(pattern, pattern.indexOf('}', offset + 2) + 1); + default -> offset + 1; + }; + } + /* Pattern (find length bounds) */ @@ -412,7 +424,7 @@ private static int length(Text pattern, int offset, int end, boolean max) { break; case '~': { if (max) return -1; // open end - if (pattern.charAt(i) == '~') i = skipUnit(pattern, i+1); + if (i < end && pattern.charAt(i) == '~') i = skipUnit(pattern, i+1); int end2 = skipUnit(pattern, i); int len2 = length(pattern, i, end2, false); if (len2 < 0) return -1; @@ -421,7 +433,7 @@ private static int length(Text pattern, int offset, int end, boolean max) { } break; case '1','2','3','4','5','6','7','8','9': { - boolean zeroOrMore = pattern.charAt(i) == '?'; + boolean zeroOrMore = i < end && pattern.charAt(i) == '?'; if (zeroOrMore) i++; // skip ? int end2 = skipUnit(pattern, i); if (max || !zeroOrMore) { @@ -443,23 +455,188 @@ private static int length(Text pattern, int offset, int end, boolean max) { return len; } - private static int skipUnit(Text pattern, int offset) { - return switch (pattern.charAt(offset)) { - // +2 (+1 for opening, +1 for first character inside) - case '|' -> pattern.indexOf('|', offset + 2) + 1; - case '[' -> pattern.indexOf(']', offset + 2) + 1; - case '(' -> pattern.indexOf(')', offset + 2) + 1; - case '{' -> skipUnit(pattern, pattern.indexOf('}', offset + 2) + 1); - default -> offset + 1; - }; - } } /* To RegEx equivalent */ - public String toRegExEquivalent() { - return ""; //TODO + /** + * @return An approximation of this expression as Regular Expression. The RegEx may be more + * permissive. + */ + public String toRegEx() { + TextBuilder regex = new TextBuilder(); + for (Pattern pattern : patterns) { + if (!regex.isEmpty()) regex.append('|'); + // to be safe the | applies correctly wrap each in non-capture group + regex.append("(?:"); + toRegEx(pattern.pattern, 0, pattern.pattern.length(), regex); + regex.append(')'); + } + return regex.toString(); + } + + private void toRegEx(Text pattern, int offset, int end, TextBuilder regex) { + int i = offset; + while (i < end) { + char opcode = pattern.charAt(i++); + switch (opcode) { + case '#' -> regex.append("[0-9]"); + case '@' -> regex.append("[-_0-9A-Za-z]"); + case '[' -> i = toRegExSet(pattern, i, skipUnit(pattern, i-1), regex); + case '|' -> i = toRegExSequence(pattern, i, skipUnit(pattern, i-1), regex); + case '?', '*', '+', '1','2','3','4','5','6','7','8','9' -> i = toRegExRepeat(pattern, i-1, regex); + case '~' -> i = toRegExScan(pattern, i, regex); + // ignore bounds as there is no regex equivalent + case '{' -> i = pattern.indexOf('}', i) + 1; + case '(' -> regex.append('('); + case ')' -> regex.append(')'); + default -> { + // everything else is taken literally + if (isAlphanumeric(opcode)) { + regex.append(opcode); + } else { + regex.append('['); + if (isRegExSetEscaped(opcode)) regex.append('\\'); + regex.append(opcode).append(']'); + } + } + } + } + } + + private int toRegExScan(Text pattern, int offset, TextBuilder regex) { + if (pattern.charAt(offset) == '~') { + //~~AB => A*?B + regex.append("(?:"); + int end1 = skipUnit(pattern, offset+1); + toRegEx(pattern, offset+1, end1, regex); + regex.append(")*?(?:"); + int end2 = skipUnit(pattern, end1); + toRegEx(pattern, end1, end2, regex); + regex.append(')'); + return end2; + } + // ~A => .*?A + int end = skipUnit(pattern, offset); + regex.append(".*?(:?"); + toRegEx(pattern, offset, end, regex); + regex.append(')'); + return end; + } + + private int toRegExRepeat(Text pattern, int offset, TextBuilder regex) { + int i = offset; + char opcode = pattern.charAt(i++); + int repMin = switch (opcode) { + case '?', '*' -> 0; + case '+' -> 1; + default -> opcode - '0'; + }; + int repMax= switch (opcode) { + case '?' -> 1; + case '*', '+' -> MAX_VALUE; + default -> opcode - '0'; + }; + if (pattern.charAt(i) == '?') { + repMin = 0; + i++; + } + int end = skipUnit(pattern, i); + regex.append("(?:"); + toRegEx(pattern, i, end, regex); + regex.append(')'); + if (repMin == 0 && repMax == 1) { + regex.append('?'); + } else if (repMin == 0 && repMax == MAX_VALUE) { + regex.append('*'); + } else if (repMin == 1 && repMax == MAX_VALUE) { + regex.append('+'); + } else { + if (repMin <= repMax) regex.append('{').append(repMin).append(','); + regex.append(repMax).append('}'); + } + return end; + } + + private int toRegExSet(Text pattern, int offset, int end, TextBuilder regex) { + regex.append('['); + int i = offset; + while (i < end) { + char opcode = pattern.charAt(i++); + switch (opcode) { + case 'b' -> regex.append("01"); + case 'd' -> regex.append("0-9"); + case 'i' -> regex.append("\\-_0-9A-Za-z"); + case 'u' -> regex.append("A-Z"); + case 'l' -> regex.append("a-z"); + case 'c' -> regex.append("A-Za-z"); + case 'a' -> regex.append("0-9A-Za-z"); + case 'x' -> regex.append("0-9A-Fa-f"); + case 's' -> regex.append("\\-+"); + case ']' -> { + if (i-1 == offset) regex.append('\\'); + regex.append(']'); + } + default -> { + if (isUpperLetter(opcode)) { + regex.append(opcode).append(toLowerCase(opcode)); + } else if (isDigit(opcode)) { + regex.append("0-").append(opcode); + } else if (isLowerLetter(opcode)) { + throw reserved(opcode); + } else { + // everything else is taken literally + if (isRegExSetEscaped(opcode)) regex.append('\\'); + regex.append(opcode); + } + } + } + } + return end; + } + + private int toRegExSequence(Text pattern, int offset, int end, TextBuilder regex) { + int i = offset; + while (i < end) { + char opcode = pattern.charAt(i++); + switch (opcode) { + case 'b' -> regex.append("[01]"); + case 'd', '#', '0', '1','2','3','4','5','6','7','8','9' -> regex.append("[0-9]"); + case 'i' -> regex.append("[-_0-9A-Za-z]"); + case 'u' -> regex.append("[A-Z]"); + case 'l' -> regex.append("[a-z]"); + case 'c' -> regex.append("[A-Za-z]"); + case 'a' -> regex.append("[0-9A-Za-z]"); + case 'x' -> regex.append("[0-9A-Fa-f]"); + case 's' -> regex.append("[-+]"); + case '?' -> regex.append("."); + case '|' -> { + if (i-1 == offset) regex.append("\\|"); + } + default -> { + if (isUpperLetter(opcode)) { + regex.append('[').append(opcode).append(toLowerCase(opcode)).append(']'); + } else if (isLowerLetter(opcode)) { + throw reserved(opcode); + } else { + if (isAlphanumeric(opcode)) { + regex.append(opcode); + } else { + // everything else is taken literally + regex.append('['); + if (isRegExSetEscaped(opcode)) regex.append('\\'); + regex.append(opcode).append(']'); + } + } + } + } + } + return end; + } + + private static boolean isRegExSetEscaped(char c) { + return c == ']' || c == '^' || c == '-' || c == '\\' || c == '/'; } } diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonString.java b/src/main/java/org/hisp/dhis/jsontree/JsonString.java index e60e893..728bd17 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonString.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonString.java @@ -40,9 +40,6 @@ * * @author Jan Bernitt */ -// TODO adjust for value duality and allow number + bool -// maybe add a JsonStrictString type (as example) -// maybe add strict to @Validation to opt-in @Validation(type = STRING) @Validation.Ignore public interface JsonString extends JsonPrimitive { diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 815a2c7..8d92e7d 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -335,12 +335,14 @@ public String toString() { */ String[] pattern() default {}; - // formats are mostly like named patterns, - // instead of repeating the pattern the name is given - // implying a certain format which is up to the impl - // so this only is useful to together with a way of linking names to a @Validation defining the - // constraints - // TODO String format() default ""; + /** + * ATM formats are purely informal to be used when expressing validations as JSON schema as it + * would be used e.g. in OpenAPI. They are meant to be used together with {@link #pattern()} to give + * patterns a human readable description or name. + * + * @return a name of the format + */ + String format() default ""; /* Validations for Numbers @@ -419,15 +421,6 @@ public String toString() { */ YesNo uniqueItems() default YesNo.AUTO; - // only useful in combination with: Class[] items() default {}; - // TODO boolean additionalItems() default YesNo.Auto; - - // a ref to a class that has a @Validation we enforce for contains - // TODO Class contains() default Void.class; //Hm.. might be same as annotating generic that - // represents the item - // TODO int minContains() default -1; - // TODO int maxContains() default -1; - /* Validations for Objects */ @@ -454,9 +447,6 @@ public String toString() { */ int maxProperties() default -1; - // recursive restrictions on the properties would come from generics - // TODO boolean additionalProperties() default YesNo.Auto; - /** * When set to AUTO any property using a Java primitive type is required. * 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 780b227..12ba12c 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -143,20 +143,20 @@ private static Validator of(Text property, PropertyValidations validations) { if (validator != null) byType.put(type, validator); }; - Set types = validations.types(); - if (types.isEmpty()) types = EnumSet.allOf(NodeType.class); - boolean acceptStrings = types.contains(STRING); - boolean acceptNumbers = types.contains(NUMBER) || types.contains(INTEGER); + Set accepted = validations.accepted(); + if (accepted.isEmpty()) accepted = EnumSet.allOf(NodeType.class); + boolean acceptStrings = accepted.contains(STRING); + boolean acceptNumbers = accepted.contains(NUMBER) || accepted.contains(INTEGER); Validator strings = ofString(validations.strings()); Validator numbers = ofNumber(validations.numbers()); Validator customs = All.of(validations.customs().stream()); if (acceptStrings) add.accept(JsonNodeType.STRING, strings); if (acceptNumbers) add.accept(JsonNodeType.NUMBER, numbers); - if (types.contains(ARRAY)) add.accept(JsonNodeType.ARRAY, ofArray(validations.arrays())); - if (types.contains(OBJECT)) add.accept(JsonNodeType.OBJECT, ofObject(validations.objects())); + if (accepted.contains(ARRAY)) add.accept(JsonNodeType.ARRAY, ofArray(validations.arrays())); + if (accepted.contains(OBJECT)) add.accept(JsonNodeType.OBJECT, ofObject(validations.objects())); - // AUTO type conversions + // strictness AUTO acceptance PropertyValidations.Strict strictness = validations.strictness(); boolean strictStrings = strings != null || customs != null || strictness.strings().isYes(); boolean strictBooleans = strictness.booleans().isYes(); @@ -167,10 +167,10 @@ private static Validator of(Text property, PropertyValidations validations) { } Validator type = null; - if (!validations.types().isEmpty()) { + if (!validations.accepted().isEmpty()) { type = new Type( - EnumSet.copyOf(validations.types()), strictBooleans, strictStrings, strictNumbers); + EnumSet.copyOf(validations.accepted()), strictBooleans, strictStrings, strictNumbers); } Validator typeSpecific = byType.isEmpty() ? null : new TypeDependent(byType); Validator items = validations.items() == null ? null : Items.of(of(property, validations.items())); @@ -192,7 +192,7 @@ private static Validator ofRequired(Text property, PropertyValidations validatio * possible and if required is AUTO */ private static boolean isRequiredImplicitly(PropertyValidations validations) { - return validations.types().stream().allMatch(type -> isRequiredImplicitly(validations, type)); + return validations.accepted().stream().allMatch(type -> isRequiredImplicitly(validations, type)); } private static boolean isRequiredImplicitly(PropertyValidations validations, NodeType type) { @@ -213,7 +213,7 @@ private static Validator ofString(@CheckNull PropertyValidations.StringValidatio strings.maxLength() <= 1 ? null : new MaxLength(strings.maxLength()), strings.pattern() == null ? null - : new Pattern(strings.pattern(), strings.pattern().toRegExEquivalent())); + : new Pattern(strings.pattern(), strings.pattern().toRegEx())); } private static Validator ofStringEnum(@NotNull PropertyValidations.StringValidation strings) { diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java index b2b0dfd..ea62e72 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -20,7 +20,7 @@ * A declarative model or description of what validation rules to check for a single property within * an object. * - * @param types the node types accepted/expected + * @param accepted the node types accepted/expected * @param customs a validator defined by class is used (custom or user defined validators), empty * list is off * @param requiredness validations about the property being required or not (presence) @@ -31,7 +31,7 @@ * @param items validations that apply to array elements or object member values (map use) */ record PropertyValidations( - @NotNull Set types, + @NotNull Set accepted, @NotNull Strict strictness, @NotNull List customs, @CheckNull RequiredValidation requiredness, @@ -64,7 +64,7 @@ Strict overlay(Strict with) { PropertyValidations overlay(@CheckNull PropertyValidations with) { if (with == null) return this; return new PropertyValidations( - overlayC(types, with.types), + overlayC(accepted, with.accepted), strictness.overlay(with.strictness), overlayEachClassAtMostOnce(customs, with.customs), requiredness == null ? with.requiredness : requiredness.overlay(with.requiredness), @@ -79,19 +79,19 @@ PropertyValidations overlay(@CheckNull PropertyValidations with) { PropertyValidations withItems(@CheckNull PropertyValidations items) { if (items == null && this.items == null) return this; return new PropertyValidations( - types, strictness, customs, requiredness, strings, numbers, arrays, objects, items); + accepted, strictness, customs, requiredness, strings, numbers, arrays, objects, items); } @NotNull PropertyValidations withCustoms(@NotNull List validators) { List merged = overlayEachClassAtMostOnce(customs, validators); return new PropertyValidations( - types, strictness, merged, requiredness, strings, numbers, arrays, objects, items); + accepted, strictness, merged, requiredness, strings, numbers, arrays, objects, items); } @NotNull public PropertyValidations varargs() { - Set newTypes = new HashSet<>(types()); + Set newTypes = new HashSet<>(accepted()); newTypes.add(NodeType.ARRAY); ArrayValidation arrays = this.arrays; if (requiredness != null && requiredness.required.isYes()) { @@ -108,7 +108,7 @@ public PropertyValidations varargs() { arrays, objects, new PropertyValidations( - types(), strictness, customs, requiredness, strings, numbers, null, objects, items)); + accepted(), strictness, customs, requiredness, strings, numbers, null, objects, items)); } /** diff --git a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java index daa5ee5..00c3330 100644 --- a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -62,13 +62,22 @@ void testScanIf() { } @Test - void testScanIf_URL() { + void testScanIf_ExampleURL() { String pattern = "/api~~(/+@)(/gist)"; assertMatches(pattern, "/api/gist", "/api/foo/gist", "/api/foo/bar/gist"); assertDoesNotMatch(pattern, "/api/foo","/api/gist/foo"); assertEquals(new Pattern(Text.of(pattern), 9, -1), Pattern.of(pattern)); } + @Test + void testGroup_NestedTail() { + // Note that while generally groups can not be nested + // it does work when the nesting is the last unit + // because the first closing ) is functionally closing all levels + assertMatches("~(foo+(bar))", "foobar", "foofoobar"); + assertMatches("~(foo+(bar~(baz)))", "foobarbarianbaz", "foofoobarberbaz"); + } + @Test void testSequenceNumeric() { assertMatches("|2050|", "2022"); @@ -77,6 +86,31 @@ void testSequenceNumeric() { assertDoesNotMatch("|2059|", "2060"); } + @Test + void testPattern_LongNumbers() { + assertMatches("|9223372036854775807|", "9223372036854775807"); + assertDoesNotMatch("|9223372036854775807|", "9223372036854775808"); + assertMatches("|-9223372036854775807|", "-9223372036854775807"); + } + + + @Test + void testPattern_ExampleInteger() { + // different variants of how to match for an integer + List patterns = + List.of("?|s|+#", "?[+-]+#", "?[s]+#", "?[+-]#9?#", "?[+-]{2147483647}(#9?#)"); + for (String pattern : patterns) + assertMatches(pattern, "1", "0", "-2", "00001", "+999", "-2147483647"); + } + + @Test + void testPattern_ExampleDecimal() { + InputExpression expr = + InputExpression.of("?[+-]+#?(.*#)?([E]?[+-]+#)", "?[+-]*#.+#?([E]?[+-]+#)"); + assertMatches(expr, ".5", "4.", "1.1", "+345.234", "-23.56e-12", "+.5e1", "-0.004E-345"); + assertDoesNotMatch(expr, ".", "-.", "0a", "a0", "1ee", "1.E+"); + } + @Test void testPattern_Dates() { assertMatches("|2050-12-31|", "2020-01-01", "2020-00-00"); @@ -100,34 +134,88 @@ void testPattern_DatesNumericBound() { } @Test - void testPattern_LongNumbers() { - assertMatches("|9223372036854775807|", "9223372036854775807"); - assertDoesNotMatch("|9223372036854775807|", "9223372036854775808"); - assertMatches("|-9223372036854775807|", "-9223372036854775807"); + void testPattern_ExampleTime() { + String hhmm = "|23:59|"; + assertMatches(hhmm, "14:53", "00:00", "23:59", "08:01"); + assertDoesNotMatch(hhmm, "23.59", "2359", "24:00", "11:60"); + String hhmmss = "|23:59:59|"; + assertMatches(hhmmss, "14:12:44", "00:00:00", "23:59:59"); + assertDoesNotMatch(hhmmss, "23.59:00", "235900", "24:00:00", "11:60:00", "00:00:60"); } @Test - void testPattern_ExamplesDecimal() { - InputExpression expr = - InputExpression.of("?[+-]+#?(.*#)?([E]?[+-]+#)", "?[+-]*#.+#?([E]?[+-]+#)"); - assertMatches(expr, ".5", "4.", "1.1", "+345.234", "-23.56e-12", "+.5e1"); + void testPattern_ExampleUUID() { + // examples given by AI from different generation methods and corner cases + String[] inputs = { + "a8098c1a-f86e-11da-bd1a-00112444be1e", + "6fa459ea-ee8a-3ca4-894e-db77e160355e", + "886313e1-3b8a-5372-9b90-0c9aee199e5d", + "019d2555-7874-7e9d-a284-9b45a0b2f165", + "00000000-0000-0000-0000-000000000000", + "ffffffff-ffff-ffff-ffff-ffffffffffff", + "FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF"}; + assertMatches("8[x]-4[x]-4[x]-4[x]-8[x]4[x]", inputs); + assertMatches("|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx|", inputs); + String[] illegalInputs = { + "gG8098c1a-f86e-11da-bd1a-00112444be1e", + "6fa459eaee8a3ca4894edb77e160355e", + }; + assertDoesNotMatch("8[x]-4[x]-4[x]-4[x]-8[x]4[x]", illegalInputs); + assertDoesNotMatch("|xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx|", illegalInputs); } @Test - void testPattern_ExamplesInteger() { - // different variants of how to match for an integer - List patterns = - List.of("?|s|+#", "?[+-]+#", "?[s]+#", "?[+-]#9?#", "?[+-]{2147483647}(#9?#)"); - for (String pattern : patterns) - assertMatches(pattern, "1", "0", "-2", "00001", "+999", "-2147483647"); + void testPattern_ExampleIPv4() { + assertMatches("|255.255.255.255|", "255.255.255.255", "192.168.001.001"); + assertMatches("#2?#.#2?#.#2?#.#2?#","255.255.255.255", "0.0.0.0", "192.168.1.1"); + assertMatches("{255}(#2?#).{255}(#2?#).{255}(#2?#).{255}(#2?#)","255.255.255.255", "0.0.0.0", "192.168.1.1"); + assertDoesNotMatch("{255}(#2?#).{255}(#2?#).{255}(#2?#).{255}(#2?#)", "256.0.0.0"); } - //TODO confirm that groups can be nested as long as the inner group appear last in the outer group - // for example: ~(foo+(bar)) - // because if the ) are mistaken it has the same effect + @Test + void testPattern_ExampleMacAddress() { + // examples given by AI + String[] inputs = { + "00:1A:2B:3C:4D:5E", + "1A:2B:3C:4D:5E:6F", + "00:80:41:ae:fd:7e", + "FF:FF:FF:FF:FF:FF", + "D8:D3:85:EB:12:E3" + }; + assertMatches("2[x]:2[x]:2[x]:2[x]:2[x]:2[x]", inputs); + assertMatches("|xx-xx-xx-xx-xx-xx|", "00-14-22-04-25-37"); + String pattern = "2[x][:-]2[x][:-]2[x][:-]2[x][:-]2[x][:-]2[x]"; + assertMatches(pattern, inputs); + assertMatches(pattern, "00-14-22-04-25-37"); + assertDoesNotMatch(pattern, "G0:1A:2B:3C:4D:5E", "1A2B3C4D5E6F"); + } @Test - void testPattern_DHIS2Periods() { + void testPattern_ExampleCurrency() { + String pattern = "$?-+#*(,3#)?(.2#)"; + assertMatches(pattern, "$2", "$0.99", "$1,234.56", "$-4.45", "$-12.34", "$1,234,000.00", "$1,234,000"); + assertDoesNotMatch(pattern, "0.99", "$0.1", "$I5"); + } + + @Test + void testPattern_ExampleUsZipCode() { + String pattern = "5#?(-4#)"; + assertMatches(pattern, "12345", "50784", "12345-1234"); + assertDoesNotMatch(pattern,"1234", "123", "12", "1", "1-2345"); + } + + @Test + void testPattern_ExampleEnum() { + InputExpression expr = + InputExpression.of("|OBJECT|", "|ARRAY|", "|STRING|", "|NUMBER|", "|BOOLEAN|", "|NULL|"); + for (JsonNodeType t : JsonNodeType.values()) { + assertMatches(expr, t.name()); + assertMatches(expr, t.name().toLowerCase()); + } + } + + @Test + void testPattern_ExampleDHIS2Periods() { InputExpression expr = InputExpression.of( "{1900-}|2050|", "{1900-}|2050|{1-}|12|", @@ -152,15 +240,17 @@ void testPattern_DHIS2Periods() { "{1900-}|2050|Nov" ); assertMatches(expr, "2022", "198011", "1980-11", "19770528", "1977-05-28"); - assertMatches(expr,"2025W51", "1989W3", "2044Q1", "2033BiW22"); + assertMatches(expr,"2025W51", "1989W3", "2044Q1", "2033BiW22", "2033Nov", "1999SatW44"); // pattern issue - assertDoesNotMatch(expr, "20221", "2022-1", "1980113", "1980-1-13"); + assertDoesNotMatch(expr, "20221", "2022-1", "1980113", "1980-1-13", "1980100"); // numerically out of bounds - assertDoesNotMatch(expr, "2051", "1899", "1980100", "198013", "1980-14", "1977-00-28"); + assertDoesNotMatch(expr, true, "2051", "1899", "198013", "1980-14", "1977-00-28"); } private static void assertMatches(String pattern, String...inputs) { InputExpression expr = InputExpression.of(pattern); + assertMatches(expr, inputs); + // also test via matches directly for (String input : inputs) { assertTrue(InputExpression.matches(pattern, input), () -> "%s should match %s".formatted(pattern, input)); assertNotNull(expr.match(input)); @@ -169,6 +259,8 @@ private static void assertMatches(String pattern, String...inputs) { private static void assertDoesNotMatch(String pattern, String...inputs) { InputExpression expr = InputExpression.of(pattern); + assertDoesNotMatch(expr, null, inputs); + // also test via matches directly for (String input : inputs) { assertFalse(InputExpression.matches(pattern, input), () -> "%s should NOT match %s".formatted(pattern, input)); assertNull(expr.match(input)); @@ -176,16 +268,30 @@ private static void assertDoesNotMatch(String pattern, String...inputs) { } private static void assertMatches(InputExpression expr, String...inputs) { + java.util.regex.Pattern regex = java.util.regex.Pattern.compile(expr.toRegEx()); for (String input : inputs) { Pattern matching = expr.match(input); assertNotNull(matching, () -> "Pattern should match %s".formatted(input)); + assertTrue(regex.matcher(input).matches(), () -> "RegEx %s should match input: %s".formatted(regex, input)); } } private static void assertDoesNotMatch(InputExpression expr, String...inputs) { + assertDoesNotMatch(expr, false, inputs); + } + + private static void assertDoesNotMatch(InputExpression expr, Boolean regexMatches, String...inputs) { + java.util.regex.Pattern regex = java.util.regex.Pattern.compile(expr.toRegEx()); for (String input : inputs) { Pattern matching = expr.match(input); assertNull(matching, () -> "Pattern should NOT match %s".formatted(input)); + if (regexMatches != null) + assertEquals( + regexMatches, + regex.matcher(input).matches(), + () -> + "RegEx %s should %s match input: %s" + .formatted(regex, regexMatches ? "" : "NOT ", input)); } } } From b2c4454accb6727335931a5e427c9e13f0b7cf83 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Tue, 14 Apr 2026 15:20:40 +0200 Subject: [PATCH 12/20] chore: dependent required v2 --- .../org/hisp/dhis/jsontree/Validation.java | 22 +- .../jsontree/validation/ObjectValidator.java | 301 ++++++------------ .../validation/PropertyValidations.java | 12 + .../JsonValidationDependentRequiredTest.java | 31 +- .../JsonValidationMiscDeepGraphTest.java | 6 +- 5 files changed, 136 insertions(+), 236 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 8d92e7d..5766727 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -463,21 +463,25 @@ public String toString() { * others are the ones that are required depending on the trigger. This property defines the * groups for the annotated property and its role using suffixes as described below. * - *

A property marked with the suffix {@code !} triggers when present, a property marked with {@code ?} triggers when - * absent. + *

Triggers

+ * Use a CSS selector-like syntax for trigger conditions: + *
    + *
  • {@code .group[=*]} triggers group when present
  • + *
  • {@code .group[=?]} triggers group when absent
  • + *
  • {@code .group[={value}]} triggers group when it has the given text value
  • + *
* - *

Multiple triggers in a group always combine with AND logic (all need to present/absent). For - * a group with multiple {@code !} triggers all must be present to trigger. For a group with - * multiple {@code ?} triggers all must be absent to trigger. For a group with both {@code !} and - * {@code ?} triggers both conditions must be met to trigger. + *

A group with multiple properties with trigger conditions always combines with AND logic, + * meaning all conditions must be met to trigger the group. * *

If none of the properties in a group is marked any of the properties makes all others in the * group required (all group properties are co-dependent). * - *

In addition, a property that is dependent required (not a trigger) can use the {@code ^} - * suffix if it is mutual exclusive to all other required properties that are marked equally. + *

Exclusive Dependent Required

+ *

In addition, a property that is dependent required (not a trigger) can be marked exclusive using {@code *} + * suffix, to indicate that it is mutual exclusive to all other dependent required properties that are marked equally. * - * @return the names of the groups the annotated property belongs to + * @return the names of the groups the annotated property belongs to or triggers */ String[] dependentRequired() default {}; 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 12ba12c..7c84918 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -1,6 +1,7 @@ package org.hisp.dhis.jsontree.validation; import static java.lang.Double.isNaN; +import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toMap; import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; import static org.hisp.dhis.jsontree.Validation.NodeType.BOOLEAN; @@ -25,8 +26,8 @@ import java.util.concurrent.ConcurrentSkipListMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; -import java.util.function.BiPredicate; import java.util.function.Consumer; +import java.util.function.Predicate; import java.util.function.Supplier; import java.util.stream.Stream; import org.hisp.dhis.jsontree.InputExpression; @@ -269,86 +270,66 @@ private static Validator ofDependentRequired( if (properties.isEmpty()) return null; if (properties.values().stream() .allMatch(p -> p.requiredness() == null || p.requiredness().dependentRequired().isEmpty())) return null; - Map> groupPropertyRole = new HashMap<>(); - Map> notExists = new HashMap<>(); + Set groups = new HashSet<>(); + Map> triggersByGroup = new HashMap<>(); + Map> nonExclusiveByGroup = new HashMap<>(); + Map> exclusiveByGroup = new HashMap<>(); properties.forEach( (name, validation) -> { - PropertyValidations.RequiredValidation values = validation.requiredness(); - notExists.put(name, notExists(values)); - if (values != null && !values.dependentRequired().isEmpty()) { - values + PropertyValidations.RequiredValidation requiredness = validation.requiredness(); + if (requiredness != null && !requiredness.dependentRequired().isEmpty()) { + requiredness .dependentRequired() .forEach( - role -> { - String group = role.replace("!", "").replace("?", ""); - if (group.startsWith("=")) group = group.substring(1); - if (group.contains("=")) group = group.substring(0, group.indexOf('=')); - groupPropertyRole - .computeIfAbsent(group, key -> new HashMap<>()) - .put(name, role); + indicator -> { + boolean trigger = indicator.indexOf('[') > 0; + String group = indicator; + if (trigger) group = group.substring(0, indicator.indexOf('[')); + boolean exclusive = !trigger && group.endsWith("*"); + if (exclusive) group = group.substring(0, group.length() - 1); + groups.add(group); + List triggers = + triggersByGroup.computeIfAbsent(group, key -> new ArrayList<>()); + if (trigger && indicator.endsWith("*]")) { + triggers.add( + new DependentRequired.Trigger( + name, requiredness.present(), "with " + name)); + } else if (trigger && indicator.endsWith("?]")) { + triggers.add( + new DependentRequired.Trigger( + name, requiredness.absent(), "without " + name)); + } else if (trigger) { + int iEquals = indicator.indexOf('='); + String value = + indicator.substring(iEquals + 1, indicator.indexOf(']', iEquals)); + triggers.add( + new DependentRequired.Trigger( + name, + e -> e.exists() && e.node().textValue().contentEquals(value), + "with %s=%s".formatted(name, value))); + } else { + DependentRequired.Dependent dependent = new DependentRequired.Dependent(name, requiredness.present()); + if (exclusive) { + exclusiveByGroup.computeIfAbsent(group, key -> new ArrayList<>()).add(dependent); + } else { + nonExclusiveByGroup.computeIfAbsent(group, key -> new ArrayList<>()).add(dependent); + } + } }); } }); List all = new ArrayList<>(); - groupPropertyRole.forEach( - (group, members) -> { - if (members.values().stream().noneMatch(ObjectValidator::isDependentRequiredRole)) { - all.add( - new DependentRequiredCodependent( - Map.copyOf(notExists), Set.copyOf(members.keySet()))); - } else { - Set> memberEntries = members.entrySet(); - List present = - memberEntries.stream() - .filter(e -> e.getValue().endsWith("!")) - .map(Map.Entry::getKey) - .toList(); - List absent = - memberEntries.stream() - .filter(e -> e.getValue().endsWith("?")) - .map(Map.Entry::getKey) - .toList(); - List dependent = - memberEntries.stream() - .filter(e -> !isDependentRequiredRole(e.getValue())) - .map(Map.Entry::getKey) - .toList(); - List exclusiveDependent = - memberEntries.stream() - .filter(e -> e.getValue().endsWith("^")) - .map(Map.Entry::getKey) - .toList(); - Map equals = - memberEntries.stream() - .filter(e -> e.getValue().contains("=")) - .collect( - toMap( - Map.Entry::getKey, - e -> Text.of(e.getValue().substring(e.getValue().indexOf('=') + 1)))); - all.add( - new DependentRequired( - Map.copyOf(notExists), - Set.copyOf(present), - Set.copyOf(absent), - Map.copyOf(equals), - Set.copyOf(dependent), - Set.copyOf(exclusiveDependent))); - } - }); + for (String group : groups) { + all.add( + new DependentRequired( + triggersByGroup.getOrDefault(group, List.of()), + nonExclusiveByGroup.getOrDefault(group, List.of()), + exclusiveByGroup.getOrDefault(group, List.of()) + )); + } return All.of(all.toArray(Validator[]::new)); } - @NotNull - private static BiPredicate notExists(PropertyValidations.RequiredValidation values) { - if (values == null || !values.allowNull().isYes()) return JsonMixed::isUndefined; - BiPredicate test = JsonMixed::exists; - return test.negate(); - } - - private static boolean isDependentRequiredRole(String group) { - return group.endsWith("?") || group.endsWith("!") || group.endsWith("^") || group.contains("="); - } - /* Node type independent or generic validators */ @@ -361,7 +342,7 @@ static Validator of(Validator... validators) { } static Validator of(Stream validators) { List actual = - validators + validators .filter(Objects::nonNull) .mapMulti( (Validator v, Consumer pipe) -> { @@ -738,160 +719,60 @@ public void validate(JsonMixed value, Consumer addError) { } } - /** - * The dependent required validator. - * - * @param notExists a predicate for each dependent property on how to check of they do not exist - * @param present a set of properties that all need to be present to trigger - * @param absent a set of properties that all need to be absent to trigger - * @param equals a map from property to value that all need to match the current value to trigger - * @param dependents a set of properties that become required when triggering - * @param exclusiveDependent a set of properties that become required when triggering but are also - * mutual exclusive - */ private record DependentRequired( - Map> notExists, - Set present, - Set absent, - Map equals, - Set dependents, - Set exclusiveDependent) + List triggers, List nonExclusive, List exclusive) implements Validator { + record Trigger(Text name, Predicate condition, String description) {} + record Dependent(Text name, Predicate present) {} + @Override public void validate(JsonMixed obj, Consumer addError) { if (!obj.isObject()) return; - boolean presentNotMet = - !present.isEmpty() && present.stream().anyMatch(p -> notExists.get(p).test(obj, p)); - boolean absentNotMet = - !absent.isEmpty() && absent.stream().anyMatch(p -> !notExists.get(p).test(obj, p)); - boolean equalsNotMet = - !equals.isEmpty() - && equals.entrySet().stream() - .anyMatch(e -> !e.getValue().contentEquals(obj.getString(e.getKey()).text())); - if (presentNotMet || absentNotMet || equalsNotMet) return; - if (!dependents.isEmpty() - && dependents.stream().anyMatch(p -> notExists.get(p).test(obj, p))) { - Set missing = - Set.copyOf(dependents.stream().filter(p -> notExists.get(p).test(obj, p)).toList()); - if (!equals.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object with %s requires all of %s, missing: %s", - equals, - dependents, - missing)); - } else if (present.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object without any of %s requires all of %s, missing: %s", - absent, - dependents, - missing)); - } else if (absent.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object with any of %s requires all of %s, missing: %s", - present, - dependents, - missing)); - } else { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object with any of %s or without any of %s requires all of %s, missing: %s", - present, - absent, - dependents, - missing)); - } + if (!triggers.isEmpty()) { + // any trigger not met => done + for (Trigger trigger : triggers) + if (!trigger.condition.test(obj.get(trigger.name))) return; + } else { + // codependent: all absent? => done + if (!nonExclusive.isEmpty() && nonExclusive.stream().noneMatch(d -> d.present.test(obj.get(d.name)))) return; } - if (!exclusiveDependent.isEmpty()) { - Set defined = - Set.copyOf( - exclusiveDependent.stream().filter(p -> !notExists.get(p).test(obj, p)).toList()); - if (defined.size() == 1) return; // it is exclusively defined => OK - if (!equals.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object with %s requires one but only one of %s, but has: %s", - equals, - exclusiveDependent, - defined)); - } else if (present.isEmpty() && absent.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object requires one but only one of %s, but has: %s", - exclusiveDependent, - defined)); - } else if (present.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object without any of %s requires one but only one of %s, but has: %s", - absent, - exclusiveDependent, - defined)); - } else if (absent.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - obj, - "object with any of %s requires one but only one of %s, but has: %s", - present, - exclusiveDependent, - defined)); - } else { + // check dependents... + if (!nonExclusive.isEmpty() && nonExclusive.stream().anyMatch(d -> !d.present.test(obj.get(d.name)))) { + List missing = + nonExclusive.stream() + .filter(d -> !d.present.test(obj.get(d.name))) + .map(Dependent::name) + .toList(); + addError.accept( + Error.of( + Rule.DEPENDENT_REQUIRED, + obj, + "object %s requires all of %s, missing: %s", + triggers.stream().map(Trigger::description).collect(joining(", ")), + Set.copyOf(nonExclusive.stream().map(Dependent::name).toList()), + Set.copyOf(missing))); + } + // check exclusivity + else if (!exclusive.isEmpty()) { + List found = + exclusive.stream() + .filter(d -> d.present.test(obj.get(d.name))) + .map(Dependent::name) + .toList(); + if (found.size() != 1) addError.accept( Error.of( Rule.DEPENDENT_REQUIRED, obj, - "object with any of %s or without any of %s requires one but only one of %s, but has: %s", - present, - absent, - exclusiveDependent, - defined)); - } + "object %s requires one but only one of %s, but has: %s", + triggers.stream().map(Trigger::description).collect(joining(", ")), + Set.copyOf(exclusive.stream().map(Dependent::name).toList()), + Set.copyOf(found))); } } } - /** - * @param notExists a predicate for each codependent property on how to check of they do not exist - * @param codependent the set of codependent properties (names) - */ - private record DependentRequiredCodependent( - Map> notExists, Set codependent) - implements Validator { - - @Override - public void validate(JsonMixed value, Consumer addError) { - if (!value.isObject()) return; - if (codependent.stream().anyMatch(p -> notExists.get(p).test(value, p)) - && codependent.stream().anyMatch(p -> !notExists.get(p).test(value, p))) - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - value, - "object with any of %1$s all of %1$s are required, missing: %s", - codependent, - Set.copyOf( - codependent.stream().filter(p -> notExists.get(p).test(value, p)).toList()))); - } - } - private record Lazy(Class of, AtomicReference instance) implements Validator { @Override diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java index ea62e72..430c374 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -1,15 +1,19 @@ package org.hisp.dhis.jsontree.validation; import static java.lang.Double.isNaN; +import static java.util.function.Predicate.not; import static java.util.stream.Collectors.toSet; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Stream; import org.hisp.dhis.jsontree.InputExpression; +import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Validator; import org.hisp.dhis.jsontree.Validation.YesNo; @@ -131,6 +135,14 @@ RequiredValidation overlay(@CheckNull PropertyValidations.RequiredValidation wit overlayC(dependentRequired, with.dependentRequired), overlayY(allowNull, with.allowNull)); } + + Predicate present() { + return allowNull.isYes() ? JsonValue::exists : not(JsonValue::isUndefined); + } + + Predicate absent() { + return allowNull.isYes() ? not(JsonValue::exists) : JsonValue::isUndefined; + } } /** diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java index 7d03aba..6713fa9 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java @@ -22,12 +22,12 @@ class JsonValidationDependentRequiredTest { public interface JsonDependentRequiredExampleA extends JsonObject { - @Validation(dependentRequired = "name") + @Validation(dependentRequired = ".name") default String firstName() { return getString("firstName").string(); } - @Validation(dependentRequired = "name") + @Validation(dependentRequired = ".name") default String lastName() { return getString("lastName").string(); } @@ -35,12 +35,12 @@ default String lastName() { public interface JsonDependentRequiredExampleB extends JsonObject { - @Validation(dependentRequired = "city+zip!") + @Validation(dependentRequired = ".city+zip[=*]") default String zip() { return getString("zip").string(); } - @Validation(dependentRequired = "city+zip") + @Validation(dependentRequired = ".city+zip") default String city() { return getString("city").string(); } @@ -48,17 +48,17 @@ default String city() { public interface JsonDependentRequiredExampleC extends JsonObject { - @Validation(dependentRequired = {"street+no?", "box"}) + @Validation(dependentRequired = {".street+no[=?]", ".box"}) default String box() { return getString("box").string(); } - @Validation(dependentRequired = {"street+no", "box?"}) + @Validation(dependentRequired = {".street+no", ".box[=?]"}) default String street() { return getString("street").string(); } - @Validation(dependentRequired = {"street+no"}) + @Validation(dependentRequired = {".street+no"}) default String no() { return getString("no").string(); } @@ -67,17 +67,17 @@ default String no() { public interface JsonDependentRequiredExampleD extends JsonObject { @Required - @Validation(dependentRequired = "=update") + @Validation(dependentRequired = ".update[=update]") default String getOp() { return getString("op").string(); } - @Validation(dependentRequired = "update") + @Validation(dependentRequired = ".update") default String getPath() { return getString("path").string(); } - @Validation(dependentRequired = "update", acceptNull = YES) + @Validation(dependentRequired = ".update", acceptNull = YES) default JsonMixed value() { return get("value", JsonMixed.class); } @@ -90,6 +90,7 @@ void testDependentRequired_Codependent() { {"firstName":"peter"}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, + "", Set.of("firstName", "lastName"), Set.of("lastName")); assertValidationError( @@ -97,6 +98,7 @@ void testDependentRequired_Codependent() { {"lastName":"peter"}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, + "", Set.of("firstName", "lastName"), Set.of("firstName")); assertValidationError( @@ -104,6 +106,7 @@ void testDependentRequired_Codependent() { {"lastName":"peter", "firstName": null}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, + "", Set.of("firstName", "lastName"), Set.of("firstName")); @@ -128,7 +131,7 @@ void testDependentRequired_PresentDependent() { {"zip":"12345"}""", JsonDependentRequiredExampleB.class, Rule.DEPENDENT_REQUIRED, - Set.of("zip"), + "with zip", Set.of("city"), Set.of("city")); @@ -153,7 +156,7 @@ void testDependentRequired_AbsentDependent() { {"street":"main"}""", JsonDependentRequiredExampleC.class, Rule.DEPENDENT_REQUIRED, - Set.of("box"), + "without box", Set.of("street", "no"), Set.of("no")); @@ -178,7 +181,7 @@ void testDependentRequired_AllowNull() { {"op":"update"}""", JsonDependentRequiredExampleD.class, Rule.DEPENDENT_REQUIRED, - Map.of("op", "update"), + "with op=update", Set.of("value", "path"), Set.of("value", "path")); assertValidationError( @@ -186,7 +189,7 @@ void testDependentRequired_AllowNull() { {"op":"update", "value": null}""", JsonDependentRequiredExampleD.class, Rule.DEPENDENT_REQUIRED, - Map.of("op", "update"), + "with op=update", Set.of("value", "path"), Set.of("path")); diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java index 39ab001..7851959 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java @@ -80,12 +80,12 @@ default String name() { return getString("name").string(); } - @Validation(dependentRequired = "val^") + @Validation(dependentRequired = ".val*") default String text() { return getString("text").string(); } - @Validation(dependentRequired = "val^") + @Validation(dependentRequired = ".val*") default Number value() { return getNumber("value").number(); } @@ -275,7 +275,7 @@ void testDeep_Error_AttributeNoTextOrValue() { Validation.Error error = assertValidationError( - json, JsonPage.class, Rule.DEPENDENT_REQUIRED, Set.of("text", "value"), Set.of()); + json, JsonPage.class, Rule.DEPENDENT_REQUIRED, "", Set.of("text", "value"), Set.of()); assertEquals(".entries.0.attributes.0", error.path().toString()); } From f17bbd21b1750f3f49642c8efa0dc49501b613c9 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Tue, 14 Apr 2026 15:47:51 +0200 Subject: [PATCH 13/20] fix: avoid toMinimizedJson in validation --- .../jsontree/validation/ObjectValidator.java | 20 +++++++++---------- .../JsonValidationMiscDeepGraphTest.java | 2 +- 2 files changed, 10 insertions(+), 12 deletions(-) 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 7c84918..483a4b0 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -325,7 +325,7 @@ private static Validator ofDependentRequired( triggersByGroup.getOrDefault(group, List.of()), nonExclusiveByGroup.getOrDefault(group, List.of()), exclusiveByGroup.getOrDefault(group, List.of()) - )); + )); } return All.of(all.toArray(Validator[]::new)); } @@ -661,23 +661,21 @@ private record UniqueItems() implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - if (value.isArray()) { - // TODO toMinimizedJson is simple but costly - List elementsAsJson = - value.asList(JsonValue.class).toList(JsonValue::toMinimizedJson); - for (int i = 0; i < elementsAsJson.size(); i++) { - int j = elementsAsJson.lastIndexOf(elementsAsJson.get(i)); - if (j != i) + if (!value.isArray() || value.isEmpty()) return; + int size = value.size(); + for (int i = 0; i < size; i++) + for (int j = 1; j < size; j++) + if (i != j && value.get(i).equivalentTo(value.get(j))) { addError.accept( Error.of( Rule.UNIQUE_ITEMS, value, "items must be unique but %s was found at index %d and %d", - elementsAsJson.get(i), + value.get(i).node().getDeclaration(), i, j)); - } - } + return; + } } } diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java index 7851959..8aad164 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java @@ -292,7 +292,7 @@ void testDeep_Error_AttributeNotUnique() { Validation.Error error = assertValidationError( - json, JsonPage.class, Rule.UNIQUE_ITEMS, "{\"name\":\"foo\",\"value\":1}", 0, 2); + json, JsonPage.class, Rule.UNIQUE_ITEMS, "{\"name\": \"foo\", \"value\": 1}", 0, 2); assertEquals(".entries.0.attributes", error.path().toString()); } } From e8e69ba17895c16f3c9e0b4eedc9df14cb721d98 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Wed, 15 Apr 2026 17:34:13 +0200 Subject: [PATCH 14/20] feat: Validation meta annotations, Text pattern based slices and more tests --- .../hisp/dhis/jsontree/InputExpression.java | 32 +++++- .../java/org/hisp/dhis/jsontree/Text.java | 77 ++++++++++++- .../org/hisp/dhis/jsontree/TextualNumber.java | 1 + .../org/hisp/dhis/jsontree/Validation.java | 105 ++++++++++++++++++ .../jsontree/validation/JsonValidator.java | 17 ++- .../jsontree/validation/ObjectValidation.java | 46 +++++--- .../jsontree/validation/ObjectValidator.java | 22 ++-- .../org/hisp/dhis/jsontree/Assertions.java | 4 +- .../java/org/hisp/dhis/jsontree/TextTest.java | 98 ++++++++++++++++ .../validation/JsonValidationMetaTest.java | 82 ++++++++++++++ .../validation/JsonValidationPatternTest.java | 50 +++++++++ .../validation/JsonValidationTypeTest.java | 5 + 12 files changed, 503 insertions(+), 36 deletions(-) create mode 100644 src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java create mode 100644 src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index 4e64725..001bd53 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -33,21 +33,43 @@ public static InputExpression of(String... patterns) { /** * @param input the sequence to check - * @return the format that matches the input, or null if none matches + * @return the {@link Pattern} that matches the input exactly, or null if none matches */ public Pattern match(CharSequence input) { for (Pattern pattern : patterns) if (pattern.matches(input)) return pattern; return null; } + /** + * @param pattern pattern to test + * @param input character sequence that is tested for the pattern + * @return true, if the pattern matches the entire input exactly + */ public static boolean matches(String pattern, CharSequence input) { return matches(pattern.toCharArray(), input); } + /** + * @param pattern pattern to test + * @param input character sequence that is tested for the pattern + * @return true, if the pattern matches the entire input exactly + */ public static boolean matches(char[] pattern, CharSequence input) { return match(pattern, 0, pattern.length, input, 0) == input.length(); } + /** + * Finds the index in the input (starting at pos) where the given slice of the pattern between + * offset and end has matched. + * + * @param pattern pattern to match + * @param offset in pattern to match + * @param end end index in pattern where the match is considered complete + * @param input input sequence to match against + * @param pos offset in input to start matching + * @return the next index in input after the pattern is matched or -1 if the pattern does not + * match starting at pos + */ public static int match(char[] pattern, int offset, int end, CharSequence input, int pos) { int i = offset; int len = input.length(); @@ -467,12 +489,13 @@ private static int length(Text pattern, int offset, int end, boolean max) { */ public String toRegEx() { TextBuilder regex = new TextBuilder(); + boolean single = patterns.size() == 1; for (Pattern pattern : patterns) { if (!regex.isEmpty()) regex.append('|'); // to be safe the | applies correctly wrap each in non-capture group - regex.append("(?:"); + if (!single) regex.append("(?:"); toRegEx(pattern.pattern, 0, pattern.pattern.length(), regex); - regex.append(')'); + if (!single) regex.append(')'); } return regex.toString(); } @@ -554,7 +577,8 @@ private int toRegExRepeat(Text pattern, int offset, TextBuilder regex) { } else if (repMin == 1 && repMax == MAX_VALUE) { regex.append('+'); } else { - if (repMin <= repMax) regex.append('{').append(repMin).append(','); + regex.append('{'); + if (repMin < repMax) regex.append(repMin).append(','); regex.append(repMax).append('}'); } return end; diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java index ef7453b..8eb406e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Text.java +++ b/src/main/java/org/hisp/dhis/jsontree/Text.java @@ -6,6 +6,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.function.Consumer; + import org.hisp.dhis.jsontree.internal.NotNull; /** @@ -211,8 +213,9 @@ default boolean matches(CharSequence input, int offset, int len) { @Override default @NotNull Text subSequence(int start, int end) { - checkSubSequence(start, end, length()); - if (start == 0 && end == length()) return this; + int length = length(); + checkSubSequence(start, end, length); + if (start == 0 && end == length) return this; int len = end - start; if (start == 0 && end == len) return this; char[] buffer = new char[len]; @@ -220,6 +223,70 @@ default boolean matches(CharSequence input, int offset, int len) { return of(buffer, 0, len); } + /** + * Same as {@link #subSequence(int, int)} except that negative end is slicing away that number of + * characters from the end and that any slice exceeding the limit of what is available results in + * an empty text not an error. The operation still fails if start or end are out of bounds (less + * than zero or larger than length). + * + * @param start offset or first character to include in the slice + * @param end first character to exclude from the slice or when negative number of characters to + * drop from the end + * @return the slice + */ + default Text slice(int start, int end) { + return subSequence(start, Math.max(start, end < 0 ? length()+end : end)); + } + + /** + * @return this text without left and right inline whitespace (tabs and spaces removed) + */ + default Text trim() { + if (isEmpty()) return this; + int len = length(); + int left = 0; + while (left < len && isTrim(charAt(left))) left++; + if (left == len) return Text.of(""); + int right = len-1; + while (right > left && isTrim(charAt(right))) right--; + if (left == 0 && right == len-1) return this; + return subSequence(left, right + 1); + } + + static boolean isTrim(char ch) { + return ch == ' ' || ch == '\t'; + } + + /** + * Slices this text into segments based on a pattern. + * Note that in contrast to a split by regular expression this matches the pattern at the start of this text, + * slices of the section as the first segment and repeats the process on the remaining tail text. + * If the pattern does not match the beginning of the text the remaining tail is the last segment. + * + * @param pattern the prefix to extract into segments + * @param segments accepts the slices of segments in order left to right + */ + default void slice(Text pattern, Consumer segments) { + if (isEmpty()) { + segments.accept(this); + return; + } + char[] p = pattern.toCharArray(); + int pos = 0; + int len = length(); + int end = InputExpression.match(p, 0, p.length, this, pos); + if (end < 0 || end >= len) { + segments.accept(this); + } else { + do { + segments.accept(subSequence(pos, end)); + pos = end; + end = InputExpression.match(p, 0, p.length, this, pos); + } while (end >= 0 && end < len); + segments.accept(subSequence(pos, len)); + } + } + /** * @see String#toCharArray() */ @@ -261,10 +328,10 @@ default boolean isTextualDecimal() { } /** - * @return true, if this text is exactly "NaN", "Infinite" or "-Infinite" (Java double special number literals) + * @return true, if this text is exactly "NaN", "Infinity" or "-Infinity" (Java double special number literals) */ default boolean isSpecialDecimal() { - return contentEquals("NaN") || contentEquals("Infinite") || contentEquals("-Infinite"); + return contentEquals("NaN") || contentEquals("Infinity") || contentEquals("-Infinity"); } /** @@ -337,7 +404,7 @@ default int compareTo(Text other) { * necessarily observed as equal. */ default boolean contentMemoryEquals(@NotNull Text other) { - return false; + return this == other; } static Text copyOf(@NotNull Text text) { diff --git a/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java b/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java index 394f831..c68b64a 100644 --- a/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java +++ b/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java @@ -127,6 +127,7 @@ static boolean isTextualDecimalBase(char[] buffer, int offset, int length) { int i0 = i; while (i < end && isDigit(buffer[i])) i++; boolean mDigits = i > i0; + if (i == end) return mDigits; if (buffer[i] == '.') { i++; // skip . i0 = i; diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 5766727..6c2d49f 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -1,5 +1,6 @@ package org.hisp.dhis.jsontree; +import java.lang.annotation.Annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -12,6 +13,10 @@ import org.hisp.dhis.jsontree.internal.CheckNull; import org.hisp.dhis.jsontree.internal.NotNull; +import static java.lang.Double.NaN; +import static org.hisp.dhis.jsontree.Validation.YesNo.AUTO; +import static org.hisp.dhis.jsontree.Validation.YesNo.NO; + /** * Structural Validations as defined by the JSON schema specification 2020-12 dialect. @@ -260,6 +265,13 @@ public String toString() { * @return When YES, do not accept JSON string as number */ YesNo numbers() default YesNo.AUTO; + + record Instance(boolean value, YesNo booleans, YesNo strings, YesNo numbers) implements Strict { + @Override + public Class annotationType() { + return Strict.class; + } + } } /** @@ -494,6 +506,15 @@ public String toString() { */ YesNo acceptNull() default YesNo.AUTO; + /** + * When a {@link Validation} annotation declares a {@link Meta} {@link Class} all other attributes are ignored. + * + * @return the factory class that can extract a {@link Validation.Instance} from another + * annotation. This is used to create custom annotation which do have attributes that transfer + * to attributes of {@link Validation}. + */ + Class meta() default Meta.class; + /** * A {@link Validator} that uses RegEx patterns. * Use via @{@link org.hisp.dhis.jsontree.Validator}. @@ -530,4 +551,88 @@ public void validate(JsonMixed value, Consumer addError) { Error.of(Rule.ENUM, value, "must be one of %s but was: %s", constants, value)); } } + + /* + Meta Annotations from other annotatons with values + */ + + interface Meta { + + Validation extract(T meta); + } + + record Instance( + NodeType[] type, + Strict strictness, + YesNo varargs, + String[] oneOfValues, + Class enumeration, + YesNo caseInsensitive, + int minLength, + int maxLength, + String[] pattern, + String format, + double minimum, + double maximum, + double exclusiveMinimum, + double exclusiveMaximum, + double multipleOf, + int minItems, + int maxItems, + YesNo uniqueItems, + int minProperties, + int maxProperties, + YesNo required, + String[] dependentRequired, + YesNo acceptNull) + implements Validation { + + public Instance( + int minLength, + int maxLength, + double minimum, + double maximum, + double exclusiveMinimum, + double exclusiveMaximum, + int minItems, + int maxItems, + int minProperties, + int maxProperties, + NodeType... types) { + this( + types, + new Validation.Strict.Instance(false, AUTO, AUTO, AUTO), + NO, + new String[0], + Enum.class, + AUTO, + minLength, + maxLength, + new String[0], + "", + minimum, + maximum, + exclusiveMinimum, + exclusiveMaximum, + NaN, + minItems, + maxItems, + AUTO, + minProperties, + maxProperties, + AUTO, + new String[0], + AUTO); + } + + @Override + public Class annotationType() { + return Validation.class; + } + + @Override + public Class meta() { + return Meta.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 4b0d631..e9fe6c9 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -81,10 +81,21 @@ private static void validate(JsonValue value, ObjectValidator validator, Consume } } - // TODO a publicly accessible way to get the JSON Schema validation description (JSON) for a - // schema class + // TODO(future) a publicly accessible way to get the JSON Schema validation description (JSON) + // for a schema class // and also for classes used as values like UID to get what the validation is on these in - // isolation for OpenAPI + // isolation for e.g. OpenAPI (basically a Validator => JsonSchema function) + /* + For type dependent this should become something like this + + { + "oneOf": [ + { "type": "string", "minLength": 1 }, + { "type": "array", "minItems": 1 } + ] + } + + */ // TODO a mode or setting that allows to skip validation on parts that are about stream processing // like Stream or Iterator types where the validation would rack the benefits 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 642c77d..06ad66a 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -140,7 +140,7 @@ private static PropertyValidations fromValueTypeUse(AnnotatedType src) { AnnotatedType[] typeArguments = pt.getAnnotatedActualTypeArguments(); if (typeArguments.length == 1) return base.withItems(fromValueTypeUse(typeArguments[0])); if (Map.class.isAssignableFrom(rawType)) { - // TODO make use of "propertyNames" (schema field) for key restrictions + // TODO(future) make use of "propertyNames" (JSON schema field) for key restrictions return base.withItems(fromValueTypeUse(typeArguments[1])); } return base; @@ -207,9 +207,15 @@ private static PropertyValidations fromMetaAnnotations(AnnotatedElement src) { } @CheckNull + @SuppressWarnings({"rawtypes", "unchecked"}) private static PropertyValidations fromMetaAnnotation(Annotation a) { Class type = a.annotationType(); if (!type.isAnnotationPresent(Validation.class)) return null; + Validation src = type.getAnnotation(Validation.class); + if (src.meta() != Validation.Meta.class) { + Validation.Meta meta = newFactory(src.meta()); + return meta == null ? null : toPropertyValidation(meta.extract(a)); + } return META_TYPE_BY_VALIDATIONS.computeIfAbsent( type, t -> @@ -366,19 +372,6 @@ private static Validation.Validator toValidator(@NotNull Validator src) { return newValidator(type, new Class[] {Validation[].class}, new Object[] {params}); } - private static Validation.Validator newValidator(Class type, Class[] types, Object[] args) { - try { - return (Validation.Validator) - MethodHandles.lookup() - .findConstructor(type, MethodType.methodType(void.class, types)) - .asFixedArity() - .invokeWithArguments(args); - } catch (Throwable ex) { - log.log(Level.ERROR, "Validator ignored, failed to construct instance", ex); - return null; - } - } - @NotNull private static Set anyOfTypes(@NotNull Validation src) { return anyOfTypes(src.type()); @@ -417,4 +410,29 @@ private static NodeType nodeTypeOf(@NotNull Class type) { if (Map.class.isAssignableFrom(type)) return OBJECT; return null; } + + private static Validation.Validator newValidator(Class type, Class[] types, Object[] args) { + try { + return (Validation.Validator) + MethodHandles.lookup() + .findConstructor(type, MethodType.methodType(void.class, types)) + .asFixedArity() + .invokeWithArguments(args); + } catch (Throwable ex) { + log.log(Level.ERROR, "Validator ignored, failed to construct instance", ex); + return null; + } + } + + @SuppressWarnings("unchecked") + private static T newFactory(Class type) { + try { + return (T) MethodHandles.lookup() + .findConstructor(type, MethodType.methodType(void.class, new Class[]{})) + .invoke(); + } catch (Throwable ex) { + log.log(Level.ERROR, "Factory ignored, failed to construct instance", ex); + return null; + } + } } 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 483a4b0..b15e2fe 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -37,7 +37,6 @@ import org.hisp.dhis.jsontree.JsonNodeType; import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.JsonPath; -import org.hisp.dhis.jsontree.JsonValue; import org.hisp.dhis.jsontree.Text; import org.hisp.dhis.jsontree.Validation.Error; import org.hisp.dhis.jsontree.Validation.NodeType; @@ -166,6 +165,10 @@ private static Validator of(Text property, PropertyValidations validations) { // accept number given as string => number constraints apply to string add.accept(JsonNodeType.STRING, numbers); } + if (!strictNumbers && acceptNumbers && numbers == null && acceptStrings && strings != null) { + // string constraints apply to numbers + add.accept(JsonNodeType.NUMBER, strings); + } Validator type = null; if (!validations.accepted().isEmpty()) { @@ -214,7 +217,7 @@ private static Validator ofString(@CheckNull PropertyValidations.StringValidatio strings.maxLength() <= 1 ? null : new MaxLength(strings.maxLength()), strings.pattern() == null ? null - : new Pattern(strings.pattern(), strings.pattern().toRegEx())); + : new Pattern(strings.pattern())); } private static Validator ofStringEnum(@NotNull PropertyValidations.StringValidation strings) { @@ -543,6 +546,9 @@ public void validate(JsonMixed value, Consumer addError) { } private record Pattern(InputExpression expr, String regExEquivalent) implements Validator { + Pattern(InputExpression expr) { + this(expr, expr.toRegEx()); + } @Override public void validate(JsonMixed value, Consumer addError) { @@ -683,36 +689,36 @@ public void validate(JsonMixed value, Consumer addError) { object values */ - private record MinProperties(int limit) implements Validator { + private record MinProperties(int count) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { if (!value.isObject()) return; int actual = value.size(); - if (actual < limit) + if (actual < count) addError.accept( Error.of( Rule.MIN_PROPERTIES, value, "must have >= %d properties but has: %d", - limit, + count, actual)); } } - private record MaxProperties(int limit) implements Validator { + private record MaxProperties(int count) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { if (!value.isObject()) return; int actual = value.size(); - if (actual > limit) + if (actual > count) addError.accept( Error.of( Rule.MAX_PROPERTIES, value, "must have <= %d properties but has: %d", - limit, + count, actual)); } } diff --git a/src/test/java/org/hisp/dhis/jsontree/Assertions.java b/src/test/java/org/hisp/dhis/jsontree/Assertions.java index a1f3fc3..6a6834f 100644 --- a/src/test/java/org/hisp/dhis/jsontree/Assertions.java +++ b/src/test/java/org/hisp/dhis/jsontree/Assertions.java @@ -27,8 +27,8 @@ public static Validation.Error assertValidationError( 1, errors.size(), () -> - "unexpected number of errors: " - + String.join("", errors.stream().map(Validation.Error::toString).toList())); + "unexpected number of errors: \n\t" + + String.join("\n\t", errors.stream().map(Validation.Error::toString).toList())); Validation.Error error = errors.get(0); assertEquals(expected, error.rule(), () -> "unexpected error type: " + error); List actualArgs = textToString(error.args()); diff --git a/src/test/java/org/hisp/dhis/jsontree/TextTest.java b/src/test/java/org/hisp/dhis/jsontree/TextTest.java index 94efcde..c1731c7 100644 --- a/src/test/java/org/hisp/dhis/jsontree/TextTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/TextTest.java @@ -2,7 +2,9 @@ import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -218,6 +220,48 @@ void testSubSequence() { assertSame(t, t.subSequence(0, 5), "subSequence(0,length()) should return this"); } + @DisplayName("slice(int, int)") + @Test + void testSlice() { + assertEquals(Text.of("hello"), Text.of("hello").slice(0, 5)); + assertEquals(Text.of("ello"), Text.of("hello").slice(1, 5)); + assertEquals(Text.of("ell"), Text.of("hello").slice(1, -1)); + assertEquals(Text.of("l"), Text.of("hello").slice(2, -2)); + assertEquals(Text.of(""), Text.of("hello").slice(2, -3)); + assertEquals(Text.of(""), Text.of("hello").slice(3, -2)); + assertEquals(Text.of(""), Text.of("hello").slice(3, -3)); + } + + @DisplayName("trim()") + @Test + void testTrim() { + assertEquals(Text.of("hello"), Text.of("hello").trim()); + assertEquals(Text.of("hello"), Text.of(" hello").trim()); + assertEquals(Text.of("hello"), Text.of("hello ").trim()); + assertEquals(Text.of("hello"), Text.of(" hello ").trim()); + assertEquals(Text.of("hello"), Text.of(" hello ").trim()); + assertEquals(Text.of(""), Text.of(" ").trim()); + } + + @DisplayName("slice(Text, Consumer)") + @Test + void testSlice_Pattern() { + List parts = new ArrayList<>(); + + Text foo = Text.of("foo"); + foo.slice(Text.of("~."), parts::add); + assertEquals(List.of(foo), parts); + assertSame(foo, parts.get(0)); + + parts.clear(); + Text.of("foo.bar").slice(Text.of("~."), parts::add); + assertEquals(List.of(Text.of("foo."), Text.of("bar")), parts); + + parts.clear(); + Text.of("foo.bar.baz").slice(Text.of("~."), parts::add); + assertEquals(List.of(Text.of("foo."), Text.of("bar."), Text.of("baz")), parts); + } + @DisplayName("toCharArray()") @Test void testToCharArray() { @@ -272,6 +316,60 @@ void testIsNumericInteger() { assertTrue(Text.of("12.00").isNumericInteger()); } + @DisplayName("isTextualDecimal()") + @Test + void testIsTextualDecimal() { + assertTrue(Text.of("0").isTextualDecimal()); + assertTrue(Text.of(".0").isTextualDecimal()); + assertTrue(Text.of("0.").isTextualDecimal()); + assertTrue(Text.of("-0").isTextualDecimal()); + assertTrue(Text.of("-.0").isTextualDecimal()); + assertTrue(Text.of("-0.").isTextualDecimal()); + assertTrue(Text.of("+0").isTextualDecimal()); + assertTrue(Text.of("+.0").isTextualDecimal()); + assertTrue(Text.of("+0.").isTextualDecimal()); + assertTrue(Text.of("123").isTextualDecimal()); + assertTrue(Text.of("123.0").isTextualDecimal()); + assertTrue(Text.of("-123.0").isTextualDecimal()); + assertTrue(Text.of("123e-2").isTextualDecimal()); + assertTrue(Text.of("123.0e234").isTextualDecimal()); + assertTrue(Text.of("-123.0001E-23").isTextualDecimal()); + + assertFalse(Text.of("").isTextualDecimal()); + assertFalse(Text.of("0a").isTextualDecimal()); + assertFalse(Text.of("-0a").isTextualDecimal()); + assertFalse(Text.of("--0").isTextualDecimal()); + assertFalse(Text.of("+-0").isTextualDecimal()); + assertFalse(Text.of("++0").isTextualDecimal()); + assertFalse(Text.of("-+0").isTextualDecimal()); + assertFalse(Text.of("+0a").isTextualDecimal()); + assertFalse(Text.of("+").isTextualDecimal()); + assertFalse(Text.of("-").isTextualDecimal()); + assertFalse(Text.of(".").isTextualDecimal()); + assertFalse(Text.of(".0e").isTextualDecimal()); + assertFalse(Text.of(".0E").isTextualDecimal()); + assertFalse(Text.of("0.e").isTextualDecimal()); + assertFalse(Text.of("0.E").isTextualDecimal()); + assertFalse(Text.of("x0").isTextualDecimal()); + assertFalse(Text.of("x").isTextualDecimal()); + assertFalse(Text.of("5.x").isTextualDecimal()); + assertFalse(Text.of("5.0ex").isTextualDecimal()); + } + + @DisplayName("isSpecialDecimal()") + @Test + void testIsSpecialDecimal() { + assertTrue(Text.of("NaN").isSpecialDecimal()); + assertTrue(Text.of("Infinity").isSpecialDecimal()); + assertTrue(Text.of("-Infinity").isSpecialDecimal()); + assertFalse(Text.of("").isSpecialDecimal()); + assertFalse(Text.of("Nan").isSpecialDecimal()); + assertFalse(Text.of("nan").isSpecialDecimal()); + assertFalse(Text.of("NaNa").isSpecialDecimal()); + assertFalse(Text.of("infinity").isSpecialDecimal()); + assertFalse(Text.of("-infinity").isSpecialDecimal()); + } + @DisplayName("parseInt()") @Test void testParseInt() { diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java new file mode 100644 index 0000000..26396fe --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java @@ -0,0 +1,82 @@ +package org.hisp.dhis.jsontree.validation; + +import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.jsontree.JsonObject; +import org.hisp.dhis.jsontree.Validation; +import org.hisp.dhis.jsontree.Validation.Rule; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.Retention; +import java.util.List; +import java.util.Map; + +import static java.lang.Double.NaN; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.hisp.dhis.jsontree.Assertions.assertValidationError; + +/** + * Creates meta annotations that mimic common Java server validation + * using {@link Validation#meta()} and {@link Validation.Meta}. + */ +class JsonValidationMetaTest { + + @Retention(RUNTIME) + @Validation(minLength = 1, minItems = 1, minProperties = 1) + @interface NotEmpty {} + + @Retention(RUNTIME) + @Validation(exclusiveMinimum = 0) + @interface Positive {} + + @Retention(RUNTIME) + @Validation(minimum = 0) + @interface PositiveOrZero {} + + @Retention(RUNTIME) + @Validation(exclusiveMaximum = 0) + @interface Negative {} + + @Retention(RUNTIME) + @Validation(maximum = 0) + @interface NegativeOrZero {} + + @Retention(RUNTIME) + @Validation(meta = Range.Meta.class) + public @interface Range { + int min(); int max(); + + record Meta() implements Validation.Meta { + + @Override + public Validation extract(Range range) { + return new Validation.Instance( + -1, -1, range.min(), range.max(), NaN, NaN, -1, -1, -1, -1, Validation.NodeType.INTEGER); + } + } + } + + record ExampleBean( + @NotEmpty String notEmptyString, + @NotEmpty List notEmptyArray, + @NotEmpty Map notEmptyMap, + @Positive int positive, + @PositiveOrZero int positiveOrZero, + @Negative int negative, + @NegativeOrZero int negativeOrZero, + @Range(min=0, max=120) int range + ) {} + + @Test + void testRange() { + JsonObject obj = JsonMixed.of(""" + { + "range": 130 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MAXIMUM, 120, 130); + obj = JsonMixed.of(""" + { + "range": -1 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MINIMUM, 0, -1); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java new file mode 100644 index 0000000..be41250 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java @@ -0,0 +1,50 @@ +package org.hisp.dhis.jsontree.validation; + +import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.jsontree.JsonObject; +import org.hisp.dhis.jsontree.Validation; +import org.hisp.dhis.jsontree.Validation.NodeType; +import org.junit.jupiter.api.Test; + +import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests for {@link Validation#pattern()} based validations. + */ +class JsonValidationPatternTest { + + /** + * An example that show how the number/string duality can be exploited to + * add a pattern validation to a number to restrict the integer and fraction digits. + */ + public record PatternOnNumber( + @Validation( + pattern = "2#.3#", + type = {NodeType.STRING, NodeType.NUMBER}) + double value) {} + + @Test + void testPattern() { + JsonObject obj = JsonMixed.of(""" + { + "value": 34.123 + }"""); + + assertDoesNotThrow(() -> obj.validate(PatternOnNumber.class)); + assertEquals(new PatternOnNumber(34.123d), obj.to(PatternOnNumber.class)); + + JsonObject obj2 = JsonMixed.of(""" + { + "value": 34.12 + }"""); + + assertValidationError( + obj2, + PatternOnNumber.class, + Validation.Rule.PATTERN, + "(?:[0-9]){2}[.](?:[0-9]){3}", + "34.12"); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java new file mode 100644 index 0000000..ab95ad4 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java @@ -0,0 +1,5 @@ +package org.hisp.dhis.jsontree.validation; + +class JsonValidationTypeTest { + //TODO test non strict accepts special double literals +} From 613605cd6e58ee37a631dd709b1556dc7a74cf5d Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 20 Apr 2026 10:22:59 +0200 Subject: [PATCH 15/20] fix: min/max and integers --- .../org/hisp/dhis/jsontree/JsonAccess.java | 63 ++++++++++++----- .../jsontree/validation/ObjectValidation.java | 14 ++-- .../jsontree/validation/ObjectValidator.java | 31 +++++---- .../validation/PropertyValidations.java | 2 + .../JsonValidationExclusiveMaximumTest.java | 6 +- .../JsonValidationExclusiveMinimumTest.java | 22 +++--- .../validation/JsonValidationMaximumTest.java | 4 +- .../validation/JsonValidationMetaTest.java | 68 +++++++++++++++---- .../validation/JsonValidationMinimumTest.java | 8 +-- .../JsonValidationMiscDeepGraphTest.java | 8 +-- 10 files changed, 153 insertions(+), 73 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java b/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java index 0708e73..7104098 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java @@ -31,6 +31,7 @@ import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Array; +import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.lang.reflect.ParameterizedType; @@ -296,12 +297,18 @@ public static Stream accessAsStream(JsonMixed stream, Type as, JsonAccessors * * @param constructor the handle for the canonical constructor * @param components the components in that constructor to pass as args + * @param defaults default arguments if the component is undefined in JSON, null of no defaults + * are available (taken from a constant with name {@code DEFAULT}). * @param of a static method to construct a record from a single argument (typical {@code * of}-method) * @param ofArgType the generic argument type of the {@link #of()}-method */ private record NewRecord( - MethodHandle constructor, RecordComponent[] components, MethodHandle of, Type ofArgType) {} + MethodHandle constructor, + RecordComponent[] components, + Object[] defaults, + MethodHandle of, + Type ofArgType) {} private static final Map, NewRecord> NEW_RECORD_BY_TYPE = new ConcurrentHashMap<>(); @@ -313,14 +320,15 @@ public static Record accessAsRecord(JsonMixed obj, Type as, JsonAccessors access NewRecord newRecord = NEW_RECORD_BY_TYPE.computeIfAbsent( type, t -> createRecordFactory(MethodHandles.lookup(), t)); + RecordComponent[] components = newRecord.components; if (!obj.isObject() && !obj.isArray()) { - boolean wrapper = newRecord.components.length == 1; + boolean wrapper = components.length == 1; MethodHandle fromSingleArg = wrapper ? newRecord.constructor : newRecord.of; if (fromSingleArg == null) throw new JsonAccessException( "JSON does not map to Java record %s, object or array expected" .formatted(type.getSimpleName())); - Type argType = wrapper ? newRecord.components[0].getGenericType() : newRecord.ofArgType; + Type argType = wrapper ? components[0].getGenericType() : newRecord.ofArgType; Object arg = accessors.accessor(getRawType(argType)).access(obj, argType, accessors); try { return type.cast(fromSingleArg.invokeWithArguments(arg)); @@ -330,23 +338,27 @@ public static Record accessAsRecord(JsonMixed obj, Type as, JsonAccessors access } } - Object[] args = new Object[newRecord.components.length]; - + Object[] args = new Object[components.length]; + Object[] defaults = newRecord.defaults; + boolean hasDefaults = defaults != null; if (obj.isObject()) { - int i = 0; - for (RecordComponent c : newRecord.components) { - args[i++] = - accessors - .accessor(c.getType()) - .access(obj.get(c.getName(), JsonMixed.class), c.getGenericType(), accessors); + for (int i = 0; i < components.length; i++) { + RecordComponent c = components[i]; + JsonMixed cValue = obj.get(c.getName()); + args[i] = + hasDefaults && cValue.isUndefined() + ? defaults[i] + : accessors.accessor(c.getType()).access(cValue, c.getGenericType(), accessors); } } else if (obj.isArray()) { - int i = 0; - for (RecordComponent c : newRecord.components) + for (int i = 0; i < components.length; i++) { + RecordComponent c = components[i]; + JsonMixed cValue = obj.get(i); args[i] = - accessors - .accessor(c.getType()) - .access(obj.get(i++, JsonMixed.class), c.getGenericType(), accessors); + hasDefaults && cValue.isUndefined() + ? defaults[i] + : accessors.accessor(c.getType()).access(cValue, c.getGenericType(), accessors); + } } try { return type.cast(newRecord.constructor.invokeWithArguments(args)); @@ -399,7 +411,7 @@ private static NewRecord createRecordFactory( } } } - return new NewRecord(canonical, components, ofMethod, ofMethodArgType); + return new NewRecord(canonical, components, defaults(type), ofMethod, ofMethodArgType); } private static MethodHandle constructor( @@ -419,4 +431,21 @@ private static MethodHandle ofStatic( return null; } } + + private static Object[] defaults(Class type) { + try { + Field defaults = type.getDeclaredField("DEFAULT"); + if (defaults.getType() != type) return null; + defaults.setAccessible(true); + Object value = defaults.get(null); + RecordComponent[] components = type.getRecordComponents(); + Object[] values = new Object[components.length]; + for (int i = 0; i < components.length; i++) { + values[i] = type.getDeclaredMethod(components[i].getName()).invoke(value); + } + return values; + } catch (Exception ex) { + return null; + } + } } 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 06ad66a..8c6515e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -116,11 +116,11 @@ private static boolean isNotIgnored(JsonObject.Property p) { @CheckNull private static PropertyValidations fromProperty(JsonObject.Property p) { - PropertyValidations onMethod = fromAnnotations(p.source()); - PropertyValidations onReturnType = fromValueTypeUse(p.javaType()); - if (onMethod == null) return onReturnType; - if (onReturnType == null) return onMethod; - return onMethod.overlay(onReturnType); + PropertyValidations onComponent = fromAnnotations(p.source()); + PropertyValidations onType = fromValueTypeUse(p.javaType()); + if (onComponent == null) return onType; + if (onType == null) return onComponent; + return onType.overlay(onComponent); } /** @@ -233,7 +233,7 @@ private static PropertyValidations fromItems(AnnotatedElement src) { @NotNull private static PropertyValidations toPropertyValidation(Class type) { RequiredValidation values = - !type.isPrimitive() ? null : new RequiredValidation(YES, Set.of(), YesNo.AUTO); + !type.isPrimitive() ? null : RequiredValidation.PRIMITIVES; StringValidation strings = !type.isEnum() ? null : new StringValidation(anyOfStrings(type), YesNo.AUTO, -1, -1, null); return new PropertyValidations( @@ -425,7 +425,7 @@ private static Validation.Validator newValidator(Class type, Class[] types } @SuppressWarnings("unchecked") - private static T newFactory(Class type) { + private static > T newFactory(Class type) { try { return (T) MethodHandles.lookup() .findConstructor(type, MethodType.methodType(void.class, new Class[]{})) 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 b15e2fe..3400d41 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -147,9 +147,10 @@ private static Validator of(Text property, PropertyValidations validations) { if (accepted.isEmpty()) accepted = EnumSet.allOf(NodeType.class); boolean acceptStrings = accepted.contains(STRING); boolean acceptNumbers = accepted.contains(NUMBER) || accepted.contains(INTEGER); + boolean acceptIntegersOnly = accepted.contains(INTEGER) && !accepted.contains(NUMBER); Validator strings = ofString(validations.strings()); - Validator numbers = ofNumber(validations.numbers()); + Validator numbers = ofNumber(validations.numbers(), acceptIntegersOnly); Validator customs = All.of(validations.customs().stream()); if (acceptStrings) add.accept(JsonNodeType.STRING, strings); if (acceptNumbers) add.accept(JsonNodeType.NUMBER, numbers); @@ -229,25 +230,29 @@ private static Validator ofStringEnum(@NotNull PropertyValidations.StringValidat } @CheckNull - private static Validator ofNumber(@CheckNull PropertyValidations.NumberValidation numbers) { + private static Validator ofNumber( + @CheckNull PropertyValidations.NumberValidation numbers, boolean acceptIntegersOnly) { if (numbers == null) return null; double min = numbers.minimum(); double max = numbers.maximum(); double minEx = numbers.exclusiveMinimum(); double maxEx = numbers.exclusiveMaximum(); + if (acceptIntegersOnly) { + return All.of( + isNaN(min) ? null : new MinimumLong((long) min), + isNaN(max) ? null : new MaximumLong((long) max), + isNaN(minEx) ? null : new MinimumLong((long) minEx + 1), + isNaN(maxEx) ? null : new MaximumLong((long) maxEx - 1), + isNaN(numbers.multipleOf()) ? null : new MultipleOf(numbers.multipleOf())); + } return All.of( - isNaN(min) ? null : isIntRange(min) ? new MinimumInt((int) min) : new Minimum(min), - isNaN(max) ? null : isIntRange(max) ? new MaximumInt((int) max) : new Maximum(max), + isNaN(min) ? null : new Minimum(min), + isNaN(max) ? null : new Maximum(max), isNaN(minEx) ? null : new ExclusiveMinimum(minEx), isNaN(maxEx) ? null : new ExclusiveMaximum(maxEx), isNaN(numbers.multipleOf()) ? null : new MultipleOf(numbers.multipleOf())); } - private static boolean isIntRange(double limit) { - if (limit % 1d != 0d) return false; - return limit < 0 ? limit >= Integer.MIN_VALUE : limit <= Integer.MAX_VALUE; - } - @CheckNull private static Validator ofArray(@CheckNull PropertyValidations.ArrayValidation arrays) { return arrays == null @@ -563,11 +568,11 @@ public void validate(JsonMixed value, Consumer addError) { number values */ - private record MinimumInt(int limit) implements Validator { + private record MinimumLong(long limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - int actual = value.intValue(); + long actual = value.longValue(); if (actual < limit) addError.accept(Error.of(Rule.MINIMUM, value, "must be >= %d but was: %d", limit, actual)); } @@ -583,10 +588,10 @@ public void validate(JsonMixed value, Consumer addError) { } } - private record MaximumInt(int limit) implements Validator { + private record MaximumLong(long limit) implements Validator { @Override public void validate(JsonMixed value, Consumer addError) { - int actual = value.intValue(); + long actual = value.longValue(); if (actual > limit) addError.accept(Error.of(Rule.MAXIMUM, value, "must be <= %d but was: %d", limit, actual)); } diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java index 430c374..fca0f1e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -127,6 +127,8 @@ public PropertyValidations varargs() { record RequiredValidation( @NotNull YesNo required, @NotNull Set dependentRequired, @NotNull YesNo allowNull) { + static final RequiredValidation PRIMITIVES = new RequiredValidation(YesNo.YES, Set.of(), YesNo.AUTO); + RequiredValidation overlay(@CheckNull PropertyValidations.RequiredValidation with) { return with == null ? this diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMaximumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMaximumTest.java index 8fe0f7d..68ed187 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMaximumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMaximumTest.java @@ -69,9 +69,9 @@ void testExclusiveMaximum_TooLarge() { """ {"age":100}""", JsonMaximumExampleA.class, - Rule.EXCLUSIVE_MAXIMUM, - 100d, - 100d); + Rule.MAXIMUM, + 99L, + 100L); assertValidationError( """ {"height":250.5}""", diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMinimumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMinimumTest.java index 1d5ed76..83e0160 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMinimumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationExclusiveMinimumTest.java @@ -21,8 +21,8 @@ class JsonValidationExclusiveMinimumTest { public interface JsonMinimumExampleA extends JsonObject { @Validation(exclusiveMinimum = 0) - default int age() { - return getNumber("age").intValue(); + default double weight() { + return getNumber("weight").intValue(); } } @@ -40,13 +40,13 @@ void testExclusiveMinimum_OK() { () -> JsonMixed.of( """ - {"age":1}""") + {"weight":1}""") .validate(JsonMinimumExampleA.class)); assertDoesNotThrow( () -> JsonMixed.of( """ - {"age":50}""") + {"weight":50}""") .validate(JsonMinimumExampleA.class)); assertDoesNotThrow(() -> JsonMixed.of("{}").validate(JsonMinimumExampleB.class)); @@ -60,14 +60,14 @@ void testExclusiveMinimum_OK() { @Test void testExclusiveMinimum_Required() { - assertValidationError("{}", JsonMinimumExampleA.class, Rule.REQUIRED, "age"); + assertValidationError("{}", JsonMinimumExampleA.class, Rule.REQUIRED, "weight"); } @Test void testExclusiveMinimum_TooSmall() { assertValidationError( """ - {"age":0}""", + {"weight":0}""", JsonMinimumExampleA.class, Rule.EXCLUSIVE_MINIMUM, 0d, @@ -76,19 +76,19 @@ void testExclusiveMinimum_TooSmall() { """ {"height":20}""", JsonMinimumExampleB.class, - Rule.EXCLUSIVE_MINIMUM, - 20d, - 20d); + Rule.MINIMUM, + 21L, + 20L); } @Test void testExclusiveMinimum_WrongType() { assertValidationError( """ - {"age":true}""", + {"weight":true}""", JsonMinimumExampleA.class, Rule.TYPE, - Set.of(NodeType.INTEGER), + Set.of(NodeType.NUMBER), NodeType.BOOLEAN); assertValidationError( """ diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java index 7ab131a..b213751 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMaximumTest.java @@ -70,8 +70,8 @@ void testMaximum_TooLarge() { {"age":101}""", JsonMaximumExampleA.class, Rule.MAXIMUM, - 100, - 101); + 100L, + 101L); assertValidationError( """ {"height":250.7}""", diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java index 26396fe..2b2fbdc 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java @@ -4,6 +4,8 @@ import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.Rule; +import org.hisp.dhis.jsontree.Validation.YesNo; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.lang.annotation.Retention; @@ -13,6 +15,10 @@ import static java.lang.Double.NaN; import static java.lang.annotation.RetentionPolicy.RUNTIME; import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; /** * Creates meta annotations that mimic common Java server validation @@ -21,6 +27,9 @@ class JsonValidationMetaTest { @Retention(RUNTIME) + @Validation(required = YesNo.NO) + @interface Optional {} + @Validation(minLength = 1, minItems = 1, minProperties = 1) @interface NotEmpty {} @@ -55,16 +64,29 @@ public Validation extract(Range range) { } } - record ExampleBean( - @NotEmpty String notEmptyString, - @NotEmpty List notEmptyArray, - @NotEmpty Map notEmptyMap, - @Positive int positive, - @PositiveOrZero int positiveOrZero, - @Negative int negative, - @NegativeOrZero int negativeOrZero, - @Range(min=0, max=120) int range - ) {} + public record ExampleBean( + @Optional @NotEmpty String notEmptyString, + @Optional @NotEmpty List notEmptyArray, + @Optional @NotEmpty Map notEmptyMap, + @Optional @Positive int positive, + @Optional @PositiveOrZero int positiveOrZero, + @Optional @Negative int negative, + @Optional @NegativeOrZero int negativeOrZero, + @Optional @Range(min=0, max=120) int range + ) { + static final ExampleBean DEFAULT = + new ExampleBean(null, List.of(), Map.of(), 1, 0, -1, 0, 0); + } + + @Test + void testDefaults() { + ExampleBean actual = JsonMixed.of("{}").to(ExampleBean.class); + assertEquals(1, actual.positive()); + assertEquals(-1, actual.negative()); + assertNull(actual.notEmptyString()); + assertEquals(List.of(), actual.notEmptyArray()); + assertEquals(Map.of(), actual.notEmptyMap()); + } @Test void testRange() { @@ -72,11 +94,33 @@ void testRange() { { "range": 130 }"""); - assertValidationError(obj, ExampleBean.class, Rule.MAXIMUM, 120, 130); + assertValidationError(obj, ExampleBean.class, Rule.MAXIMUM, 120L, 130L); + obj = JsonMixed.of(""" { "range": -1 }"""); - assertValidationError(obj, ExampleBean.class, Rule.MINIMUM, 0, -1); + assertValidationError(obj, ExampleBean.class, Rule.MINIMUM, 0L, -1L); + + JsonObject valid = JsonMixed.of(""" + { + "range": 10 + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + } + + @Test + void testPositive() { + JsonObject obj = JsonMixed.of(""" + { + "positive": -1 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MINIMUM, 1L, -1L); + + JsonObject valid = JsonMixed.of(""" + { + "positive": 10 + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); } } diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java index 7d013fd..52ec86c 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java @@ -72,15 +72,15 @@ void testMinimum_TooSmall() { {"age":-1}""", JsonMinimumExampleA.class, Rule.MINIMUM, - 0, - -1); + 0L, + -1L); assertValidationError( """ {"height":19}""", JsonMinimumExampleB.class, Rule.MINIMUM, - 20, - 19); + 20L, + 19L); } @Test diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java index 8aad164..891389d 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java @@ -140,8 +140,8 @@ void testDeep_Error_PagerSizeZero() { {"pager": {"size": 0, "page": 0}, "entries":[]}""", JsonPage.class, Rule.MINIMUM, - 1, - 0); + 1L, + 0L); assertEquals(".pager.size", error.path().toString()); } @@ -153,8 +153,8 @@ void testDeep_Error_PagerPageNegative() { {"pager": {"size": 20, "page": -1}, "entries":[]}""", JsonPage.class, Rule.MINIMUM, - 0, - -1); + 0L, + -1L); assertEquals(".pager.page", error.path().toString()); } From 780578ca03fcc13325ceec7762e7c73bdb390f2f Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 20 Apr 2026 11:39:24 +0200 Subject: [PATCH 16/20] test: more meta annotation example tests --- .../org/hisp/dhis/jsontree/Validation.java | 12 +- .../java/org/hisp/dhis/jsontree/TextTest.java | 20 ++ .../validation/JsonValidationMetaTest.java | 186 ++++++++++++++++-- 3 files changed, 197 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index 6c2d49f..a4358ae 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -1,5 +1,9 @@ package org.hisp.dhis.jsontree; +import static java.lang.Double.NaN; +import static org.hisp.dhis.jsontree.Validation.YesNo.AUTO; +import static org.hisp.dhis.jsontree.Validation.YesNo.NO; + import java.lang.annotation.Annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -9,14 +13,9 @@ import java.util.Set; import java.util.function.Consumer; import java.util.regex.Pattern; - import org.hisp.dhis.jsontree.internal.CheckNull; import org.hisp.dhis.jsontree.internal.NotNull; -import static java.lang.Double.NaN; -import static org.hisp.dhis.jsontree.Validation.YesNo.AUTO; -import static org.hisp.dhis.jsontree.Validation.YesNo.NO; - /** * Structural Validations as defined by the JSON schema specification 2020-12 dialect. @@ -267,6 +266,7 @@ public String toString() { YesNo numbers() default YesNo.AUTO; record Instance(boolean value, YesNo booleans, YesNo strings, YesNo numbers) implements Strict { + public static final Strict AUTO = new Instance(false, YesNo.AUTO, YesNo.AUTO, YesNo.AUTO); @Override public Class annotationType() { return Strict.class; @@ -601,7 +601,7 @@ public Instance( NodeType... types) { this( types, - new Validation.Strict.Instance(false, AUTO, AUTO, AUTO), + Strict.Instance.AUTO, NO, new String[0], Enum.class, diff --git a/src/test/java/org/hisp/dhis/jsontree/TextTest.java b/src/test/java/org/hisp/dhis/jsontree/TextTest.java index c1731c7..1be109f 100644 --- a/src/test/java/org/hisp/dhis/jsontree/TextTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/TextTest.java @@ -16,6 +16,14 @@ */ class TextTest { + @Test + void testCopyOf() { + Text original = Text.of("hello"); + Text copy = Text.copyOf(original); + assertEquals(original, copy); + assertNotSame(original, copy); + } + @DisplayName("indexOf(char)") @Test void testIndexOf_char() { @@ -402,6 +410,18 @@ void testParseLong() { assertThrows(NumberFormatException.class, () -> Text.of("12.3").parseLong()); } + @Test + void testParseBoolean() { + assertTrue(Text.of("true").parseBoolean()); + assertTrue(Text.of("TRUE").parseBoolean()); + assertFalse(Text.of("false").parseBoolean()); + assertFalse(Text.of("FALSE").parseBoolean()); + assertThrowsExactly(IllegalArgumentException.class, () -> Text.of("t").parseBoolean()); + assertThrowsExactly(IllegalArgumentException.class, () -> Text.of("f").parseBoolean()); + assertThrowsExactly(IllegalArgumentException.class, () -> Text.of("tear").parseBoolean()); + assertThrowsExactly(IllegalArgumentException.class, () -> Text.of("fear").parseBoolean()); + } + @DisplayName("compareTo(Text)") @Test void testCompareTo() { diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java index 2b2fbdc..e3f734f 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java @@ -1,35 +1,40 @@ package org.hisp.dhis.jsontree.validation; -import org.hisp.dhis.jsontree.JsonMixed; -import org.hisp.dhis.jsontree.JsonObject; -import org.hisp.dhis.jsontree.Validation; -import org.hisp.dhis.jsontree.Validation.Rule; -import org.hisp.dhis.jsontree.Validation.YesNo; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.lang.annotation.Retention; -import java.util.List; -import java.util.Map; - import static java.lang.Double.NaN; import static java.lang.annotation.RetentionPolicy.RUNTIME; import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.hisp.dhis.jsontree.Validation.YesNo.AUTO; +import static org.hisp.dhis.jsontree.Validation.YesNo.NO; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import java.lang.annotation.Retention; +import java.util.List; +import java.util.Map; +import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.jsontree.JsonObject; +import org.hisp.dhis.jsontree.Validation; +import org.hisp.dhis.jsontree.Validation.Rule; +import org.hisp.dhis.jsontree.Validation.YesNo; +import org.junit.jupiter.api.Test; + /** * Creates meta annotations that mimic common Java server validation * using {@link Validation#meta()} and {@link Validation.Meta}. */ class JsonValidationMetaTest { + /** + * This test is not specifically testing that components can be made optional, + * it merely uses this so the JSON used in the test can only include the property + * that is under test. + */ @Retention(RUNTIME) @Validation(required = YesNo.NO) @interface Optional {} + @Retention(RUNTIME) @Validation(minLength = 1, minItems = 1, minProperties = 1) @interface NotEmpty {} @@ -64,6 +69,44 @@ public Validation extract(Range range) { } } + @Retention(RUNTIME) + @Validation(meta = Digits.Meta.class) + public @interface Digits { + int integer(); + int fraction() default 0; + + record Meta() implements Validation.Meta { + + @Override + public Validation extract(Digits digits) { + String pattern = digits.integer()+"#"; + if (digits.fraction() > 0) pattern += "."+digits.fraction()+"#"; + return new Validation.Instance(new Validation.NodeType[]{Validation.NodeType.STRING, Validation.NodeType.NUMBER}, Validation.Strict.Instance.AUTO, + NO, + new String[0], + Enum.class, + AUTO, + -1, + -1, + new String[] { pattern }, + "", + NaN, + NaN, + NaN, + NaN, + NaN, + -1, + -1, + AUTO, + -1, + -1, + AUTO, + new String[0], + AUTO); + } + } + } + public record ExampleBean( @Optional @NotEmpty String notEmptyString, @Optional @NotEmpty List notEmptyArray, @@ -72,10 +115,11 @@ public record ExampleBean( @Optional @PositiveOrZero int positiveOrZero, @Optional @Negative int negative, @Optional @NegativeOrZero int negativeOrZero, - @Optional @Range(min=0, max=120) int range + @Optional @Range(min=0, max=120) int range, + @Optional @Digits(integer = 3, fraction = 1) double digits ) { static final ExampleBean DEFAULT = - new ExampleBean(null, List.of(), Map.of(), 1, 0, -1, 0, 0); + new ExampleBean(null, List.of(), Map.of(), 1, 0, -1, 0, 0, 123.4); } @Test @@ -88,6 +132,51 @@ void testDefaults() { assertEquals(Map.of(), actual.notEmptyMap()); } + @Test + void testNotEmpty_String() { + JsonObject obj = JsonMixed.of(""" + { + "notEmptyString": "" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MIN_LENGTH, 1, 0); + + JsonObject valid = JsonMixed.of(""" + { + "notEmptyString": "_" + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + } + + @Test + void testNotEmpty_Array() { + JsonObject obj = JsonMixed.of(""" + { + "notEmptyArray": [] + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MIN_ITEMS, 1, 0); + + JsonObject valid = JsonMixed.of(""" + { + "notEmptyArray": [""] + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + } + + @Test + void testNotEmpty_Object() { + JsonObject obj = JsonMixed.of(""" + { + "notEmptyMap": {} + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MIN_PROPERTIES, 1, 0); + + JsonObject valid = JsonMixed.of(""" + { + "notEmptyMap": {"a": null} + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + } + @Test void testRange() { JsonObject obj = JsonMixed.of(""" @@ -123,4 +212,71 @@ void testPositive() { }"""); assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); } + + @Test + void testPositiveOrZero() { + JsonObject obj = JsonMixed.of(""" + { + "positiveOrZero": -1 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MINIMUM, 0L, -1L); + + JsonObject valid = JsonMixed.of(""" + { + "positiveOrZero": 0 + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + } + + @Test + void testNegative() { + JsonObject obj = JsonMixed.of(""" + { + "negative": 0 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MAXIMUM, -1L, 0L); + + JsonObject valid = JsonMixed.of(""" + { + "negative": -1 + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + } + + @Test + void testNegativeOrZero() { + JsonObject obj = JsonMixed.of(""" + { + "negativeOrZero": 1 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MAXIMUM, 0L, 1L); + + JsonObject valid = JsonMixed.of(""" + { + "negativeOrZero": 0 + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + } + + @Test + void testDigits() { + JsonObject obj = JsonMixed.of(""" + { + "digits": 12.3 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.PATTERN, "(?:[0-9]){3}[.](?:[0-9]){1}", "12.3"); + + JsonObject valid = JsonMixed.of(""" + { + "digits": 444.4 + }"""); + assertDoesNotThrow(() -> valid.validate(ExampleBean.class)); + + JsonObject valid2 = JsonMixed.of(""" + { + "digits": "444.4" + }"""); + assertDoesNotThrow(() -> valid2.validate(ExampleBean.class)); + assertEquals(444.4d, valid2.to(ExampleBean.class).digits); + } } From 66c669fe5d0a435ce27360219d3372ceb78845a3 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 20 Apr 2026 12:30:23 +0200 Subject: [PATCH 17/20] test: strictness --- .../JsonValidationStrictnessTest.java | 171 ++++++++++++++++++ .../validation/JsonValidationTypeTest.java | 5 - 2 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java delete mode 100644 src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java new file mode 100644 index 0000000..348396e --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java @@ -0,0 +1,171 @@ +package org.hisp.dhis.jsontree.validation; + +import org.hisp.dhis.jsontree.Assertions; +import org.hisp.dhis.jsontree.JsonMixed; +import org.hisp.dhis.jsontree.JsonObject; +import org.hisp.dhis.jsontree.Validation; +import org.hisp.dhis.jsontree.Validation.NodeType; +import org.hisp.dhis.jsontree.Validation.Rule; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.hisp.dhis.jsontree.Validation.YesNo.NO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests the consequences of {@link Validation#strictness()}. + */ +class JsonValidationStrictnessTest { + + public record ExampleBean( + @Validation(required = NO, strictness = @Validation.Strict(true)) + String strictString, + @Validation(required = NO) + String nonStrictString, + @Validation(required = NO, strictness = @Validation.Strict(true)) + double strictDouble, + @Validation(required = NO) + double nonStrictDouble, + @Validation(required = NO, strictness = @Validation.Strict(true)) + int strictInteger, + @Validation(required = NO) + int nonStrictInteger, + @Validation(required = NO, strictness = @Validation.Strict(true)) + boolean strictBoolean, + @Validation(required = NO) + boolean nonStrictBoolean + ) { + static final ExampleBean DEFAULT = new ExampleBean("", "", 0d, 0d, 0, 0, false, false); + } + + @Test + void testStrictString() { + JsonObject obj = JsonMixed.of(""" + { + "strictString": 123 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.STRING), NodeType.NUMBER); + obj = JsonMixed.of(""" + { + "strictString": true + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.STRING), NodeType.BOOLEAN); + } + + @Test + void testNonStrictString() { + JsonObject obj = JsonMixed.of(""" + { + "nonStrictString": 123 + }"""); + assertEquals("123", obj.to(ExampleBean.class).nonStrictString); + obj = JsonMixed.of(""" + { + "nonStrictString": true + }"""); + assertEquals("true", obj.to(ExampleBean.class).nonStrictString); + } + + @Test + void testNonStrictDouble() { + JsonObject obj = JsonMixed.of(""" + { + "nonStrictDouble": "NaN" + }"""); + assertEquals(Double.NaN, obj.to(ExampleBean.class).nonStrictDouble); + obj = JsonMixed.of(""" + { + "nonStrictDouble": "Infinity" + }"""); + assertEquals(Double.POSITIVE_INFINITY, obj.to(ExampleBean.class).nonStrictDouble); + obj = JsonMixed.of(""" + { + "nonStrictDouble": "-Infinity" + }"""); + assertEquals(Double.NEGATIVE_INFINITY, obj.to(ExampleBean.class).nonStrictDouble); + } + + @Test + void testStrictDouble() { + JsonObject obj = JsonMixed.of(""" + { + "strictDouble": "NaN" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.NUMBER), NodeType.STRING); + obj = JsonMixed.of(""" + { + "strictDouble": "Infinity" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.NUMBER), NodeType.STRING); + obj = JsonMixed.of(""" + { + "strictDouble": "-Infinity" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.NUMBER), NodeType.STRING); + } + + @Test + void testNonStrictInteger() { + JsonObject obj = JsonMixed.of(""" + { + "nonStrictInteger": "NaN" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.INTEGER), NodeType.STRING); + obj = JsonMixed.of(""" + { + "nonStrictInteger": 1.0 + }"""); + assertEquals(1, obj.to(ExampleBean.class).nonStrictInteger); + obj = JsonMixed.of(""" + { + "nonStrictInteger": 1.6 + }"""); + assertEquals(1, obj.to(ExampleBean.class).nonStrictInteger); + } + + @Test + void testStrictInteger() { + JsonObject obj = JsonMixed.of(""" + { + "strictInteger": "NaN" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.INTEGER), NodeType.STRING); + obj = JsonMixed.of(""" + { + "strictInteger": "Infinity" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.INTEGER), NodeType.STRING); + obj = JsonMixed.of(""" + { + "strictInteger": "-Infinity" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.INTEGER), NodeType.STRING); + obj = JsonMixed.of(""" + { + "strictInteger": 0.1 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.INTEGER), NodeType.NUMBER); + } + + @Test + void testStrictBoolean() { + JsonObject obj = JsonMixed.of(""" + { + "strictBoolean": "true" + }"""); + assertValidationError(obj, ExampleBean.class, Rule.TYPE, Set.of(NodeType.BOOLEAN), NodeType.STRING); + } + + @Test + void testNonStrictBoolean() { + JsonObject obj = JsonMixed.of(""" + { + "nonStrictBoolean": "true" + }"""); + assertTrue(obj.to(ExampleBean.class).nonStrictBoolean); + } + +} diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java deleted file mode 100644 index ab95ad4..0000000 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationTypeTest.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.hisp.dhis.jsontree.validation; - -class JsonValidationTypeTest { - //TODO test non strict accepts special double literals -} From daae0142452121bec8fa1018900cc830466561a7 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 20 Apr 2026 15:33:03 +0200 Subject: [PATCH 18/20] refactor: JsonPrimitive as union type, test URL parameter mapping --- .../org/hisp/dhis/jsontree/JsonBoolean.java | 2 +- .../java/org/hisp/dhis/jsontree/JsonDate.java | 3 + .../org/hisp/dhis/jsontree/JsonNumber.java | 2 +- .../org/hisp/dhis/jsontree/JsonObject.java | 3 + .../org/hisp/dhis/jsontree/JsonPrimitive.java | 10 +- .../org/hisp/dhis/jsontree/JsonString.java | 2 +- .../hisp/dhis/jsontree/JsonVirtualTree.java | 19 ++- .../org/hisp/dhis/jsontree/Validation.java | 54 +++++++ .../jsontree/validation/ObjectValidation.java | 27 +--- .../jsontree/JsonObjectPropertiesTest.java | 7 +- .../hisp/dhis/jsontree/JsonValueToTest.java | 140 ++++++++++++++++++ 11 files changed, 228 insertions(+), 41 deletions(-) create mode 100644 src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonBoolean.java b/src/main/java/org/hisp/dhis/jsontree/JsonBoolean.java index 699516e..8b45eb2 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonBoolean.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonBoolean.java @@ -38,7 +38,7 @@ */ @Validation(type = BOOLEAN) @Validation.Ignore -public interface JsonBoolean extends JsonPrimitive { +public interface JsonBoolean extends JsonValue { @Override default JsonBoolean getValue() { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonDate.java b/src/main/java/org/hisp/dhis/jsontree/JsonDate.java index 860a1cc..43c461e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonDate.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonDate.java @@ -27,6 +27,8 @@ */ package org.hisp.dhis.jsontree; +import org.hisp.dhis.jsontree.Validation.NodeType; + import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; @@ -40,6 +42,7 @@ * * @author Jan Bernitt */ +@Validation(type = {NodeType.STRING, NodeType.INTEGER}) @Validation.Ignore public interface JsonDate extends JsonString { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonNumber.java b/src/main/java/org/hisp/dhis/jsontree/JsonNumber.java index c1205f6..b640191 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonNumber.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonNumber.java @@ -39,7 +39,7 @@ */ @Validation(type = NUMBER) @Validation.Ignore -public interface JsonNumber extends JsonPrimitive { +public interface JsonNumber extends JsonValue { @Override default JsonNumber getValue() { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java index 09ed6c9..0889f00 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java @@ -33,6 +33,7 @@ import java.lang.reflect.AnnotatedType; import java.util.Collection; import java.util.List; +import java.util.Set; import java.util.function.Function; import java.util.stream.Stream; import org.hisp.dhis.jsontree.JsonNode.Index; @@ -66,6 +67,7 @@ default JsonObject getValue() { * @param jsonName of the property * @param jsonType the type the property is resolved to internally when calling {@link * #get(CharSequence, Class)} + * @param types the expected node types based on validation constraints, empty if unknown or unspecified * @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 @@ -76,6 +78,7 @@ record Property( Class in, Text jsonName, Class jsonType, + Set types, String javaName, AnnotatedType javaType, AnnotatedElement source) {} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java b/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java index ecd9b20..1889cb9 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonPrimitive.java @@ -32,10 +32,16 @@ import static org.hisp.dhis.jsontree.Validation.NodeType.STRING; /** - * A common base type for the primitive nodes in a JSON tree. + * The union of all JSON simple or primitive types. * * @author Jan Bernitt */ @Validation(type = {BOOLEAN, NUMBER, STRING}) @Validation.Ignore -public interface JsonPrimitive extends JsonValue {} +public interface JsonPrimitive extends JsonBoolean, JsonNumber, JsonString { + + @Override + default JsonPrimitive getValue() { + return this; + } +} diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonString.java b/src/main/java/org/hisp/dhis/jsontree/JsonString.java index 728bd17..653eb6f 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonString.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonString.java @@ -42,7 +42,7 @@ */ @Validation(type = STRING) @Validation.Ignore -public interface JsonString extends JsonPrimitive { +public interface JsonString extends JsonValue { @Override default JsonString getValue() { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java index 405af9a..bcc16c9 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonVirtualTree.java @@ -479,13 +479,17 @@ private static List componentProperties(Class of) { return Stream.of(of.getRecordComponents()) .map( c -> - new JsonObject.Property( - of, - Text.of(c.getName()), - JsonMixed.class, - c.getName(), - c.getAnnotatedType(), - c)) + { + Class jsonType = Validation.NodeType.toJsonType(c.getType()); + return new Property( + of, + Text.of(c.getName()), + jsonType, + Validation.NodeType.ofJsonType(jsonType), + c.getName(), + c.getAnnotatedType(), + c); + }) .toList(); } @@ -512,6 +516,7 @@ private static List captureProperties(Class of) in, name, type, + Validation.NodeType.ofJsonType(type), method.getName(), method.getAnnotatedReturnType(), method)); diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index a4358ae..cf6593e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -6,10 +6,20 @@ import java.lang.annotation.Annotation; import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import java.math.BigInteger; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Collection; +import java.util.Date; +import java.util.EnumSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; import java.util.regex.Pattern; @@ -155,6 +165,50 @@ public static NodeType of(@CheckNull JsonNodeType type) { case NUMBER -> NUMBER; }; } + + @NotNull + @SuppressWarnings("unchecked") + public static Set of(@NotNull Class type) { + return ofJsonType( + JsonValue.class.isAssignableFrom(type) + ? (Class) type + : toJsonType(type)); + } + + static Class toJsonType(Class type) { + if (type == String.class || type.isEnum()) return JsonString.class; + if (type == Character.class || type == char.class) return JsonString.class; + if (type == Date.class) return JsonDate.class; + if (type == LocalDate.class || type == LocalTime.class || type == LocalDateTime.class) return JsonMixed.class; + if (type == Boolean.class || type == boolean.class) return JsonBoolean.class; + if (type == Integer.class || type == Long.class || type == BigInteger.class || type == Instant.class) + return JsonInteger.class; + if (Number.class.isAssignableFrom(type)) return JsonNumber.class; + if (type.isPrimitive()) + return type == float.class || type == double.class ? JsonNumber.class : JsonInteger.class; + if (Collection.class.isAssignableFrom(type)) return JsonArray.class; + if (Object[].class.isAssignableFrom(type)) return JsonArray.class; + if (Map.class.isAssignableFrom(type)) return JsonObject.class; + if (Record.class.isAssignableFrom(type)) return JsonObject.class; + return JsonValue.class; + } + + @SuppressWarnings("unchecked") + static Set ofJsonType(Class type) { + Validation validation = type.getAnnotation(Validation.class); + if (validation != null) { + NodeType[] types = validation.type(); + if (types.length == 0) return Set.of(); + return EnumSet.of(types[0], types); + } + EnumSet res = EnumSet.noneOf(NodeType.class); + for (Class si : type.getInterfaces()) { + if (JsonValue.class.isAssignableFrom(si)) { + res.addAll(ofJsonType((Class) si)); + } + } + return res; + } } /** 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 8c6515e..cdacfad 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -237,7 +237,7 @@ private static PropertyValidations toPropertyValidation(Class type) { StringValidation strings = !type.isEnum() ? null : new StringValidation(anyOfStrings(type), YesNo.AUTO, -1, -1, null); return new PropertyValidations( - anyOfTypes(type), PropertyValidations.Strict.DEFAULT, List.of(), values, strings, null, null, null, null); + NodeType.of(type), PropertyValidations.Strict.DEFAULT, List.of(), values, strings, null, null, null, null); } @NotNull @@ -386,31 +386,6 @@ private static Set anyOfTypes(NodeType... type) { return Set.copyOf(anyOf); } - @NotNull - private static Set anyOfTypes(Class type) { - NodeType main = nodeTypeOf(type); - if (type == Date.class && main != null) return Set.of(main, INTEGER); - if (main != null) return Set.of(main); - return Set.of(); - } - - @CheckNull - private static NodeType nodeTypeOf(@NotNull Class type) { - if (type == String.class || type.isEnum() || type == Date.class) return STRING; - if (type == Character.class || type == char.class) return STRING; - if (type == Boolean.class || type == boolean.class) return BOOLEAN; - if (type == Integer.class || type == Long.class || type == BigInteger.class) - return NodeType.INTEGER; - if (Number.class.isAssignableFrom(type)) return NUMBER; - if (type == Void.class || type == void.class) return NULL; - if (type.isPrimitive()) - return type == float.class || type == double.class ? NUMBER : NodeType.INTEGER; - if (Collection.class.isAssignableFrom(type)) return ARRAY; - if (Object[].class.isAssignableFrom(type)) return ARRAY; - if (Map.class.isAssignableFrom(type)) return OBJECT; - return null; - } - private static Validation.Validator newValidator(Class type, Class[] types, Object[] args) { try { return (Validation.Validator) diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java index dac74a5..6eb2f20 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectPropertiesTest.java @@ -10,6 +10,7 @@ import java.util.List; import java.util.Set; import org.hisp.dhis.jsontree.JsonObject.Property; +import org.hisp.dhis.jsontree.Validation.NodeType; import org.junit.jupiter.api.Test; /** @@ -39,7 +40,7 @@ default String name() { void testString() { List properties = JsonObject.properties(User.class); Property expected = - new Property(User.class, Text.of("username"), JsonString.class, "username", STRING, null); + new Property(User.class, Text.of("username"), JsonString.class, Set.of(NodeType.STRING),"username", STRING, null); assertPropertyExists("username", expected, properties); } @@ -55,11 +56,11 @@ void testString_Multiple() { assertPropertyExists( "firstName", - new Property(User.class, Text.of("firstName"), JsonString.class, "name", STRING, null), + new Property(User.class, Text.of("firstName"), JsonString.class, Set.of(NodeType.STRING), "name", STRING, null), properties); assertPropertyExists( "lastName", - new Property(User.class, Text.of("lastName"), JsonString.class, "name", STRING, null), + new Property(User.class, Text.of("lastName"), JsonString.class, Set.of(NodeType.STRING), "name", STRING, null), properties); } diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java new file mode 100644 index 0000000..611debe --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java @@ -0,0 +1,140 @@ +package org.hisp.dhis.jsontree; + +import org.hisp.dhis.jsontree.Validation.NodeType; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.Map.entry; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Tests an advanced scenario of the {@link JsonValue#to(Class)} method where the {@link + * JsonObject#properties(Class)} are used to create a JSON object from web REST API URL parameters + * in form of a {@link Map} of {@link String}s which then is mapped to the target record using the + * to-method. + * + * @author Jan Bernitt + */ +class JsonValueToTest { + + public record ExampleParams( + @Validation + int number, + Integer optionalNumber, + String string, + Set types, + List items, + ExampleItem fallback + ) {} + + @Validation(type = NodeType.STRING) + public record ExampleItem(String key, double value) { + public static ExampleItem of(String value) { + return new ExampleItem( + value.substring(0, value.indexOf(':')), + Double.parseDouble(value.substring(value.indexOf(':') + 1))); + } + } + + @Test + void testMinimal() { + assertParamsEquals( + new ExampleParams(10, null, null, null, null, null), Map.of("number", List.of("10"))); + } + + @Test + void testEmpty() { + assertParamsEquals( + new ExampleParams(10, null, null, Set.of(), List.of(), null), + Map.ofEntries( + entry("number", List.of("10")), + entry("optionalNumber", List.of()), + entry("string", List.of()), + entry("types", List.of()), + entry("items", List.of()), + entry("fallback", List.of()))); + } + + @Test + void testMaximal() { + assertParamsEquals( + new ExampleParams( + 10, + 20, + "hello", + Set.of(JsonNodeType.STRING, JsonNodeType.NULL), + List.of(new ExampleItem("a", 1.5), new ExampleItem("b", 2.0)), + new ExampleItem("c", 5)), + Map.ofEntries( + entry("number", List.of("10")), + entry("optionalNumber", List.of("20")), + entry("string", List.of("hello")), + entry("types", List.of("STRING", "NULL")), + entry("items", List.of("a:1.5", "b:2.0")), + entry("fallback", List.of("c:5")))); + } + + @Test + void testSingle() { + assertParamsEquals( + new ExampleParams( + 10, + 20, + "hello", + Set.of(JsonNodeType.STRING), + List.of(new ExampleItem("a", 1.5)), + new ExampleItem("c", 5)), + Map.ofEntries( + entry("number", List.of("10")), + entry("optionalNumber", List.of("20")), + entry("string", List.of("hello")), + entry("types", List.of("STRING")), + entry("items", List.of("a:1.5")), + entry("fallback", List.of("c:5")))); + } + + private static void assertParamsEquals(ExampleParams expected, Map> actual) { + List properties = JsonObject.properties(ExampleParams.class); + JsonNode object = + JsonBuilder.createObject( + obj -> { + for (JsonObject.Property p : properties) { + Text name = p.jsonName(); + String key = name.toString(); + if (!actual.containsKey(key)) continue; + List values = actual.get(key); + Set types = p.types(); + if (values == null || values.isEmpty()) { + if (types.contains(NodeType.BOOLEAN)) { + obj.addBoolean(name, true); + } else if (types.contains(NodeType.ARRAY)) { + obj.addArray(name, arr -> {}); + } + } else { + NodeType type = types.iterator().next(); + if (types.contains(NodeType.ARRAY)) type = NodeType.ARRAY; + if (types.size() == 1) { + switch (type) { + case INTEGER, NUMBER, BOOLEAN, NULL -> + obj.addMember(name, JsonNode.of(values.get(0))); + case STRING, OBJECT -> + obj.addString( + name, values.size() == 1 ? values.get(0) : String.join(",", values)); + case ARRAY -> + obj.addArray( + name, + arr -> + arr.addElements(values, JsonBuilder.JsonArrayBuilder::addString)); + } + } + } + } + }); + JsonMixed params = JsonMixed.of(object); + params.validate(ExampleParams.class); + assertEquals(expected, params.to(ExampleParams.class)); + } +} From 8152e7ff00a6216196fa2cf9bd4cedc19e133714 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 20 Apr 2026 16:02:15 +0200 Subject: [PATCH 19/20] chore: imports --- .../org/hisp/dhis/jsontree/InputExpression.java | 6 +++--- src/main/java/org/hisp/dhis/jsontree/JsonDate.java | 3 +-- src/main/java/org/hisp/dhis/jsontree/Text.java | 1 - .../java/org/hisp/dhis/jsontree/Validation.java | 1 - .../dhis/jsontree/validation/ObjectValidation.java | 10 ---------- .../dhis/jsontree/validation/ObjectValidator.java | 1 - .../jsontree/validation/PropertyValidations.java | 1 - .../hisp/dhis/jsontree/InputExpressionTest.java | 9 ++++----- .../org/hisp/dhis/jsontree/JsonValueToTest.java | 9 ++++----- .../JsonValidationDependentRequiredTest.java | 1 - .../validation/JsonValidationEnumTest.java | 1 - .../validation/JsonValidationMinimumTest.java | 2 -- .../JsonValidationMiscDeepGraphTest.java | 1 - .../validation/JsonValidationPatternTest.java | 8 ++++---- .../validation/JsonValidationRequiredTest.java | 1 - .../validation/JsonValidationStrictnessTest.java | 14 ++++++-------- 16 files changed, 22 insertions(+), 47 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index 001bd53..2eb98ba 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -1,11 +1,11 @@ package org.hisp.dhis.jsontree; -import java.util.List; -import java.util.stream.Stream; - import static java.lang.Character.toLowerCase; import static java.lang.Integer.MAX_VALUE; +import java.util.List; +import java.util.stream.Stream; + /** * Input patterns are specifically designed to match short sequences where the relevant features to * match are in the ASCII range. Typical examples are numbers, like dates or telephone numbers as diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonDate.java b/src/main/java/org/hisp/dhis/jsontree/JsonDate.java index 43c461e..221562a 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonDate.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonDate.java @@ -27,12 +27,11 @@ */ package org.hisp.dhis.jsontree; -import org.hisp.dhis.jsontree.Validation.NodeType; - import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import org.hisp.dhis.jsontree.Validation.NodeType; /** * A {@link JsonDate} is a {@link JsonString} with a special format. diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java index 8eb406e..81fc133 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Text.java +++ b/src/main/java/org/hisp/dhis/jsontree/Text.java @@ -7,7 +7,6 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.function.Consumer; - import org.hisp.dhis.jsontree.internal.NotNull; /** diff --git a/src/main/java/org/hisp/dhis/jsontree/Validation.java b/src/main/java/org/hisp/dhis/jsontree/Validation.java index cf6593e..34e6e30 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Validation.java +++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java @@ -6,7 +6,6 @@ import java.lang.annotation.Annotation; import java.lang.annotation.ElementType; -import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; 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 cdacfad..acb0ba8 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidation.java @@ -2,13 +2,6 @@ import static java.lang.Double.isNaN; import static java.util.Comparator.comparing; -import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; -import static org.hisp.dhis.jsontree.Validation.NodeType.BOOLEAN; -import static org.hisp.dhis.jsontree.Validation.NodeType.INTEGER; -import static org.hisp.dhis.jsontree.Validation.NodeType.NULL; -import static org.hisp.dhis.jsontree.Validation.NodeType.NUMBER; -import static org.hisp.dhis.jsontree.Validation.NodeType.OBJECT; -import static org.hisp.dhis.jsontree.Validation.NodeType.STRING; import static org.hisp.dhis.jsontree.Validation.YesNo.AUTO; import static org.hisp.dhis.jsontree.Validation.YesNo.YES; @@ -21,9 +14,6 @@ import java.lang.reflect.AnnotatedType; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; -import java.math.BigInteger; -import java.util.Collection; -import java.util.Date; import java.util.EnumSet; import java.util.HashMap; import java.util.List; 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 3400d41..64bb000 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -2,7 +2,6 @@ import static java.lang.Double.isNaN; import static java.util.stream.Collectors.joining; -import static java.util.stream.Collectors.toMap; import static org.hisp.dhis.jsontree.Validation.NodeType.ARRAY; import static org.hisp.dhis.jsontree.Validation.NodeType.BOOLEAN; import static org.hisp.dhis.jsontree.Validation.NodeType.INTEGER; diff --git a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java index fca0f1e..144f406 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -10,7 +10,6 @@ import java.util.Set; import java.util.function.Predicate; import java.util.stream.Stream; - import org.hisp.dhis.jsontree.InputExpression; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonValue; diff --git a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java index 00c3330..8988321 100644 --- a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -1,16 +1,15 @@ package org.hisp.dhis.jsontree; -import org.hisp.dhis.jsontree.InputExpression.Pattern; -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.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; +import org.hisp.dhis.jsontree.InputExpression.Pattern; +import org.junit.jupiter.api.Test; + /** * Tests for the {@link InputExpression} pattern matching implementation. * diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java index 611debe..ea7d4f6 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java @@ -1,14 +1,13 @@ package org.hisp.dhis.jsontree; -import org.hisp.dhis.jsontree.Validation.NodeType; -import org.junit.jupiter.api.Test; +import static java.util.Map.entry; +import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; import java.util.Map; import java.util.Set; - -import static java.util.Map.entry; -import static org.junit.jupiter.api.Assertions.assertEquals; +import org.hisp.dhis.jsontree.Validation.NodeType; +import org.junit.jupiter.api.Test; /** * Tests an advanced scenario of the {@link JsonValue#to(Class)} method where the {@link diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java index 6713fa9..6a6c5e5 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java @@ -4,7 +4,6 @@ import static org.hisp.dhis.jsontree.Validation.YesNo.YES; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import java.util.Map; import java.util.Set; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java index 020b1aa..7bddbdf 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationEnumTest.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.lang.annotation.ElementType; -import java.util.List; import java.util.Set; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java index 52ec86c..55aa72a 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMinimumTest.java @@ -1,7 +1,6 @@ package org.hisp.dhis.jsontree.validation; import static org.hisp.dhis.jsontree.Assertions.assertValidationError; -import static org.hisp.dhis.jsontree.Validation.YesNo.YES; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import java.util.Set; @@ -10,7 +9,6 @@ import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Rule; -import org.hisp.dhis.jsontree.Validation.Strict; import org.junit.jupiter.api.Test; /** diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java index 891389d..8040ec9 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java @@ -15,7 +15,6 @@ import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Rule; -import org.hisp.dhis.jsontree.Validation.Strict; import org.junit.jupiter.api.Test; /** diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java index be41250..998c761 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java @@ -1,15 +1,15 @@ package org.hisp.dhis.jsontree.validation; +import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.junit.jupiter.api.Test; -import static org.hisp.dhis.jsontree.Assertions.assertValidationError; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; - /** * Tests for {@link Validation#pattern()} based validations. */ diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java index 0197edf..552b220 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java @@ -46,7 +46,6 @@ import org.hisp.dhis.jsontree.Validation; import org.hisp.dhis.jsontree.Validation.NodeType; import org.hisp.dhis.jsontree.Validation.Rule; -import org.hisp.dhis.jsontree.internal.Language; import org.junit.jupiter.api.Test; /** diff --git a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java index 348396e..004975c 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java @@ -1,6 +1,11 @@ package org.hisp.dhis.jsontree.validation; -import org.hisp.dhis.jsontree.Assertions; +import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.hisp.dhis.jsontree.Validation.YesNo.NO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.Validation; @@ -8,13 +13,6 @@ import org.hisp.dhis.jsontree.Validation.Rule; import org.junit.jupiter.api.Test; -import java.util.Set; - -import static org.hisp.dhis.jsontree.Assertions.assertValidationError; -import static org.hisp.dhis.jsontree.Validation.YesNo.NO; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - /** * Tests the consequences of {@link Validation#strictness()}. */ From aadc263675438852c948c66fb2c6388cb349a038 Mon Sep 17 00:00:00 2001 From: Jan Bernitt Date: Mon, 20 Apr 2026 16:09:12 +0200 Subject: [PATCH 20/20] fix: sonar issues --- src/main/java/org/hisp/dhis/jsontree/InputExpression.java | 2 +- .../org/hisp/dhis/jsontree/validation/JsonValidator.java | 5 ++--- .../org/hisp/dhis/jsontree/validation/ObjectValidator.java | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index 2eb98ba..a65654e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -258,7 +258,7 @@ private static int matchSequence(char[] pattern, int offset, CharSequence input, throw reserved(opcode); } else { // everything else is taken literally - if (opcode != in) return -1; break; + if (opcode != in) return -1; } } } 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 e9fe6c9..1a98e74 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -67,9 +67,8 @@ private static Result validate(JsonValue value, Class schema, Validation.Mode } else { validate(value, validator, addError); Result result = new Result(value, schema, rules, errors); - if (mode != Validation.Mode.PROBE_ALL) - if (!errors.isEmpty()) - throw new JsonSchemaException("%d errors".formatted(errors.size()), result); + if (mode != Validation.Mode.PROBE_ALL && !errors.isEmpty()) + throw new JsonSchemaException("%d errors".formatted(errors.size()), result); return result; } } 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 64bb000..1c84cdf 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -289,7 +289,7 @@ private static Validator ofDependentRequired( .dependentRequired() .forEach( indicator -> { - boolean trigger = indicator.indexOf('[') > 0; + boolean trigger = indicator.indexOf('[') >= 0; String group = indicator; if (trigger) group = group.substring(0, indicator.indexOf('[')); boolean exclusive = !trigger && group.endsWith("*");