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 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 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 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/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) 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; + } } /** @@ -145,8 +228,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)); @@ -188,6 +279,54 @@ public String toString() { */ NodeType[] type() default {}; + /** + * @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: 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: 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: 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 + */ + 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; + } + } + } + /** * A property marked as varargs will allow {@link NodeType#ARRAY} to occur, each element then is * validated against the present simple value validations. @@ -255,16 +394,20 @@ 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 ""; + 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 @@ -343,15 +486,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 */ @@ -378,9 +512,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. * @@ -392,26 +523,30 @@ 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 - * 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. + * 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 {}; @@ -423,4 +558,134 @@ public String toString() { * @since 1.1 */ 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}. + */ + 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)); + } + } + + /** + * 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)); + } + } + + /* + 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, + Strict.Instance.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 de058b8..1a98e74 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/JsonValidator.java @@ -1,18 +1,20 @@ 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.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 @@ -20,15 +22,13 @@ */ public final class JsonValidator { - public static void validate(JsonValue value, Class schema) { - validate(value, schema, Set.of()); + 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, Validation.Rule... rules) { - validate(value, schema, Set.of(rules)); - } - - 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,27 +42,59 @@ 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 && !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 - // 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 538b1e9..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; @@ -33,21 +23,23 @@ 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; +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; -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.RequiredValidation; +import org.hisp.dhis.jsontree.validation.PropertyValidations.StringValidation; /** - * 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 @@ -58,7 +50,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<>(); @@ -67,7 +59,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)); /** @@ -75,7 +67,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<>(); /** @@ -95,7 +87,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) @@ -113,12 +105,12 @@ 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()); - if (onMethod == null) return onReturnType; - if (onReturnType == null) return onMethod; - return onMethod.overlay(onReturnType); + private static PropertyValidations fromProperty(JsonObject.Property p) { + PropertyValidations onComponent = fromAnnotations(p.source()); + PropertyValidations onType = fromValueTypeUse(p.javaType()); + if (onComponent == null) return onType; + if (onType == null) return onComponent; + return onType.overlay(onComponent); } /** @@ -126,7 +118,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)); @@ -134,23 +126,23 @@ 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)) { - // 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; } @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; @@ -158,15 +150,25 @@ 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(), + PropertyValidations.Strict.DEFAULT, + List.of(), null, + null, + null, + null, + null, + null); return base.withCustoms(validators).withItems(items); } @@ -183,21 +185,27 @@ 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) { + @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 -> @@ -207,26 +215,29 @@ 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) { - ValueValidation values = - !type.isPrimitive() ? null : new ValueValidation(YES, Set.of(), AUTO, Set.of(), List.of()); + private static PropertyValidations toPropertyValidation(Class type) { + RequiredValidation values = + !type.isPrimitive() ? null : RequiredValidation.PRIMITIVES; StringValidation strings = - !type.isEnum() ? null : new StringValidation(anyOfStrings(type), AUTO, -1, -1, ""); - return new PropertyValidation(anyOfTypes(type), values, strings, null, null, null, null); + !type.isEnum() ? null : new StringValidation(anyOfStrings(type), YesNo.AUTO, -1, -1, null); + return new PropertyValidations( + NodeType.of(type), PropertyValidations.Strict.DEFAULT, List.of(), 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), + toStrict(src.strictness()), + toCustoms(src), + toRequiredValidation(src), toStringValidation(src), toNumberValidation(src), toArrayValidation(src), @@ -235,22 +246,33 @@ private static PropertyValidation 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(); + 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 = - 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) { @@ -270,14 +292,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) { @@ -308,9 +331,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 @@ -339,19 +362,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()); @@ -366,28 +376,28 @@ 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(); + 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; + } } - @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; + @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 6d3a251..1c84cdf 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/ObjectValidator.java @@ -1,8 +1,9 @@ package org.hisp.dhis.jsontree.validation; import static java.lang.Double.isNaN; -import static java.util.stream.Collectors.toMap; +import static java.util.stream.Collectors.joining; 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; @@ -23,18 +25,17 @@ 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; import org.hisp.dhis.jsontree.JsonList; import org.hisp.dhis.jsontree.JsonMap; import org.hisp.dhis.jsontree.JsonMixed; import org.hisp.dhis.jsontree.JsonNodeType; import org.hisp.dhis.jsontree.JsonObject; import org.hisp.dhis.jsontree.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; @@ -81,10 +82,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, @@ -92,16 +93,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); - Validator validator = If.of(propValidator, propTypeValidator); + ofJavaType(objectValidation.types().get(property), currentlyResolved); + Validator validator = Chain.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)); }); @@ -116,7 +117,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)) { @@ -126,202 +127,216 @@ 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) { 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, 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())); - - Validator type = anyOf.isEmpty() ? null : new Type(anyOf); - Validator anyType = create(node.values()); - Validator typeDependent = byType.isEmpty() ? null : new TypeDependent(byType); - Validator items = node.items() == null ? null : Items.of(create(node.items(), property)); - Validator whenDefined = If.of(type, If.of(anyType, If.of(typeDependent, items))); - - PropertyValidation.ValueValidation values = node.values(); - boolean isRequiredYes = values != null && values.required().isYes(); + + 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); + boolean acceptIntegersOnly = accepted.contains(INTEGER) && !accepted.contains(NUMBER); + + Validator strings = ofString(validations.strings()); + 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); + if (accepted.contains(ARRAY)) add.accept(JsonNodeType.ARRAY, ofArray(validations.arrays())); + if (accepted.contains(OBJECT)) add.accept(JsonNodeType.OBJECT, ofObject(validations.objects())); + + // strictness AUTO acceptance + PropertyValidations.Strict strictness = validations.strictness(); + boolean strictStrings = strings != null || customs != null || strictness.strings().isYes(); + boolean strictBooleans = strictness.booleans().isYes(); + boolean strictNumbers = strictness.numbers().isYes(); + if (!strictNumbers && acceptNumbers && numbers != null && !acceptStrings) { + // 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()) { + type = + new Type( + 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())); + Validator required = ofRequired(property, validations); + return Chain.of(required, type, customs, typeSpecific, items); + } + + private static Validator ofRequired(Text property, PropertyValidations validations) { + PropertyValidations.RequiredValidation requiredness = validations.requiredness(); + boolean isRequiredYes = requiredness != null && requiredness.required().isYes(); boolean isRequiredAuto = - (values == null || values.required().isAuto()) && isRequiredImplicitly(node, anyOf); - boolean isAllowNull = values != null && values.allowNull().isYes(); - Validator required = - !isRequiredYes && !isRequiredAuto ? null : new Required(property, isAllowNull); - return If.of(required, whenDefined); + (requiredness == null || requiredness.required().isAuto()) && isRequiredImplicitly(validations); + boolean isAllowNull = requiredness != null && requiredness.allowNull().isYes(); + 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(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) { + return validations.accepted().stream().allMatch(type -> isRequiredImplicitly(validations, type)); } - @CheckNull - private static Validator create(@CheckNull PropertyValidation.ValueValidation values) { - return values == null - ? null - : All.of( - values.anyOfJsons().isEmpty() ? null : new EnumAnyJson(values.anyOfJsons()), - values.customs().isEmpty() ? null : new All(values.customs())); + 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.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().isEmpty() + strings.pattern() == null ? null - : new Pattern(java.util.regex.Pattern.compile(strings.pattern()))); + : new Pattern(strings.pattern())); + } + + 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, 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 : 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())); } @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; - Map> groupPropertyRole = new HashMap<>(); - Map> isMissing = new HashMap<>(); + .allMatch(p -> p.requiredness() == null || p.requiredness().dependentRequired().isEmpty())) return null; + Set groups = new HashSet<>(); + Map> triggersByGroup = new HashMap<>(); + Map> nonExclusiveByGroup = new HashMap<>(); + Map> exclusiveByGroup = new HashMap<>(); properties.forEach( (name, validation) -> { - PropertyValidation.ValueValidation values = validation.values(); - isMissing.put(name, isMissing(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(isMissing), 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 -> e.getValue().substring(e.getValue().indexOf('=') + 1))); - all.add( - new DependentRequired( - Map.copyOf(isMissing), - 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 isMissing(PropertyValidation.ValueValidation 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 */ @@ -330,8 +345,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) -> { @@ -352,13 +370,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 @@ -374,23 +400,52 @@ 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)); } } - 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); } } @@ -422,43 +477,51 @@ public void validate(JsonMixed value, Consumer addError) { } } - /** - * The value must be one of the provided JSON strings - * - * @param constants each a valid JSON string + /* + string values */ - private record EnumAnyJson(Set constants) 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) { - if (value.isUndefined()) return; - String json = value.toMinimizedJson(); - if (!constants.contains(json)) + Text actual = value.text(); + if (!constants.contains(actual)) addError.accept( - Error.of(Rule.ENUM, value, "must be one of %s but was: %s", constants, json)); + Error.of( + Rule.ENUM, + value, + "must be one of %s but was: %s", + constants, + actual)); } } - /* - string values - */ + private record EnumCaseInsensitive(InputExpression expr, Set constants) implements Validator { - private record EnumAnyString(Set constants, boolean caseInsensitive) - 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)); } @@ -468,8 +531,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)); @@ -480,23 +542,24 @@ 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)); } } - private record Pattern(java.util.regex.Pattern regex) implements Validator { + private record Pattern(InputExpression expr, String regExEquivalent) implements Validator { + Pattern(InputExpression expr) { + this(expr, expr.toRegEx()); + } @Override public void validate(JsonMixed value, Consumer addError) { - if (!value.isString()) return; - String actual = value.string(); - if (!regex.matcher(actual).matches()) + 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)); } } @@ -504,23 +567,40 @@ public void validate(JsonMixed value, Consumer addError) { number values */ + private record MinimumLong(long limit) implements Validator { + + @Override + public void validate(JsonMixed value, Consumer addError) { + long actual = value.longValue(); + 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 MaximumLong(long limit) implements Validator { + @Override + public void validate(JsonMixed value, Consumer addError) { + long actual = value.longValue(); + 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)); } @@ -530,8 +610,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)); @@ -542,8 +621,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)); @@ -554,8 +632,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)); @@ -594,23 +671,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; + } } } @@ -618,189 +693,94 @@ 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)); } } - /** - * The dependent required validator. - * - * @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> isMissing, - 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 value, Consumer addError) { - if (!value.isObject()) return; - boolean presentNotMet = - !present.isEmpty() && present.stream().anyMatch(p -> isMissing.get(p).test(value, p)); - boolean absentNotMet = - !absent.isEmpty() && absent.stream().anyMatch(p -> !isMissing.get(p).test(value, p)); - boolean equalsNotMet = - !equals.isEmpty() - && equals.entrySet().stream() - .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))) { - Set missing = - Set.copyOf(dependents.stream().filter(p -> isMissing.get(p).test(value, p)).toList()); - if (!equals.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - value, - "object with %s requires all of %s, missing: %s", - equals, - dependents, - missing)); - } else if (present.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - value, - "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, - value, - "object with any of %s requires all of %s, missing: %s", - present, - dependents, - missing)); - } else { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - value, - "object with any of %s or without any of %s requires all of %s, missing: %s", - present, - absent, - dependents, - missing)); - } + public void validate(JsonMixed obj, Consumer addError) { + if (!obj.isObject()) return; + 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 -> !isMissing.get(p).test(value, p)).toList()); - if (defined.size() == 1) return; // it is exclusively defined => OK - if (!equals.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - value, - "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, - value, - "object requires one but only one of %s, but has: %s", - exclusiveDependent, - defined)); - } else if (present.isEmpty()) { - addError.accept( - Error.of( - Rule.DEPENDENT_REQUIRED, - value, - "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, - value, - "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, - value, - "object with any of %s or without any of %s requires one but only one of %s, but has: %s", - present, - absent, - exclusiveDependent, - defined)); - } + obj, + "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))); } } } - private record DependentRequiredCodependent( - Map> isMissing, 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))) - 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 -> isMissing.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/PropertyValidation.java b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java similarity index 68% 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 8c19b7f..144f406 100644 --- a/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidation.java +++ b/src/main/java/org/hisp/dhis/jsontree/validation/PropertyValidations.java @@ -1,13 +1,18 @@ 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; @@ -15,24 +20,40 @@ 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 + * @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) * @param strings validations that apply to string nodes * @param numbers validations that apply to number nodes * @param arrays validations that apply to array nodes * @param objects validations that apply to object nodes * @param items validations that apply to array elements or object member values (map use) */ -record PropertyValidation( - @NotNull Set anyOfTypes, - @CheckNull ValueValidation values, +record PropertyValidations( + @NotNull Set accepted, + @NotNull Strict strictness, + @NotNull List customs, + @CheckNull RequiredValidation requiredness, @CheckNull StringValidation strings, @CheckNull NumberValidation numbers, @CheckNull ArrayValidation arrays, @CheckNull ObjectValidation objects, - @CheckNull PropertyValidation items) { + @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 @@ -43,11 +64,13 @@ 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( - overlayC(anyOfTypes, with.anyOfTypes), - values == null ? with.values : values.overlay(with.values), + return new PropertyValidations( + overlayC(accepted, with.accepted), + strictness.overlay(with.strictness), + 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), @@ -56,43 +79,39 @@ 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( + accepted, strictness, customs, requiredness, strings, numbers, arrays, objects, items); } @NotNull - PropertyValidation 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 PropertyValidation(anyOfTypes, newValues, strings, numbers, arrays, objects, items); + PropertyValidations withCustoms(@NotNull List validators) { + List merged = overlayEachClassAtMostOnce(customs, validators); + return new PropertyValidations( + accepted, strictness, merged, requiredness, strings, numbers, arrays, objects, items); } @NotNull - public PropertyValidation varargs() { - Set anyOfTypes = new HashSet<>(anyOfTypes()); - anyOfTypes.add(NodeType.ARRAY); + public PropertyValidations varargs() { + Set newTypes = new HashSet<>(accepted()); + 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 PropertyValidation( - Set.copyOf(anyOfTypes), - values, + return new PropertyValidations( + Set.copyOf(newTypes), + strictness, + customs, + requiredness, strings, numbers, arrays, objects, - new PropertyValidation(anyOfTypes(), values, strings, numbers, null, objects, items)); + new PropertyValidations( + accepted(), strictness, customs, requiredness, strings, numbers, null, objects, items)); } /** @@ -103,26 +122,27 @@ public PropertyValidation 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) { + + static final RequiredValidation PRIMITIVES = new RequiredValidation(YesNo.YES, Set.of(), YesNo.AUTO); - 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)); + } + + Predicate present() { + return allowNull.isYes() ? JsonValue::exists : not(JsonValue::isUndefined); + } + + Predicate absent() { + return allowNull.isYes() ? not(JsonValue::exists) : JsonValue::isUndefined; } } @@ -133,14 +153,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 +169,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 +251,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; } @@ -255,7 +275,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/Assertions.java b/src/test/java/org/hisp/dhis/jsontree/Assertions.java index 33e98bb..6a6834f 100644 --- a/src/test/java/org/hisp/dhis/jsontree/Assertions.java +++ b/src/test/java/org/hisp/dhis/jsontree/Assertions.java @@ -22,13 +22,13 @@ 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(), () -> - "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()); @@ -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 new file mode 100644 index 0000000..8988321 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -0,0 +1,296 @@ +package org.hisp.dhis.jsontree; + +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. + * + * @author Jan Bernitt + */ +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_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"); + assertDoesNotMatch("|2050|", "2061"); + assertMatches("|2059|", "0001","2059"); + 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"); + 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_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_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_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"); + } + + @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_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|", + "{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", "2033Nov", "1999SatW44"); + // pattern issue + assertDoesNotMatch(expr, "20221", "2022-1", "1980113", "1980-1-13", "1980100"); + // numerically out of bounds + 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)); + } + } + + 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)); + } + } + + 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)); + } + } +} 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..ea7d4f6 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java @@ -0,0 +1,139 @@ +package org.hisp.dhis.jsontree; + +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 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 + * 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)); + } +} diff --git a/src/test/java/org/hisp/dhis/jsontree/TextTest.java b/src/test/java/org/hisp/dhis/jsontree/TextTest.java index 94efcde..1be109f 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; @@ -14,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() { @@ -218,6 +228,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 +324,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() { @@ -304,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/JsonValidationDependentRequiredTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationDependentRequiredTest.java index 7d03aba..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; @@ -22,12 +21,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 +34,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 +47,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 +66,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 +89,7 @@ void testDependentRequired_Codependent() { {"firstName":"peter"}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, + "", Set.of("firstName", "lastName"), Set.of("lastName")); assertValidationError( @@ -97,6 +97,7 @@ void testDependentRequired_Codependent() { {"lastName":"peter"}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, + "", Set.of("firstName", "lastName"), Set.of("firstName")); assertValidationError( @@ -104,6 +105,7 @@ void testDependentRequired_Codependent() { {"lastName":"peter", "firstName": null}""", JsonDependentRequiredExampleA.class, Rule.DEPENDENT_REQUIRED, + "", Set.of("firstName", "lastName"), Set.of("firstName")); @@ -128,7 +130,7 @@ void testDependentRequired_PresentDependent() { {"zip":"12345"}""", JsonDependentRequiredExampleB.class, Rule.DEPENDENT_REQUIRED, - Set.of("zip"), + "with zip", Set.of("city"), Set.of("city")); @@ -153,7 +155,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 +180,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 +188,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/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 fa6363c..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, - 100d, - 101d); + 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 new file mode 100644 index 0000000..e3f734f --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMetaTest.java @@ -0,0 +1,282 @@ +package org.hisp.dhis.jsontree.validation; + +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.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 {} + + @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); + } + } + } + + @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, + @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, + @Optional @Digits(integer = 3, fraction = 1) double digits + ) { + static final ExampleBean DEFAULT = + new ExampleBean(null, List.of(), Map.of(), 1, 0, -1, 0, 0, 123.4); + } + + @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 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(""" + { + "range": 130 + }"""); + assertValidationError(obj, ExampleBean.class, Rule.MAXIMUM, 120L, 130L); + + obj = JsonMixed.of(""" + { + "range": -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)); + } + + @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); + } +} 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..55aa72a 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); + 0L, + -1L); assertValidationError( """ {"height":19}""", JsonMinimumExampleB.class, Rule.MINIMUM, - 20d, - 19d); + 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 bddd495..8040ec9 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationMiscDeepGraphTest.java @@ -79,12 +79,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(); } @@ -139,8 +139,8 @@ void testDeep_Error_PagerSizeZero() { {"pager": {"size": 0, "page": 0}, "entries":[]}""", JsonPage.class, Rule.MINIMUM, - 1d, - 0d); + 1L, + 0L); 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); + 0L, + -1L); assertEquals(".pager.page", error.path().toString()); } @@ -274,7 +274,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()); } @@ -291,7 +291,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()); } } 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/JsonValidationPatternTest.java index 75b3cbf..998c761 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationPatternTest.java @@ -2,84 +2,49 @@ 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 java.util.Set; 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; /** - * Tests Validation of the @{@link Validation#pattern()} property. - * - * @author Jan Bernitt + * Tests for {@link Validation#pattern()} based validations. */ class JsonValidationPatternTest { - public interface JsonPatternExampleA extends JsonObject { - - @Validation(pattern = "[0-9]{1,4}[A-Z]?") - default String no() { - return getString("no").string(); - } - } + /** + * 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 testMaxLength_OK() { - assertDoesNotThrow( - () -> - JsonMixed.of( - """ - {}""") - .validate(JsonPatternExampleA.class)); - assertDoesNotThrow( - () -> - JsonMixed.of( - """ - {"no":null}""") - .validate(JsonPatternExampleA.class)); - assertDoesNotThrow( - () -> - JsonMixed.of( - """ - {"no":"12"}""") - .validate(JsonPatternExampleA.class)); - assertDoesNotThrow( - () -> - JsonMixed.of( - """ - {"no":"12B"}""") - .validate(JsonPatternExampleA.class)); - } + void testPattern() { + JsonObject obj = JsonMixed.of(""" + { + "value": 34.123 + }"""); - @Test - void testMaxLength_NoMatch() { - assertValidationError( - """ - {"no":"B12"}""", - JsonPatternExampleA.class, - Rule.PATTERN, - "[0-9]{1,4}[A-Z]?", - "B12"); - assertValidationError( - """ - {"no":"12345"}""", - JsonPatternExampleA.class, - Rule.PATTERN, - "[0-9]{1,4}[A-Z]?", - "12345"); - } + assertDoesNotThrow(() -> obj.validate(PatternOnNumber.class)); + assertEquals(new PatternOnNumber(34.123d), obj.to(PatternOnNumber.class)); + + JsonObject obj2 = JsonMixed.of(""" + { + "value": 34.12 + }"""); - @Test - void testMaxLength_WrongType() { assertValidationError( - """ - {"no":true}""", - JsonPatternExampleA.class, - Rule.TYPE, - Set.of(NodeType.STRING), - NodeType.BOOLEAN); + 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/JsonValidationRegExPatternTest.java b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRegExPatternTest.java new file mode 100644 index 0000000..8ff5b26 --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRegExPatternTest.java @@ -0,0 +1,86 @@ +package org.hisp.dhis.jsontree.validation; + +import static org.hisp.dhis.jsontree.Assertions.assertValidationError; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.util.Set; +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.hisp.dhis.jsontree.Validator; +import org.junit.jupiter.api.Test; + +/** + * Tests Validation of the @{@link Validation#pattern()} property. + * + * @author Jan Bernitt + */ +class JsonValidationRegExPatternTest { + + public interface JsonPatternExampleA extends JsonObject { + + @Validator(value = Validation.RegEx.class, params = @Validation(pattern = "[0-9]{1,4}[A-Z]?")) + default String no() { + return getString("no").string(); + } + } + + @Test + void testMaxLength_OK() { + assertDoesNotThrow( + () -> + JsonMixed.of( + """ + {}""") + .validate(JsonPatternExampleA.class)); + assertDoesNotThrow( + () -> + JsonMixed.of( + """ + {"no":null}""") + .validate(JsonPatternExampleA.class)); + assertDoesNotThrow( + () -> + JsonMixed.of( + """ + {"no":"12"}""") + .validate(JsonPatternExampleA.class)); + assertDoesNotThrow( + () -> + JsonMixed.of( + """ + {"no":"12B"}""") + .validate(JsonPatternExampleA.class)); + } + + @Test + void testMaxLength_NoMatch() { + assertValidationError( + """ + {"no":"B12"}""", + JsonPatternExampleA.class, + Rule.PATTERN, + "[0-9]{1,4}[A-Z]?", + "B12"); + assertValidationError( + """ + {"no":"12345"}""", + JsonPatternExampleA.class, + Rule.PATTERN, + "[0-9]{1,4}[A-Z]?", + "12345"); + } + + @Test + void testMaxLength_WrongType() { + assertValidationError( + """ + {"no":true}""", + JsonPatternExampleA.class, + Rule.TYPE, + Set.of(NodeType.STRING), + NodeType.BOOLEAN); + } +} 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..552b220 100644 --- a/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationRequiredTest.java @@ -102,7 +102,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)); @@ -117,8 +117,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 @@ -211,7 +211,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); } 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..004975c --- /dev/null +++ b/src/test/java/org/hisp/dhis/jsontree/validation/JsonValidationStrictnessTest.java @@ -0,0 +1,169 @@ +package org.hisp.dhis.jsontree.validation; + +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; +import org.hisp.dhis.jsontree.Validation.NodeType; +import org.hisp.dhis.jsontree.Validation.Rule; +import org.junit.jupiter.api.Test; + +/** + * 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); + } + +}