diff --git a/PLAN_121.md b/PLAN_121.md new file mode 100644 index 0000000..4cee111 --- /dev/null +++ b/PLAN_121.md @@ -0,0 +1,116 @@ +## Issue #121: Add JsonPath module with AST parser and evaluator + +### Goal + +Add a new Maven module that implements **JsonPath** as described by Stefan Goessner: +`https://goessner.net/articles/JsonPath/index.html`. + +This module must: + +- Parse a JsonPath string into a **custom AST** +- Evaluate that AST against a JSON document already parsed by this repo’s + `jdk.sandbox.java.util.json` API +- Have **no runtime dependencies** outside `java.base` (and the existing `json-java21` module) +- Target **Java 21** and follow functional / data-oriented style (records + sealed interfaces) + +### Non-goals + +- Parsing JSON documents from strings (the core `Json.parse(...)` already does that) +- Adding non-trivial external dependencies (no regex engines beyond JDK, no parser generators) +- Supporting every JsonPath dialect ever published; the baseline is the article examples + +### Public API shape (module `json-java21-jsonpath`) + +Package: `json.java21.jsonpath` + +- `JsonPathExpression JsonPath.parse(String path)` + - Parses a JsonPath string into an AST-backed compiled expression. +- `List JsonPathExpression.select(JsonValue document)` + - Evaluates the expression against a parsed JSON document and returns matched nodes in traversal order. + +Notes: +- The return type is a `List` to avoid introducing a JSON encoding of “result sets”. + Callers can wrap it into `JsonArray.of(...)` if desired. + +### AST plan + +Use a sealed interface with records (no inheritance trees with stateful objects): + +- `sealed interface PathNode permits Root, StepChain` +- `record Root() implements PathNode` +- `record StepChain(PathNode base, Step step) implements PathNode` + +Steps are a separate sealed protocol: + +- `sealed interface Step permits Child, RecursiveDescent, Wildcard, ArrayIndex, ArraySlice, Union, Filter` +- `record Child(Name name)` where `Name` is either identifier or quoted key +- `record RecursiveDescent(Step selector)` where selector is `Child` or `Wildcard` +- `record Wildcard()` +- `record ArrayIndex(int index)` supports negative indices per examples +- `record ArraySlice(Integer start, Integer end, Integer step)` to cover `[:2]`, `[-1:]`, etc. +- `record Union(List selectors)` for `[0,1]`, `['a','b']` +- `record Filter(PredicateExpr expr)` for `[?(...)]` + +Filter expressions: + +- Keep a minimal expression AST that supports the article examples: + - `@.field` access + - `@.length` pseudo-property for array length + - numeric literals + - string literals + - comparison operators: `<`, `<=`, `>`, `>=`, `==`, `!=` + - arithmetic: `+`, `-` (only what’s needed for `(@.length-1)`) + +### Parser plan + +Hand-rolled scanner + recursive descent parser: + +- Lex JsonPath into tokens (`$`, `.`, `..`, `[`, `]`, `*`, `,`, `:`, `?(`, `)`, identifiers, quoted strings, numbers, operators). +- Parse according to the article grammar: + - Root `$` must appear first + - Dot-notation steps: `.name`, `.*`, `..name`, `..*` + - Bracket steps: + - `['name']` + - `[0]`, `[-1]` + - `[0,1]`, `['a','b']` + - `[:2]`, `[2:]`, `[-1:]` + - `[?(...)]` + - `[(...)]` for script expressions used as array index in examples (limited support) + +### Evaluator plan + +Evaluator is a pure function over immutable inputs, implemented as static methods: + +- Maintain a worklist of “current nodes” (starting with the document root). +- For each step: + - **Child**: for objects, pick member by key; for arrays, apply to each element if they’re objects (per example behavior). + - **RecursiveDescent**: walk the subtree of each current node (object members + array elements) and apply the selector to every node encountered. + - **Wildcard**: for objects select all member values; for arrays select all elements. + - **ArrayIndex / Slice / Union**: apply only when the current node is an array. + - **Filter**: apply only when current node is an array; keep elements where predicate is true. + +Ordering: +- Preserve traversal order implied by iterating object members (`JsonObject.members()` is order-preserving) and array elements order. + +### Tests (TDD baseline) + +Add tests that correspond 1:1 with every example on the article page: + +- Use the article’s sample document (embedded as a Java text block) and parse with `Json.parse(...)`. +- Assertions check matched values by rendering to JSON (`JsonValue.toString()`) and comparing to expected fragments. +- Every test method logs an INFO banner at start (common base class). + +### Verification + +Run focused module tests with logging: + +```bash +$(command -v mvnd || command -v mvn || command -v ./mvnw) -pl json-java21-jsonpath test -Djava.util.logging.ConsoleHandler.level=FINE +``` + +Run full suite once stable: + +```bash +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -Djava.util.logging.ConsoleHandler.level=INFO +``` + diff --git a/README.md b/README.md index 0e9328b..63060f3 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,29 @@ This repo contains an incubating JTD validator that has the core JSON API as its A complete JSON Type Definition validator is included (module: json-java21-jtd). +## JsonPath (AST + evaluator over `java.util.json`) + +This repo also includes (in progress) a **JsonPath** module based on Stefan Goessner’s article: +`https://goessner.net/articles/JsonPath/index.html`. + +Design goals: +- **No runtime deps beyond `java.base`** (and the core `json-java21` module) +- **Parse JsonPath strings to a custom AST** +- **Evaluate against already-parsed JSON** (`JsonValue`), not against JSON text +- Pure Java 21, functional/data-oriented style (records + sealed interfaces) +- Unit tests mirror the article examples + +Planned public API (module: `json-java21-jsonpath`): + +```java +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jsonpath.JsonPath; + +JsonValue doc = /* Json.parse(...) from json-java21 */; +var expr = JsonPath.parse("$.store.book[*].author"); +var matches = expr.select(doc); // List +``` + ### Empty Schema `{}` Semantics (RFC 8927) Per **RFC 8927 (JSON Typedef)**, the empty schema `{}` is the **empty form** and diff --git a/json-java21-jsonpath/README.md b/json-java21-jsonpath/README.md new file mode 100644 index 0000000..ddb4b9a --- /dev/null +++ b/json-java21-jsonpath/README.md @@ -0,0 +1,22 @@ +# JsonPath (Goessner) for `java.util.json` (Java 21) + +This module implements **JsonPath** as described by Stefan Goessner: +`https://goessner.net/articles/JsonPath/index.html`. + +Design constraints: +- Evaluates over already-parsed JSON (`jdk.sandbox.java.util.json.JsonValue`) +- Parses JsonPath strings into a custom AST +- No runtime dependencies outside `java.base` (and the core `java.util.json` backport) + +## Usage (planned API) + +```java +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jsonpath.JsonPath; + +JsonValue doc = Json.parse("{\"a\": {\"b\": [1,2,3]}}"); +var expr = JsonPath.parse("$.a.b[0]"); +var matches = expr.select(doc); +``` + diff --git a/json-java21-jsonpath/pom.xml b/json-java21-jsonpath/pom.xml new file mode 100644 index 0000000..b6463d9 --- /dev/null +++ b/json-java21-jsonpath/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + io.github.simbo1905.json + parent + 0.1.9 + + + java.util.json.jsonpath + jar + java.util.json Java21 Backport JsonPath + https://simbo1905.github.io/java.util.json.Java21/ + + scm:git:https://github.com/simbo1905/java.util.json.Java21.git + scm:git:git@github.com:simbo1905/java.util.json.Java21.git + https://github.com/simbo1905/java.util.json.Java21 + HEAD + + Experimental JsonPath (Goessner) parser to AST and evaluator over the java.util.json Java 21 backport. + + + UTF-8 + 21 + + + + + io.github.simbo1905.json + java.util.json + ${project.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 21 + + -Xlint:all + -Werror + -Xdiags:verbose + + + + + + + diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/BoolExpr.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/BoolExpr.java new file mode 100644 index 0000000..0eb129e --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/BoolExpr.java @@ -0,0 +1,24 @@ +package json.java21.jsonpath; + +import java.util.Objects; + +sealed interface BoolExpr permits BoolExpr.Exists, BoolExpr.Compare { + record Exists(ValueExpr.Path path) implements BoolExpr { + public Exists { + Objects.requireNonNull(path, "path must not be null"); + } + } + + enum CmpOp { + LT, LE, GT, GE, EQ, NE + } + + record Compare(CmpOp op, ValueExpr left, ValueExpr right) implements BoolExpr { + public Compare { + Objects.requireNonNull(op, "op must not be null"); + Objects.requireNonNull(left, "left must not be null"); + Objects.requireNonNull(right, "right must not be null"); + } + } +} + diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java new file mode 100644 index 0000000..92404fa --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java @@ -0,0 +1,15 @@ +package json.java21.jsonpath; + +import java.util.Objects; + +/// JsonPath entry point: parse to an AST-backed expression. +public final class JsonPath { + + public static JsonPathExpression parse(String path) { + Objects.requireNonNull(path, "path must not be null"); + return new JsonPathExpression(JsonPathParser.parse(path)); + } + + private JsonPath() {} +} + diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java new file mode 100644 index 0000000..59fa9c7 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java @@ -0,0 +1,79 @@ +package json.java21.jsonpath; + +import java.util.List; +import java.util.Objects; + +record JsonPathAst(List steps) { + JsonPathAst { + Objects.requireNonNull(steps, "steps must not be null"); + steps.forEach(s -> Objects.requireNonNull(s, "step must not be null")); + } + + sealed interface Step permits + Step.Child, + Step.Wildcard, + Step.RecursiveDescent, + Step.ArrayIndex, + Step.ArraySlice, + Step.Union, + Step.Filter, + Step.ScriptIndex { + + record Child(String name) implements Step { + public Child { + Objects.requireNonNull(name, "name must not be null"); + } + } + + record Wildcard() implements Step {} + + record RecursiveDescent(RecursiveSelector selector) implements Step { + public RecursiveDescent { + Objects.requireNonNull(selector, "selector must not be null"); + } + } + + sealed interface RecursiveSelector permits RecursiveSelector.Name, RecursiveSelector.Wildcard { + record Name(String name) implements RecursiveSelector { + public Name { + Objects.requireNonNull(name, "name must not be null"); + } + } + + record Wildcard() implements RecursiveSelector {} + } + + record ArrayIndex(int index) implements Step {} + + record ArraySlice(Integer startInclusive, Integer endExclusive) implements Step {} + + record Union(List selectors) implements Step { + public Union { + Objects.requireNonNull(selectors, "selectors must not be null"); + selectors.forEach(s -> Objects.requireNonNull(s, "selector must not be null")); + } + } + + sealed interface Selector permits Selector.Name, Selector.Index { + record Name(String name) implements Selector { + public Name { + Objects.requireNonNull(name, "name must not be null"); + } + } + + record Index(int index) implements Selector {} + } + + record Filter(BoolExpr predicate) implements Step { + public Filter { + Objects.requireNonNull(predicate, "predicate must not be null"); + } + } + + record ScriptIndex(ValueExpr expr) implements Step { + public ScriptIndex { + Objects.requireNonNull(expr, "expr must not be null"); + } + } + } +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathEvaluator.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathEvaluator.java new file mode 100644 index 0000000..39c899f --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathEvaluator.java @@ -0,0 +1,349 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonNull; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonNumber; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +import static json.java21.jsonpath.BoolExpr.CmpOp; +import static json.java21.jsonpath.JsonPathAst.Step; + +sealed interface JsonPathEvaluator permits JsonPathEvaluator.Impl { + + static List select(JsonPathAst ast, JsonValue document) { + return Impl.select(ast, document); + } + + final class Impl implements JsonPathEvaluator { + static List select(JsonPathAst ast, JsonValue document) { + var current = List.of(document); + for (final var step : ast.steps()) { + current = apply(step, current); + } + return current; + } + + private static List apply(Step step, List current) { + return switch (step) { + case Step.Child child -> selectChild(current, child.name()); + case Step.Wildcard ignored -> selectWildcard(current); + case Step.RecursiveDescent rd -> selectRecursiveDescent(current, rd); + case Step.ArrayIndex idx -> selectArrayIndex(current, idx.index()); + case Step.ArraySlice slice -> selectArraySlice(current, slice.startInclusive(), slice.endExclusive()); + case Step.Union union -> selectUnion(current, union.selectors()); + case Step.Filter filter -> selectFilter(current, filter.predicate()); + case Step.ScriptIndex script -> selectScriptIndex(current, script.expr()); + }; + } + + private static List selectChild(List current, String name) { + final var out = new ArrayList(); + for (final var node : current) { + switch (node) { + case JsonObject obj -> { + final var val = obj.members().get(name); + if (val != null) out.add(val); + } + case JsonArray arr -> arr.elements().forEach(v -> { + if (v instanceof JsonObject o) { + final var val = o.members().get(name); + if (val != null) out.add(val); + } + }); + default -> { + } + } + } + return List.copyOf(out); + } + + private static List selectWildcard(List current) { + final var out = new ArrayList(); + for (final var node : current) { + switch (node) { + case JsonObject obj -> out.addAll(obj.members().values()); + case JsonArray arr -> out.addAll(arr.elements()); + default -> { + } + } + } + return List.copyOf(out); + } + + private static List selectRecursiveDescent(List current, Step.RecursiveDescent rd) { + final var out = new ArrayList(); + for (final var node : current) { + final Deque stack = new ArrayDeque<>(); + stack.push(node); + + while (!stack.isEmpty()) { + final var v = stack.pop(); + out.addAll(applyRecursiveSelector(rd.selector(), v)); + pushChildrenReversed(stack, v); + } + } + return List.copyOf(out); + } + + private static List applyRecursiveSelector(Step.RecursiveSelector selector, JsonValue node) { + return switch (selector) { + case Step.RecursiveSelector.Name name -> { + if (node instanceof JsonObject obj) { + final var val = obj.members().get(name.name()); + if (val == null) yield List.of(); + yield List.of(val); + } + yield List.of(); + } + case Step.RecursiveSelector.Wildcard ignored -> { + if (node instanceof JsonObject obj) { + yield List.copyOf(obj.members().values()); + } + if (node instanceof JsonArray arr) { + yield arr.elements(); + } + yield List.of(); + } + }; + } + + private static void pushChildrenReversed(Deque stack, JsonValue node) { + switch (node) { + case JsonObject obj -> { + final var values = obj.members().values().toArray(JsonValue[]::new); + for (int i = values.length - 1; i >= 0; i--) { + stack.push(values[i]); + } + } + case JsonArray arr -> { + final var values = arr.elements(); + for (int i = values.size() - 1; i >= 0; i--) { + stack.push(values.get(i)); + } + } + default -> { + } + } + } + + private static List selectArrayIndex(List current, int index) { + final var out = new ArrayList(); + for (final var node : current) { + if (node instanceof JsonArray arr) { + final int idx = normalizeIndex(index, arr.elements().size()); + if (idx >= 0 && idx < arr.elements().size()) { + out.add(arr.elements().get(idx)); + } + } + } + return List.copyOf(out); + } + + private static List selectArraySlice(List current, Integer startInclusive, Integer endExclusive) { + final var out = new ArrayList(); + for (final var node : current) { + if (node instanceof JsonArray arr) { + final int len = arr.elements().size(); + int start = startInclusive == null ? 0 : normalizeIndex(startInclusive, len); + int end = endExclusive == null ? len : normalizeIndex(endExclusive, len); + + if (start < 0) start = 0; + if (end < 0) end = 0; + if (start > len) start = len; + if (end > len) end = len; + + for (int i = start; i < end; i++) { + out.add(arr.elements().get(i)); + } + } + } + return List.copyOf(out); + } + + private static int normalizeIndex(int index, int len) { + return index < 0 ? len + index : index; + } + + private static List selectUnion(List current, List selectors) { + final var out = new ArrayList(); + for (final var node : current) { + switch (node) { + case JsonObject obj -> selectors.forEach(sel -> { + if (sel instanceof Step.Selector.Name n) { + final var v = obj.members().get(n.name()); + if (v != null) out.add(v); + } + }); + case JsonArray arr -> selectors.forEach(sel -> { + if (sel instanceof Step.Selector.Index idx) { + final int i = normalizeIndex(idx.index(), arr.elements().size()); + if (i >= 0 && i < arr.elements().size()) { + out.add(arr.elements().get(i)); + } + } + }); + default -> { + } + } + } + return List.copyOf(out); + } + + private static List selectFilter(List current, BoolExpr predicate) { + final var out = new ArrayList(); + for (final var node : current) { + if (node instanceof JsonArray arr) { + for (final var elem : arr.elements()) { + if (evalPredicate(predicate, elem)) { + out.add(elem); + } + } + } + } + return List.copyOf(out); + } + + private static List selectScriptIndex(List current, ValueExpr expr) { + final var out = new ArrayList(); + for (final var node : current) { + if (node instanceof JsonArray arr) { + final var value = evalValue(expr, arr); + final var number = toNumber(value); + if (number == null) continue; + final int idx = normalizeIndex((int) number.doubleValue(), arr.elements().size()); + if (idx >= 0 && idx < arr.elements().size()) { + out.add(arr.elements().get(idx)); + } + } + } + return List.copyOf(out); + } + + private static boolean evalPredicate(BoolExpr predicate, JsonValue at) { + return switch (predicate) { + case BoolExpr.Exists exists -> { + final var value = evalValue(exists.path(), at); + yield switch (value) { + case Value.Missing ignored -> false; + case Value.Json(var jv) -> !(jv instanceof JsonNull); + case Value.Num ignored -> true; + case Value.Str ignored -> true; + }; + } + case BoolExpr.Compare cmp -> evalCompare(cmp.op(), cmp.left(), cmp.right(), at); + }; + } + + private static boolean evalCompare(CmpOp op, ValueExpr left, ValueExpr right, JsonValue at) { + final var lv = evalValue(left, at); + final var rv = evalValue(right, at); + + return switch (op) { + case LT, LE, GT, GE -> { + final var lnum = toNumber(lv); + final var rnum = toNumber(rv); + if (lnum == null || rnum == null) yield false; + final double a = lnum; + final double b = rnum; + yield switch (op) { + case LT -> a < b; + case LE -> a <= b; + case GT -> a > b; + case GE -> a >= b; + default -> throw new IllegalStateException("unexpected op: " + op); + }; + } + case EQ, NE -> { + final boolean eq = equalsValue(lv, rv); + yield op == CmpOp.EQ ? eq : !eq; + } + }; + } + + private static boolean equalsValue(Value a, Value b) { + return switch (a) { + case Value.Missing ignored -> b instanceof Value.Missing; + case Value.Num(var x) -> (b instanceof Value.Num n) && Double.compare(x, n.value()) == 0; + case Value.Str(var x) -> (b instanceof Value.Str s) && x.equals(s.value()); + case Value.Json(var x) -> { + if (b instanceof Value.Json j) { + yield switch (x) { + case JsonString js -> (j.value() instanceof JsonString rhs) && js.string().equals(rhs.string()); + case JsonNumber jn -> (j.value() instanceof JsonNumber rhs) && Double.compare(jn.toDouble(), rhs.toDouble()) == 0; + default -> x.equals(j.value()); + }; + } + yield false; + } + }; + } + + private static Value evalValue(ValueExpr expr, JsonValue at) { + return switch (expr) { + case ValueExpr.Num n -> new Value.Num(n.value()); + case ValueExpr.Str s -> new Value.Str(s.value()); + case ValueExpr.Path p -> evalPath(p.parts(), at); + case ValueExpr.Arith arith -> { + final var l = toNumber(evalValue(arith.left(), at)); + final var r = toNumber(evalValue(arith.right(), at)); + if (l == null || r == null) yield new Value.Missing(); + yield new Value.Num(arith.op() == ValueExpr.ArithOp.ADD ? l + r : l - r); + } + }; + } + + private static Value evalPath(List parts, JsonValue at) { + if (parts.size() == 1 && "length".equals(parts.getFirst()) && at instanceof JsonArray arr) { + return new Value.Num(arr.elements().size()); + } + JsonValue cur = at; + for (final var part : parts) { + if (cur instanceof JsonObject obj) { + final var next = obj.members().get(part); + if (next == null) return new Value.Missing(); + cur = next; + } else { + return new Value.Missing(); + } + } + return new Value.Json(cur); + } + + private static Double toNumber(Value v) { + return switch (v) { + case Value.Num n -> n.value(); + case Value.Str ignored -> null; + case Value.Missing ignored -> null; + case Value.Json(var jv) -> switch (jv) { + case JsonNumber n -> n.toDouble(); + case JsonString s -> { + try { + yield Double.parseDouble(s.string()); + } catch (NumberFormatException ex) { + yield null; + } + } + default -> null; + }; + }; + } + + private sealed interface Value permits Value.Num, Value.Str, Value.Json, Value.Missing { + record Num(double value) implements Value {} + + record Str(String value) implements Value {} + + record Json(JsonValue value) implements Value {} + + record Missing() implements Value {} + } + } +} + diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathExpression.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathExpression.java new file mode 100644 index 0000000..e3c67b7 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathExpression.java @@ -0,0 +1,27 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.List; +import java.util.Objects; + +/// A compiled JsonPath expression (AST + evaluator). +public final class JsonPathExpression { + + private final JsonPathAst ast; + + JsonPathExpression(JsonPathAst ast) { + this.ast = Objects.requireNonNull(ast, "ast must not be null"); + } + + public List select(JsonValue document) { + Objects.requireNonNull(document, "document must not be null"); + return JsonPathEvaluator.select(ast, document); + } + + @Override + public String toString() { + return "JsonPathExpression[" + ast + "]"; + } +} + diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java new file mode 100644 index 0000000..aaf55a9 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java @@ -0,0 +1,374 @@ +package json.java21.jsonpath; + +import java.util.ArrayList; +import java.util.List; + +import json.java21.jsonpath.BoolExpr.CmpOp; +import static json.java21.jsonpath.JsonPathAst.Step; + +sealed interface JsonPathParser permits JsonPathParser.Impl { + + static JsonPathAst parse(String path) { + return new Impl(path).parse(); + } + + final class Impl implements JsonPathParser { + private final String in; + private int i; + + Impl(String in) { + this.in = in; + } + + JsonPathAst parse() { + skipWs(); + expect('$'); + + final var steps = new ArrayList(); + while (true) { + skipWs(); + if (eof()) break; + + if (peek('.')) { + consume('.'); + if (peek('.')) { + consume('.'); + steps.add(parseRecursiveDescentSelector()); + } else if (peek('*')) { + consume('*'); + steps.add(new Step.Wildcard()); + } else { + steps.add(new Step.Child(parseIdentifier())); + } + continue; + } + + if (peek('[')) { + steps.add(parseBracketStep()); + continue; + } + + throw error("Unexpected character '" + cur() + "'"); + } + + return new JsonPathAst(List.copyOf(steps)); + } + + private Step parseRecursiveDescentSelector() { + if (peek('*')) { + consume('*'); + return new Step.RecursiveDescent(new Step.RecursiveSelector.Wildcard()); + } + return new Step.RecursiveDescent(new Step.RecursiveSelector.Name(parseIdentifier())); + } + + private Step parseBracketStep() { + expect('['); + skipWs(); + + if (peek('*')) { + consume('*'); + skipWs(); + expect(']'); + return new Step.Wildcard(); + } + + if (peek('?')) { + consume('?'); + skipWs(); + expect('('); + final var predicate = parseBoolExpr(); + expect(')'); + skipWs(); + expect(']'); + return new Step.Filter(predicate); + } + + if (peek('(')) { + consume('('); + final var expr = parseValueExpr(); + expect(')'); + skipWs(); + expect(']'); + return new Step.ScriptIndex(expr); + } + + if (peek('\'') || peek('"')) { + final var selectors = new ArrayList(); + selectors.add(new Step.Selector.Name(parseQuotedString())); + skipWs(); + while (peek(',')) { + consume(','); + skipWs(); + selectors.add(new Step.Selector.Name(parseQuotedString())); + skipWs(); + } + expect(']'); + if (selectors.size() == 1) { + return new Step.Child(((Step.Selector.Name) selectors.getFirst()).name()); + } + return new Step.Union(List.copyOf(selectors)); + } + + // number / union / slice + final Integer startOrIndex; + if (peek(':')) { + startOrIndex = null; + } else { + startOrIndex = parseSignedInt(); + } + skipWs(); + + if (peek(':')) { + // slice: [start?:end?] + consume(':'); + skipWs(); + final Integer end; + if (peek(']')) { + end = null; + } else { + end = parseSignedInt(); + } + skipWs(); + expect(']'); + return new Step.ArraySlice(startOrIndex, end); + } + + if (peek(',')) { + final var selectors = new ArrayList(); + selectors.add(new Step.Selector.Index(requireNonNullInt(startOrIndex))); + while (peek(',')) { + consume(','); + skipWs(); + selectors.add(new Step.Selector.Index(parseSignedInt())); + skipWs(); + } + expect(']'); + return new Step.Union(List.copyOf(selectors)); + } + + expect(']'); + return new Step.ArrayIndex(requireNonNullInt(startOrIndex)); + } + + private int requireNonNullInt(Integer i) { + if (i == null) throw error("Expected integer"); + return i; + } + + private String parseQuotedString() { + if (eof()) throw error("Expected string, got "); + final char q = cur(); + if (q != '\'' && q != '"') throw error("Expected quote, got '" + cur() + "'"); + consume(q); + final var sb = new StringBuilder(); + while (!eof()) { + final char c = cur(); + if (c == q) { + consume(q); + return sb.toString(); + } + // JsonPath examples use simple quoted names; support basic escapes for completeness + if (c == '\\') { + consume('\\'); + if (eof()) throw error("Unterminated escape sequence"); + final char e = cur(); + consume(e); + sb.append(e); + continue; + } + consume(c); + sb.append(c); + } + throw error("Unterminated string literal"); + } + + private BoolExpr parseBoolExpr() { + final var left = parseValueExpr(); + skipWs(); + if (peek(')')) { + if (left instanceof ValueExpr.Path p) { + return new BoolExpr.Exists(p); + } + throw error("Filter expression without operator must be a path, got: " + left); + } + + final var op = parseCmpOp(); + skipWs(); + final var right = parseValueExpr(); + return new BoolExpr.Compare(op, left, right); + } + + private CmpOp parseCmpOp() { + if (eof()) throw error("Expected operator, got "); + + if (peek('<')) { + consume('<'); + if (peek('=')) { + consume('='); + return CmpOp.LE; + } + return CmpOp.LT; + } + if (peek('>')) { + consume('>'); + if (peek('=')) { + consume('='); + return CmpOp.GE; + } + return CmpOp.GT; + } + if (peek('=')) { + consume('='); + expect('='); + return CmpOp.EQ; + } + if (peek('!')) { + consume('!'); + expect('='); + return CmpOp.NE; + } + + throw error("Expected comparison operator"); + } + + private ValueExpr parseValueExpr() { + var expr = parsePrimary(); + skipWs(); + while (!eof() && (peek('+') || peek('-'))) { + final char op = cur(); + consume(op); + skipWs(); + final var rhs = parsePrimary(); + expr = new ValueExpr.Arith(op == '+' ? ValueExpr.ArithOp.ADD : ValueExpr.ArithOp.SUB, expr, rhs); + skipWs(); + } + return expr; + } + + private ValueExpr parsePrimary() { + skipWs(); + if (eof()) throw error("Expected expression, got "); + + if (peek('@')) { + consume('@'); + final var parts = new ArrayList(); + while (!eof() && peek('.')) { + consume('.'); + parts.add(parseIdentifier()); + } + return new ValueExpr.Path(List.copyOf(parts)); + } + + if (peek('\'') || peek('"')) { + return new ValueExpr.Str(parseQuotedString()); + } + + if (peek('(')) { + consume('('); + final var inner = parseValueExpr(); + expect(')'); + return inner; + } + + if (peek('-') || isDigit(cur())) { + return new ValueExpr.Num(parseNumber()); + } + + throw error("Unexpected token in expression: '" + cur() + "'"); + } + + private double parseNumber() { + final int start = i; + if (peek('-')) consume('-'); + if (eof() || !isDigit(cur())) throw error("Expected number"); + while (!eof() && isDigit(cur())) i++; + if (!eof() && peek('.')) { + consume('.'); + while (!eof() && isDigit(cur())) i++; + } + final var raw = in.substring(start, i); + try { + return Double.parseDouble(raw); + } catch (NumberFormatException ex) { + throw error("Invalid number: " + raw); + } + } + + private int parseSignedInt() { + final int start = i; + if (peek('-')) consume('-'); + if (eof() || !isDigit(cur())) throw error("Expected integer"); + while (!eof() && isDigit(cur())) i++; + final var raw = in.substring(start, i); + try { + return Integer.parseInt(raw); + } catch (NumberFormatException ex) { + throw error("Invalid integer: " + raw); + } + } + + private String parseIdentifier() { + if (eof()) throw error("Expected identifier, got "); + final int start = i; + if (!isIdentStart(cur())) throw error("Expected identifier, got '" + cur() + "'"); + i++; + while (!eof() && isIdentPart(cur())) { + i++; + } + return in.substring(start, i); + } + + private boolean isIdentStart(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_'; + } + + private boolean isIdentPart(char c) { + return isIdentStart(c) || (c >= '0' && c <= '9'); + } + + private boolean isDigit(char c) { + return c >= '0' && c <= '9'; + } + + private void expect(char c) { + if (!peek(c)) { + throw error("Expected '" + c + "', got " + (eof() ? "" : "'" + cur() + "'")); + } + consume(c); + } + + private boolean peek(char c) { + return !eof() && cur() == c; + } + + private void consume(char c) { + assert cur() == c : "internal error: consume expected '" + c + "' got '" + cur() + "'"; + i++; + } + + private void skipWs() { + while (!eof()) { + final char c = cur(); + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') { + i++; + } else { + break; + } + } + } + + private boolean eof() { + return i >= in.length(); + } + + private char cur() { + return in.charAt(i); + } + + private IllegalArgumentException error(String msg) { + return new IllegalArgumentException(msg + " at index " + i + " in: " + in); + } + } +} + diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/ValueExpr.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/ValueExpr.java new file mode 100644 index 0000000..57b6075 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/ValueExpr.java @@ -0,0 +1,35 @@ +package json.java21.jsonpath; + +import java.util.List; +import java.util.Objects; + +sealed interface ValueExpr permits ValueExpr.Path, ValueExpr.Num, ValueExpr.Str, ValueExpr.Arith { + + record Path(List parts) implements ValueExpr { + public Path { + Objects.requireNonNull(parts, "parts must not be null"); + parts.forEach(p -> Objects.requireNonNull(p, "path part must not be null")); + } + } + + record Num(double value) implements ValueExpr {} + + record Str(String value) implements ValueExpr { + public Str { + Objects.requireNonNull(value, "value must not be null"); + } + } + + enum ArithOp { + ADD, SUB + } + + record Arith(ArithOp op, ValueExpr left, ValueExpr right) implements ValueExpr { + public Arith { + Objects.requireNonNull(op, "op must not be null"); + Objects.requireNonNull(left, "left must not be null"); + Objects.requireNonNull(right, "right must not be null"); + } + } +} + diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathExamplesTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathExamplesTest.java new file mode 100644 index 0000000..d8934e2 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathExamplesTest.java @@ -0,0 +1,223 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonString; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JsonPathExamplesTest extends JsonPathTestBase { + + static final String STORE_DOC = """ + { "store": { + "book": [ + { "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + { "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + { "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + ], + "bicycle": { + "color": "red", + "price": 19.95 + } + } + } + """; + + @Test + void store_book_selects_book_array() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$.store.book").select(doc); + assertThat(matches).hasSize(1); + assertThat(matches.getFirst()).isInstanceOf(JsonArray.class); + } + + @Test + void store_wildcard_selects_all_store_children() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$.store.*").select(doc); + assertThat(matches).hasSize(2); + assertThat(matches.getFirst()).isInstanceOf(JsonArray.class); + assertThat(matches.get(1)).isInstanceOf(JsonObject.class); + } + + @Test + void recursive_descent_author_selects_all_authors() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$..author").select(doc); + assertThat(asStrings(matches)).containsExactly( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void book_wildcard_author_selects_all_authors() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$.store.book[*].author").select(doc); + assertThat(asStrings(matches)).containsExactly( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void bracket_notation_can_select_authors_by_index() { + JsonValue doc = Json.parse(STORE_DOC); + + assertThat(singleString(doc, "$['store']['book'][0]['author']")).isEqualTo("Nigel Rees"); + assertThat(singleString(doc, "$['store']['book'][1]['author']")).isEqualTo("Evelyn Waugh"); + assertThat(singleString(doc, "$['store']['book'][2]['author']")).isEqualTo("Herman Melville"); + assertThat(singleString(doc, "$['store']['book'][3]['author']")).isEqualTo("J. R. R. Tolkien"); + } + + @Test + void store_recursive_descent_price_selects_all_prices() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$.store..price").select(doc); + assertThat(matches).hasSize(5); + assertThat(matches.stream().map(JsonValue::toDouble).toList()).containsExactly( + 8.95, 12.99, 8.99, 22.99, 19.95 + ); + } + + @Test + void recursive_descent_book_index_selects_third_book() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$..book[2]").select(doc); + assertThat(matches).hasSize(1); + assertThat(((JsonObject) matches.getFirst()).get("title").string()).isEqualTo("Moby Dick"); + } + + @Test + void script_index_can_select_last_book() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$..book[(@.length-1)]").select(doc); + assertThat(matches).hasSize(1); + assertThat(((JsonObject) matches.getFirst()).get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void slice_can_select_last_book() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$..book[-1:]").select(doc); + assertThat(matches).hasSize(1); + assertThat(((JsonObject) matches.getFirst()).get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void union_and_slice_can_select_first_two_books() { + JsonValue doc = Json.parse(STORE_DOC); + + final var union = JsonPath.parse("$..book[0,1]").select(doc); + assertThat(union).hasSize(2); + assertThat(union.stream().map(v -> ((JsonObject) v).get("title").string()).toList()).containsExactly( + "Sayings of the Century", + "Sword of Honour" + ); + + final var slice = JsonPath.parse("$..book[:2]").select(doc); + assertThat(slice).hasSize(2); + assertThat(slice.stream().map(v -> ((JsonObject) v).get("title").string()).toList()).containsExactly( + "Sayings of the Century", + "Sword of Honour" + ); + } + + @Test + void filter_can_select_books_by_presence_of_isbn() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$..book[?(@.isbn)]").select(doc); + assertThat(matches).hasSize(2); + assertThat(matches.stream().map(v -> ((JsonObject) v).get("title").string()).toList()).containsExactly( + "Moby Dick", + "The Lord of the Rings" + ); + } + + @Test + void filter_can_select_books_by_price_less_than_10() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matchesTight = JsonPath.parse("$..book[?(@.price<10)]").select(doc); + assertThat(matchesTight).hasSize(2); + assertThat(matchesTight.stream().map(v -> ((JsonObject) v).get("title").string()).toList()).containsExactly( + "Sayings of the Century", + "Moby Dick" + ); + + final var matchesSpaced = JsonPath.parse("$.store.book[?(@.price < 10)]").select(doc); + assertThat(matchesSpaced.stream().map(v -> ((JsonObject) v).get("title").string()).toList()).containsExactly( + "Sayings of the Century", + "Moby Dick" + ); + } + + @Test + void dot_notation_examples_select_titles() { + JsonValue doc = Json.parse(STORE_DOC); + + assertThat(singleString(doc, "$.store.book[0].title")).isEqualTo("Sayings of the Century"); + assertThat(singleString(doc, "$.store.book[(@.length-1)].title")).isEqualTo("The Lord of the Rings"); + } + + @Test + void recursive_descent_wildcard_selects_all_children_in_tree() { + JsonValue doc = Json.parse(STORE_DOC); + + final var matches = JsonPath.parse("$..*").select(doc); + assertThat(matches).hasSize(27); + assertThat(matches.stream().map(JsonValue::toString).toList()).contains( + "\"Nigel Rees\"", + "\"red\"", + "19.95" + ); + } + + private static List asStrings(List values) { + return values.stream().map(v -> ((JsonString) v).string()).toList(); + } + + private static String singleString(JsonValue doc, String path) { + final var matches = JsonPath.parse(path).select(doc); + assertThat(matches).hasSize(1); + return ((JsonString) matches.getFirst()).string(); + } +} + diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathLoggingConfig.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathLoggingConfig.java new file mode 100644 index 0000000..1b12807 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathLoggingConfig.java @@ -0,0 +1,38 @@ +package json.java21.jsonpath; + +import org.junit.jupiter.api.BeforeAll; + +import java.util.Locale; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class JsonPathLoggingConfig { + @BeforeAll + static void enableJulDebug() { + Logger root = Logger.getLogger(""); + String levelProp = System.getProperty("java.util.logging.ConsoleHandler.level"); + Level targetLevel = Level.INFO; + if (levelProp != null) { + try { + targetLevel = Level.parse(levelProp.trim()); + } catch (IllegalArgumentException ex) { + try { + targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + System.err.println("Unrecognized logging level from 'java.util.logging.ConsoleHandler.level': " + levelProp); + } + } + } + if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) { + root.setLevel(targetLevel); + } + for (Handler handler : root.getHandlers()) { + Level handlerLevel = handler.getLevel(); + if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) { + handler.setLevel(targetLevel); + } + } + } +} + diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathTestBase.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathTestBase.java new file mode 100644 index 0000000..2721dd6 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathTestBase.java @@ -0,0 +1,22 @@ +package json.java21.jsonpath; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInfo; + +import java.util.logging.Logger; + +/// Base class for all JsonPath tests. +/// - Emits an INFO banner per test. +public class JsonPathTestBase extends JsonPathLoggingConfig { + + static final Logger LOG = Logger.getLogger("json.java21.jsonpath"); + + @BeforeEach + void announce(TestInfo testInfo) { + final String cls = testInfo.getTestClass().map(Class::getSimpleName).orElse("UnknownTest"); + final String name = testInfo.getTestMethod().map(java.lang.reflect.Method::getName) + .orElseGet(testInfo::getDisplayName); + LOG.info(() -> "TEST: " + cls + "#" + name); + } +} + diff --git a/pom.xml b/pom.xml index 175d1eb..547788d 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,7 @@ json-java21-api-tracker json-compatibility-suite json-java21-jtd + json-java21-jsonpath