diff --git a/json-java21-jsonpath/AGENTS.md b/json-java21-jsonpath/AGENTS.md index b8d9ba7..706d683 100644 --- a/json-java21-jsonpath/AGENTS.md +++ b/json-java21-jsonpath/AGENTS.md @@ -5,13 +5,43 @@ This file is for contributor/agent operational notes. Read `json-java21-jsonpath - User docs MUST recommend only `./mvnw`. - The `$(command -v mvnd || command -v mvn || command -v ./mvnw)` wrapper is for local developer speed only; do not put it in user-facing docs. -Stable code entry points: -- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java` -- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java` -- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java` -- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java` -- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathStreams.java` - -When changing syntax/behavior: -- Update `JsonPathAst` + `JsonPathParser` + `JsonPath` together. +## Architecture + +JsonPath is a sealed interface with two implementations: +- `JsonPathInterpreted`: AST-walking interpreter (default from `JsonPath.parse()`) +- `JsonPathCompiled`: Bytecode-compiled version (from `JsonPath.compile()`) + +The compilation flow: +1. `JsonPath.parse()` -> `JsonPathInterpreted` (fast parsing, AST-based evaluation) +2. `JsonPath.compile()` -> `JsonPathCompiled` (generates Java source, compiles with JDK ToolProvider) + +## Stable Code Entry Points + +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java` - Public sealed interface +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java` - Parses expressions to AST +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java` - AST node definitions +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathInterpreted.java` - AST-walking evaluator +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiler.java` - Code generator and compiler +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiled.java` - Compiled executor wrapper +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathExecutor.java` - Public interface for generated executors +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathHelpers.java` - Helpers for generated code +- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathStreams.java` - Stream processing utilities + +## When Changing Syntax/Behavior + +- Update `JsonPathAst` + `JsonPathParser` + `JsonPathInterpreted` together. +- Update `JsonPathCompiler` code generation to match any AST changes. - Add parser + evaluation tests; new tests should extend `JsonPathLoggingConfig`. +- Add compiler tests in `JsonPathCompilerTest` to verify compiled and interpreted produce identical results. + +## Code Generation Notes + +The `JsonPathCompiler` generates Java source code that: +- Imports from `jdk.sandbox.java.util.json.*` and `json.java21.jsonpath.*` +- Implements `JsonPathExecutor` functional interface +- Uses `JsonPathHelpers` for complex operations (recursive descent, comparisons) +- Is compiled in-memory using `javax.tools.ToolProvider` + +When adding new AST node types: +1. Add the case to `generateSegmentEvaluation()` in `JsonPathCompiler` +2. Consider if a helper method in `JsonPathHelpers` would simplify the generated code diff --git a/json-java21-jsonpath/README.md b/json-java21-jsonpath/README.md index f38d36f..5646450 100644 --- a/json-java21-jsonpath/README.md +++ b/json-java21-jsonpath/README.md @@ -60,6 +60,57 @@ This implementation follows Goessner-style JSONPath operators, including: - `[?(@.prop)]` and `[?(@.prop op value)]` basic filters - `[(@.length-1)]` limited script support +## Runtime Compilation (Performance Optimization) + +For performance-critical code paths, you can compile a parsed JsonPath to bytecode: + +```java +// Parse once, compile for best performance +JsonPath compiled = JsonPath.compile(JsonPath.parse("$.store.book[*].author")); + +// Reuse the compiled path for many documents +for (JsonValue doc : documents) { + List results = compiled.query(doc); + // process results... +} +``` + +### How It Works + +The `JsonPath.compile()` method: +1. Takes a parsed (interpreted) JsonPath +2. Generates optimized Java source code +3. Compiles it to bytecode using the JDK compiler API (`javax.tools.ToolProvider`) +4. Returns a compiled JsonPath that executes as native bytecode + +### When to Use Compilation + +- **Hot paths**: Use `compile()` when the same path will be executed many times +- **One-off queries**: Use `parse()` directly for single-use paths (compilation overhead isn't worth it) +- **JRE environments**: Compilation requires a JDK; if unavailable, use interpreted paths + +### Compilation is Idempotent + +Calling `compile()` on an already-compiled path returns the same instance: + +```java +JsonPath interpreted = JsonPath.parse("$.store"); +JsonPath compiled = JsonPath.compile(interpreted); +JsonPath sameCompiled = JsonPath.compile(compiled); // Returns same instance + +// Check if a path is compiled +boolean isCompiled = compiled.isCompiled(); +boolean isInterpreted = interpreted.isInterpreted(); +``` + +### Supported Features in Compiled Mode + +All JsonPath features are supported in compiled mode: +- Property access, array indices, slices, wildcards +- Recursive descent (`$..property`) +- Filters with comparisons and logical operators +- Unions and limited script expressions (e.g., `[(@.length-1)]`) + ## Stream-Based Functions (Aggregations) Some JsonPath implementations include aggregation functions such as `$.numbers.avg()`. 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 index a18225a..fc413de 100644 --- a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java @@ -1,13 +1,12 @@ package json.java21.jsonpath; -import jdk.sandbox.java.util.json.*; +import jdk.sandbox.java.util.json.JsonValue; -import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.logging.Logger; -/// JsonPath query evaluator for JSON documents. +/// JsonPath query evaluator interface for JSON documents. /// Parses JsonPath expressions and evaluates them against JsonValue instances. /// /// Usage examples: @@ -19,30 +18,16 @@ /// // Compiled + static call site (also reusable) /// JsonPath path = JsonPath.parse("$.store.book[*].author"); /// List results = JsonPath.query(path, json); +/// +/// // Runtime-compiled for best performance +/// JsonPath compiled = JsonPath.compile(path); +/// List results = compiled.query(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()); +public sealed interface JsonPath permits JsonPathInterpreted, JsonPathCompiled { - private final JsonPathAst.Root ast; - - private JsonPath(JsonPathAst.Root ast) { - 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(ast); - } + Logger LOG = Logger.getLogger(JsonPath.class.getName()); /// Queries matching values from a JSON document. /// @@ -52,16 +37,30 @@ public static JsonPath parse(String path) { /// @param json the JSON document to query /// @return a list of matching JsonValue instances (maybe 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: " + this); - return evaluate(ast, json); + List query(JsonValue json); + + /// Parses a JsonPath expression and returns an interpreted JsonPath for reuse. + /// @param path the JsonPath expression + /// @return an interpreted JsonPath that can be used to select from multiple documents + /// @throws NullPointerException if path is null + /// @throws JsonPathParseException if the path is invalid + 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 JsonPathInterpreted(ast); } - /// Reconstructs the JsonPath expression from the AST. - @Override - public String toString() { - return reconstruct(ast); + /// Compiles a JsonPath into optimized bytecode for best performance. + /// If the input is already compiled, returns it unchanged. + /// @param path the JsonPath to compile (typically from `parse()`) + /// @return a compiled JsonPath with optimal performance + static JsonPath compile(JsonPath path) { + Objects.requireNonNull(path, "path must not be null"); + return switch (path) { + case JsonPathCompiled compiled -> compiled; + case JsonPathInterpreted interpreted -> JsonPathCompiler.compile(interpreted); + }; } /// Evaluates a compiled JsonPath against a JSON document. @@ -69,7 +68,7 @@ public String toString() { /// @param json the JSON document to query /// @return a list of matching JsonValue instances (maybe empty) /// @throws NullPointerException if path or JSON is null - public static List query(JsonPath path, JsonValue json) { + static List query(JsonPath path, JsonValue json) { Objects.requireNonNull(path, "path must not be null"); return path.query(json); } @@ -83,534 +82,29 @@ public static List query(JsonPath path, JsonValue json) { /// @return a list of matching JsonValue instances (maybe empty) /// @throws NullPointerException if path or JSON is null /// @throws JsonPathParseException if the path is invalid - public static List query(String path, JsonValue json) { + static List query(String path, JsonValue json) { Objects.requireNonNull(path, "path must not be null"); Objects.requireNonNull(json, "json must not be null"); return parse(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 (maybe 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 ignored -> 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); - } + /// Returns true if this JsonPath is an AST-based interpreter (not yet compiled). + default boolean isInterpreted() { + return this instanceof JsonPathInterpreted; } - 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); - } - } + /// Returns true if this JsonPath is compiled to bytecode. + default boolean isCompiled() { + return this instanceof JsonPathCompiled; } - 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(); - - final int step = slice.step() != null ? slice.step() : 1; - - if (step == 0) { - return; // Invalid step - } - - if (step > 0) { - int start = slice.start() != null ? normalizeIndex(slice.start(), size) : 0; - int end = slice.end() != null ? normalizeIndex(slice.end(), size) : size; - - start = Math.max(0, Math.min(start, size)); - end = Math.max(0, Math.min(end, size)); - - for (int i = start; i < end; i += step) { - evaluateSegments(segments, index + 1, elements.get(i), root, results); - } - } else { - int start = slice.start() != null ? normalizeIndex(slice.start(), size) : size - 1; - final int end = slice.end() != null ? normalizeIndex(slice.end(), size) : -1; - - start = Math.max(0, Math.min(start, size - 1)); - - 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 ignored -> { - 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 ignored1 -> true; - case JsonPathAst.PropertyPath path -> resolvePropertyPath(path, current) != null; - case JsonPathAst.LiteralValue ignored -> true; + /// Returns the AST for this JsonPath, if available. + /// Compiled paths may not have the AST readily available. + /// @return the AST root, or null if not available + default JsonPathAst.Root ast() { + return switch (this) { + case JsonPathInterpreted interpreted -> interpreted.ast(); + case JsonPathCompiled compiled -> compiled.ast(); }; } - - 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 ignored -> 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 ignored -> 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 - switch (left) { - case Number leftNum when 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 - case String ignored when 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 - case Boolean ignored when right instanceof Boolean -> { - return switch (op) { - case EQ -> left.equals(right); - case NE -> !left.equals(right); - default -> false; - }; - } - default -> { - } - } - - // 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); - } - } - } - - private static String reconstruct(JsonPathAst.Root root) { - final var sb = new StringBuilder("$"); - for (final var segment : root.segments()) { - appendSegment(sb, segment); - } - return sb.toString(); - } - - private static void appendSegment(StringBuilder sb, JsonPathAst.Segment segment) { - switch (segment) { - case JsonPathAst.PropertyAccess prop -> { - if (isSimpleIdentifier(prop.name())) { - sb.append(".").append(prop.name()); - } else { - sb.append("['").append(escape(prop.name())).append("']"); - } - } - case JsonPathAst.ArrayIndex arr -> sb.append("[").append(arr.index()).append("]"); - case JsonPathAst.ArraySlice slice -> { - sb.append("["); - if (slice.start() != null) sb.append(slice.start()); - sb.append(":"); - if (slice.end() != null) sb.append(slice.end()); - if (slice.step() != null) sb.append(":").append(slice.step()); - sb.append("]"); - } - case JsonPathAst.Wildcard ignored -> sb.append(".*"); - case JsonPathAst.RecursiveDescent desc -> { - sb.append(".."); - // RecursiveDescent target is usually PropertyAccess or Wildcard, - // but can be other things in theory. - // If target is PropertyAccess("foo"), append "foo". - // If target is Wildcard, append "*". - // Our AST structure wraps the target segment. - // We need to handle how it's appended. - // appendSegment prepends "." or "[" usually. - // But ".." replaces the dot. - // Let special case the target printing. - appendRecursiveTarget(sb, desc.target()); - } - case JsonPathAst.Filter filter -> { - sb.append("[?("); - appendFilterExpression(sb, filter.expression()); - sb.append(")]"); - } - case JsonPathAst.Union union -> { - sb.append("["); - final var selectors = union.selectors(); - for (int i = 0; i < selectors.size(); i++) { - if (i > 0) sb.append(","); - appendUnionSelector(sb, selectors.get(i)); - } - sb.append("]"); - } - case JsonPathAst.ScriptExpression script -> sb.append("[(").append(script.script()).append(")]"); - } - } - - private static void appendRecursiveTarget(StringBuilder sb, JsonPathAst.Segment target) { - if (target instanceof JsonPathAst.PropertyAccess(String name)) { - sb.append(name); // ..name - } else if (target instanceof JsonPathAst.Wildcard) { - sb.append("*"); // ..* - } else { - // Fallback for other types if they ever occur in recursive position - appendSegment(sb, target); - } - } - - private static void appendUnionSelector(StringBuilder sb, JsonPathAst.Segment selector) { - if (selector instanceof JsonPathAst.PropertyAccess(String name)) { - sb.append("'").append(escape(name)).append("'"); - } else if (selector instanceof JsonPathAst.ArrayIndex(int index)) { - sb.append(index); - } else { - // Fallback - appendSegment(sb, selector); - } - } - - private static void appendFilterExpression(StringBuilder sb, JsonPathAst.FilterExpression expr) { - switch (expr) { - case JsonPathAst.ExistsFilter exists -> appendFilterExpression(sb, exists.path()); // Should print the path - case JsonPathAst.ComparisonFilter comp -> { - appendFilterExpression(sb, comp.left()); - sb.append(comp.op().symbol()); - appendFilterExpression(sb, comp.right()); - } - case JsonPathAst.LogicalFilter logical -> { - if (logical.op() == JsonPathAst.LogicalOp.NOT) { - sb.append("!"); - appendFilterExpression(sb, logical.left()); - } else { - sb.append("("); - appendFilterExpression(sb, logical.left()); - sb.append(" ").append(logical.op().symbol()).append(" "); - appendFilterExpression(sb, logical.right()); - sb.append(")"); - } - } - case JsonPathAst.CurrentNode ignored -> sb.append("@"); - case JsonPathAst.PropertyPath path -> { - sb.append("@"); - for (String p : path.properties()) { - if (isSimpleIdentifier(p)) { - sb.append(".").append(p); - } else { - sb.append("['").append(escape(p)).append("']"); - } - } - } - case JsonPathAst.LiteralValue lit -> { - if (lit.value() instanceof String s) { - sb.append("'").append(escape(s)).append("'"); - } else { - sb.append(lit.value()); - } - } - } - } - - private static boolean isSimpleIdentifier(String name) { - if (name.isEmpty()) return false; - if (!Character.isJavaIdentifierStart(name.charAt(0))) return false; - for (int i = 1; i < name.length(); i++) { - if (!Character.isJavaIdentifierPart(name.charAt(i))) return false; - } - return true; - } - - private static String escape(String s) { - return s.replace("'", "\\'"); - } } diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiled.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiled.java new file mode 100644 index 0000000..8174fe7 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiled.java @@ -0,0 +1,41 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; + +/// Runtime-compiled implementation of JsonPath for optimal performance. +/// Uses JDK compiler tools to generate and compile Java bytecode at runtime. +final class JsonPathCompiled implements JsonPath { + + private static final Logger LOG = Logger.getLogger(JsonPathCompiled.class.getName()); + + private final JsonPathAst.Root astRoot; + private final JsonPathExecutor executor; + private final String originalPath; + + JsonPathCompiled(JsonPathAst.Root ast, JsonPathExecutor executor, String originalPath) { + this.astRoot = Objects.requireNonNull(ast, "ast must not be null"); + this.executor = Objects.requireNonNull(executor, "executor must not be null"); + this.originalPath = Objects.requireNonNull(originalPath, "originalPath must not be null"); + } + + @Override + public JsonPathAst.Root ast() { + return astRoot; + } + + @Override + public List query(JsonValue json) { + Objects.requireNonNull(json, "json must not be null"); + LOG.fine(() -> "Querying document with compiled path: " + this); + return executor.execute(json, json); + } + + @Override + public String toString() { + return originalPath; + } +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiler.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiler.java new file mode 100644 index 0000000..2bf95d8 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathCompiler.java @@ -0,0 +1,611 @@ +package json.java21.jsonpath; + +import javax.tools.*; +import java.io.*; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.logging.Logger; + +/// Compiles JsonPath AST into optimized bytecode using JDK compiler tools. +/// Uses the Java Compiler API (javax.tools) to generate and compile Java source at runtime. +final class JsonPathCompiler { + + private static final Logger LOG = Logger.getLogger(JsonPathCompiler.class.getName()); + + /// Counter for generating unique class names + private static long classCounter = 0; + + private JsonPathCompiler() { + // utility class + } + + /// Compiles an interpreted JsonPath into an optimized compiled version. + /// @param interpreted the AST-based JsonPath to compile + /// @return a compiled JsonPath with generated bytecode + static JsonPathCompiled compile(JsonPathInterpreted interpreted) { + Objects.requireNonNull(interpreted, "interpreted must not be null"); + LOG.fine(() -> "Compiling JsonPath: " + interpreted); + + final var ast = interpreted.ast(); + final var originalPath = interpreted.toString(); + + // Generate a unique class name + final var className = generateClassName(); + LOG.finer(() -> "Generated class name: " + className); + + // Generate the Java source code + final var sourceCode = generateSourceCode(className, ast); + LOG.finer(() -> "Generated source code:\n" + sourceCode); + + // Compile the source code + final var executor = compileAndInstantiate(className, sourceCode); + + return new JsonPathCompiled(ast, executor, originalPath); + } + + /// Generates a unique class name for the compiled JsonPath executor. + private static synchronized String generateClassName() { + return "JsonPathExecutor_" + (classCounter++); + } + + /// Generates Java source code that implements the JsonPath query. + static String generateSourceCode(String className, JsonPathAst.Root ast) { + final var sb = new StringBuilder(); + + // Package and imports + sb.append(""" + package json.java21.jsonpath.generated; + + import jdk.sandbox.java.util.json.*; + import json.java21.jsonpath.JsonPathExecutor; + import json.java21.jsonpath.JsonPathHelpers; + import java.util.*; + + public final class %s implements JsonPathExecutor { + + @Override + public List execute(JsonValue current, JsonValue root) { + final var results = new ArrayList(); + """.formatted(className)); + + // Generate the evaluation code + generateSegmentEvaluation(sb, ast.segments(), 0, "current", "root", "results"); + + // Close the execute method and class + sb.append(""" + return results; + } + } + """); + + return sb.toString(); + } + + /// Generates evaluation code for a sequence of segments. + private static void generateSegmentEvaluation( + StringBuilder sb, + List segments, + int segmentIndex, + String currentVar, + String rootVar, + String resultsVar) { + + // If we've processed all segments, add current to results + if (segmentIndex >= segments.size()) { + sb.append(" %s.add(%s);\n".formatted(resultsVar, currentVar)); + return; + } + + final var segment = segments.get(segmentIndex); + final var nextIndex = segmentIndex + 1; + + switch (segment) { + case JsonPathAst.PropertyAccess prop -> + generatePropertyAccess(sb, prop, segments, nextIndex, currentVar, rootVar, resultsVar); + + case JsonPathAst.ArrayIndex arr -> + generateArrayIndex(sb, arr, segments, nextIndex, currentVar, rootVar, resultsVar); + + case JsonPathAst.ArraySlice slice -> + generateArraySlice(sb, slice, segments, nextIndex, currentVar, rootVar, resultsVar); + + case JsonPathAst.Wildcard ignored -> + generateWildcard(sb, segments, nextIndex, currentVar, rootVar, resultsVar); + + case JsonPathAst.RecursiveDescent desc -> + generateRecursiveDescent(sb, desc, segments, segmentIndex, currentVar, rootVar, resultsVar); + + case JsonPathAst.Filter filter -> + generateFilter(sb, filter, segments, nextIndex, currentVar, rootVar, resultsVar); + + case JsonPathAst.Union union -> + generateUnion(sb, union, segments, nextIndex, currentVar, rootVar, resultsVar); + + case JsonPathAst.ScriptExpression script -> + generateScriptExpression(sb, script, segments, nextIndex, currentVar, rootVar, resultsVar); + } + } + + private static void generatePropertyAccess( + StringBuilder sb, + JsonPathAst.PropertyAccess prop, + List segments, + int nextIndex, + String currentVar, + String rootVar, + String resultsVar) { + + final var tempVar = "v" + nextIndex; + sb.append(" if (%s instanceof JsonObject obj%d) {\n".formatted(currentVar, nextIndex)); + sb.append(" final var %s = obj%d.members().get(\"%s\");\n" + .formatted(tempVar, nextIndex, escapeJavaString(prop.name()))); + sb.append(" if (%s != null) {\n".formatted(tempVar)); + generateSegmentEvaluation(sb, segments, nextIndex, tempVar, rootVar, resultsVar); + sb.append(" }\n"); + sb.append(" }\n"); + } + + private static void generateArrayIndex( + StringBuilder sb, + JsonPathAst.ArrayIndex arr, + List segments, + int nextIndex, + String currentVar, + String rootVar, + String resultsVar) { + + final var tempVar = "e" + nextIndex; + final var idxVar = "idx" + nextIndex; + sb.append(" if (%s instanceof JsonArray arr%d) {\n".formatted(currentVar, nextIndex)); + sb.append(" final var elements%d = arr%d.elements();\n".formatted(nextIndex, nextIndex)); + sb.append(" int %s = %d;\n".formatted(idxVar, arr.index())); + sb.append(" if (%s < 0) %s = elements%d.size() + %s;\n".formatted(idxVar, idxVar, nextIndex, idxVar)); + sb.append(" if (%s >= 0 && %s < elements%d.size()) {\n".formatted(idxVar, idxVar, nextIndex)); + sb.append(" final var %s = elements%d.get(%s);\n".formatted(tempVar, nextIndex, idxVar)); + generateSegmentEvaluation(sb, segments, nextIndex, tempVar, rootVar, resultsVar); + sb.append(" }\n"); + sb.append(" }\n"); + } + + private static void generateArraySlice( + StringBuilder sb, + JsonPathAst.ArraySlice slice, + List segments, + int nextIndex, + String currentVar, + String rootVar, + String resultsVar) { + + sb.append(" if (%s instanceof JsonArray arr%d) {\n".formatted(currentVar, nextIndex)); + sb.append(" final var elements%d = arr%d.elements();\n".formatted(nextIndex, nextIndex)); + sb.append(" final int size%d = elements%d.size();\n".formatted(nextIndex, nextIndex)); + + final int step = slice.step() != null ? slice.step() : 1; + sb.append(" final int step%d = %d;\n".formatted(nextIndex, step)); + + if (step > 0) { + final var startExpr = slice.start() != null ? + "JsonPathHelpers.normalizeIdx(%d, size%d)".formatted(slice.start(), nextIndex) : "0"; + final var endExpr = slice.end() != null ? + "JsonPathHelpers.normalizeIdx(%d, size%d)".formatted(slice.end(), nextIndex) : "size" + nextIndex; + + sb.append(" int start%d = %s;\n".formatted(nextIndex, startExpr)); + sb.append(" int end%d = %s;\n".formatted(nextIndex, endExpr)); + sb.append(" start%d = Math.max(0, Math.min(start%d, size%d));\n".formatted(nextIndex, nextIndex, nextIndex)); + sb.append(" end%d = Math.max(0, Math.min(end%d, size%d));\n".formatted(nextIndex, nextIndex, nextIndex)); + sb.append(" for (int i%d = start%d; i%d < end%d; i%d += step%d) {\n" + .formatted(nextIndex, nextIndex, nextIndex, nextIndex, nextIndex, nextIndex)); + } else { + final var startExpr = slice.start() != null ? + "JsonPathHelpers.normalizeIdx(%d, size%d)".formatted(slice.start(), nextIndex) : "size%d - 1".formatted(nextIndex); + final var endExpr = slice.end() != null ? + "JsonPathHelpers.normalizeIdx(%d, size%d)".formatted(slice.end(), nextIndex) : "-1"; + + sb.append(" int start%d = %s;\n".formatted(nextIndex, startExpr)); + sb.append(" int end%d = %s;\n".formatted(nextIndex, endExpr)); + sb.append(" start%d = Math.max(0, Math.min(start%d, size%d - 1));\n".formatted(nextIndex, nextIndex, nextIndex)); + sb.append(" for (int i%d = start%d; i%d > end%d; i%d += step%d) {\n" + .formatted(nextIndex, nextIndex, nextIndex, nextIndex, nextIndex, nextIndex)); + } + + final var elemVar = "elem" + nextIndex; + sb.append(" final var %s = elements%d.get(i%d);\n".formatted(elemVar, nextIndex, nextIndex)); + generateSegmentEvaluation(sb, segments, nextIndex, elemVar, rootVar, resultsVar); + sb.append(" }\n"); + sb.append(" }\n"); + } + + private static void generateWildcard( + StringBuilder sb, + List segments, + int nextIndex, + String currentVar, + String rootVar, + String resultsVar) { + + final var elemVar = "wc" + nextIndex; + sb.append(" if (%s instanceof JsonObject wobj%d) {\n".formatted(currentVar, nextIndex)); + sb.append(" for (final var %s : wobj%d.members().values()) {\n".formatted(elemVar, nextIndex)); + generateSegmentEvaluation(sb, segments, nextIndex, elemVar, rootVar, resultsVar); + sb.append(" }\n"); + sb.append(" } else if (%s instanceof JsonArray warr%d) {\n".formatted(currentVar, nextIndex)); + sb.append(" for (final var %s : warr%d.elements()) {\n".formatted(elemVar, nextIndex)); + generateSegmentEvaluation(sb, segments, nextIndex, elemVar, rootVar, resultsVar); + sb.append(" }\n"); + sb.append(" }\n"); + } + + private static void generateRecursiveDescent( + StringBuilder sb, + JsonPathAst.RecursiveDescent desc, + List segments, + int segmentIndex, + String currentVar, + String rootVar, + String resultsVar) { + + final int nextIndex = segmentIndex + 1; + + // For simple cases (property access or wildcard with no following segments), generate inline + if (nextIndex >= segments.size()) { + // Terminal recursive descent - generate simple code + switch (desc.target()) { + case JsonPathAst.PropertyAccess prop -> { + sb.append(" // Recursive descent for property: %s\n".formatted(prop.name())); + sb.append(" JsonPathHelpers.evaluateRecursiveDescent(\"%s\", %s, %s);\n" + .formatted(escapeJavaString(prop.name()), currentVar, resultsVar)); + } + case JsonPathAst.Wildcard ignored -> { + sb.append(" // Recursive descent wildcard\n"); + sb.append(" JsonPathHelpers.evaluateRecursiveDescent(null, %s, %s);\n" + .formatted(currentVar, resultsVar)); + } + default -> { + // Complex target - generate a recursive helper inline + sb.append(" // Recursive descent with complex target - inline generation\n"); + generateRecursiveDescentInline(sb, desc, segments, segmentIndex, currentVar, rootVar, resultsVar); + } + } + } else { + // Has following segments - need to generate more complex code + generateRecursiveDescentInline(sb, desc, segments, segmentIndex, currentVar, rootVar, resultsVar); + } + } + + private static void generateRecursiveDescentInline( + StringBuilder sb, + JsonPathAst.RecursiveDescent desc, + List segments, + int segmentIndex, + String currentVar, + String rootVar, + String resultsVar) { + + final int nextIndex = segmentIndex + 1; + final var collectVar = "rdCollect" + nextIndex; + final var iterVar = "rdItem" + nextIndex; + + // For complex recursive descent, we collect intermediate results first + // then process them with subsequent segments + switch (desc.target()) { + case JsonPathAst.PropertyAccess prop -> { + sb.append(" // Recursive descent for property with following segments: %s\n".formatted(prop.name())); + sb.append(" final var %s = new ArrayList();\n".formatted(collectVar)); + sb.append(" JsonPathHelpers.evaluateRecursiveDescent(\"%s\", %s, %s);\n" + .formatted(escapeJavaString(prop.name()), currentVar, collectVar)); + sb.append(" for (final var %s : %s) {\n".formatted(iterVar, collectVar)); + generateSegmentEvaluation(sb, segments, nextIndex, iterVar, rootVar, resultsVar); + sb.append(" }\n"); + } + case JsonPathAst.Wildcard ignored -> { + sb.append(" // Recursive descent wildcard with following segments\n"); + sb.append(" final var %s = new ArrayList();\n".formatted(collectVar)); + sb.append(" JsonPathHelpers.evaluateRecursiveDescent(null, %s, %s);\n" + .formatted(currentVar, collectVar)); + sb.append(" for (final var %s : %s) {\n".formatted(iterVar, collectVar)); + generateSegmentEvaluation(sb, segments, nextIndex, iterVar, rootVar, resultsVar); + sb.append(" }\n"); + } + case JsonPathAst.ArrayIndex arr -> { + // Recursive descent with array index target (e.g., $..book[2]) + sb.append(" // Recursive descent for array index: [%d]\n".formatted(arr.index())); + sb.append(" final var %s = new ArrayList();\n".formatted(collectVar)); + // First collect all arrays + sb.append(" JsonPathHelpers.collectArrays(%s, %s);\n".formatted(currentVar, collectVar)); + sb.append(" for (final var arrItem%d : %s) {\n".formatted(nextIndex, collectVar)); + generateArrayIndex(sb, arr, segments, nextIndex, "arrItem" + nextIndex, rootVar, resultsVar); + sb.append(" }\n"); + } + default -> { + // Unsupported recursive descent target - no-op to match interpreter behavior + // The interpreter logs and returns no matches for unsupported targets like + // $..[0:2], $..[?(@.x)], or $..['a','b'] + sb.append(" // Unsupported recursive descent target (no matches per interpreter semantics)\n"); + } + } + } + + + private static void generateFilter( + StringBuilder sb, + JsonPathAst.Filter filter, + List segments, + int nextIndex, + String currentVar, + String rootVar, + String resultsVar) { + + final var elemVar = "fe" + nextIndex; + sb.append(" if (%s instanceof JsonArray farr%d) {\n".formatted(currentVar, nextIndex)); + sb.append(" for (final var %s : farr%d.elements()) {\n".formatted(elemVar, nextIndex)); + sb.append(" if ("); + generateFilterCondition(sb, filter.expression(), elemVar); + sb.append(") {\n"); + generateSegmentEvaluation(sb, segments, nextIndex, elemVar, rootVar, resultsVar); + sb.append(" }\n"); + sb.append(" }\n"); + sb.append(" }\n"); + } + + private static void generateFilterCondition(StringBuilder sb, JsonPathAst.FilterExpression expr, String currentVar) { + switch (expr) { + case JsonPathAst.ExistsFilter exists -> { + generatePropertyPathAccess(sb, exists.path(), currentVar); + sb.append(" != null"); + } + case JsonPathAst.ComparisonFilter comp -> { + sb.append("JsonPathHelpers.compareValues("); + generateFilterValue(sb, comp.left(), currentVar); + sb.append(", \"").append(comp.op().name()).append("\", "); + generateFilterValue(sb, comp.right(), currentVar); + sb.append(")"); + } + case JsonPathAst.LogicalFilter logical -> { + switch (logical.op()) { + case AND -> { + sb.append("("); + generateFilterCondition(sb, logical.left(), currentVar); + sb.append(" && "); + generateFilterCondition(sb, logical.right(), currentVar); + sb.append(")"); + } + case OR -> { + sb.append("("); + generateFilterCondition(sb, logical.left(), currentVar); + sb.append(" || "); + generateFilterCondition(sb, logical.right(), currentVar); + sb.append(")"); + } + case NOT -> { + sb.append("!("); + generateFilterCondition(sb, logical.left(), currentVar); + sb.append(")"); + } + } + } + case JsonPathAst.CurrentNode ignored -> sb.append("true"); + case JsonPathAst.PropertyPath path -> { + generatePropertyPathAccess(sb, path, currentVar); + sb.append(" != null"); + } + case JsonPathAst.LiteralValue ignored -> sb.append("true"); + } + } + + private static void generatePropertyPathAccess(StringBuilder sb, JsonPathAst.PropertyPath path, String currentVar) { + sb.append("JsonPathHelpers.getPath(%s".formatted(currentVar)); + for (final var prop : path.properties()) { + sb.append(", \"").append(escapeJavaString(prop)).append("\""); + } + sb.append(")"); + } + + private static void generateFilterValue(StringBuilder sb, JsonPathAst.FilterExpression expr, String currentVar) { + switch (expr) { + case JsonPathAst.PropertyPath path -> { + sb.append("JsonPathHelpers.toComparable("); + generatePropertyPathAccess(sb, path, currentVar); + sb.append(")"); + } + case JsonPathAst.LiteralValue lit -> { + if (lit.value() == null) { + sb.append("null"); + } else if (lit.value() instanceof String s) { + sb.append("\"").append(escapeJavaString(s)).append("\""); + } else if (lit.value() instanceof Number n) { + sb.append(n.doubleValue()); + } else if (lit.value() instanceof Boolean b) { + sb.append(b); + } else { + sb.append("null"); + } + } + case JsonPathAst.CurrentNode ignored -> + sb.append("JsonPathHelpers.toComparable(%s)".formatted(currentVar)); + default -> sb.append("null"); + } + } + + private static void generateUnion( + StringBuilder sb, + JsonPathAst.Union union, + List segments, + int nextIndex, + String currentVar, + String rootVar, + String resultsVar) { + + for (final var selector : union.selectors()) { + switch (selector) { + case JsonPathAst.ArrayIndex arr -> + generateArrayIndex(sb, arr, segments, nextIndex, currentVar, rootVar, resultsVar); + case JsonPathAst.PropertyAccess prop -> + generatePropertyAccess(sb, prop, segments, nextIndex, currentVar, rootVar, resultsVar); + default -> { + // Unsupported selector, skip + } + } + } + } + + private static void generateScriptExpression( + StringBuilder sb, + JsonPathAst.ScriptExpression script, + List segments, + int nextIndex, + String currentVar, + String rootVar, + String resultsVar) { + + // Only support @.length-1 pattern for now + if (script.script().trim().equals("@.length-1")) { + sb.append(" if (%s instanceof JsonArray sarr%d) {\n".formatted(currentVar, nextIndex)); + sb.append(" final int lastIdx%d = sarr%d.elements().size() - 1;\n".formatted(nextIndex, nextIndex)); + sb.append(" if (lastIdx%d >= 0) {\n".formatted(nextIndex)); + final var elemVar = "se" + nextIndex; + sb.append(" final var %s = sarr%d.elements().get(lastIdx%d);\n".formatted(elemVar, nextIndex, nextIndex)); + generateSegmentEvaluation(sb, segments, nextIndex, elemVar, rootVar, resultsVar); + sb.append(" }\n"); + sb.append(" }\n"); + } else { + // Unsupported script - throw exception at runtime for clarity + // Only @.length-1 is supported in compiled mode + sb.append(" throw new UnsupportedOperationException(\"Unsupported script expression in compiled mode: '%s'. Consider using slice notation instead (e.g., [-1:] for last element).\");\n" + .formatted(escapeJavaString(script.script()))); + } + } + + /// Compiles the generated source code and instantiates the executor. + private static JsonPathExecutor compileAndInstantiate(String className, String sourceCode) { + final var compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + LOG.warning(() -> "No Java compiler available - falling back to interpreter delegation"); + throw new RuntimeException("Java compiler not available. Ensure you're running with a JDK, not a JRE."); + } + + final var fileManager = new InMemoryFileManager(compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8)); + + // Create a source file object + final var fullClassName = "json.java21.jsonpath.generated." + className; + final var sourceFile = new InMemoryJavaFileObject(fullClassName, sourceCode); + + // Compile the source + final var diagnostics = new DiagnosticCollector(); + final var compilationUnits = Collections.singletonList(sourceFile); + final var options = List.of( + "-classpath", System.getProperty("java.class.path"), + "--add-exports", "json.java21.jsonpath/json.java21.jsonpath=ALL-UNNAMED", + "--add-exports", "json.java21/jdk.sandbox.java.util.json=ALL-UNNAMED" + ); + + final var task = compiler.getTask(null, fileManager, diagnostics, options, null, compilationUnits); + final boolean success = task.call(); + + if (!success) { + final var errors = new StringBuilder("Compilation failed:\n"); + for (final var diagnostic : diagnostics.getDiagnostics()) { + errors.append(diagnostic.toString()).append("\n"); + } + errors.append("\nSource code:\n").append(sourceCode); + LOG.severe(() -> "ERROR: " + errors); + throw new RuntimeException(errors.toString()); + } + + // Load and instantiate the compiled class + try { + final var classLoader = new InMemoryClassLoader(fileManager.getClassBytes()); + final var clazz = classLoader.loadClass(fullClassName); + return (JsonPathExecutor) clazz.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate compiled JsonPath executor", e); + } + } + + private static String escapeJavaString(String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + /// In-memory Java source file object for compilation. + private static final class InMemoryJavaFileObject extends SimpleJavaFileObject { + private final String code; + + InMemoryJavaFileObject(String className, String code) { + super(URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return code; + } + } + + /// In-memory class file object for capturing compiled bytecode. + private static final class InMemoryClassFileObject extends SimpleJavaFileObject { + private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + + InMemoryClassFileObject(String className) { + super(URI.create("mem:///" + className.replace('.', '/') + Kind.CLASS.extension), Kind.CLASS); + } + + @Override + public OutputStream openOutputStream() { + return outputStream; + } + + byte[] getBytes() { + return outputStream.toByteArray(); + } + } + + /// In-memory file manager that captures compiled class files. + private static final class InMemoryFileManager extends ForwardingJavaFileManager { + private final Map classFiles = new HashMap<>(); + + InMemoryFileManager(StandardJavaFileManager fileManager) { + super(fileManager); + } + + @Override + public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) { + if (kind == JavaFileObject.Kind.CLASS) { + final var classFile = new InMemoryClassFileObject(className); + classFiles.put(className, classFile); + return classFile; + } + return null; + } + + Map getClassBytes() { + final var result = new HashMap(); + for (final var entry : classFiles.entrySet()) { + result.put(entry.getKey(), entry.getValue().getBytes()); + } + return result; + } + } + + /// Class loader that loads compiled classes from memory. + private static final class InMemoryClassLoader extends ClassLoader { + private final Map classBytes; + + InMemoryClassLoader(Map classBytes) { + super(JsonPathCompiler.class.getClassLoader()); + this.classBytes = classBytes; + } + + @Override + protected Class findClass(String name) throws ClassNotFoundException { + final var bytes = classBytes.get(name); + if (bytes != null) { + return defineClass(name, bytes, 0, bytes.length); + } + return super.findClass(name); + } + } +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathExecutor.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathExecutor.java new file mode 100644 index 0000000..9194901 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathExecutor.java @@ -0,0 +1,16 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.JsonValue; + +import java.util.List; + +/// Functional interface for compiled JsonPath executors. +/// This interface is public to allow generated code to implement it. +@FunctionalInterface +public interface JsonPathExecutor { + /// Executes the compiled JsonPath query against a JSON document. + /// @param current the current node being evaluated + /// @param root the root document (for $ references in filters) + /// @return a list of matching JsonValue instances + List execute(JsonValue current, JsonValue root); +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathHelpers.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathHelpers.java new file mode 100644 index 0000000..3ffce69 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathHelpers.java @@ -0,0 +1,175 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; + +import java.util.List; + +/// Public helper methods for runtime-compiled JsonPath executors. +/// This class provides access to internal evaluation methods that generated code needs. +public final class JsonPathHelpers { + + private JsonPathHelpers() { + // utility class + } + + /// Resolves a property path from a JsonValue. + /// @param current the current JSON value + /// @param props the property path to resolve + /// @return the resolved value, or null if not found + public static JsonValue getPath(JsonValue current, String... props) { + JsonValue value = current; + for (final var prop : props) { + if (value instanceof JsonObject obj) { + value = obj.members().get(prop); + if (value == null) return null; + } else { + return null; + } + } + return value; + } + + /// Converts a JsonValue to a comparable object for filter comparisons. + /// @param value the JSON value to convert + /// @return a comparable object (String, Number, Boolean, or null) + public static Object toComparable(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 ignored -> null; + default -> value; + }; + } + + /// Compares two values using the specified operator. + /// @param left the left operand + /// @param op the comparison operator name (EQ, NE, LT, LE, GT, GE) + /// @param right the right operand + /// @return true if the comparison is satisfied + @SuppressWarnings("unchecked") + public static boolean compareValues(Object left, String op, Object right) { + if (left == null || right == null) { + return switch (op) { + case "EQ" -> left == right; + case "NE" -> left != right; + default -> false; + }; + } + + // 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; + default -> false; + }; + } + + // 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; + default -> false; + }; + } + + // 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; + }; + } + + /// Normalizes an array index (handles negative indices). + /// @param index the index (possibly negative) + /// @param size the array size + /// @return the normalized index + public static int normalizeIdx(int index, int size) { + return index < 0 ? size + index : index; + } + + /// Evaluates recursive descent from a starting value. + /// This is used for $.. patterns that need to search all descendants. + /// @param propertyName the property name to search for (null for wildcard) + /// @param current the current value to search from + /// @param results the list to add matching values to + public static void evaluateRecursiveDescent(String propertyName, JsonValue current, List results) { + // First, try matching at current level + if (propertyName == null) { + // Wildcard - match all children + if (current instanceof JsonObject obj) { + results.addAll(obj.members().values()); + for (final var value : obj.members().values()) { + evaluateRecursiveDescent(null, value, results); + } + } else if (current instanceof JsonArray array) { + results.addAll(array.elements()); + for (final var element : array.elements()) { + evaluateRecursiveDescent(null, element, results); + } + } + } else { + // Named property - match specific property + if (current instanceof JsonObject obj) { + final var value = obj.members().get(propertyName); + if (value != null) { + results.add(value); + } + for (final var child : obj.members().values()) { + evaluateRecursiveDescent(propertyName, child, results); + } + } else if (current instanceof JsonArray array) { + for (final var element : array.elements()) { + evaluateRecursiveDescent(propertyName, element, results); + } + } + } + } + + /// Collects all arrays recursively from a JSON value. + /// Used for recursive descent with array index targets like $..book[2]. + /// @param current the current JSON value to search + /// @param arrays the list to collect arrays into + public static void collectArrays(JsonValue current, List arrays) { + if (current instanceof JsonArray array) { + arrays.add(array); + for (final var element : array.elements()) { + collectArrays(element, arrays); + } + } else if (current instanceof JsonObject obj) { + for (final var value : obj.members().values()) { + if (value instanceof JsonArray) { + collectArrays(value, arrays); + } else if (value instanceof JsonObject) { + collectArrays(value, arrays); + } + } + } + } + +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathInterpreted.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathInterpreted.java new file mode 100644 index 0000000..b2db283 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathInterpreted.java @@ -0,0 +1,565 @@ +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; + +/// AST-walking interpreter implementation of JsonPath. +/// Parses JsonPath expressions and evaluates them against JsonValue instances by walking the AST. +final class JsonPathInterpreted implements JsonPath { + + private static final Logger LOG = Logger.getLogger(JsonPathInterpreted.class.getName()); + + private final JsonPathAst.Root astRoot; + + JsonPathInterpreted(JsonPathAst.Root ast) { + this.astRoot = Objects.requireNonNull(ast, "ast must not be null"); + } + + /// Returns the AST for this interpreted JsonPath. + @Override + public JsonPathAst.Root ast() { + return astRoot; + } + + @Override + public List query(JsonValue json) { + Objects.requireNonNull(json, "json must not be null"); + LOG.fine(() -> "Querying document with path: " + this); + return evaluate(astRoot, json); + } + + /// Reconstructs the JsonPath expression from the AST. + @Override + public String toString() { + return reconstruct(astRoot); + } + + /// 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 (maybe 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; + } + + 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 ignored -> 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); + } + } + + 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); + } + } + } + + 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); + } + } + } + + 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(); + + final int step = slice.step() != null ? slice.step() : 1; + + if (step == 0) { + return; // Invalid step + } + + if (step > 0) { + int start = slice.start() != null ? normalizeIndex(slice.start(), size) : 0; + int end = slice.end() != null ? normalizeIndex(slice.end(), size) : size; + + start = Math.max(0, Math.min(start, size)); + end = Math.max(0, Math.min(end, size)); + + for (int i = start; i < end; i += step) { + evaluateSegments(segments, index + 1, elements.get(i), root, results); + } + } else { + int start = slice.start() != null ? normalizeIndex(slice.start(), size) : size - 1; + final int end = slice.end() != null ? normalizeIndex(slice.end(), size) : -1; + + start = Math.max(0, Math.min(start, size - 1)); + + for (int i = start; i > end; i += step) { + evaluateSegments(segments, index + 1, elements.get(i), root, results); + } + } + } + } + + static int normalizeIndex(int index, int size) { + if (index < 0) { + return size + index; + } + return index; + } + + 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); + } + } + } + + 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); + } + } + } + + 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 ignored -> { + 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); + } + } + + 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); + } + } + } + } + + 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 ignored1 -> true; + case JsonPathAst.PropertyPath path -> resolvePropertyPath(path, current) != null; + case JsonPathAst.LiteralValue ignored -> true; + }; + } + + 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 ignored -> jsonValueToComparable(current); + default -> null; + }; + } + + 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; + } + + 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 ignored -> null; + default -> value; + }; + } + + @SuppressWarnings("unchecked") + 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 + switch (left) { + case Number leftNum when 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 + case String ignored when 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 + case Boolean ignored when right instanceof Boolean -> { + return switch (op) { + case EQ -> left.equals(right); + case NE -> !left.equals(right); + default -> false; + }; + } + default -> { + } + } + + // Fallback equality + return switch (op) { + case EQ -> left.equals(right); + case NE -> !left.equals(right); + default -> false; + }; + } + + 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); + } + } + } + + 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); + } + } + } + + static String reconstruct(JsonPathAst.Root root) { + final var sb = new StringBuilder("$"); + for (final var segment : root.segments()) { + appendSegment(sb, segment); + } + return sb.toString(); + } + + static void appendSegment(StringBuilder sb, JsonPathAst.Segment segment) { + switch (segment) { + case JsonPathAst.PropertyAccess prop -> { + if (isSimpleIdentifier(prop.name())) { + sb.append(".").append(prop.name()); + } else { + sb.append("['").append(escape(prop.name())).append("']"); + } + } + case JsonPathAst.ArrayIndex arr -> sb.append("[").append(arr.index()).append("]"); + case JsonPathAst.ArraySlice slice -> { + sb.append("["); + if (slice.start() != null) sb.append(slice.start()); + sb.append(":"); + if (slice.end() != null) sb.append(slice.end()); + if (slice.step() != null) sb.append(":").append(slice.step()); + sb.append("]"); + } + case JsonPathAst.Wildcard ignored -> sb.append(".*"); + case JsonPathAst.RecursiveDescent desc -> { + sb.append(".."); + // RecursiveDescent target is usually PropertyAccess or Wildcard, + // but can be other things in theory. + // If target is PropertyAccess("foo"), append "foo". + // If target is Wildcard, append "*". + // Our AST structure wraps the target segment. + // We need to handle how it's appended. + // appendSegment prepends "." or "[" usually. + // But ".." replaces the dot. + // Let special case the target printing. + appendRecursiveTarget(sb, desc.target()); + } + case JsonPathAst.Filter filter -> { + sb.append("[?("); + appendFilterExpression(sb, filter.expression()); + sb.append(")]"); + } + case JsonPathAst.Union union -> { + sb.append("["); + final var selectors = union.selectors(); + for (int i = 0; i < selectors.size(); i++) { + if (i > 0) sb.append(","); + appendUnionSelector(sb, selectors.get(i)); + } + sb.append("]"); + } + case JsonPathAst.ScriptExpression script -> sb.append("[(").append(script.script()).append(")]"); + } + } + + static void appendRecursiveTarget(StringBuilder sb, JsonPathAst.Segment target) { + if (target instanceof JsonPathAst.PropertyAccess(String name)) { + sb.append(name); // ..name + } else if (target instanceof JsonPathAst.Wildcard) { + sb.append("*"); // ..* + } else { + // Fallback for other types if they ever occur in recursive position + appendSegment(sb, target); + } + } + + static void appendUnionSelector(StringBuilder sb, JsonPathAst.Segment selector) { + if (selector instanceof JsonPathAst.PropertyAccess(String name)) { + sb.append("'").append(escape(name)).append("'"); + } else if (selector instanceof JsonPathAst.ArrayIndex(int index)) { + sb.append(index); + } else { + // Fallback + appendSegment(sb, selector); + } + } + + static void appendFilterExpression(StringBuilder sb, JsonPathAst.FilterExpression expr) { + switch (expr) { + case JsonPathAst.ExistsFilter exists -> appendFilterExpression(sb, exists.path()); // Should print the path + case JsonPathAst.ComparisonFilter comp -> { + appendFilterExpression(sb, comp.left()); + sb.append(comp.op().symbol()); + appendFilterExpression(sb, comp.right()); + } + case JsonPathAst.LogicalFilter logical -> { + if (logical.op() == JsonPathAst.LogicalOp.NOT) { + sb.append("!"); + appendFilterExpression(sb, logical.left()); + } else { + sb.append("("); + appendFilterExpression(sb, logical.left()); + sb.append(" ").append(logical.op().symbol()).append(" "); + appendFilterExpression(sb, logical.right()); + sb.append(")"); + } + } + case JsonPathAst.CurrentNode ignored -> sb.append("@"); + case JsonPathAst.PropertyPath path -> { + sb.append("@"); + for (String p : path.properties()) { + if (isSimpleIdentifier(p)) { + sb.append(".").append(p); + } else { + sb.append("['").append(escape(p)).append("']"); + } + } + } + case JsonPathAst.LiteralValue lit -> { + if (lit.value() instanceof String s) { + sb.append("'").append(escape(s)).append("'"); + } else { + sb.append(lit.value()); + } + } + } + } + + static boolean isSimpleIdentifier(String name) { + if (name.isEmpty()) return false; + if (!Character.isJavaIdentifierStart(name.charAt(0))) return false; + for (int i = 1; i < name.length(); i++) { + if (!Character.isJavaIdentifierPart(name.charAt(i))) return false; + } + return true; + } + + static String escape(String s) { + return s.replace("'", "\\'"); + } +} 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 index b2a7823..d0d59f1 100644 --- a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java @@ -60,7 +60,7 @@ 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); } diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathCompilerTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathCompilerTest.java new file mode 100644 index 0000000..432e499 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathCompilerTest.java @@ -0,0 +1,380 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonObject; +import jdk.sandbox.java.util.json.JsonValue; +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 runtime compilation. +/// Verifies that compiled paths produce identical results to interpreted paths. +class JsonPathCompilerTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathCompilerTest.class.getName()); + + 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 compiler tests"); + } + + // Basic compilation tests + + @Test + void testCompileReturnsCompiledInstance() { + LOG.info(() -> "TEST: testCompileReturnsCompiledInstance"); + final var interpreted = JsonPath.parse("$.store"); + assertThat(interpreted.isInterpreted()).isTrue(); + assertThat(interpreted.isCompiled()).isFalse(); + + final var compiled = JsonPath.compile(interpreted); + assertThat(compiled.isCompiled()).isTrue(); + assertThat(compiled.isInterpreted()).isFalse(); + } + + @Test + void testCompileIdempotent() { + LOG.info(() -> "TEST: testCompileIdempotent - compiling a compiled path returns same instance"); + final var interpreted = JsonPath.parse("$.store"); + final var compiled = JsonPath.compile(interpreted); + final var compiledAgain = JsonPath.compile(compiled); + assertThat(compiledAgain).isSameAs(compiled); + } + + // Simple property access tests + + @Test + void testCompiledRootOnly() { + LOG.info(() -> "TEST: testCompiledRootOnly - $ returns the root document"); + assertCompiledMatchesInterpreted("$", storeJson); + } + + @Test + void testCompiledSingleProperty() { + LOG.info(() -> "TEST: testCompiledSingleProperty - $.store"); + assertCompiledMatchesInterpreted("$.store", storeJson); + } + + @Test + void testCompiledNestedProperty() { + LOG.info(() -> "TEST: testCompiledNestedProperty - $.store.bicycle"); + assertCompiledMatchesInterpreted("$.store.bicycle", storeJson); + } + + @Test + void testCompiledDeepProperty() { + LOG.info(() -> "TEST: testCompiledDeepProperty - $.store.bicycle.color"); + assertCompiledMatchesInterpreted("$.store.bicycle.color", storeJson); + } + + @Test + void testCompiledBracketNotation() { + LOG.info(() -> "TEST: testCompiledBracketNotation - $['store']['book']"); + assertCompiledMatchesInterpreted("$['store']['book']", storeJson); + } + + @Test + void testCompiledPropertyNotFound() { + LOG.info(() -> "TEST: testCompiledPropertyNotFound - $.nonexistent"); + assertCompiledMatchesInterpreted("$.nonexistent", storeJson); + } + + // Array index tests + + @Test + void testCompiledArrayIndexFirst() { + LOG.info(() -> "TEST: testCompiledArrayIndexFirst - $.store.book[0]"); + assertCompiledMatchesInterpreted("$.store.book[0]", storeJson); + } + + @Test + void testCompiledArrayIndexLast() { + LOG.info(() -> "TEST: testCompiledArrayIndexLast - $.store.book[-1]"); + assertCompiledMatchesInterpreted("$.store.book[-1]", storeJson); + } + + @Test + void testCompiledArrayIndexOutOfBounds() { + LOG.info(() -> "TEST: testCompiledArrayIndexOutOfBounds - $.store.book[100]"); + assertCompiledMatchesInterpreted("$.store.book[100]", storeJson); + } + + @Test + void testCompiledArrayIndexChained() { + LOG.info(() -> "TEST: testCompiledArrayIndexChained - $.store.book[0].title"); + assertCompiledMatchesInterpreted("$.store.book[0].title", storeJson); + } + + // Array slice tests + + @Test + void testCompiledArraySliceBasic() { + LOG.info(() -> "TEST: testCompiledArraySliceBasic - $.store.book[:2]"); + assertCompiledMatchesInterpreted("$.store.book[:2]", storeJson); + } + + @Test + void testCompiledArraySliceWithStep() { + LOG.info(() -> "TEST: testCompiledArraySliceWithStep - $.store.book[0:4:2]"); + assertCompiledMatchesInterpreted("$.store.book[0:4:2]", storeJson); + } + + @Test + void testCompiledArraySliceReverse() { + LOG.info(() -> "TEST: testCompiledArraySliceReverse - $.store.book[::-1]"); + assertCompiledMatchesInterpreted("$.store.book[::-1]", storeJson); + } + + @Test + void testCompiledArraySliceNegativeStart() { + LOG.info(() -> "TEST: testCompiledArraySliceNegativeStart - $.store.book[-1:]"); + assertCompiledMatchesInterpreted("$.store.book[-1:]", storeJson); + } + + // Wildcard tests + + @Test + void testCompiledWildcardObject() { + LOG.info(() -> "TEST: testCompiledWildcardObject - $.store.*"); + assertCompiledMatchesInterpreted("$.store.*", storeJson); + } + + @Test + void testCompiledWildcardArray() { + LOG.info(() -> "TEST: testCompiledWildcardArray - $.store.book[*]"); + assertCompiledMatchesInterpreted("$.store.book[*]", storeJson); + } + + @Test + void testCompiledWildcardChained() { + LOG.info(() -> "TEST: testCompiledWildcardChained - $.store.book[*].author"); + assertCompiledMatchesInterpreted("$.store.book[*].author", storeJson); + } + + // Filter tests + + @Test + void testCompiledFilterExists() { + LOG.info(() -> "TEST: testCompiledFilterExists - $.store.book[?(@.isbn)]"); + assertCompiledMatchesInterpreted("$.store.book[?(@.isbn)]", storeJson); + } + + @Test + void testCompiledFilterNotExists() { + LOG.info(() -> "TEST: testCompiledFilterNotExists - $.store.book[?(!@.isbn)]"); + assertCompiledMatchesInterpreted("$.store.book[?(!@.isbn)]", storeJson); + } + + @Test + void testCompiledFilterCompareLessThan() { + LOG.info(() -> "TEST: testCompiledFilterCompareLessThan - $.store.book[?(@.price<10)]"); + assertCompiledMatchesInterpreted("$.store.book[?(@.price<10)]", storeJson); + } + + @Test + void testCompiledFilterCompareGreaterThan() { + LOG.info(() -> "TEST: testCompiledFilterCompareGreaterThan - $.store.book[?(@.price>20)]"); + assertCompiledMatchesInterpreted("$.store.book[?(@.price>20)]", storeJson); + } + + @Test + void testCompiledFilterCompareEquals() { + LOG.info(() -> "TEST: testCompiledFilterCompareEquals - $.store.book[?(@.category=='fiction')]"); + assertCompiledMatchesInterpreted("$.store.book[?(@.category=='fiction')]", storeJson); + } + + @Test + void testCompiledFilterLogicalAnd() { + LOG.info(() -> "TEST: testCompiledFilterLogicalAnd - $.store.book[?(@.isbn && @.price>20)]"); + assertCompiledMatchesInterpreted("$.store.book[?(@.isbn && @.price>20)]", storeJson); + } + + @Test + void testCompiledFilterLogicalOr() { + LOG.info(() -> "TEST: testCompiledFilterLogicalOr - $.store.book[?(@.price<10 || @.price>20)]"); + assertCompiledMatchesInterpreted("$.store.book[?(@.price<10 || @.price>20)]", storeJson); + } + + @Test + void testCompiledFilterComplex() { + LOG.info(() -> "TEST: testCompiledFilterComplex - $.store.book[?(@.isbn && (@.price<10 || @.price>20))]"); + assertCompiledMatchesInterpreted("$.store.book[?(@.isbn && (@.price<10 || @.price>20))]", storeJson); + } + + // Union tests + + @Test + void testCompiledUnionIndices() { + LOG.info(() -> "TEST: testCompiledUnionIndices - $.store.book[0,1]"); + assertCompiledMatchesInterpreted("$.store.book[0,1]", storeJson); + } + + @Test + void testCompiledUnionProperties() { + LOG.info(() -> "TEST: testCompiledUnionProperties - $.store['book','bicycle']"); + assertCompiledMatchesInterpreted("$.store['book','bicycle']", storeJson); + } + + // Recursive descent tests + + @Test + void testCompiledRecursiveDescentProperty() { + LOG.info(() -> "TEST: testCompiledRecursiveDescentProperty - $..author"); + assertCompiledMatchesInterpreted("$..author", storeJson); + } + + @Test + void testCompiledRecursiveDescentPrice() { + LOG.info(() -> "TEST: testCompiledRecursiveDescentPrice - $..price"); + assertCompiledMatchesInterpreted("$..price", storeJson); + } + + @Test + void testCompiledRecursiveDescentWildcard() { + LOG.info(() -> "TEST: testCompiledRecursiveDescentWildcard - $..*"); + assertCompiledMatchesInterpreted("$..*", storeJson); + } + + @Test + void testCompiledRecursiveDescentWithIndex() { + LOG.info(() -> "TEST: testCompiledRecursiveDescentWithIndex - $..book[2]"); + assertCompiledMatchesInterpreted("$..book[2]", storeJson); + } + + // Script expression tests + + @Test + void testCompiledScriptExpressionLastElement() { + LOG.info(() -> "TEST: testCompiledScriptExpressionLastElement - $.store.book[(@.length-1)]"); + assertCompiledMatchesInterpreted("$.store.book[(@.length-1)]", storeJson); + } + + // Code generation verification tests + + @Test + void testCodeGenerationForSimpleProperty() { + LOG.info(() -> "TEST: testCodeGenerationForSimpleProperty - verify generated code compiles"); + final var path = JsonPath.parse("$.store.bicycle.color"); + final var compiled = JsonPath.compile(path); + assertThat(compiled).isInstanceOf(JsonPathCompiled.class); + + // Verify it works + final var results = compiled.query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst().string()).isEqualTo("red"); + } + + @Test + void testCompiledToStringReconstructsPath() { + LOG.info(() -> "TEST: testCompiledToStringReconstructsPath"); + final var path = JsonPath.parse("$.store.book[*].author"); + final var compiled = JsonPath.compile(path); + + // toString should reconstruct the path + assertThat(compiled.toString()).isEqualTo(path.toString()); + } + + @Test + void testCompiledAstAccessible() { + LOG.info(() -> "TEST: testCompiledAstAccessible"); + final var path = JsonPath.parse("$.store.book"); + final var compiled = JsonPath.compile(path); + + // AST should be accessible from compiled path + assertThat(compiled.ast()).isNotNull(); + assertThat(compiled.ast().segments()).hasSize(2); + } + + // Edge case tests + + @Test + void testCompiledEmptyPath() { + LOG.info(() -> "TEST: testCompiledEmptyPath - $ with no segments"); + final var json = Json.parse("42"); + assertCompiledMatchesInterpreted("$", json); + } + + @Test + void testCompiledOnDifferentDocuments() { + LOG.info(() -> "TEST: testCompiledOnDifferentDocuments - reuse compiled path"); + final var compiled = JsonPath.compile(JsonPath.parse("$.name")); + + final var doc1 = Json.parse("{\"name\": \"Alice\"}"); + final var doc2 = Json.parse("{\"name\": \"Bob\"}"); + + final var results1 = compiled.query(doc1); + final var results2 = compiled.query(doc2); + + assertThat(results1.getFirst().string()).isEqualTo("Alice"); + assertThat(results2.getFirst().string()).isEqualTo("Bob"); + } + + @Test + void testCompiledWithSpecialCharactersInPropertyName() { + LOG.info(() -> "TEST: testCompiledWithSpecialCharactersInPropertyName"); + final var json = Json.parse("{\"special-key\": 123}"); + assertCompiledMatchesInterpreted("$['special-key']", json); + } + + /// Helper method to verify compiled and interpreted paths produce identical results. + private void assertCompiledMatchesInterpreted(String pathExpr, JsonValue json) { + final var interpreted = JsonPath.parse(pathExpr); + final var compiled = JsonPath.compile(interpreted); + + final var interpretedResults = interpreted.query(json); + final var compiledResults = compiled.query(json); + + LOG.fine(() -> "Path: " + pathExpr); + LOG.fine(() -> "Interpreted results: " + interpretedResults.size()); + LOG.fine(() -> "Compiled results: " + compiledResults.size()); + + assertThat(compiledResults) + .as("Compiled results for '%s' should match interpreted results", pathExpr) + .containsExactlyElementsOf(interpretedResults); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathFilterEvaluationTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathFilterEvaluationTest.java index 54db45d..0e3f220 100644 --- a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathFilterEvaluationTest.java +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathFilterEvaluationTest.java @@ -80,12 +80,12 @@ void testLogicalNot() { // If @.active exists and is true -> false. // If @.active exists and is false -> true. // If @.active is missing -> ExistsFilter returns false -> !false -> true. - - // However, "ExistsFilter" checks for existence. - // @.active matches id 1 (true) and 2 (false). + + // However, "ExistsFilter" checks for existence. + // @.active matches id 1 (true) and 2 (false). // Wait, ExistsFilter checks if the path *exists* and is non-null. // Let's verify specific behavior for boolean value comparison vs existence. - + // Case A: Existence check negation // [?(!@.active)] -> Match items where "active" does NOT exist. var missingResults = JsonPath.parse("$[?(!@.active)]").query(json); @@ -110,11 +110,11 @@ void testParenthesesPrecedence() { // A=true, B=false, C=true // A && (B || C) -> T && (F || T) -> T && T -> MATCH // (A && B) || C -> (T && F) || T -> F || T -> MATCH (Wait, bad example, both match) - + // Let's try: A=false, B=true, C=true // A && (B || C) -> F && T -> NO MATCH // (A && B) || C -> F || T -> MATCH - + var json = Json.parse(""" [ {"id": 1, "A": false, "B": true, "C": true} @@ -138,7 +138,7 @@ void testParenthesesPrecedence() { void testComplexNestedLogic() { LOG.info(() -> "TEST: testComplexNestedLogic"); // (Price < 10 OR (Category == 'fiction' AND Published is false)) - // Note: !@.published would mean "published does not exist". + // Note: !@.published would mean "published does not exist". // To check for false value, use @.published == false. var json = Json.parse(""" [ 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 index 3fcd3a8..52b0458 100644 --- a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java @@ -406,11 +406,11 @@ void testStaticQueryWithCompiledPath() { 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}}