diff --git a/INPUT_EXPRESSIONS.md b/INPUT_EXPRESSIONS.md
new file mode 100644
index 0000000..ced27fa
--- /dev/null
+++ b/INPUT_EXPRESSIONS.md
@@ -0,0 +1,240 @@
+# Input Expressions
+
+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.
+
+An Input Expression always looks for "what you want" (whitelist-y), and so it has
+
+- no OR
+- no "not in" character sets
+- no backtracking
+
+---
+
+## Overview
+
+A pattern is given using the following building blocks:
+
+- 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.
+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 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 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
+
+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.
+
+**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`).
+
+> [!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]`).
+
+---
+
+
+### Character Sets: `[…]`-Mode
+
+`[{code1}{code2}…]` matches a **single** character that belongs to **any** of the listed sets.
+
+| 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 | - |
+| 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 | + => `+`, é => `é`, ... |
+
+- 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
+
+---
+
+### Sequence of Sets: `|…|`-Mode
+
+`|{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.
+
+Note that a sequence always has a 1:1 length relation to the input it matches.
+
+| 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 permitted digit | e.g. `3` => `0`-`3` |
+| ... | any other character is matched literally | + => `+`, é => `é`, ... |
+
+- 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.
+
+### 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}(#?#)`.
+
+---
+
+## 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]+#` (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. |
+
+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
new file mode 100644
index 0000000..a65654e
--- /dev/null
+++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java
@@ -0,0 +1,666 @@
+package org.hisp.dhis.jsontree;
+
+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
+ * 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 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
+ * @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 {@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();
+ // 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 (pos >= len || !isDigit(input.charAt(pos++))) return -1; break;
+ case '@': if (pos >= len || !isIdentifier(input.charAt(pos++))) return -1; break;
+ case '[': {
+ 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 '|': {
+ 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;
+ }
+ 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 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 -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 -1; break;
+ } else if (isDigit(opcode)) {
+ if (!isDigit(in)) return -1; // easy case
+ int pos0 = pos-1;
+ pos = matchNumericSequence(pattern, i-1, input, pos0);
+ 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 -1;
+ }
+ }
+ }
+ return pos;
+ }
+
+ 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;
+ };
+ }
+
+ 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)
+ */
+
+ /**
+ * 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 (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;
+ len += len2;
+ i = end2;
+ }
+ break;
+ case '1','2','3','4','5','6','7','8','9': {
+ boolean zeroOrMore = i < end && 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;
+ }
+
+ }
+
+ /*
+ To RegEx equivalent
+ */
+
+ /**
+ * @return An approximation of this expression as Regular Expression. The RegEx may be more
+ * permissive.
+ */
+ 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
+ if (!single) regex.append("(?:");
+ toRegEx(pattern.pattern, 0, pattern.pattern.length(), regex);
+ if (!single) 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 {
+ regex.append('{');
+ if (repMin < repMax) regex.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/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/JsonAccess.java b/src/main/java/org/hisp/dhis/jsontree/JsonAccess.java
index 5d9860f..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;
@@ -266,7 +267,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));
}
@@ -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 extends Record> 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/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..221562a 100644
--- a/src/main/java/org/hisp/dhis/jsontree/JsonDate.java
+++ b/src/main/java/org/hisp/dhis/jsontree/JsonDate.java
@@ -31,6 +31,7 @@
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.
@@ -40,6 +41,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/JsonNode.java b/src/main/java/org/hisp/dhis/jsontree/JsonNode.java
index 8da80a8..1f6075f 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;
+ }
}
/**
@@ -350,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
@@ -384,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/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 58a45a8..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 extends JsonValue> jsonType,
+ Set types,
String javaName,
AnnotatedType javaType,
AnnotatedElement source) {}
@@ -180,19 +183,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 +214,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/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/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/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/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 -> {
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/JsonString.java b/src/main/java/org/hisp/dhis/jsontree/JsonString.java
index 299290f..653eb6f 100644
--- a/src/main/java/org/hisp/dhis/jsontree/JsonString.java
+++ b/src/main/java/org/hisp/dhis/jsontree/JsonString.java
@@ -40,12 +40,9 @@
*
* @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 {
+public interface JsonString extends JsonValue {
@Override
default JsonString getValue() {
@@ -55,7 +52,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 +61,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 +95,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/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/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 extends Record> of) {
return Stream.of(of.getRecordComponents())
.map(
c ->
- new JsonObject.Property(
- of,
- Text.of(c.getName()),
- JsonMixed.class,
- c.getName(),
- c.getAnnotatedType(),
- c))
+ {
+ Class extends JsonValue> 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 extends JsonObject> of)
in,
name,
type,
+ Validation.NodeType.ofJsonType(type),
method.getName(),
method.getAnnotatedReturnType(),
method));
diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java
index 91c0360..81fc133 100644
--- a/src/main/java/org/hisp/dhis/jsontree/Text.java
+++ b/src/main/java/org/hisp/dhis/jsontree/Text.java
@@ -6,6 +6,7 @@
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;
/**
@@ -188,10 +189,32 @@ 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;
+ }
+
@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];
@@ -199,6 +222,70 @@ default boolean regionMatches(int startIndex, CharSequence sample, int offset, i
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()
*/
@@ -239,6 +326,13 @@ default boolean isTextualDecimal() {
return TextualNumber.isTextualDecimal(toCharArray());
}
+ /**
+ * @return true, if this text is exactly "NaN", "Infinity" or "-Infinity" (Java double special number literals)
+ */
+ default boolean isSpecialDecimal() {
+ return contentEquals("NaN") || contentEquals("Infinity") || contentEquals("-Infinity");
+ }
+
/**
* In contrast to the JDK method this only accepts valid inputs.
*
@@ -309,7 +403,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) {
@@ -428,6 +522,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/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 3587220..34e6e30 100644
--- a/src/main/java/org/hisp/dhis/jsontree/Validation.java
+++ b/src/main/java/org/hisp/dhis/jsontree/Validation.java
@@ -1,12 +1,27 @@
package org.hisp.dhis.jsontree;
-import java.io.Serializable;
+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;
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;
import org.hisp.dhis.jsontree.internal.CheckNull;
import org.hisp.dhis.jsontree.internal.NotNull;
@@ -57,6 +72,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,
@@ -125,6 +164,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 extends JsonValue>) type
+ : toJsonType(type));
+ }
+
+ static Class extends JsonValue> 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 extends JsonValue> 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 extends JsonValue>) si));
+ }
+ }
+ return res;
+ }
}
/**
@@ -145,8 +228,16 @@ interface Validator {
void validate(JsonMixed value, Consumer addError);
}
- record Error(Rule rule, JsonPath path, JsonValue value, String template, List