diff --git a/json-java21-jsonpath/AGENTS.md b/json-java21-jsonpath/AGENTS.md new file mode 100644 index 0000000..4fb86ff --- /dev/null +++ b/json-java21-jsonpath/AGENTS.md @@ -0,0 +1,77 @@ +# json-java21-jsonpath Module AGENTS.md + +## Purpose +This module implements a JsonPath query engine for the java.util.json Java 21 backport. It parses JsonPath expressions into an AST and evaluates them against JSON documents. + +## Specification +Based on Stefan Goessner's JSONPath specification: +https://goessner.net/articles/JsonPath/ + +## Module Structure + +### Main Classes +- `JsonPath`: Public API facade with `query()` method +- `JsonPathAst`: Sealed interface hierarchy defining the AST +- `JsonPathParser`: Recursive descent parser +- `JsonPathParseException`: Parse error exception + +### Test Classes +- `JsonPathLoggingConfig`: JUL test configuration base class +- `JsonPathAstTest`: Unit tests for AST records +- `JsonPathParserTest`: Unit tests for parser +- `JsonPathGoessnerTest`: Integration tests based on Goessner article examples + +## Development Guidelines + +### Adding New Operators +1. Add new record type to `JsonPathAst.Segment` sealed interface +2. Update `JsonPathParser` to parse the new syntax +3. Add parser tests in `JsonPathParserTest` +4. Implement evaluation in `JsonPath.evaluateSegments()` +5. Add integration tests in `JsonPathGoessnerTest` + +### Testing +```bash +# Run all tests +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Djava.util.logging.ConsoleHandler.level=INFO + +# Run specific test class with debug logging +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Dtest=JsonPathGoessnerTest -Djava.util.logging.ConsoleHandler.level=FINE + +# Run single test method +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Dtest=JsonPathGoessnerTest#testAuthorsOfAllBooks -Djava.util.logging.ConsoleHandler.level=FINEST +``` + +## Design Principles + +1. **No external dependencies**: Only java.base is allowed +2. **Pure TDD**: Tests first, then implementation +3. **Functional style**: Immutable records, pure evaluation functions +4. **Java 21 features**: Records, sealed interfaces, pattern matching with switch +5. **Defensive copies**: All collections in records are copied for immutability + +## Known Limitations + +1. **Script expressions**: Only `@.length-1` pattern is supported +2. **No general expression evaluation**: Complex scripts are not supported +3. **Stack-based recursion**: May overflow on very deep documents + +## API Usage + +```java +import jdk.sandbox.java.util.json.*; +import json.java21.jsonpath.JsonPath; + +JsonValue json = Json.parse(jsonString); + +// Preferred: parse once (cache) and reuse +JsonPath path = JsonPath.parse("$.store.book[*].author"); +List results = path.query(json); + +// If you want a static call site, pass the compiled JsonPath +List sameResults = JsonPath.query(path, json); +``` + +Notes: +- Parsing a JsonPath expression is relatively expensive compared to evaluation; cache compiled `JsonPath` instances in hot code paths. +- `JsonPath.query(String, JsonValue)` is intended for one-off usage only. diff --git a/json-java21-jsonpath/ARCHITECTURE.md b/json-java21-jsonpath/ARCHITECTURE.md new file mode 100644 index 0000000..14d86bd --- /dev/null +++ b/json-java21-jsonpath/ARCHITECTURE.md @@ -0,0 +1,207 @@ +# JsonPath Module Architecture + +## Overview + +This module implements a JsonPath query engine for the java.util.json Java 21 backport. It parses JsonPath expressions into an AST (Abstract Syntax Tree) and evaluates them against JSON documents parsed by the core library. + +**Key Design Principles:** +- **No external dependencies**: Only uses java.base +- **Pure TDD development**: Tests define expected behavior before implementation +- **Functional programming style**: Immutable data structures, pure functions +- **Java 21 features**: Records, sealed interfaces, pattern matching + +## JsonPath Specification + +Based on the original JSONPath specification by Stefan Goessner: +https://goessner.net/articles/JsonPath/ + +### Supported Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `$` | Root element | `$` | +| `.property` | Child property access | `$.store` | +| `['property']` | Bracket notation property | `$['store']` | +| `[n]` | Array index (supports negative) | `$.book[0]`, `$.book[-1]` | +| `[start:end:step]` | Array slice | `$.book[:2]`, `$.book[::2]` | +| `[*]` or `.*` | Wildcard (all children) | `$.store.*`, `$.book[*]` | +| `..` | Recursive descent | `$..author` | +| `[n,m]` | Union of indices | `$.book[0,1]` | +| `['a','b']` | Union of properties | `$.store['book','bicycle']` | +| `[?(@.prop)]` | Filter by existence | `$..book[?(@.isbn)]` | +| `[?(@.prop op value)]` | Filter by comparison | `$..book[?(@.price<10)]` | +| `[(@.length-1)]` | Script expression | `$..book[(@.length-1)]` | + +### Comparison Operators + +| Operator | Description | +|----------|-------------| +| `==` | Equal | +| `!=` | Not equal | +| `<` | Less than | +| `<=` | Less than or equal | +| `>` | Greater than | +| `>=` | Greater than or equal | + +## Module Structure + +``` +json-java21-jsonpath/ +├── src/main/java/json/java21/jsonpath/ +│ ├── JsonPath.java # Public API facade +│ ├── JsonPathAst.java # AST definition (sealed interface + records) +│ ├── JsonPathParser.java # Recursive descent parser +│ └── JsonPathParseException.java # Parse error exception +└── src/test/java/json/java21/jsonpath/ + ├── JsonPathLoggingConfig.java # JUL test configuration + ├── JsonPathAstTest.java # AST unit tests + ├── JsonPathParserTest.java # Parser unit tests + └── JsonPathGoessnerTest.java # Goessner article examples +``` + +## AST Design + +The AST uses a sealed interface hierarchy with record implementations: + +```mermaid +classDiagram + class JsonPathAst { + <> + } + class Root { + <> + +segments: List~Segment~ + } + class Segment { + <> + } + class PropertyAccess { + <> + +name: String + } + class ArrayIndex { + <> + +index: int + } + class ArraySlice { + <> + +start: Integer + +end: Integer + +step: Integer + } + class Wildcard { + <> + } + class RecursiveDescent { + <> + +target: Segment + } + class Filter { + <> + +expression: FilterExpression + } + class Union { + <> + +selectors: List~Segment~ + } + class ScriptExpression { + <> + +script: String + } + + JsonPathAst <|-- Root + Segment <|-- PropertyAccess + Segment <|-- ArrayIndex + Segment <|-- ArraySlice + Segment <|-- Wildcard + Segment <|-- RecursiveDescent + Segment <|-- Filter + Segment <|-- Union + Segment <|-- ScriptExpression +``` + +## Evaluation Flow + +```mermaid +flowchart TD + A[JsonPath.query] --> B[JsonPathParser.parse] + B --> C[AST Root] + C --> D[JsonPath.evaluate] + D --> E[evaluateSegments] + E --> F{Segment Type} + F -->|PropertyAccess| G[evaluatePropertyAccess] + F -->|ArrayIndex| H[evaluateArrayIndex] + F -->|Wildcard| I[evaluateWildcard] + F -->|RecursiveDescent| J[evaluateRecursiveDescent] + F -->|Filter| K[evaluateFilter] + F -->|ArraySlice| L[evaluateArraySlice] + F -->|Union| M[evaluateUnion] + F -->|ScriptExpression| N[evaluateScriptExpression] + G --> O[Recurse with next segment] + H --> O + I --> O + J --> O + K --> O + L --> O + M --> O + N --> O + O --> P{More segments?} + P -->|Yes| E + P -->|No| Q[Add to results] +``` + +## Usage Example + +```java +import jdk.sandbox.java.util.json.*; +import json.java21.jsonpath.JsonPath; + +// Parse JSON +JsonValue json = Json.parse(""" + { + "store": { + "book": [ + {"title": "Book 1", "price": 8.95}, + {"title": "Book 2", "price": 12.99} + ] + } + } + """); + +// Query authors of all books +List titles = JsonPath.query("$.store.book[*].title", json); + +// Query books cheaper than 10 +List cheapBooks = JsonPath.query("$.store.book[?(@.price<10)]", json); + +// Query all prices recursively +List prices = JsonPath.query("$..price", json); +``` + +## Testing + +Run all JsonPath tests: + +```bash +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Djava.util.logging.ConsoleHandler.level=INFO +``` + +Run specific test with debug logging: + +```bash +$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Dtest=JsonPathGoessnerTest -Djava.util.logging.ConsoleHandler.level=FINE +``` + +## Limitations + +1. **Script expressions**: Limited support - only `@.length-1` pattern +2. **Logical operators in filters**: `&&`, `||`, `!` - implemented but not extensively tested +3. **Deep nesting**: May cause stack overflow on extremely deep documents +4. **Complex scripts**: No general JavaScript/expression evaluation + +## Performance Considerations + +1. **Recursive evaluation**: Uses Java call stack for segment traversal +2. **Result collection**: Results collected in ArrayList during traversal +3. **No caching**: Each query parses the path fresh +4. **Defensive copies**: AST records create defensive copies for immutability diff --git a/json-java21-jsonpath/pom.xml b/json-java21-jsonpath/pom.xml new file mode 100644 index 0000000..1115dbc --- /dev/null +++ b/json-java21-jsonpath/pom.xml @@ -0,0 +1,86 @@ + + + 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 + + JsonPath implementation for the java.util.json Java 21 backport; parses JsonPath expressions to AST and matches against JSON documents. + + + 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 + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -ea + + + + + 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..b4b6850 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java @@ -0,0 +1,476 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; + +/// JsonPath query evaluator for JSON documents. +/// Parses JsonPath expressions and evaluates them against JsonValue instances. +/// +/// Usage examples: +/// ```java +/// // Fluent API (preferred) +/// JsonValue json = Json.parse(jsonString); +/// List results = JsonPath.parse("$.store.book[*].author").query(json); +/// +/// // Compiled + static call site (also reusable) +/// JsonPath path = JsonPath.parse("$.store.book[*].author"); +/// List results = JsonPath.query(path, json); +/// ``` +/// +/// Based on the JSONPath specification from [...](https://goessner.net/articles/JsonPath/) +public final class JsonPath { + + private static final Logger LOG = Logger.getLogger(JsonPath.class.getName()); + + private final JsonPathAst.Root ast; + private final String pathExpression; + + private JsonPath(String pathExpression, JsonPathAst.Root ast) { + this.pathExpression = pathExpression; + this.ast = ast; + } + + /// Parses a JsonPath expression and returns a compiled JsonPath for reuse. + /// @param path the JsonPath expression + /// @return a compiled JsonPath that can be used to select from multiple documents + /// @throws NullPointerException if path is null + /// @throws JsonPathParseException if the path is invalid + public static JsonPath parse(String path) { + Objects.requireNonNull(path, "path must not be null"); + LOG.fine(() -> "Parsing path: " + path); + final var ast = JsonPathParser.parse(path); + return new JsonPath(path, ast); + } + + /// Selects matching values from a JSON document. + /// @param json the JSON document to query + /// @return a list of matching JsonValue instances (may be empty) + /// @throws NullPointerException if json is null + /// @deprecated Use `query(JsonValue)` (aligns with Goessner JSONPath terminology). + @Deprecated(forRemoval = false) + public List select(JsonValue json) { + return query(json); + } + + /// Queries matching values from a JSON document. + /// + /// This is the preferred instance API: compile once via `parse(String)`, then call `query(JsonValue)` + /// for each already-parsed JSON document. + /// + /// @param json the JSON document to query + /// @return a list of matching JsonValue instances (may be empty) + /// @throws NullPointerException if json is null + public List query(JsonValue json) { + Objects.requireNonNull(json, "json must not be null"); + LOG.fine(() -> "Querying document with path: " + pathExpression); + return evaluate(ast, json); + } + + /// Returns the original path expression. + public String expression() { + return pathExpression; + } + + /// Returns the parsed AST. + public JsonPathAst.Root ast() { + return ast; + } + + /// Evaluates a compiled JsonPath against a JSON document. + /// @param path a compiled JsonPath (typically cached) + /// @param json the JSON document to query + /// @return a list of matching JsonValue instances (may be empty) + /// @throws NullPointerException if path or json is null + public static List query(JsonPath path, JsonValue json) { + Objects.requireNonNull(path, "path must not be null"); + return path.query(json); + } + + /// Evaluates a pre-parsed JsonPath AST against a JSON document. + /// @param ast the parsed JsonPath AST + /// @param json the JSON document to query + /// @return a list of matching JsonValue instances (may be empty) + static List evaluate(JsonPathAst.Root ast, JsonValue json) { + Objects.requireNonNull(ast, "ast must not be null"); + Objects.requireNonNull(json, "json must not be null"); + + final var results = new ArrayList(); + evaluateSegments(ast.segments(), 0, json, json, results); + return results; + } + + private static void evaluateSegments( + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + // If we've processed all segments, current is a match + if (index >= segments.size()) { + results.add(current); + return; + } + + final var segment = segments.get(index); + LOG.finer(() -> "Evaluating segment " + index + ": " + segment); + + switch (segment) { + case JsonPathAst.PropertyAccess prop -> evaluatePropertyAccess(prop, segments, index, current, root, results); + case JsonPathAst.ArrayIndex arr -> evaluateArrayIndex(arr, segments, index, current, root, results); + case JsonPathAst.ArraySlice slice -> evaluateArraySlice(slice, segments, index, current, root, results); + case JsonPathAst.Wildcard wildcard -> evaluateWildcard(segments, index, current, root, results); + case JsonPathAst.RecursiveDescent desc -> evaluateRecursiveDescent(desc, segments, index, current, root, results); + case JsonPathAst.Filter filter -> evaluateFilter(filter, segments, index, current, root, results); + case JsonPathAst.Union union -> evaluateUnion(union, segments, index, current, root, results); + case JsonPathAst.ScriptExpression script -> evaluateScriptExpression(script, segments, index, current, root, results); + } + } + + private static void evaluatePropertyAccess( + JsonPathAst.PropertyAccess prop, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonObject obj) { + final var value = obj.members().get(prop.name()); + if (value != null) { + evaluateSegments(segments, index + 1, value, root, results); + } + } + } + + private static void evaluateArrayIndex( + JsonPathAst.ArrayIndex arr, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonArray array) { + final var elements = array.elements(); + int idx = arr.index(); + + // Handle negative indices (from end) + if (idx < 0) { + idx = elements.size() + idx; + } + + if (idx >= 0 && idx < elements.size()) { + evaluateSegments(segments, index + 1, elements.get(idx), root, results); + } + } + } + + private static void evaluateArraySlice( + JsonPathAst.ArraySlice slice, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonArray array) { + final var elements = array.elements(); + final int size = elements.size(); + + // Resolve start, end, step with defaults + int start = slice.start() != null ? normalizeIndex(slice.start(), size) : 0; + int end = slice.end() != null ? normalizeIndex(slice.end(), size) : size; + int step = slice.step() != null ? slice.step() : 1; + + if (step == 0) { + return; // Invalid step + } + + // Clamp values + start = Math.max(0, Math.min(start, size)); + end = Math.max(0, Math.min(end, size)); + + if (step > 0) { + for (int i = start; i < end; i += step) { + evaluateSegments(segments, index + 1, elements.get(i), root, results); + } + } else { + for (int i = start; i > end; i += step) { + evaluateSegments(segments, index + 1, elements.get(i), root, results); + } + } + } + } + + private static int normalizeIndex(int index, int size) { + if (index < 0) { + return size + index; + } + return index; + } + + private static void evaluateWildcard( + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonObject obj) { + for (final var value : obj.members().values()) { + evaluateSegments(segments, index + 1, value, root, results); + } + } else if (current instanceof JsonArray array) { + for (final var element : array.elements()) { + evaluateSegments(segments, index + 1, element, root, results); + } + } + } + + private static void evaluateRecursiveDescent( + JsonPathAst.RecursiveDescent desc, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + // First, try matching the target at current level + evaluateTargetSegment(desc.target(), segments, index, current, root, results); + + // Then recurse into children + if (current instanceof JsonObject obj) { + for (final var value : obj.members().values()) { + evaluateRecursiveDescent(desc, segments, index, value, root, results); + } + } else if (current instanceof JsonArray array) { + for (final var element : array.elements()) { + evaluateRecursiveDescent(desc, segments, index, element, root, results); + } + } + } + + private static void evaluateTargetSegment( + JsonPathAst.Segment target, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + switch (target) { + case JsonPathAst.PropertyAccess prop -> { + if (current instanceof JsonObject obj) { + final var value = obj.members().get(prop.name()); + if (value != null) { + evaluateSegments(segments, index + 1, value, root, results); + } + } + } + case JsonPathAst.Wildcard w -> { + if (current instanceof JsonObject obj) { + for (final var value : obj.members().values()) { + evaluateSegments(segments, index + 1, value, root, results); + } + } else if (current instanceof JsonArray array) { + for (final var element : array.elements()) { + evaluateSegments(segments, index + 1, element, root, results); + } + } + } + case JsonPathAst.ArrayIndex arr -> { + if (current instanceof JsonArray array) { + final var elements = array.elements(); + int idx = arr.index(); + if (idx < 0) idx = elements.size() + idx; + if (idx >= 0 && idx < elements.size()) { + evaluateSegments(segments, index + 1, elements.get(idx), root, results); + } + } + } + default -> { + // Other segment types in recursive descent context + LOG.finer(() -> "Unsupported target in recursive descent: " + target); + } + } + } + + private static void evaluateFilter( + JsonPathAst.Filter filter, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonArray array) { + for (final var element : array.elements()) { + if (matchesFilter(filter.expression(), element)) { + evaluateSegments(segments, index + 1, element, root, results); + } + } + } + } + + private static boolean matchesFilter(JsonPathAst.FilterExpression expr, JsonValue current) { + return switch (expr) { + case JsonPathAst.ExistsFilter exists -> { + final var value = resolvePropertyPath(exists.path(), current); + yield value != null; + } + case JsonPathAst.ComparisonFilter comp -> { + final var leftValue = resolveFilterExpression(comp.left(), current); + final var rightValue = resolveFilterExpression(comp.right(), current); + yield compareValues(leftValue, comp.op(), rightValue); + } + case JsonPathAst.LogicalFilter logical -> { + final var leftMatch = matchesFilter(logical.left(), current); + yield switch (logical.op()) { + case AND -> leftMatch && matchesFilter(logical.right(), current); + case OR -> leftMatch || matchesFilter(logical.right(), current); + case NOT -> !leftMatch; + }; + } + case JsonPathAst.CurrentNode cn -> true; + case JsonPathAst.PropertyPath path -> resolvePropertyPath(path, current) != null; + case JsonPathAst.LiteralValue lv -> true; + }; + } + + private static Object resolveFilterExpression(JsonPathAst.FilterExpression expr, JsonValue current) { + return switch (expr) { + case JsonPathAst.PropertyPath path -> { + final var value = resolvePropertyPath(path, current); + yield jsonValueToComparable(value); + } + case JsonPathAst.LiteralValue lit -> lit.value(); + case JsonPathAst.CurrentNode cn2 -> jsonValueToComparable(current); + default -> null; + }; + } + + private static JsonValue resolvePropertyPath(JsonPathAst.PropertyPath path, JsonValue current) { + JsonValue value = current; + for (final var prop : path.properties()) { + if (value instanceof JsonObject obj) { + value = obj.members().get(prop); + if (value == null) { + return null; + } + } else { + return null; + } + } + return value; + } + + private static Object jsonValueToComparable(JsonValue value) { + if (value == null) return null; + return switch (value) { + case JsonString s -> s.string(); + case JsonNumber n -> n.toDouble(); + case JsonBoolean b -> b.bool(); + case JsonNull jn -> null; + default -> value; + }; + } + + @SuppressWarnings("unchecked") + private static boolean compareValues(Object left, JsonPathAst.ComparisonOp op, Object right) { + if (left == null || right == null) { + return switch (op) { + case EQ -> left == right; + case NE -> left != right; + default -> false; + }; + } + + // Try numeric comparison + if (left instanceof Number leftNum && right instanceof Number rightNum) { + final double l = leftNum.doubleValue(); + final double r = rightNum.doubleValue(); + return switch (op) { + case EQ -> l == r; + case NE -> l != r; + case LT -> l < r; + case LE -> l <= r; + case GT -> l > r; + case GE -> l >= r; + }; + } + + // String comparison + if (left instanceof String && right instanceof String) { + @SuppressWarnings("rawtypes") + final int cmp = ((Comparable) left).compareTo(right); + return switch (op) { + case EQ -> cmp == 0; + case NE -> cmp != 0; + case LT -> cmp < 0; + case LE -> cmp <= 0; + case GT -> cmp > 0; + case GE -> cmp >= 0; + }; + } + + // Boolean comparison + if (left instanceof Boolean && right instanceof Boolean) { + return switch (op) { + case EQ -> left.equals(right); + case NE -> !left.equals(right); + default -> false; + }; + } + + // Fallback equality + return switch (op) { + case EQ -> left.equals(right); + case NE -> !left.equals(right); + default -> false; + }; + } + + private static void evaluateUnion( + JsonPathAst.Union union, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + for (final var selector : union.selectors()) { + switch (selector) { + case JsonPathAst.ArrayIndex arr -> evaluateArrayIndex(arr, segments, index, current, root, results); + case JsonPathAst.PropertyAccess prop -> evaluatePropertyAccess(prop, segments, index, current, root, results); + default -> LOG.finer(() -> "Unsupported selector in union: " + selector); + } + } + } + + private static void evaluateScriptExpression( + JsonPathAst.ScriptExpression script, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonArray array) { + // Simple support for @.length-1 pattern + final var scriptText = script.script().trim(); + if (scriptText.equals("@.length-1")) { + final int lastIndex = array.elements().size() - 1; + if (lastIndex >= 0) { + evaluateSegments(segments, index + 1, array.elements().get(lastIndex), root, results); + } + } else { + LOG.warning(() -> "Unsupported script expression: " + scriptText); + } + } + } +} 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..44b116f --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java @@ -0,0 +1,187 @@ +package json.java21.jsonpath; + +import java.util.List; +import java.util.Objects; + +/// AST representation for JsonPath expressions. +/// Based on the JSONPath specification from https://goessner.net/articles/JsonPath/ +/// +/// A JsonPath expression is a sequence of path segments starting from root ($). +/// Each segment can be: +/// - PropertyAccess: access a named property (e.g., .store or ['store']) +/// - ArrayIndex: access array element by index (e.g., [0] or [-1]) +/// - ArraySlice: slice array with start:end:step (e.g., [0:2] or [::2]) +/// - Wildcard: match all children (e.g., .* or [*]) +/// - RecursiveDescent: search all descendants (e.g., ..author) +/// - Filter: filter by predicate (e.g., [?(@.isbn)] or [?(@.price<10)]) +/// - Union: multiple indices or names (e.g., [0,1] or ['a','b']) +/// - ScriptExpression: computed index (e.g., [(@.length-1)]) +sealed interface JsonPathAst { + + /// Root element ($) - the starting point of all JsonPath expressions + record Root(List segments) implements JsonPathAst { + public Root { + Objects.requireNonNull(segments, "segments must not be null"); + segments = List.copyOf(segments); // defensive copy + } + } + + /// A single segment in a JsonPath expression + sealed interface Segment permits + PropertyAccess, + ArrayIndex, + ArraySlice, + Wildcard, + RecursiveDescent, + Filter, + Union, + ScriptExpression {} + + /// Access a named property: .name or ['name'] + record PropertyAccess(String name) implements Segment { + public PropertyAccess { + Objects.requireNonNull(name, "name must not be null"); + } + } + + /// Access array element by index: \[n\] where n can be negative for reverse indexing + record ArrayIndex(int index) implements Segment {} + + /// Slice array: \[start:end:step\] + /// All fields are optional (null means not specified) + record ArraySlice(Integer start, Integer end, Integer step) implements Segment {} + + /// Wildcard: * matches all children (both object properties and array elements) + record Wildcard() implements Segment {} + + /// Recursive descent: .. searches all descendants + /// The property field specifies what to search for (can be null for ..[*] or ..*) + record RecursiveDescent(Segment target) implements Segment { + public RecursiveDescent { + Objects.requireNonNull(target, "target must not be null"); + } + } + + /// Filter expression: [?(@.isbn)] or [?(@.price < 10)] + record Filter(FilterExpression expression) implements Segment { + public Filter { + Objects.requireNonNull(expression, "expression must not be null"); + } + } + + /// Union of multiple selectors: [0,1] or ['a','b'] + record Union(List selectors) implements Segment { + public Union { + Objects.requireNonNull(selectors, "selectors must not be null"); + if (selectors.size() < 2) { + throw new IllegalArgumentException("Union must have at least 2 selectors"); + } + selectors = List.copyOf(selectors); // defensive copy + } + } + + /// Script expression for computed index: [(@.length-1)] + record ScriptExpression(String script) implements Segment { + public ScriptExpression { + Objects.requireNonNull(script, "script must not be null"); + } + } + + /// Filter expressions used in [?(...)] predicates + sealed interface FilterExpression permits + ExistsFilter, + ComparisonFilter, + LogicalFilter, + CurrentNode, + PropertyPath, + LiteralValue {} + + /// Check if property exists: [?(@.isbn)] + record ExistsFilter(PropertyPath path) implements FilterExpression { + public ExistsFilter { + Objects.requireNonNull(path, "path must not be null"); + } + } + + /// Comparison filter: [?(@.price < 10)] + record ComparisonFilter( + FilterExpression left, + ComparisonOp op, + FilterExpression right + ) implements FilterExpression { + public ComparisonFilter { + Objects.requireNonNull(left, "left must not be null"); + Objects.requireNonNull(op, "op must not be null"); + Objects.requireNonNull(right, "right must not be null"); + } + } + + /// Logical combination of filters: &&, ||, ! + record LogicalFilter( + FilterExpression left, + LogicalOp op, + FilterExpression right + ) implements FilterExpression { + public LogicalFilter { + Objects.requireNonNull(left, "left must not be null"); + Objects.requireNonNull(op, "op must not be null"); + // right can be null for NOT operator + } + } + + /// Current node reference: @ in filter expressions + record CurrentNode() implements FilterExpression {} + + /// Property path in filter expressions: @.price or @.store.book + record PropertyPath(List properties) implements FilterExpression { + public PropertyPath { + Objects.requireNonNull(properties, "properties must not be null"); + if (properties.isEmpty()) { + throw new IllegalArgumentException("PropertyPath must have at least one property"); + } + properties = List.copyOf(properties); // defensive copy + } + } + + /// Literal value in filter expressions + record LiteralValue(Object value) implements FilterExpression { + // value can be null (for JSON null), String, Number, or Boolean + } + + /// Comparison operators + enum ComparisonOp { + EQ("=="), // equals + NE("!="), // not equals + LT("<"), // less than + LE("<="), // less than or equal + GT(">"), // greater than + GE(">="); // greater than or equal + + private final String symbol; + + ComparisonOp(String symbol) { + this.symbol = symbol; + } + + public String symbol() { + return symbol; + } + } + + /// Logical operators + enum LogicalOp { + AND("&&"), + OR("||"), + NOT("!"); + + private final String symbol; + + LogicalOp(String symbol) { + this.symbol = symbol; + } + + public String symbol() { + return symbol; + } + } +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java new file mode 100644 index 0000000..7dd439c --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java @@ -0,0 +1,56 @@ +package json.java21.jsonpath; + +/// Exception thrown when a JsonPath expression cannot be parsed. +/// This is a runtime exception as JsonPath parsing failures are typically programming errors. +public class JsonPathParseException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final int position; + private final String path; + + /// Creates a new parse exception with the given message. + public JsonPathParseException(String message) { + super(message); + this.position = -1; + this.path = null; + } + + /// Creates a new parse exception with position information. + public JsonPathParseException(String message, String path, int position) { + super(formatMessage(message, path, position)); + this.position = position; + this.path = path; + } + + /// Creates a new parse exception with a cause. + public JsonPathParseException(String message, Throwable cause) { + super(message, cause); + this.position = -1; + this.path = null; + } + + /// Returns the position in the path where the error occurred, or -1 if unknown. + public int position() { + return position; + } + + /// Returns the path that was being parsed, or null if unknown. + public String path() { + return path; + } + + private static String formatMessage(String message, String path, int position) { + if (path == null || position < 0) { + return message; + } + final var sb = new StringBuilder(); + sb.append(message); + sb.append(" at position ").append(position); + sb.append(" in path: ").append(path); + if (position < path.length()) { + sb.append(" (near '").append(path.charAt(position)).append("')"); + } + return sb.toString(); + } +} 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..e37bbc2 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java @@ -0,0 +1,561 @@ +package json.java21.jsonpath; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; + +/// Parser for JsonPath expressions into AST. +/// Implements a recursive descent parser for JsonPath syntax. +/// +/// Supported syntax based on [...](https://goessner.net/articles/JsonPath/): +/// - $ : root element +/// - .name : child property access +/// - \['name'\] or \["name"\] : bracket notation property access +/// - \[n\] : array index (supports negative indices) +/// - \[start:end:step\] : array slice +/// - \[*\] or .* : wildcard +/// - .. : recursive descent +/// - \[n,m\] : union of indices +/// - \['a','b'\] : union of properties +/// - \[?(@.prop)\] : filter expression for existence +/// - \[?(@.prop op value)\] : filter expression with comparison +/// - \[(@.length-1)\] : script expression +final class JsonPathParser { + + private static final Logger LOG = Logger.getLogger(JsonPathParser.class.getName()); + + private final String path; + private int pos; + + private JsonPathParser(String path) { + this.path = path; + this.pos = 0; + } + + /// Parses a JsonPath expression string into an AST. + /// @param path the JsonPath expression to parse + /// @return the parsed AST + /// @throws NullPointerException if path is null + /// @throws JsonPathParseException if the path is invalid + public static JsonPathAst.Root parse(String path) { + Objects.requireNonNull(path, "path must not be null"); + LOG.fine(() -> "Parsing JsonPath: " + path); + return new JsonPathParser(path).parseRoot(); + } + + private JsonPathAst.Root parseRoot() { + if (path.isEmpty() || path.charAt(0) != '$') { + throw new JsonPathParseException("JsonPath must start with $", path, 0); + } + pos = 1; // skip $ + + final var segments = new ArrayList(); + + while (pos < path.length()) { + final var segment = parseSegment(); + if (segment != null) { + segments.add(segment); + LOG.finer(() -> "Parsed segment: " + segment); + } + } + + return new JsonPathAst.Root(segments); + } + + private JsonPathAst.Segment parseSegment() { + if (pos >= path.length()) { + return null; + } + + final char c = path.charAt(pos); + + return switch (c) { + case '.' -> parseDotNotation(); + case '[' -> parseBracketNotation(); + default -> throw new JsonPathParseException("Unexpected character", path, pos); + }; + } + + private JsonPathAst.Segment parseDotNotation() { + pos++; // skip . + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path after '.'", path, pos); + } + + final char c = path.charAt(pos); + + // Check for recursive descent (..) + if (c == '.') { + pos++; // skip second . + return parseRecursiveDescent(); + } + + // Check for wildcard (.*) + if (c == '*') { + pos++; // skip * + return new JsonPathAst.Wildcard(); + } + + // Property name + return parsePropertyName(); + } + + private JsonPathAst.RecursiveDescent parseRecursiveDescent() { + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path after '..'", path, pos); + } + + final char c = path.charAt(pos); + + // Check for ..* (recursive wildcard) + if (c == '*') { + pos++; + return new JsonPathAst.RecursiveDescent(new JsonPathAst.Wildcard()); + } + + // Check for ..[ + if (c == '[') { + // Parse the bracket notation but wrap the target + final var segment = parseBracketNotation(); + return new JsonPathAst.RecursiveDescent(segment); + } + + // Property name after .. + final var property = parsePropertyName(); + return new JsonPathAst.RecursiveDescent(property); + } + + private JsonPathAst.PropertyAccess parsePropertyName() { + final int start = pos; + + // Parse until we hit a special character or end + while (pos < path.length()) { + final char c = path.charAt(pos); + if (c == '.' || c == '[') { + break; + } + pos++; + } + + if (pos == start) { + throw new JsonPathParseException("Expected property name", path, pos); + } + + final var name = path.substring(start, pos); + return new JsonPathAst.PropertyAccess(name); + } + + private JsonPathAst.Segment parseBracketNotation() { + pos++; // skip [ + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path after '['", path, pos); + } + + final char c = path.charAt(pos); + + // Wildcard [*] + if (c == '*') { + pos++; // skip * + expectChar(']'); + return new JsonPathAst.Wildcard(); + } + + // Filter expression [?(...)] + if (c == '?') { + return parseFilterExpression(); + } + + // Script expression [(...)], but not (?...) + if (c == '(') { + return parseScriptExpression(); + } + + // Quoted property name or union + if (c == '\'' || c == '"') { + return parseQuotedPropertyOrUnion(); + } + + // Number, slice, or union + if (c == '-' || c == ':' || Character.isDigit(c)) { + return parseNumberOrSliceOrUnion(); + } + + throw new JsonPathParseException("Unexpected character in bracket notation", path, pos); + } + + private JsonPathAst.Filter parseFilterExpression() { + pos++; // skip ? + + expectChar('('); + + final var expression = parseFilterContent(); + + expectChar(')'); + expectChar(']'); + + return new JsonPathAst.Filter(expression); + } + + private JsonPathAst.FilterExpression parseFilterContent() { + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of filter expression", path, pos); + } + + // Parse the left side (usually @.property) + final var left = parseFilterAtom(); + + skipWhitespace(); + + // Check if there's a comparison operator + if (pos < path.length() && path.charAt(pos) != ')') { + final var op = parseComparisonOp(); + skipWhitespace(); + final var right = parseFilterAtom(); + return new JsonPathAst.ComparisonFilter(left, op, right); + } + + // No operator means existence check + if (left instanceof JsonPathAst.PropertyPath pp) { + return new JsonPathAst.ExistsFilter(pp); + } + + throw new JsonPathParseException("Invalid filter expression - expected property path", path, pos); + } + + private JsonPathAst.FilterExpression parseFilterAtom() { + skipWhitespace(); + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of filter expression", path, pos); + } + + final char c = path.charAt(pos); + + // Current element reference @ + if (c == '@') { + return parseCurrentNodePath(); + } + + // String literal + if (c == '\'' || c == '"') { + return parseLiteralString(); + } + + // Number literal + if (c == '-' || Character.isDigit(c)) { + return parseLiteralNumber(); + } + + // Boolean literal + if (path.substring(pos).startsWith("true")) { + pos += 4; + return new JsonPathAst.LiteralValue(true); + } + if (path.substring(pos).startsWith("false")) { + pos += 5; + return new JsonPathAst.LiteralValue(false); + } + + // Null literal + if (path.substring(pos).startsWith("null")) { + pos += 4; + return new JsonPathAst.LiteralValue(null); + } + + throw new JsonPathParseException("Unexpected token in filter expression", path, pos); + } + + private JsonPathAst.PropertyPath parseCurrentNodePath() { + pos++; // skip @ + + final var properties = new ArrayList(); + + while (pos < path.length() && path.charAt(pos) == '.') { + pos++; // skip . + final int start = pos; + + while (pos < path.length()) { + final char c = path.charAt(pos); + if (!Character.isLetterOrDigit(c) && c != '_') { + break; + } + pos++; + } + + if (pos == start) { + throw new JsonPathParseException("Expected property name after '.'", path, pos); + } + + properties.add(path.substring(start, pos)); + } + + if (properties.isEmpty()) { + // Just @ with no properties + return new JsonPathAst.PropertyPath(List.of("@")); + } + + return new JsonPathAst.PropertyPath(properties); + } + + private JsonPathAst.LiteralValue parseLiteralString() { + final char quote = path.charAt(pos); + pos++; // skip opening quote + + final int start = pos; + while (pos < path.length() && path.charAt(pos) != quote) { + if (path.charAt(pos) == '\\' && pos + 1 < path.length()) { + pos++; // skip escape character + } + pos++; + } + + if (pos >= path.length()) { + throw new JsonPathParseException("Unterminated string literal", path, start); + } + + final var value = path.substring(start, pos); + pos++; // skip closing quote + + return new JsonPathAst.LiteralValue(value); + } + + private JsonPathAst.LiteralValue parseLiteralNumber() { + final int start = pos; + + if (path.charAt(pos) == '-') { + pos++; + } + + while (pos < path.length() && Character.isDigit(path.charAt(pos))) { + pos++; + } + + // Check for decimal point + if (pos < path.length() && path.charAt(pos) == '.') { + do { + pos++; + } while (pos < path.length() && Character.isDigit(path.charAt(pos))); + } + + final var numStr = path.substring(start, pos); + if (numStr.contains(".")) { + return new JsonPathAst.LiteralValue(Double.parseDouble(numStr)); + } else { + return new JsonPathAst.LiteralValue(Long.parseLong(numStr)); + } + } + + private JsonPathAst.ComparisonOp parseComparisonOp() { + if (path.substring(pos).startsWith("==")) { + pos += 2; + return JsonPathAst.ComparisonOp.EQ; + } + if (path.substring(pos).startsWith("!=")) { + pos += 2; + return JsonPathAst.ComparisonOp.NE; + } + if (path.substring(pos).startsWith("<=")) { + pos += 2; + return JsonPathAst.ComparisonOp.LE; + } + if (path.substring(pos).startsWith(">=")) { + pos += 2; + return JsonPathAst.ComparisonOp.GE; + } + if (path.charAt(pos) == '<') { + pos++; + return JsonPathAst.ComparisonOp.LT; + } + if (path.charAt(pos) == '>') { + pos++; + return JsonPathAst.ComparisonOp.GT; + } + + throw new JsonPathParseException("Expected comparison operator", path, pos); + } + + private JsonPathAst.ScriptExpression parseScriptExpression() { + pos++; // skip ( + + final int start = pos; + int depth = 1; + + while (pos < path.length() && depth > 0) { + final char c = path.charAt(pos); + if (c == '(') depth++; + else if (c == ')') depth--; + pos++; + } + + if (depth != 0) { + throw new JsonPathParseException("Unmatched parenthesis in script expression", path, start); + } + + // pos is now past the closing ) + final var script = path.substring(start, pos - 1); + + expectChar(']'); + + return new JsonPathAst.ScriptExpression(script); + } + + private JsonPathAst.Segment parseQuotedPropertyOrUnion() { + final var properties = new ArrayList(); + + while (true) { + skipWhitespace(); + final var prop = parseQuotedProperty(); + properties.add(prop); + skipWhitespace(); + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path in bracket notation", path, pos); + } + + if (path.charAt(pos) == ']') { + pos++; // skip ] + break; + } + + if (path.charAt(pos) == ',') { + pos++; // skip , + continue; + } + + throw new JsonPathParseException("Expected ',' or ']' in bracket notation", path, pos); + } + + if (properties.size() == 1) { + return properties.getFirst(); + } + + return new JsonPathAst.Union(properties); + } + + private JsonPathAst.PropertyAccess parseQuotedProperty() { + final char quote = path.charAt(pos); + pos++; // skip opening quote + + final int start = pos; + while (pos < path.length() && path.charAt(pos) != quote) { + if (path.charAt(pos) == '\\' && pos + 1 < path.length()) { + pos++; // skip escape character + } + pos++; + } + + if (pos >= path.length()) { + throw new JsonPathParseException("Unterminated string in bracket notation", path, start); + } + + final var name = path.substring(start, pos); + pos++; // skip closing quote + + return new JsonPathAst.PropertyAccess(name); + } + + private JsonPathAst.Segment parseNumberOrSliceOrUnion() { + // Collect all the numbers and operators to determine what we have + final var elements = new ArrayList(); // Integer values (null for missing) + boolean hasColon = false; + boolean hasComma = false; + + // Parse first element (maybe empty for [:end]) + if (path.charAt(pos) == ':') { + elements.add(null); // empty start + hasColon = true; + pos++; + // After initial ':', check if there's a number for end + if (pos < path.length() && (Character.isDigit(path.charAt(pos)) || path.charAt(pos) == '-')) { + elements.add(parseInteger()); + } + } else { + elements.add(parseInteger()); + } + + // Continue parsing + while (pos < path.length()) { + final char c = path.charAt(pos); + + if (c == ']') { + pos++; + break; + } + + if (c == ':') { + hasColon = true; + pos++; + // Parse next element or leave as null + if (pos < path.length() && (Character.isDigit(path.charAt(pos)) || path.charAt(pos) == '-')) { + elements.add(parseInteger()); + } else if (pos < path.length() && path.charAt(pos) != ':' && path.charAt(pos) != ']') { + // Not a digit, not another colon, not end bracket - unexpected + throw new JsonPathParseException("Unexpected character after ':' in slice", path, pos); + } else { + elements.add(null); + } + } else if (c == ',') { + hasComma = true; + pos++; + skipWhitespace(); + elements.add(parseInteger()); + } else { + throw new JsonPathParseException("Unexpected character in array subscript", path, pos); + } + } + + // Determine what we parsed + if (hasColon) { + // It's a slice [start:end:step] + final Integer start = !elements.isEmpty() ? elements.get(0) : null; + final Integer end = elements.size() > 1 ? elements.get(1) : null; + final Integer step = elements.size() > 2 ? elements.get(2) : null; + return new JsonPathAst.ArraySlice(start, end, step); + } + + if (hasComma) { + // It's a union [n,m,...] + final var indices = new ArrayList(); + for (final var elem : elements) { + indices.add(new JsonPathAst.ArrayIndex(elem)); + } + return new JsonPathAst.Union(indices); + } + + // Single index + return new JsonPathAst.ArrayIndex(elements.getFirst()); + } + + private int parseInteger() { + final int start = pos; + if (pos < path.length() && path.charAt(pos) == '-') { + pos++; + } + while (pos < path.length() && Character.isDigit(path.charAt(pos))) { + pos++; + } + if (pos == start || (pos == start + 1 && path.charAt(start) == '-')) { + throw new JsonPathParseException("Expected integer", path, pos); + } + return Integer.parseInt(path.substring(start, pos)); + } + + private void expectChar(char expected) { + if (pos >= path.length()) { + throw new JsonPathParseException("Expected '" + expected + "' but reached end of path", path, pos); + } + if (path.charAt(pos) != expected) { + throw new JsonPathParseException("Expected '" + expected + "'", path, pos); + } + pos++; + } + + private void skipWhitespace() { + while (pos < path.length() && Character.isWhitespace(path.charAt(pos))) { + pos++; + } + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java new file mode 100644 index 0000000..e99d426 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java @@ -0,0 +1,166 @@ +package json.java21.jsonpath; + +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// Unit tests for JsonPathAst record types +class JsonPathAstTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathAstTest.class.getName()); + + @Test + void testRootCreation() { + LOG.info(() -> "TEST: testRootCreation"); + final var root = new JsonPathAst.Root(List.of()); + assertThat(root.segments()).isEmpty(); + } + + @Test + void testRootWithSegments() { + LOG.info(() -> "TEST: testRootWithSegments"); + final var segments = List.of( + new JsonPathAst.PropertyAccess("store"), + new JsonPathAst.PropertyAccess("book") + ); + final var root = new JsonPathAst.Root(segments); + assertThat(root.segments()).hasSize(2); + } + + @Test + void testRootDefensiveCopy() { + LOG.info(() -> "TEST: testRootDefensiveCopy"); + final var mutableList = new java.util.ArrayList(); + mutableList.add(new JsonPathAst.PropertyAccess("store")); + final var root = new JsonPathAst.Root(mutableList); + mutableList.add(new JsonPathAst.PropertyAccess("book")); + // Root should have defensive copy, so adding to original list doesn't affect it + assertThat(root.segments()).hasSize(1); + } + + @Test + void testPropertyAccess() { + LOG.info(() -> "TEST: testPropertyAccess"); + final var prop = new JsonPathAst.PropertyAccess("author"); + assertThat(prop.name()).isEqualTo("author"); + } + + @Test + void testPropertyAccessNullThrows() { + LOG.info(() -> "TEST: testPropertyAccessNullThrows"); + assertThatThrownBy(() -> new JsonPathAst.PropertyAccess(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void testArrayIndex() { + LOG.info(() -> "TEST: testArrayIndex"); + final var index = new JsonPathAst.ArrayIndex(0); + assertThat(index.index()).isEqualTo(0); + + final var negIndex = new JsonPathAst.ArrayIndex(-1); + assertThat(negIndex.index()).isEqualTo(-1); + } + + @Test + void testArraySlice() { + LOG.info(() -> "TEST: testArraySlice"); + final var slice = new JsonPathAst.ArraySlice(0, 2, null); + assertThat(slice.start()).isEqualTo(0); + assertThat(slice.end()).isEqualTo(2); + assertThat(slice.step()).isNull(); + } + + @Test + void testWildcard() { + LOG.info(() -> "TEST: testWildcard"); + final var wildcard = new JsonPathAst.Wildcard(); + assertThat(wildcard).isNotNull(); + } + + @Test + void testRecursiveDescent() { + LOG.info(() -> "TEST: testRecursiveDescent"); + final var descent = new JsonPathAst.RecursiveDescent( + new JsonPathAst.PropertyAccess("author") + ); + assertThat(descent.target()).isInstanceOf(JsonPathAst.PropertyAccess.class); + } + + @Test + void testFilter() { + LOG.info(() -> "TEST: testFilter"); + final var filter = new JsonPathAst.Filter( + new JsonPathAst.ExistsFilter( + new JsonPathAst.PropertyPath(List.of("isbn")) + ) + ); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ExistsFilter.class); + } + + @Test + void testUnion() { + LOG.info(() -> "TEST: testUnion"); + final var union = new JsonPathAst.Union(List.of( + new JsonPathAst.ArrayIndex(0), + new JsonPathAst.ArrayIndex(1) + )); + assertThat(union.selectors()).hasSize(2); + } + + @Test + void testUnionRequiresAtLeastTwoSelectors() { + LOG.info(() -> "TEST: testUnionRequiresAtLeastTwoSelectors"); + assertThatThrownBy(() -> new JsonPathAst.Union(List.of(new JsonPathAst.ArrayIndex(0)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 2"); + } + + @Test + void testComparisonFilter() { + LOG.info(() -> "TEST: testComparisonFilter"); + final var filter = new JsonPathAst.ComparisonFilter( + new JsonPathAst.PropertyPath(List.of("price")), + JsonPathAst.ComparisonOp.LT, + new JsonPathAst.LiteralValue(10) + ); + assertThat(filter.op()).isEqualTo(JsonPathAst.ComparisonOp.LT); + } + + @Test + void testPropertyPath() { + LOG.info(() -> "TEST: testPropertyPath"); + final var path = new JsonPathAst.PropertyPath(List.of("store", "book", "price")); + assertThat(path.properties()).containsExactly("store", "book", "price"); + } + + @Test + void testPropertyPathRequiresAtLeastOneProperty() { + LOG.info(() -> "TEST: testPropertyPathRequiresAtLeastOneProperty"); + assertThatThrownBy(() -> new JsonPathAst.PropertyPath(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one property"); + } + + @Test + void testComparisonOps() { + LOG.info(() -> "TEST: testComparisonOps"); + assertThat(JsonPathAst.ComparisonOp.EQ.symbol()).isEqualTo("=="); + assertThat(JsonPathAst.ComparisonOp.NE.symbol()).isEqualTo("!="); + assertThat(JsonPathAst.ComparisonOp.LT.symbol()).isEqualTo("<"); + assertThat(JsonPathAst.ComparisonOp.LE.symbol()).isEqualTo("<="); + assertThat(JsonPathAst.ComparisonOp.GT.symbol()).isEqualTo(">"); + assertThat(JsonPathAst.ComparisonOp.GE.symbol()).isEqualTo(">="); + } + + @Test + void testLogicalOps() { + LOG.info(() -> "TEST: testLogicalOps"); + assertThat(JsonPathAst.LogicalOp.AND.symbol()).isEqualTo("&&"); + assertThat(JsonPathAst.LogicalOp.OR.symbol()).isEqualTo("||"); + assertThat(JsonPathAst.LogicalOp.NOT.symbol()).isEqualTo("!"); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java new file mode 100644 index 0000000..1b692b5 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java @@ -0,0 +1,385 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Tests for JsonPath based on examples from [...](https://goessner.net/articles/JsonPath/) +/// This test class uses the sample JSON document from the article. +class JsonPathGoessnerTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathGoessnerTest.class.getName()); + + /// Sample JSON from Goessner article + private static final String STORE_JSON = """ + { + "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 + } + } + } + """; + + private static JsonValue storeJson; + + @BeforeAll + static void parseJson() { + storeJson = Json.parse(STORE_JSON); + LOG.info(() -> "Parsed store JSON for Goessner tests"); + } + + // ========== Basic path queries ========== + + @Test + void testRootOnly() { + LOG.info(() -> "TEST: testRootOnly - $ returns the root document"); + final var results = JsonPath.parse("$").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isEqualTo(storeJson); + } + + @Test + void testSingleProperty() { + LOG.info(() -> "TEST: testSingleProperty - $.store returns the store object"); + final var results = JsonPath.parse("$.store").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonObject.class); + } + + @Test + void testNestedProperty() { + LOG.info(() -> "TEST: testNestedProperty - $.store.bicycle returns the bicycle object"); + final var results = JsonPath.parse("$.store.bicycle").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonObject.class); + final var bicycle = (JsonObject) results.getFirst(); + assertThat(bicycle.members().get("color")).isInstanceOf(JsonString.class); + assertThat(bicycle.members().get("color").string()).isEqualTo("red"); + } + + // ========== Goessner Article Examples ========== + + @Test + void testAuthorsOfAllBooks() { + LOG.info(() -> "TEST: testAuthorsOfAllBooks - $.store.book[*].author"); + final var results = JsonPath.parse("$.store.book[*].author").query(storeJson); + assertThat(results).hasSize(4); + final var authors = results.stream() + .map(JsonValue::string) + .toList(); + assertThat(authors).containsExactly( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void testAllBooks() { + LOG.info(() -> "TEST: testAllBooks - $.store.book"); + final var results = JsonPath.parse("$.store.book").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonArray.class); + assertThat(((JsonArray) results.getFirst()).elements()).hasSize(4); + } + + @Test + void testAllAuthorsRecursive() { + LOG.info(() -> "TEST: testAllAuthorsRecursive - $..author"); + final var results = JsonPath.parse("$..author").query(storeJson); + assertThat(results).hasSize(4); + final var authors = results.stream() + .map(JsonValue::string) + .toList(); + assertThat(authors).containsExactlyInAnyOrder( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void testAllThingsInStore() { + LOG.info(() -> "TEST: testAllThingsInStore - $.store.*"); + final var results = JsonPath.parse("$.store.*").query(storeJson); + assertThat(results).hasSize(2); // book array and bicycle object + } + + @Test + void testAllPricesInStore() { + LOG.info(() -> "TEST: testAllPricesInStore - $.store..price"); + final var results = JsonPath.parse("$.store..price").query(storeJson); + assertThat(results).hasSize(5); // 4 book prices + 1 bicycle price + final var prices = results.stream() + .map(JsonValue::toDouble) + .toList(); + assertThat(prices).containsExactlyInAnyOrder(8.95, 12.99, 8.99, 22.99, 19.95); + } + + @Test + void testThirdBook() { + LOG.info(() -> "TEST: testThirdBook - $..book[2]"); + final var results = JsonPath.parse("$..book[2]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("Moby Dick"); + } + + @Test + void testLastBookScriptExpression() { + LOG.info(() -> "TEST: testLastBookScriptExpression - $..book[(@.length-1)]"); + final var results = JsonPath.parse("$..book[(@.length-1)]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void testLastBookSlice() { + LOG.info(() -> "TEST: testLastBookSlice - $..book[-1:]"); + final var results = JsonPath.parse("$..book[-1:]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void testFirstTwoBooksUnion() { + LOG.info(() -> "TEST: testFirstTwoBooksUnion - $..book[0,1]"); + final var results = JsonPath.parse("$..book[0,1]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactly("Sayings of the Century", "Sword of Honour"); + } + + @Test + void testFirstTwoBooksSlice() { + LOG.info(() -> "TEST: testFirstTwoBooksSlice - $..book[:2]"); + final var results = JsonPath.parse("$..book[:2]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactly("Sayings of the Century", "Sword of Honour"); + } + + @Test + void testBooksWithIsbn() { + LOG.info(() -> "TEST: testBooksWithIsbn - $..book[?(@.isbn)]"); + final var results = JsonPath.parse("$..book[?(@.isbn)]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactlyInAnyOrder("Moby Dick", "The Lord of the Rings"); + } + + @Test + void testBooksCheaperThan10() { + LOG.info(() -> "TEST: testBooksCheaperThan10 - $..book[?(@.price<10)]"); + final var results = JsonPath.parse("$..book[?(@.price<10)]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactlyInAnyOrder("Sayings of the Century", "Moby Dick"); + } + + @Test + void testAllMembersRecursive() { + LOG.info(() -> "TEST: testAllMembersRecursive - $..*"); + final var results = JsonPath.parse("$..*").query(storeJson); + // This should return all nodes in the tree + assertThat(results).isNotEmpty(); + LOG.fine(() -> "Found " + results.size() + " members recursively"); + } + + // ========== Additional edge cases ========== + + @Test + void testArrayIndexFirst() { + LOG.info(() -> "TEST: testArrayIndexFirst - $.store.book[0]"); + final var results = JsonPath.parse("$.store.book[0]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("author").string()).isEqualTo("Nigel Rees"); + } + + @Test + void testArrayIndexNegative() { + LOG.info(() -> "TEST: testArrayIndexNegative - $.store.book[-1]"); + final var results = JsonPath.parse("$.store.book[-1]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("author").string()).isEqualTo("J. R. R. Tolkien"); + } + + @Test + void testBracketNotationProperty() { + LOG.info(() -> "TEST: testBracketNotationProperty - $['store']['book'][0]"); + final var results = JsonPath.parse("$['store']['book'][0]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("author").string()).isEqualTo("Nigel Rees"); + } + + @Test + void testFilterEquality() { + LOG.info(() -> "TEST: testFilterEquality - $..book[?(@.category=='fiction')]"); + final var results = JsonPath.parse("$..book[?(@.category=='fiction')]").query(storeJson); + assertThat(results).hasSize(3); // 3 fiction books + } + + @Test + void testPropertyNotFound() { + LOG.info(() -> "TEST: testPropertyNotFound - $.nonexistent"); + final var results = JsonPath.parse("$.nonexistent").query(storeJson); + assertThat(results).isEmpty(); + } + + @Test + void testArrayIndexOutOfBounds() { + LOG.info(() -> "TEST: testArrayIndexOutOfBounds - $.store.book[100]"); + final var results = JsonPath.parse("$.store.book[100]").query(storeJson); + assertThat(results).isEmpty(); + } + + @Test + void testSliceWithStep() { + LOG.info(() -> "TEST: testSliceWithStep - $.store.book[0:4:2] (every other book)"); + final var results = JsonPath.parse("$.store.book[0:4:2]").query(storeJson); + assertThat(results).hasSize(2); // books at index 0 and 2 + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactly("Sayings of the Century", "Moby Dick"); + } + + @Test + void testDeepNestedAccess() { + LOG.info(() -> "TEST: testDeepNestedAccess - $.store.book[0].title"); + final var results = JsonPath.parse("$.store.book[0].title").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst().string()).isEqualTo("Sayings of the Century"); + } + + @Test + void testRecursiveDescentOnArray() { + LOG.info(() -> "TEST: testRecursiveDescentOnArray - $..book"); + final var results = JsonPath.parse("$..book").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonArray.class); + } + + @Test + void testPropertyUnion() { + LOG.info(() -> "TEST: testPropertyUnion - $.store['book','bicycle']"); + final var results = JsonPath.parse("$.store['book','bicycle']").query(storeJson); + assertThat(results).hasSize(2); + } + + @Test + void testFilterGreaterThan() { + LOG.info(() -> "TEST: testFilterGreaterThan - $..book[?(@.price>20)]"); + final var results = JsonPath.parse("$..book[?(@.price>20)]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void testFilterLessOrEqual() { + LOG.info(() -> "TEST: testFilterLessOrEqual - $..book[?(@.price<=8.99)]"); + final var results = JsonPath.parse("$..book[?(@.price<=8.99)]").query(storeJson); + assertThat(results).hasSize(2); + } + + // ========== Fluent API tests ========== + + @Test + void testFluentApiParseAndSelect() { + LOG.info(() -> "TEST: testFluentApiParseAndSelect - JsonPath.parse(...).query(...)"); + final var matches = JsonPath.parse("$.store.book").query(storeJson); + assertThat(matches).hasSize(1); + assertThat(matches.getFirst()).isInstanceOf(JsonArray.class); + final var bookArray = (JsonArray) matches.getFirst(); + assertThat(bookArray.elements()).hasSize(4); // 4 books in the array + } + + @Test + void testStaticQueryWithCompiledPath() { + LOG.info(() -> "TEST: testStaticQueryWithCompiledPath - JsonPath.query(JsonPath, JsonValue) does not re-parse"); + final var compiled = JsonPath.parse("$.store.book[*].author"); + final var results = JsonPath.query(compiled, storeJson); + assertThat(results).hasSize(4); + assertThat(results.stream().map(JsonValue::string).toList()).containsExactly( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void testFluentApiReusable() { + LOG.info(() -> "TEST: testFluentApiReusable - compiled path can be reused"); + final var compiledPath = JsonPath.parse("$..price"); + + // Use on store doc + final var storeResults = compiledPath.query(storeJson); + assertThat(storeResults).hasSize(5); // 4 book prices + 1 bicycle price + + // Use on a different doc + final var simpleDoc = Json.parse(""" + {"item": {"price": 99.99}} + """); + final var simpleResults = compiledPath.query(simpleDoc); + assertThat(simpleResults).hasSize(1); + assertThat(simpleResults.getFirst().toDouble()).isEqualTo(99.99); + } + + @Test + void testFluentApiExpressionAccessor() { + LOG.info(() -> "TEST: testFluentApiExpressionAccessor - expression() returns original path"); + final var path = JsonPath.parse("$.store.book[*].author"); + assertThat(path.expression()).isEqualTo("$.store.book[*].author"); + } +} 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..49eecd5 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathLoggingConfig.java @@ -0,0 +1,46 @@ +package json.java21.jsonpath; + +import org.junit.jupiter.api.BeforeAll; +import java.util.Locale; +import java.util.logging.*; + +/// Base class for JsonPath tests that configures JUL logging from system properties. +/// All test classes should extend this class to enable consistent logging behavior. +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); + } + } + } + // Ensure the root logger honors the most verbose configured level + 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); + } + } + + // Ensure test resource base is absolute and portable across CI and local runs + String prop = System.getProperty("jsonpath.test.resources"); + if (prop == null || prop.isBlank()) { + java.nio.file.Path base = java.nio.file.Paths.get("src", "test", "resources").toAbsolutePath(); + System.setProperty("jsonpath.test.resources", base.toString()); + Logger.getLogger(JsonPathLoggingConfig.class.getName()).config( + () -> "jsonpath.test.resources set to " + base); + } + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParserTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParserTest.java new file mode 100644 index 0000000..76c8c6c --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParserTest.java @@ -0,0 +1,365 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// Unit tests for JsonPathParser - tests parsing of JsonPath strings to AST +/// Based on examples from [...](https://goessner.net/articles/JsonPath/) +class JsonPathParserTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathParserTest.class.getName()); + + // ========== Basic path parsing ========== + + @Test + void testParseRootOnly() { + LOG.info(() -> "TEST: testParseRootOnly - parse $"); + final var ast = JsonPathParser.parse("$"); + assertThat(ast).isInstanceOf(JsonPathAst.Root.class); + assertThat(ast.segments()).isEmpty(); + } + + @Test + void testParseSingleProperty() { + LOG.info(() -> "TEST: testParseSingleProperty - parse $.store"); + final var ast = JsonPathParser.parse("$.store"); + assertThat(ast.segments()).hasSize(1); + assertThat(ast.segments().getFirst()).isInstanceOf(JsonPathAst.PropertyAccess.class); + final var prop = (JsonPathAst.PropertyAccess) ast.segments().getFirst(); + assertThat(prop.name()).isEqualTo("store"); + } + + @Test + void testParseNestedProperties() { + LOG.info(() -> "TEST: testParseNestedProperties - parse $.store.book"); + final var ast = JsonPathParser.parse("$.store.book"); + assertThat(ast.segments()).hasSize(2); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(1)).name()).isEqualTo("book"); + } + + @Test + void testParseBracketNotation() { + LOG.info(() -> "TEST: testParseBracketNotation - parse $['store']"); + final var ast = JsonPathParser.parse("$['store']"); + assertThat(ast.segments()).hasSize(1); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().getFirst()).name()).isEqualTo("store"); + } + + @Test + void testParseBracketNotationWithDoubleQuotes() { + LOG.info(() -> "TEST: testParseBracketNotationWithDoubleQuotes - parse $[\"store\"]"); + final var ast = JsonPathParser.parse("$[\"store\"]"); + assertThat(ast.segments()).hasSize(1); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().getFirst()).name()).isEqualTo("store"); + } + + // ========== Array index parsing ========== + + @Test + void testParseArrayIndex() { + LOG.info(() -> "TEST: testParseArrayIndex - parse $.store.book[0]"); + final var ast = JsonPathParser.parse("$.store.book[0]"); + assertThat(ast.segments()).hasSize(3); + assertThat(ast.segments().get(2)).isInstanceOf(JsonPathAst.ArrayIndex.class); + final var index = (JsonPathAst.ArrayIndex) ast.segments().get(2); + assertThat(index.index()).isEqualTo(0); + } + + @Test + void testParseNegativeArrayIndex() { + LOG.info(() -> "TEST: testParseNegativeArrayIndex - parse $..book[-1]"); + final var ast = JsonPathParser.parse("$..book[-1]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArrayIndex.class); + final var index = (JsonPathAst.ArrayIndex) ast.segments().get(1); + assertThat(index.index()).isEqualTo(-1); + } + + @Test + void testParseThirdBook() { + LOG.info(() -> "TEST: testParseThirdBook - parse $..book[2] (third book)"); + final var ast = JsonPathParser.parse("$..book[2]"); + assertThat(ast.segments()).hasSize(2); + // First segment is recursive descent for book + assertThat(ast.segments().get(0)).isInstanceOf(JsonPathAst.RecursiveDescent.class); + // Second segment is index 2 + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArrayIndex.class); + assertThat(((JsonPathAst.ArrayIndex) ast.segments().get(1)).index()).isEqualTo(2); + } + + // ========== Wildcard parsing ========== + + @Test + void testParseWildcard() { + LOG.info(() -> "TEST: testParseWildcard - parse $.store.*"); + final var ast = JsonPathParser.parse("$.store.*"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Wildcard.class); + } + + @Test + void testParseWildcardInBrackets() { + LOG.info(() -> "TEST: testParseWildcardInBrackets - parse $.store.book[*]"); + final var ast = JsonPathParser.parse("$.store.book[*]"); + assertThat(ast.segments()).hasSize(3); + assertThat(ast.segments().get(2)).isInstanceOf(JsonPathAst.Wildcard.class); + } + + @Test + void testParseAllAuthors() { + LOG.info(() -> "TEST: testParseAllAuthors - parse $.store.book[*].author"); + final var ast = JsonPathParser.parse("$.store.book[*].author"); + assertThat(ast.segments()).hasSize(4); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(1)).name()).isEqualTo("book"); + assertThat(ast.segments().get(2)).isInstanceOf(JsonPathAst.Wildcard.class); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(3)).name()).isEqualTo("author"); + } + + // ========== Recursive descent parsing ========== + + @Test + void testParseRecursiveDescent() { + LOG.info(() -> "TEST: testParseRecursiveDescent - parse $..author"); + final var ast = JsonPathParser.parse("$..author"); + assertThat(ast.segments()).hasSize(1); + assertThat(ast.segments().getFirst()).isInstanceOf(JsonPathAst.RecursiveDescent.class); + final var descent = (JsonPathAst.RecursiveDescent) ast.segments().getFirst(); + assertThat(descent.target()).isInstanceOf(JsonPathAst.PropertyAccess.class); + assertThat(((JsonPathAst.PropertyAccess) descent.target()).name()).isEqualTo("author"); + } + + @Test + void testParseRecursiveDescentWithWildcard() { + LOG.info(() -> "TEST: testParseRecursiveDescentWithWildcard - parse $..*"); + final var ast = JsonPathParser.parse("$..*"); + assertThat(ast.segments()).hasSize(1); + assertThat(ast.segments().getFirst()).isInstanceOf(JsonPathAst.RecursiveDescent.class); + final var descent = (JsonPathAst.RecursiveDescent) ast.segments().getFirst(); + assertThat(descent.target()).isInstanceOf(JsonPathAst.Wildcard.class); + } + + @Test + void testParseStorePrice() { + LOG.info(() -> "TEST: testParseStorePrice - parse $.store..price"); + final var ast = JsonPathParser.parse("$.store..price"); + assertThat(ast.segments()).hasSize(2); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.RecursiveDescent.class); + final var descent = (JsonPathAst.RecursiveDescent) ast.segments().get(1); + assertThat(((JsonPathAst.PropertyAccess) descent.target()).name()).isEqualTo("price"); + } + + // ========== Slice parsing ========== + + @Test + void testParseSlice() { + LOG.info(() -> "TEST: testParseSlice - parse $..book[:2] (first two books)"); + final var ast = JsonPathParser.parse("$..book[:2]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArraySlice.class); + final var slice = (JsonPathAst.ArraySlice) ast.segments().get(1); + assertThat(slice.start()).isNull(); + assertThat(slice.end()).isEqualTo(2); + assertThat(slice.step()).isNull(); + } + + @Test + void testParseSliceFromEnd() { + LOG.info(() -> "TEST: testParseSliceFromEnd - parse $..book[-1:] (last book)"); + final var ast = JsonPathParser.parse("$..book[-1:]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArraySlice.class); + final var slice = (JsonPathAst.ArraySlice) ast.segments().get(1); + assertThat(slice.start()).isEqualTo(-1); + assertThat(slice.end()).isNull(); + assertThat(slice.step()).isNull(); + } + + @Test + void testParseSliceWithStep() { + LOG.info(() -> "TEST: testParseSliceWithStep - parse $.book[0:10:2] (every other book)"); + final var ast = JsonPathParser.parse("$.book[0:10:2]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArraySlice.class); + final var slice = (JsonPathAst.ArraySlice) ast.segments().get(1); + assertThat(slice.start()).isEqualTo(0); + assertThat(slice.end()).isEqualTo(10); + assertThat(slice.step()).isEqualTo(2); + } + + // ========== Union parsing ========== + + @Test + void testParseUnionIndices() { + LOG.info(() -> "TEST: testParseUnionIndices - parse $..book[0,1] (first two books)"); + final var ast = JsonPathParser.parse("$..book[0,1]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Union.class); + final var union = (JsonPathAst.Union) ast.segments().get(1); + assertThat(union.selectors()).hasSize(2); + assertThat(((JsonPathAst.ArrayIndex) union.selectors().get(0)).index()).isEqualTo(0); + assertThat(((JsonPathAst.ArrayIndex) union.selectors().get(1)).index()).isEqualTo(1); + } + + @Test + void testParseUnionProperties() { + LOG.info(() -> "TEST: testParseUnionProperties - parse $.store['book','bicycle']"); + final var ast = JsonPathParser.parse("$.store['book','bicycle']"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Union.class); + final var union = (JsonPathAst.Union) ast.segments().get(1); + assertThat(union.selectors()).hasSize(2); + assertThat(((JsonPathAst.PropertyAccess) union.selectors().get(0)).name()).isEqualTo("book"); + assertThat(((JsonPathAst.PropertyAccess) union.selectors().get(1)).name()).isEqualTo("bicycle"); + } + + // ========== Filter parsing ========== + + @Test + void testParseFilterExists() { + LOG.info(() -> "TEST: testParseFilterExists - parse $..book[?(@.isbn)] (books with ISBN)"); + final var ast = JsonPathParser.parse("$..book[?(@.isbn)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ExistsFilter.class); + } + + @Test + void testParseFilterComparison() { + LOG.info(() -> "TEST: testParseFilterComparison - parse $..book[?(@.price<10)] (books cheaper than 10)"); + final var ast = JsonPathParser.parse("$..book[?(@.price<10)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ComparisonFilter.class); + final var comparison = (JsonPathAst.ComparisonFilter) filter.expression(); + assertThat(comparison.op()).isEqualTo(JsonPathAst.ComparisonOp.LT); + } + + @Test + void testParseFilterComparisonWithEquals() { + LOG.info(() -> "TEST: testParseFilterComparisonWithEquals - parse $..book[?(@.category=='fiction')]"); + final var ast = JsonPathParser.parse("$..book[?(@.category=='fiction')]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ComparisonFilter.class); + final var comparison = (JsonPathAst.ComparisonFilter) filter.expression(); + assertThat(comparison.op()).isEqualTo(JsonPathAst.ComparisonOp.EQ); + } + + // ========== Script expression parsing ========== + + @Test + void testParseScriptExpression() { + LOG.info(() -> "TEST: testParseScriptExpression - parse $..book[(@.length-1)] (last book)"); + final var ast = JsonPathParser.parse("$..book[(@.length-1)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ScriptExpression.class); + final var script = (JsonPathAst.ScriptExpression) ast.segments().get(1); + assertThat(script.script()).isEqualTo("@.length-1"); + } + + // ========== Complex paths ========== + + @Test + void testParsePropertyAfterArrayIndex() { + LOG.info(() -> "TEST: testParsePropertyAfterArrayIndex - parse $.store.book[0].title"); + final var ast = JsonPathParser.parse("$.store.book[0].title"); + assertThat(ast.segments()).hasSize(4); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(1)).name()).isEqualTo("book"); + assertThat(((JsonPathAst.ArrayIndex) ast.segments().get(2)).index()).isEqualTo(0); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(3)).name()).isEqualTo("title"); + } + + // ========== Error cases ========== + + @Test + void testParseEmptyStringThrows() { + LOG.info(() -> "TEST: testParseEmptyStringThrows"); + assertThatThrownBy(() -> JsonPathParser.parse("")) + .isInstanceOf(JsonPathParseException.class) + .hasMessageContaining("must start with $"); + } + + @Test + void testParseNullThrows() { + LOG.info(() -> "TEST: testParseNullThrows"); + assertThatThrownBy(() -> JsonPathParser.parse(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void testParseMissingRootThrows() { + LOG.info(() -> "TEST: testParseMissingRootThrows"); + assertThatThrownBy(() -> JsonPathParser.parse("store.book")) + .isInstanceOf(JsonPathParseException.class) + .hasMessageContaining("must start with $"); + } + + @ParameterizedTest + @ValueSource(strings = {"$.", "$[", "$...", "$.store[", "$.store."}) + void testParseIncompletePathThrows(String path) { + LOG.info(() -> "TEST: testParseIncompletePathThrows - " + path); + assertThatThrownBy(() -> JsonPathParser.parse(path)) + .isInstanceOf(JsonPathParseException.class); + } + + public static void main(String[] args) { + final var storeDoc = """ + { "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 + } + } + } + """; + + final JsonValue doc = Json.parse(storeDoc); + + final var path = args.length > 0 ? args[0] : "$.store.book"; + final var compiled = JsonPath.parse(path); + final var matches = compiled.query(doc); + + System.out.println("path: " + path); + System.out.println("matches: " + matches.size()); + matches.forEach(v -> System.out.println(Json.toDisplayString(v, 2))); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/test/TestPublicAPI.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/test/TestPublicAPI.java new file mode 100644 index 0000000..65ee09f --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/test/TestPublicAPI.java @@ -0,0 +1,66 @@ +package json.java21.jsonpath.test; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonArray; +import jdk.sandbox.java.util.json.JsonValue; +import json.java21.jsonpath.JsonPath; + +import java.util.List; + +public class TestPublicAPI { + /// Sample JSON from Goessner article + private static final String STORE_JSON = """ + { + "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 + } + } + } + """; + + public static void main(String[] args) { + final JsonValue doc = Json.parse(STORE_JSON); + final JsonPath path = JsonPath.parse("$.store.book[*].title"); + + final List matches = path.query(doc).stream().map(Object::toString).toList(); + if( matches.size()!=4){ + throw new AssertionError("Expected 4 books, got " + matches.size()); + } + + final var raw = JsonPath.parse("$.store.book").query(doc); + if( raw instanceof JsonArray array && array.elements().size() != 4) { + throw new AssertionError("Expected 4 books, got " + raw.size()); + } + } + +} 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