Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions PLAN_121.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
## Issue #121: Add JsonPath module with AST parser and evaluator

### Goal

Add a new Maven module that implements **JsonPath** as described by Stefan Goessner:
`https://goessner.net/articles/JsonPath/index.html`.

This module must:

- Parse a JsonPath string into a **custom AST**
- Evaluate that AST against a JSON document already parsed by this repo’s
`jdk.sandbox.java.util.json` API
- Have **no runtime dependencies** outside `java.base` (and the existing `json-java21` module)
- Target **Java 21** and follow functional / data-oriented style (records + sealed interfaces)

### Non-goals

- Parsing JSON documents from strings (the core `Json.parse(...)` already does that)
- Adding non-trivial external dependencies (no regex engines beyond JDK, no parser generators)
- Supporting every JsonPath dialect ever published; the baseline is the article examples

### Public API shape (module `json-java21-jsonpath`)

Package: `json.java21.jsonpath`

- `JsonPathExpression JsonPath.parse(String path)`
- Parses a JsonPath string into an AST-backed compiled expression.
- `List<JsonValue> JsonPathExpression.select(JsonValue document)`
- Evaluates the expression against a parsed JSON document and returns matched nodes in traversal order.

Notes:
- The return type is a `List<JsonValue>` to avoid introducing a JSON encoding of “result sets”.
Callers can wrap it into `JsonArray.of(...)` if desired.

### AST plan

Use a sealed interface with records (no inheritance trees with stateful objects):

- `sealed interface PathNode permits Root, StepChain`
- `record Root() implements PathNode`
- `record StepChain(PathNode base, Step step) implements PathNode`

Steps are a separate sealed protocol:

- `sealed interface Step permits Child, RecursiveDescent, Wildcard, ArrayIndex, ArraySlice, Union, Filter`
- `record Child(Name name)` where `Name` is either identifier or quoted key
- `record RecursiveDescent(Step selector)` where selector is `Child` or `Wildcard`
- `record Wildcard()`
- `record ArrayIndex(int index)` supports negative indices per examples
- `record ArraySlice(Integer start, Integer end, Integer step)` to cover `[:2]`, `[-1:]`, etc.
- `record Union(List<Step> selectors)` for `[0,1]`, `['a','b']`
- `record Filter(PredicateExpr expr)` for `[?(...)]`

Filter expressions:

- Keep a minimal expression AST that supports the article examples:
- `@.field` access
- `@.length` pseudo-property for array length
- numeric literals
- string literals
- comparison operators: `<`, `<=`, `>`, `>=`, `==`, `!=`
- arithmetic: `+`, `-` (only what’s needed for `(@.length-1)`)

### Parser plan

Hand-rolled scanner + recursive descent parser:

- Lex JsonPath into tokens (`$`, `.`, `..`, `[`, `]`, `*`, `,`, `:`, `?(`, `)`, identifiers, quoted strings, numbers, operators).
- Parse according to the article grammar:
- Root `$` must appear first
- Dot-notation steps: `.name`, `.*`, `..name`, `..*`
- Bracket steps:
- `['name']`
- `[0]`, `[-1]`
- `[0,1]`, `['a','b']`
- `[:2]`, `[2:]`, `[-1:]`
- `[?(...)]`
- `[(...)]` for script expressions used as array index in examples (limited support)

### Evaluator plan

Evaluator is a pure function over immutable inputs, implemented as static methods:

- Maintain a worklist of “current nodes” (starting with the document root).
- For each step:
- **Child**: for objects, pick member by key; for arrays, apply to each element if they’re objects (per example behavior).
- **RecursiveDescent**: walk the subtree of each current node (object members + array elements) and apply the selector to every node encountered.
- **Wildcard**: for objects select all member values; for arrays select all elements.
- **ArrayIndex / Slice / Union**: apply only when the current node is an array.
- **Filter**: apply only when current node is an array; keep elements where predicate is true.

Ordering:
- Preserve traversal order implied by iterating object members (`JsonObject.members()` is order-preserving) and array elements order.

### Tests (TDD baseline)

Add tests that correspond 1:1 with every example on the article page:

- Use the article’s sample document (embedded as a Java text block) and parse with `Json.parse(...)`.
- Assertions check matched values by rendering to JSON (`JsonValue.toString()`) and comparing to expected fragments.
- Every test method logs an INFO banner at start (common base class).

### Verification

Run focused module tests with logging:

```bash
$(command -v mvnd || command -v mvn || command -v ./mvnw) -pl json-java21-jsonpath test -Djava.util.logging.ConsoleHandler.level=FINE
```

Run full suite once stable:

```bash
$(command -v mvnd || command -v mvn || command -v ./mvnw) test -Djava.util.logging.ConsoleHandler.level=INFO
```

23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,29 @@ This repo contains an incubating JTD validator that has the core JSON API as its

A complete JSON Type Definition validator is included (module: json-java21-jtd).

## JsonPath (AST + evaluator over `java.util.json`)

This repo also includes (in progress) a **JsonPath** module based on Stefan Goessner’s article:
`https://goessner.net/articles/JsonPath/index.html`.

Design goals:
- **No runtime deps beyond `java.base`** (and the core `json-java21` module)
- **Parse JsonPath strings to a custom AST**
- **Evaluate against already-parsed JSON** (`JsonValue`), not against JSON text
- Pure Java 21, functional/data-oriented style (records + sealed interfaces)
- Unit tests mirror the article examples

Planned public API (module: `json-java21-jsonpath`):

```java
import jdk.sandbox.java.util.json.JsonValue;
import json.java21.jsonpath.JsonPath;

JsonValue doc = /* Json.parse(...) from json-java21 */;
var expr = JsonPath.parse("$.store.book[*].author");
var matches = expr.select(doc); // List<JsonValue>
```

### Empty Schema `{}` Semantics (RFC 8927)

Per **RFC 8927 (JSON Typedef)**, the empty schema `{}` is the **empty form** and
Expand Down
22 changes: 22 additions & 0 deletions json-java21-jsonpath/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# JsonPath (Goessner) for `java.util.json` (Java 21)

This module implements **JsonPath** as described by Stefan Goessner:
`https://goessner.net/articles/JsonPath/index.html`.

Design constraints:
- Evaluates over already-parsed JSON (`jdk.sandbox.java.util.json.JsonValue`)
- Parses JsonPath strings into a custom AST
- No runtime dependencies outside `java.base` (and the core `java.util.json` backport)

## Usage (planned API)

```java
import jdk.sandbox.java.util.json.Json;
import jdk.sandbox.java.util.json.JsonValue;
import json.java21.jsonpath.JsonPath;

JsonValue doc = Json.parse("{\"a\": {\"b\": [1,2,3]}}");
var expr = JsonPath.parse("$.a.b[0]");
var matches = expr.select(doc);
```

80 changes: 80 additions & 0 deletions json-java21-jsonpath/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>io.github.simbo1905.json</groupId>
<artifactId>parent</artifactId>
<version>0.1.9</version>
</parent>

<artifactId>java.util.json.jsonpath</artifactId>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The artifactId java.util.json.jsonpath is unconventional and inconsistent with the module's directory name (json-java21-jsonpath). While this seems to follow a pattern from the jtd module, it can be confusing. Maven convention generally suggests that the artifactId matches the directory name for multi-module projects. Consider renaming the artifactId to json-java21-jsonpath for better clarity and consistency with standard Maven practices.

Suggested change
<artifactId>java.util.json.jsonpath</artifactId>
<artifactId>json-java21-jsonpath</artifactId>

<packaging>jar</packaging>
<name>java.util.json Java21 Backport JsonPath</name>
<url>https://simbo1905.github.io/java.util.json.Java21/</url>
<scm>
<connection>scm:git:https://github.com/simbo1905/java.util.json.Java21.git</connection>
<developerConnection>scm:git:git@github.com:simbo1905/java.util.json.Java21.git</developerConnection>
<url>https://github.com/simbo1905/java.util.json.Java21</url>
<tag>HEAD</tag>
</scm>
<description>Experimental JsonPath (Goessner) parser to AST and evaluator over the java.util.json Java 21 backport.</description>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
</properties>

<dependencies>
<dependency>
<groupId>io.github.simbo1905.json</groupId>
<artifactId>java.util.json</artifactId>
<version>${project.version}</version>
</dependency>

<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<!-- Treat all warnings as errors, enable all lint warnings -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<release>21</release>
<compilerArgs>
<arg>-Xlint:all</arg>
<arg>-Werror</arg>
<arg>-Xdiags:verbose</arg>
</compilerArgs>
</configuration>
</plugin>
</plugins>
</build>
</project>

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package json.java21.jsonpath;

import java.util.Objects;

sealed interface BoolExpr permits BoolExpr.Exists, BoolExpr.Compare {
record Exists(ValueExpr.Path path) implements BoolExpr {
public Exists {
Objects.requireNonNull(path, "path must not be null");
}
}

enum CmpOp {
LT, LE, GT, GE, EQ, NE
}

record Compare(CmpOp op, ValueExpr left, ValueExpr right) implements BoolExpr {
public Compare {
Objects.requireNonNull(op, "op must not be null");
Objects.requireNonNull(left, "left must not be null");
Objects.requireNonNull(right, "right must not be null");
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package json.java21.jsonpath;

import java.util.Objects;

/// JsonPath entry point: parse to an AST-backed expression.
public final class JsonPath {

public static JsonPathExpression parse(String path) {
Objects.requireNonNull(path, "path must not be null");
return new JsonPathExpression(JsonPathParser.parse(path));
}

private JsonPath() {}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package json.java21.jsonpath;

import java.util.List;
import java.util.Objects;

record JsonPathAst(List<Step> steps) {
JsonPathAst {
Objects.requireNonNull(steps, "steps must not be null");
steps.forEach(s -> Objects.requireNonNull(s, "step must not be null"));
}

sealed interface Step permits
Step.Child,
Step.Wildcard,
Step.RecursiveDescent,
Step.ArrayIndex,
Step.ArraySlice,
Step.Union,
Step.Filter,
Step.ScriptIndex {

record Child(String name) implements Step {
public Child {
Objects.requireNonNull(name, "name must not be null");
}
}

record Wildcard() implements Step {}

record RecursiveDescent(RecursiveSelector selector) implements Step {
public RecursiveDescent {
Objects.requireNonNull(selector, "selector must not be null");
}
}

sealed interface RecursiveSelector permits RecursiveSelector.Name, RecursiveSelector.Wildcard {
record Name(String name) implements RecursiveSelector {
public Name {
Objects.requireNonNull(name, "name must not be null");
}
}

record Wildcard() implements RecursiveSelector {}
}

record ArrayIndex(int index) implements Step {}

record ArraySlice(Integer startInclusive, Integer endExclusive) implements Step {}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ArraySlice record is missing the step parameter. The JsonPath syntax as described in the Goessner article includes support for [<start>:<end>:<step>]. The implementation plan in PLAN_121.md also mentioned step. This is a feature gap that limits the expressiveness of slice operations. Please consider adding support for the step parameter, which would also require updates to the parser and evaluator.

Suggested change
record ArraySlice(Integer startInclusive, Integer endExclusive) implements Step {}
record ArraySlice(Integer startInclusive, Integer endExclusive, Integer step) implements Step {}


record Union(List<Selector> selectors) implements Step {
public Union {
Objects.requireNonNull(selectors, "selectors must not be null");
selectors.forEach(s -> Objects.requireNonNull(s, "selector must not be null"));
}
}

sealed interface Selector permits Selector.Name, Selector.Index {
record Name(String name) implements Selector {
public Name {
Objects.requireNonNull(name, "name must not be null");
}
}

record Index(int index) implements Selector {}
}

record Filter(BoolExpr predicate) implements Step {
public Filter {
Objects.requireNonNull(predicate, "predicate must not be null");
}
}

record ScriptIndex(ValueExpr expr) implements Step {
public ScriptIndex {
Objects.requireNonNull(expr, "expr must not be null");
}
}
}
}
Loading
Loading