diff --git a/HIGHLIGHTS_1.9.md b/HIGHLIGHTS_1.9.md new file mode 100644 index 0000000..1a9db05 --- /dev/null +++ b/HIGHLIGHTS_1.9.md @@ -0,0 +1,91 @@ + +# v1.9 `json-tree` Highlights + +A presentation about what happened in version 1.9 + +---- + +## Features 🎉 + +* `.to(Class)` API (JSON to Java) +* `.query(JsonSelector)` API (finding matching nodes RFC-9535-ish) +* `InputExpression`s (RegEx-ish pattern matching but "safe" and "cheap") +* `JsonPointer` support via `JsonPath` (RFC-6901) +* `Json5` input support (JSON5-ish) +* `Jurl` = JSON in the URL (a JSON notation that is "URL-safe") +* `JsonNode` + `JsonValue` with small footprint `Serializable` support +* `JsonString`-`JsonNumber` + `JsonString`-`JsonBoolean` duality + +---- + +## Specified Edge-cases + +* a full JSON node validation will occur at the latest when `JsonNode#endIndex` is called +* duplicate `keys()` in objects are observed +* duplicate key `entries()` are observed with the value of the **first** occurrence + when nodes `Index` cache is effective, otherwise they are observed as defined in JSON + +---- + +## Validation + + + +---- + +## Performance 🏃 + +`Text` goodness + +* `Text`-views (a rich `CharSequence` API instead of `String`s) +* essential APIs all are `Text`-based (avoids lots of array copying) +* JSON string as lazy `Text`-views (if no escaping was used) +* O(1) cached numeric `char[]` for array indexes => `Text` +* O(1) "is numeric" for cache backed numeric `Text` +* O(1) lookup cached short name `Text`s (for loops with string properties) +* O(1) hashing for `Text` (main component in `JsonNode` nodes map) + +Slim `JsonNode`s + +* more compact `JsonNode` implementations (2 refs + 2 ints per node) +* `JsonNode` + `JsonValue` as `Map.Entry`s (no need for `Map.entry`) + +Avoid or defer work (fewer indirections, more control) + +* `Streamable` provides rich API with minimal overhead +* Nodes `Index` control for "looping" +* `JsonPath` as chain (no alloc parent access, O(1) append) +* once resolved a `JsonNode` is remembered in `JsonValue` +* JSON numbers as lazy `TextualNumber`-views + + +---- + +## That escalated quickly... + +The origins of `Text`... + +https://github.com/dhis2/json-tree/blob/ce68c5acafe49ad0262967404415adca839e0f88/src/main/java/org/hisp/dhis/jsontree/JsonTree.java#L589 + +_A few moments later..._ + +https://github.com/dhis2/json-tree/pull/86 + +---- + +## Performance - `Text`-views + +from +``` + {"key":"value", "key2":42, ...} + | + v + new String("key") ---> new byte[] { ... } +``` +to +``` + {"key":"value", "key2":42, ...} + ^ ^ + | | + new Text.Slice(s, e) +``` \ No newline at end of file diff --git a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java index a65654e..bc34e1f 100644 --- a/src/main/java/org/hisp/dhis/jsontree/InputExpression.java +++ b/src/main/java/org/hisp/dhis/jsontree/InputExpression.java @@ -2,9 +2,9 @@ import static java.lang.Character.toLowerCase; import static java.lang.Integer.MAX_VALUE; +import static java.util.Arrays.asList; import java.util.List; -import java.util.stream.Stream; /** * Input patterns are specifically designed to match short sequences where the relevant features to @@ -28,7 +28,10 @@ public record InputExpression(List patterns) { public static InputExpression of(String... patterns) { - return new InputExpression(Stream.of(patterns).map(Pattern::of).toList()); + if (patterns.length == 1) return new InputExpression(List.of(Pattern.of(0, patterns[0]))); + Pattern[] arr = new Pattern[patterns.length]; + for (int i = 0; i < patterns.length; i++) arr[i] = Pattern.of(i, patterns[i]); + return new InputExpression(asList(arr)); } /** @@ -399,17 +402,18 @@ private static int skipUnit(Text pattern, int offset) { /** * A pattern is one alternative within a multi-pattern-{@link InputExpression}. * + * @param ordinal the ID or serial number within an {@link InputExpression#patterns()} list * @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 record Pattern(int ordinal, Text pattern, int minLength, int maxLength) { - public static Pattern of(CharSequence pattern) { + public static Pattern of(int ordinal, CharSequence pattern) { Text p = Text.of(pattern); - return new Pattern(p, length(p, 0, p.length(), false), length(p, 0, p.length(), true)); + return new Pattern(ordinal, p, length(p, 0, p.length(), false), length(p, 0, p.length(), true)); } public boolean matches(CharSequence input) { diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java index 12e1c25..eac74ae 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonAbstractObject.java @@ -115,11 +115,9 @@ default boolean exists(CharSequence name) { } /** - * Note that keys may differ from the member names as defined in the JSON document in case that - * their literal interpretation would have clashed with key syntax. In that case the object member - * name is "escaped" so that using the returned key with {@link #get(CharSequence)} will return - * the value. Use {@link #names()} to receive the literal object member names as defined in the - * document. + * Stream of the raw JSON object member names in order of declaration. + * If keys re-occur in the JSON (duplicates) they also re-occur in the stream. + * Use {@link Stream#distinct()} on the result to de-duplicate when needed. * * @return The keys of this map. * @throws JsonTreeException in case this node does exist but is not an object node @@ -155,6 +153,7 @@ default Stream entries() { /** * Lists raw JSON object member names in order of declaration. + * If keys re-occur in the JSON (duplicates) they also re-occur in the list. * * @return The list of object member names in the order they were defined. * @throws JsonTreeException in case this node does exist but is not an object node diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java index 0889f00..d6cee84 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonObject.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonObject.java @@ -167,6 +167,11 @@ default Stream entries() { } /** + * Whether duplicate keys are observed as the first definition every time or as declared in JSON + * depends on the {@link Index} strategy used. If the index is effective (either from prior + * indexing or because {@link Index#ADD} is used) the first declaration is observed every time the + * key re-occurs, otherwise the value is observed as declared in the JSON source. + * * @implNote This utilizes {@link JsonNode#members(Index)} avoiding map lookups for each element. * On {@link JsonAbstractArray} level this cannot be done as the node cannot be {@link * JsonNode#lift(JsonAccessors)} ed to the unknown generic target type. diff --git a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java index 1338920..a10e673 100644 --- a/src/main/java/org/hisp/dhis/jsontree/JsonTree.java +++ b/src/main/java/org/hisp/dhis/jsontree/JsonTree.java @@ -27,7 +27,6 @@ */ package org.hisp.dhis.jsontree; -import static java.util.Collections.emptyIterator; import static java.util.Objects.requireNonNull; import static org.hisp.dhis.jsontree.Chars.expectChar; import static org.hisp.dhis.jsontree.Chars.expectDigit; @@ -41,19 +40,11 @@ import java.io.Serial; import java.io.Serializable; -import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.NoSuchElementException; -import java.util.Spliterator; -import java.util.function.BooleanSupplier; -import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.IntSupplier; -import java.util.function.Supplier; -import java.util.stream.Stream; -import java.util.stream.StreamSupport; import org.hisp.dhis.jsontree.internal.CheckNull; import org.hisp.dhis.jsontree.internal.NotNull; @@ -114,17 +105,17 @@ private abstract static class LazyJsonNode implements JsonNode { Map.Entry */ @Override - public Text getKey() { + public final Text getKey() { return path.segment(); } @Override - public JsonNode getValue() { + public final JsonNode getValue() { return this; } @Override - public JsonNode setValue(JsonNode value) { + public final JsonNode setValue(JsonNode value) { throw new UnsupportedOperationException("JSON nodes are immutable"); } @@ -197,14 +188,17 @@ public final boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof LazyJsonNode other)) return false; if (tree.json == other.tree.json && start == other.start) return true; - return path().equals(other.path()) && getDeclaration().contentEquals(other.getDeclaration()); + return path.equals(other.path) + && type() == other.type() + && getDeclaration().contentEquals(other.getDeclaration()); } /** * This methods necessity is an unfortunate detail of valid JSON which does allow whitespace - * after the root but no further other characters. Since the {@link #endIndex()} (skipping over + * after the root but no other further characters. Since the {@link #endIndex()} (skipping over * the JSON) should be lazy and at least deferred until actual usage an issue with dangling - * trash can first be checked for when user accesses the {@link #value()}. + * trash can first be found for when a caller accesses the {@link #endIndex()} directly or + * indirectly. */ final void checkNoDanglingTrash(int end) { Chars.expectEndOfBuffer(tree.json, skipWhitespace(tree.json, end)); @@ -229,7 +223,7 @@ private Object readResolve() { } } - private static final class LazyJsonObject extends LazyJsonNode implements Streamable { + private static final class LazyJsonObject extends LazyJsonNode implements Iterable { private int size = -1; @@ -242,17 +236,6 @@ public Iterable value() { return this; } - @NotNull - @Override - public Iterator iterator() { - return membersIterator(Index.AUTO); - } - - @Override - public @NotNull Stream stream() { - return JsonTree.stream(iterator(), size()); - } - @Override public @NotNull JsonNodeType type() { return JsonNodeType.OBJECT; @@ -282,11 +265,6 @@ public JsonNode getIfExists(@NotNull JsonPath subPath) { return tree.get(path.concat(subPath), true); } - @Override - public Streamable members() { - return this; - } - @Override public boolean isEmpty() { if (size >= 0) return size == 0; @@ -337,20 +315,32 @@ private JsonNode member(Text name, boolean orNull) throws JsonPathException { throw noSuchMember(path, name); } + @NotNull + @Override + public Iterator iterator() { + return members(Index.AUTO).iterator(); + } + @Override - public Streamable members(Index index) { - return new StreamableAdapter<>(() -> membersIterator(index), this::size, this::isEmpty); + public Streamable members() { + return members(Index.AUTO); } - private Iterator membersIterator(Index op) { - if (isEmpty()) return emptyIterator(); - return new Iterator<>() { + @Override + public Streamable members(Index op) { + if (isEmpty()) return Streamable.empty(); + return new Streamable.Sized<>() { private final char[] json = tree.json; private final Index index = op.resolve(tree.auto); private int offset = skipWhitespace(json, expectChar(json, start, '{')); private int n = 0; + @Override + public long getExactSizeIfKnown() { + return size(); + } + @Override public boolean hasNext() { return offset < json.length && json[offset] != '}'; @@ -394,47 +384,49 @@ public boolean isMember(@NotNull Text name) { @Override public Streamable paths() { - return keys(path::chain); + return keysSized(path::chain); } @Override public Streamable keys() { - return keys(name -> name); - } - - private Streamable keys(Function toKey) { - return new StreamableAdapter<>( - () -> - new Iterator<>() { - private final char[] json = tree.json; - private int offset = skipWhitespace(json, expectChar(json, start, '{')); - - @Override - public boolean hasNext() { - return offset < json.length && json[offset] != '}'; - } - - @Override - public E next() { - if (!hasNext()) - throw new NoSuchElementException("next() called without checking hasNext()"); - Text name = Chars.unescapeJsonString(json, offset); - offset = expectColon(json, skipString(json, offset)); // move after : - JsonNode member = tree.autoDetect(path.chain(name), offset, Index.ADD); - // move after value - offset = - member == null || member.endIndex() < offset // (duplicates) - ? expectCommaOrEnd(json, skipNodeAutodetect(json, offset), '}') - : expectCommaOrEnd(json, member.endIndex(), '}'); - return toKey.apply(name); - } - }, - this::size, - this::isEmpty); + return keysSized(name -> name); + } + + private Streamable.Sized keysSized(Function toKey) { + if (isEmpty()) return Streamable.empty(); + return new Streamable.Sized<>() { + private final char[] json = tree.json; + private int offset = skipWhitespace(json, expectChar(json, start, '{')); + + @Override + public long getExactSizeIfKnown() { + return size(); + } + + @Override + public boolean hasNext() { + return offset < json.length && json[offset] != '}'; + } + + @Override + public E next() { + if (!hasNext()) + throw new NoSuchElementException("next() called without checking hasNext()"); + Text name = Chars.unescapeJsonString(json, offset); + offset = expectColon(json, skipString(json, offset)); // move after : + JsonNode member = tree.autoDetect(path.chain(name), offset, Index.ADD); + // move after value + offset = + member == null || member.endIndex() < offset // (duplicates) + ? expectCommaOrEnd(json, skipNodeAutodetect(json, offset), '}') + : expectCommaOrEnd(json, member.endIndex(), '}'); + return toKey.apply(name); + } + }; } } - private static final class LazyJsonArray extends LazyJsonNode implements Streamable { + private static final class LazyJsonArray extends LazyJsonNode implements Iterable { private int size = -1; @@ -447,27 +439,11 @@ public Iterable value() { return this; } - @NotNull - @Override - public Iterator iterator() { - return elementsIterator(Index.AUTO); - } - - @Override - public @NotNull Stream stream() { - return JsonTree.stream(iterator(), size()); - } - @Override public @NotNull JsonNodeType type() { return JsonNodeType.ARRAY; } - @Override - public Streamable elements() { - return this; - } - @Override public JsonNode get(Text name) throws JsonPathException, JsonTreeException { if (name.isTextualInteger()) return element(name.parseInt(), name, false); @@ -581,23 +557,30 @@ private JsonNode element(int index, Text segment, boolean orNull) throws JsonPat } @Override - public Streamable elements(Index index) { - return new StreamableAdapter<>(() -> elementsIterator(index), this::size, this::isEmpty); + public @NotNull Iterator iterator() { + return elements(Index.AUTO).iterator(); } @Override - public Streamable values() { - return new StreamableAdapter<>(this::valuesIterator, this::size, this::isEmpty); + public Streamable elements() { + return elements(Index.AUTO); } - private Iterator elementsIterator(Index op) { - return new Iterator<>() { + @Override + public Streamable elements(Index op) { + if (isEmpty()) return Streamable.empty(); + return new Streamable.Sized<>() { private final char[] json = tree.json; private final Index index = op.resolve(tree.auto); private int offset = skipWhitespace(json, expectChar(json, start, '[')); private int n = 0; + @Override + public long getExactSizeIfKnown() { + return size(); + } + @Override public boolean hasNext() { return offset < json.length && json[offset] != ']'; @@ -616,13 +599,20 @@ public JsonNode next() { }; } - private Iterator valuesIterator() { - return new Iterator<>() { + @Override + public Streamable values() { + if (isEmpty()) return Streamable.empty(); + return new Streamable.Sized<>() { private final char[] json = tree.json; private int offset = skipWhitespace(json, expectChar(json, start, '[')); private int n = 0; + @Override + public long getExactSizeIfKnown() { + return size(); + } + @Override public boolean hasNext() { return offset < json.length && json[offset] != ']'; @@ -1033,61 +1023,4 @@ private static int skipChar(char[] json, int offset, char c) { Stream support */ - private static Stream stream(Iterator iterator, int knownExactSize) { - return StreamSupport.stream(new ItemSpliterator<>(iterator, knownExactSize), false); - } - - private record StreamableAdapter( - Supplier> newIterator, IntSupplier size, BooleanSupplier isEmpty) - implements Streamable { - @Override - public @NotNull Iterator iterator() { - return isEmpty.getAsBoolean() ? Collections.emptyIterator() : newIterator.get(); - } - - @Override - public @NotNull Stream stream() { - return isEmpty.getAsBoolean() ? Stream.empty() : JsonTree.stream(iterator(), size.getAsInt()); - } - } - - private record ItemSpliterator(Iterator iterator, int knownExactSize) - implements Spliterator { - - @Override - public void forEachRemaining(Consumer action) { - iterator.forEachRemaining(action); - } - - @Override - public boolean tryAdvance(Consumer action) { - if (iterator.hasNext()) { - action.accept(iterator.next()); - return true; - } - return false; - } - - @Override - public Spliterator trySplit() { - return null; // not splitting please and thank you... - } - - @Override - public long getExactSizeIfKnown() { - return knownExactSize; - } - - @Override - public long estimateSize() { - // This is not accounting for being called after some items have been consumed - // but the JDK Iterator wrappers don't either, so it got to be fine anyhow - return knownExactSize; - } - - @Override - public int characteristics() { - return ORDERED | SIZED | NONNULL | IMMUTABLE; - } - } } diff --git a/src/main/java/org/hisp/dhis/jsontree/Streamable.java b/src/main/java/org/hisp/dhis/jsontree/Streamable.java index e3402d8..c2eff69 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Streamable.java +++ b/src/main/java/org/hisp/dhis/jsontree/Streamable.java @@ -1,6 +1,13 @@ package org.hisp.dhis.jsontree; +import static java.util.Collections.emptyIterator; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.Spliterator; +import java.util.function.Consumer; import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.hisp.dhis.jsontree.internal.NotNull; /** @@ -21,6 +28,95 @@ */ public interface Streamable extends Iterable { + @SuppressWarnings("unchecked") + static Sized empty() { + return (Sized) EMPTY; + } + @NotNull Stream stream(); + + /** + * The adapter API for sized immutable sources. + * + *

It allows to implement {@link Iterator}-based iteration and {@link Stream}-based consumption + * of data sources by implementing {@link Iterator#hasNext()}, {@link Iterator#next()} and {@link + * Spliterator#getExactSizeIfKnown()} without causing any unnecessary indirections or allocation + * of scaffolding objects. Thereby a highly convenient API is provided without extra costs while + * keeping the implementation of subclasses straight forward. + * + * @author Jan Bernitt + */ + interface Sized extends Iterator, Spliterator, Streamable { + + // override to make it abstract again + @Override + long getExactSizeIfKnown(); + + @Override + default @NotNull Stream stream() { + return StreamSupport.stream(this, false); + } + + @Override + default @NotNull Iterator iterator() { + return this; + } + + @Override + default void forEachRemaining(Consumer action) { + while (hasNext()) action.accept(next()); + } + + @Override + default boolean tryAdvance(Consumer action) { + if (hasNext()) { + action.accept(next()); + return true; + } + return false; + } + + default Spliterator trySplit() { + return null; // not splitting please and thank you... + } + + default long estimateSize() { + // This is not accounting for being called after some items have been consumed + // but the JDK Iterator wrappers don't either, so it got to be fine anyhow + return getExactSizeIfKnown(); + } + + @Override + default int characteristics() { + return ORDERED | SIZED | NONNULL | IMMUTABLE; + } + } + + Sized EMPTY = new Sized<>() { + @Override + public boolean hasNext() { + return false; + } + + @Override + public Object next() { + throw new NoSuchElementException("Empty does not have a next() element"); + } + + @Override + public @NotNull Stream stream() { + return Stream.empty(); + } + + @Override + public @NotNull Iterator iterator() { + return emptyIterator(); + } + + @Override + public long getExactSizeIfKnown() { + return 0L; + } + }; } diff --git a/src/main/java/org/hisp/dhis/jsontree/Text.java b/src/main/java/org/hisp/dhis/jsontree/Text.java index 81fc133..9405a9e 100644 --- a/src/main/java/org/hisp/dhis/jsontree/Text.java +++ b/src/main/java/org/hisp/dhis/jsontree/Text.java @@ -256,6 +256,19 @@ static boolean isTrim(char ch) { return ch == ' ' || ch == '\t'; } + /** + * @see String#concat(String) + */ + default Text concat(CharSequence suffix) { + if (suffix.isEmpty()) return this; + char[] a = toCharArray(); + int len = suffix.length(); + char[] res = Arrays.copyOf(a, a.length+ len); + for (int i = 0; i < len; i++) + res[a.length+i] = suffix.charAt(i); + return of(res, 0, res.length); + } + /** * 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, @@ -557,6 +570,20 @@ public boolean contentMemoryEquals(@NotNull Text other) { return other instanceof Slice s && s.buffer == buffer; } + @Override + public Text concat(CharSequence suffix) { + if (isEmpty()) return of(suffix); + if (suffix.isEmpty()) return this; + char[] res = new char[length + suffix.length()]; + System.arraycopy(buffer, offset, res, 0, length); + if (suffix instanceof Slice b) { + System.arraycopy(b.buffer, b.offset, res, length, b.length); + } else { + for (int i = 0; i < suffix.length(); i++) res[length + i] = suffix.charAt(i); + } + return of(res, 0, res.length); + } + @Override public char[] toCharArray() { return Arrays.copyOfRange(buffer, offset, offset + length); diff --git a/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java b/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java index c68b64a..18ccd73 100644 --- a/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java +++ b/src/main/java/org/hisp/dhis/jsontree/TextualNumber.java @@ -28,11 +28,24 @@ public static Number of(char @NotNull [] buffer) { return of(buffer, 0, buffer.length); } + /** + * Does not allow whitespace at start/end of the number. + * + * @param buffer with content of an integer or decimal number + * @param offset start of the number in the buffer + * @param length number of characters from offset that represent the number + * @return buffer as a {@link Number} + * @throws NumberFormatException in case the buffer does not contain a valid number or if offset + * or length do not form a valid slice of characters for the given buffer + */ public static Number of(char @NotNull [] buffer, int offset, int length) { if (length < 0) throw nanError(buffer, offset, length, "length must be >= 0"); if (offset + length > buffer.length) throw new NumberFormatException("offset + length must be <= buffer.length"); if (isTextualInteger(buffer, offset, length)) { + // preserve -0 as distinct from 0 by using a double + if (buffer[offset] == '-' && isNumericZero(buffer, offset, length)) + return -0d; // up to 9 digits is always in int range if (length <= 9) return parseIntExact(buffer, offset, length); // will overflow for sure @@ -46,8 +59,8 @@ public static Number of(char @NotNull [] buffer, int offset, int length) { return new TextualNumber(buffer, offset, length); } } - if (!isTextualDecimal(buffer, offset, length)) - throw nanError(buffer, offset, length, "Is neither a integer or decimal number"); + if (!isTextualDecimal(buffer, offset, length) && !Text.of(buffer, offset, length).isSpecialDecimal()) + throw nanError(buffer, offset, length, "Is neither an integer nor a decimal number: "); return new TextualNumber(buffer, offset, length); } @@ -148,6 +161,20 @@ static boolean isTextualInteger(char[] buffer, int offset, int length) { return true; } + static boolean isNumericZero(char[] buffer, int offset, int length) { + if (length <= 0) return false; + char sign = buffer[offset]; + int i = offset; + if (sign == '-' || sign == '+') i++; + int end = offset + length; + int d0 = i; + while (i < end && buffer[i] == '0') i++; + if (i >= end) return i > d0; + if (buffer[i++] != '.') return false; + while (i < end && buffer[i] == '0') i++; + return i == end; + } + static boolean isNumericInteger(char[] buffer) { return isNumericInteger(buffer, 0, buffer.length); } @@ -197,7 +224,7 @@ static int parseIntCast(char[] buffer, int offset, int length) { if (offsetExponent(buffer, offset, length) < 0) { int dpOffset = offsetDecimalPoint(buffer, offset, length); if (dpOffset > 0) - return parseIntCast(buffer, offset, length - (dpOffset - offset)); + return parseIntCast(buffer, offset, dpOffset - offset); } return (int) parseDouble(buffer, offset, length); } @@ -217,7 +244,7 @@ static long parseLongCast(char[] buffer, int offset, int length) { if (offsetExponent(buffer, offset, length) < 0) { int dpOffset = offsetDecimalPoint(buffer, offset, length); if (dpOffset > 0) - return parseLongCast(buffer, offset, length - (dpOffset - offset)); + return parseLongCast(buffer, offset, dpOffset - offset); } return (long) parseDouble(buffer, offset, length); } diff --git a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java index 8988321..ca29090 100644 --- a/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/InputExpressionTest.java @@ -50,14 +50,14 @@ void testRepeat_ZeroTimesMatchesEndOfInput() { @Test void testScan() { assertMatches("He~o", "Hello", "Hero", "Heo"); - assertEquals(new Pattern(Text.of("He~o"), 3, -1), Pattern.of("He~o")); + assertEquals(new Pattern(0, Text.of("He~o"), 3, -1), Pattern.of(0,"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")); + assertEquals(new Pattern(0, Text.of("He~~lo"), 3, -1), Pattern.of(0,"He~~lo")); } @Test @@ -65,7 +65,7 @@ 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)); + assertEquals(new Pattern(0, Text.of(pattern), 9, -1), Pattern.of(0, pattern)); } @Test diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java index 2c7f2ec..e3c369a 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonObjectTest.java @@ -7,6 +7,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; + +import org.hisp.dhis.jsontree.JsonNode.Index; import org.junit.jupiter.api.Test; /** @@ -19,9 +21,31 @@ class JsonObjectTest { @Test void testSize() { assertEquals(0, Json5.of("{}").size()); - assertEquals(1, Json5.of("{\"a\": 1}").size()); - assertEquals(2, Json5.of("{\"a\": 1, \"b\": 2}").size()); - assertEquals(3, Json5.of("{\"a\": 1, \"b\": 2, \"c\": null}").size()); + assertEquals(1, Json5.of("{'a': 1}").size()); + assertEquals(2, Json5.of("{'a': 1, 'b': 2}").size()); + assertEquals(3, Json5.of("{'a': 1, 'b': 2, 'c': null}").size()); + // duplicate keys count for size + assertEquals(3, Json5.of("{'a': 1, 'b': 2, 'a': 2}").size()); + } + + @Test + void testMembers_Duplicates() { + JsonMixed duplicates = Json5.of("{'a': 1, 'b': 2, 'a': 3}"); + // with CHECK nodes do not get indexes and so duplicates are observed + assertEquals( + List.of(1, 2, 3), + duplicates.entries(Index.CHECK).map(JsonMixed::intValue).toList()); + assertEquals( + List.of(1, 2, 1), + duplicates.entries(Index.ADD).map(JsonMixed::intValue).toList()); + // now that they are indexes even CHECK de-duplicates + assertEquals( + List.of(1, 2, 1), + duplicates.entries(Index.CHECK).map(JsonMixed::intValue).toList()); + // but if we skip duplicates are observed again + assertEquals( + List.of(1, 2, 3), + duplicates.entries(Index.SKIP).map(JsonMixed::intValue).toList()); } @Test @@ -51,6 +75,13 @@ void testNames_Empty() { assertEquals(List.of("a", "b"), Json5.of("{'a':1,'b':2}").names()); } + + @Test + void testNames_Duplicates() { + assertEquals(List.of(), JsonMixed.of("{}").names()); + assertEquals(List.of("a", "b", "a"), Json5.of("{'a':1,'b':2, 'a': 3}").names()); + } + @Test void testNames_Special() { JsonMixed value = Json5.of("{'.':1,'{uid}':2,'[0]': 3, '': 4}"); diff --git a/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java index ea7d4f6..be8b8e0 100644 --- a/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/JsonValueToTest.java @@ -20,7 +20,6 @@ class JsonValueToTest { public record ExampleParams( - @Validation int number, Integer optionalNumber, String string, diff --git a/src/test/java/org/hisp/dhis/jsontree/TextTest.java b/src/test/java/org/hisp/dhis/jsontree/TextTest.java index 1be109f..c21b595 100644 --- a/src/test/java/org/hisp/dhis/jsontree/TextTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/TextTest.java @@ -251,6 +251,19 @@ void testTrim() { assertEquals(Text.of(""), Text.of(" ").trim()); } + @DisplayName("concat()") + @Test + void testConcat() { + Text hello = Text.of("hello"); + assertSame(hello, hello.concat("")); + assertSame(hello, hello.concat(Text.of(""))); + assertEquals(hello, Text.of("").concat("hello")); + assertSame(hello, Text.of("").concat(hello)); + + assertEquals(Text.of("hello world!"), Text.of("hello").concat(" world!")); + assertEquals(Text.of("hello world!"), Text.of("hello").concat(Text.of(" world!"))); + } + @DisplayName("slice(Text, Consumer)") @Test void testSlice_Pattern() { diff --git a/src/test/java/org/hisp/dhis/jsontree/TextualNumberTest.java b/src/test/java/org/hisp/dhis/jsontree/TextualNumberTest.java index 796b8a1..117025f 100644 --- a/src/test/java/org/hisp/dhis/jsontree/TextualNumberTest.java +++ b/src/test/java/org/hisp/dhis/jsontree/TextualNumberTest.java @@ -1,7 +1,9 @@ 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.assertThrowsExactly; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import java.util.Random; @@ -40,7 +42,14 @@ void testParseDouble_RandomIntegers() { StringBuilder num = new StringBuilder(); appendInteger(num); - assertDoubleEquals(num.toString()); + String number = num.toString(); + assertDoubleEquals(number); + assertLongEquals(number); + assertLongCastEquals(number); + if (number.length() < 10) { + assertIntEquals(number); + assertIntCastEquals(number); + } } } @@ -100,6 +109,7 @@ void testParseDouble_EdgeCases() { 123.0 -123.0 123.00 0.0 0.00 // Boundary of fast‑path (15 significant digits) + -123456789012345 // 15 digits 123456789012345 // 15 digits 123456789012345.0 // still 15 significant 123456789012345e0 // 15 significant @@ -170,6 +180,13 @@ void testParseInt_EdgeCases() { assertIntEquals("0000000000000000000000000000000000000000000000000000000000"); } + @Test + void testParseInt_Cast() { + assertIntCastEquals("100.0"); + assertIntCastEquals("123456.7890"); + assertIntCastEquals("-0.7890"); + } + @Test void testParseInt_EdgeCasesThrows() { assertIntThrows("02147483647.0"); // max with leading zero + 0 fraction @@ -192,6 +209,14 @@ void testParseLong_EdgeCases() { assertLongEquals("0000000000000000000000000000000000000000000000000000000000"); } + + @Test + void testParseLong_Cast() { + assertLongCastEquals("100.0"); + assertLongCastEquals("123456.7890"); + assertLongCastEquals("-0.7890"); + } + @Test void testParseLong_EdgeCasesThrows() { assertLongThrows("09223372036854775807.0"); // max with leading zero + 0 fraction @@ -205,6 +230,35 @@ void testParseLong_EdgeCasesThrows() { assertLongThrows("9223372036854775808", ArithmeticException.class); // a too large number not pre-identified } + @Test + void testIsNumericZero() { + assertNumericZero("0"); + assertNumericZero("+0"); + assertNumericZero("-0"); + assertNumericZero("0."); + assertNumericZero("0.0"); + assertNumericZero("-0.0"); + assertNumericZero("00.00"); + assertNumericZero("-00.00"); + + assertNotNumericZero("1"); + assertNotNumericZero("0.1"); + assertNotNumericZero("0.0001"); + assertNotNumericZero("0.000100"); + assertNotNumericZero("-1"); + assertNotNumericZero("-0.1"); + assertNotNumericZero("-0.00010"); + assertNotNumericZero("hello"); + } + + private static void assertNumericZero(String value) { + assertTrue(TextualNumber.isNumericZero(value.toCharArray(), 0, value.length())); + } + + private static void assertNotNumericZero(String value) { + assertFalse(TextualNumber.isNumericZero(value.toCharArray(), 0, value.length())); + } + private static void assertDoubleThrows(String number) { assertThrowsExactly( NumberFormatException.class, @@ -213,21 +267,26 @@ private static void assertDoubleThrows(String number) { assertThrowsExactly( NumberFormatException.class, () -> TextualNumber.parseDoubleCast(number.toCharArray()), - "Should throw for: " + number); + "parseDoubleCast() should throw for: " + number); + assertThrowsExactly(NumberFormatException.class, + () -> TextualNumber.of(number.toCharArray()), + "TextualNumber.of() should throw for: "+number); } private static void assertDoubleEquals(String number) { - assertDoubleEqualsExact(number); - assertDoubleEqualsExact(number + " "); - assertDoubleEqualsExact(" " + number); - assertDoubleEqualsExact(" " + number + " "); + assertDoubleEqualsNoWS(number); + assertDoubleEqualsNoWS(number + " "); + assertDoubleEqualsNoWS(" " + number); + assertDoubleEqualsNoWS(" " + number + " "); } - private static void assertDoubleEqualsExact(String number) { + private static void assertDoubleEqualsNoWS(String number) { double expected = Double.parseDouble(number); try { double actual = TextualNumber.parseDoubleCast(number.toCharArray(), 0, number.length()); assertEquals(expected, actual, "Failed for: " + number); + Number actualNumber = TextualNumber.of(number.trim().toCharArray()); + assertEquals(expected, actualNumber.doubleValue(), "Failed TextualNumber.of().doubleValue() for: "+number); } catch (NumberFormatException ex) { fail("Number valid for Double.parseDouble was rejected: " + number.replace(' ', '_'), ex); } @@ -237,12 +296,28 @@ private static void assertLongEquals(String number) { long expected = Long.parseLong(number); try { long actual = TextualNumber.parseLongExact(number.toCharArray(), 0, number.length()); - assertEquals(expected, actual, "Failed for: " + number); + assertEquals(expected, actual, "Failed parseLongExact() for: " + number); + long actualCast = TextualNumber.parseLongCast(number.toCharArray(), 0, number.length()); + assertEquals(expected, actualCast, "Failed parseLongCast() for: " + number); + Number actualNumber = TextualNumber.of(number.trim().toCharArray()); + assertEquals(expected, actualNumber.longValue(), "Failed TextualNumber.of().longValue() for: "+number); } catch (NumberFormatException ex) { fail("Number valid for Long.parseLong was rejected: " + number.replace(' ', '_'), ex); } } + private static void assertLongCastEquals(String number) { + long expected = (long) Double.parseDouble(number); + try { + long actual = TextualNumber.parseLongCast(number.toCharArray(), 0, number.length()); + assertEquals(expected, actual, "Failed parseLongCast() for: "+number); + Number actualNumber = TextualNumber.of(number.trim().toCharArray()); + assertEquals(expected, actualNumber.longValue(), "Failed TextualNumber.of().longValue() for: "+number); + } catch (NumberFormatException ex) { + fail("Number valid for Double.parseDouble was rejected: " + number.replace(' ', '_'), ex); + } + } + private static void assertLongThrows(String number) { assertLongThrows(number, NumberFormatException.class); } @@ -262,11 +337,26 @@ private static void assertIntEquals(String number) { int expected = Integer.parseInt(number); try { int actual = TextualNumber.parseIntExact(number.toCharArray(), 0, number.length()); - assertEquals(expected, actual, "Failed for: " + number); + assertEquals(expected, actual, "Failed parseIntExact() for: " + number); + int actualCast = TextualNumber.parseIntCast(number.toCharArray(), 0, number.length()); + assertEquals(expected, actualCast, "Failed parseIntCast() for: " + number); + Number actualNumber = TextualNumber.of(number.toCharArray()); + assertEquals(expected, actualNumber.intValue(), "Failed TextualNumber.of().intValue() for: "+number); } catch (NumberFormatException ex) { fail("Number valid for Integer.parseInt was rejected: " + number.replace(' ', '_'), ex); } } + + private static void assertIntCastEquals(String number) { + int expected = (int) Double.parseDouble(number); + try { + int actual = TextualNumber.parseIntCast(number.toCharArray(), 0, number.length()); + assertEquals(expected, actual, "Failed parseIntCast() for: "+number); + } catch (NumberFormatException ex) { + fail("Number valid for Double.parseDouble was rejected: " + number.replace(' ', '_'), ex); + } + } + private static void assertIntThrows(String number) { assertIntThrows(number, NumberFormatException.class); }