Skip to content

Commit 9d9c1db

Browse files
committed
Issue #129 Tighten JsonPath docs and fix reverse slices
1 parent 7c0450a commit 9d9c1db

File tree

12 files changed

+106
-287
lines changed

12 files changed

+106
-287
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
for k in totals: totals[k]+=int(r.get(k,'0'))
4040
except Exception:
4141
pass
42-
exp_tests=610
42+
exp_tests=611
4343
exp_skipped=0
4444
if totals['tests']!=exp_tests or totals['skipped']!=exp_skipped:
4545
print(f"Unexpected test totals: {totals} != expected tests={exp_tests}, skipped={exp_skipped}")

json-java21-jsonpath/AGENTS.md

Lines changed: 13 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,17 @@
1-
# json-java21-jsonpath Module AGENTS.md
1+
# json-java21-jsonpath/AGENTS.md
22

3-
## Purpose
4-
This module implements a JsonPath query engine for the java.util.json Java 21 backport. It parses JsonPath expressions into an AST and evaluates them against JSON documents.
3+
This file is for contributor/agent operational notes. Read `json-java21-jsonpath/README.md` for purpose, supported syntax, and user-facing examples.
54

6-
## Specification
7-
Based on Stefan Goessner's JSONPath specification:
8-
https://goessner.net/articles/JsonPath/
5+
- User docs MUST recommend only `./mvnw`.
6+
- 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.
97

10-
## Module Structure
8+
Stable code entry points:
9+
- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java`
10+
- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java`
11+
- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java`
12+
- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java`
13+
- `json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathStreams.java`
1114

12-
### Main Classes
13-
- `JsonPath`: Public API facade with `query()` method
14-
- `JsonPathAst`: Sealed interface hierarchy defining the AST
15-
- `JsonPathParser`: Recursive descent parser
16-
- `JsonPathParseException`: Parse error exception
17-
18-
### Test Classes
19-
- `JsonPathLoggingConfig`: JUL test configuration base class
20-
- `JsonPathAstTest`: Unit tests for AST records
21-
- `JsonPathParserTest`: Unit tests for parser
22-
- `JsonPathGoessnerTest`: Integration tests based on Goessner article examples
23-
24-
## Development Guidelines
25-
26-
### Adding New Operators
27-
1. Add new record type to `JsonPathAst.Segment` sealed interface
28-
2. Update `JsonPathParser` to parse the new syntax
29-
3. Add parser tests in `JsonPathParserTest`
30-
4. Implement evaluation in `JsonPath.evaluateSegments()`
31-
5. Add integration tests in `JsonPathGoessnerTest`
32-
33-
### Testing
34-
```bash
35-
# Run all tests
36-
$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Djava.util.logging.ConsoleHandler.level=INFO
37-
38-
# Run specific test class with debug logging
39-
$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Dtest=JsonPathGoessnerTest -Djava.util.logging.ConsoleHandler.level=FINE
40-
41-
# Run single test method
42-
$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jsonpath -Dtest=JsonPathGoessnerTest#testAuthorsOfAllBooks -Djava.util.logging.ConsoleHandler.level=FINEST
43-
```
44-
45-
## Design Principles
46-
47-
1. **No external dependencies**: Only java.base is allowed
48-
2. **Pure TDD**: Tests first, then implementation
49-
3. **Functional style**: Immutable records, pure evaluation functions
50-
4. **Java 21 features**: Records, sealed interfaces, pattern matching with switch
51-
5. **Defensive copies**: All collections in records are copied for immutability
52-
53-
## Known Limitations
54-
55-
1. **Script expressions**: Only `@.length-1` pattern is supported
56-
2. **No general expression evaluation**: Complex scripts are not supported
57-
3. **Stack-based recursion**: May overflow on very deep documents
58-
59-
## API Usage
60-
61-
```java
62-
import jdk.sandbox.java.util.json.*;
63-
import json.java21.jsonpath.JsonPath;
64-
65-
JsonValue json = Json.parse(jsonString);
66-
67-
// Preferred: parse once (cache) and reuse
68-
JsonPath path = JsonPath.parse("$.store.book[*].author");
69-
List<JsonValue> results = path.query(json);
70-
71-
// If you want a static call site, pass the compiled JsonPath
72-
List<JsonValue> sameResults = JsonPath.query(path, json);
73-
```
74-
75-
Notes:
76-
- Parsing a JsonPath expression is relatively expensive compared to evaluation; cache compiled `JsonPath` instances in hot code paths.
77-
- `JsonPath.query(String, JsonValue)` is intended for one-off usage only.
15+
When changing syntax/behavior:
16+
- Update `JsonPathAst` + `JsonPathParser` + `JsonPath` together.
17+
- Add parser + evaluation tests; new tests should extend `JsonPathLoggingConfig`.

json-java21-jsonpath/README.md

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,47 @@ This module provides a JSONPath-style query engine for JSON documents parsed wit
55
It is based on the original Stefan Goessner JSONPath article:
66
https://goessner.net/articles/JsonPath/
77

8-
## Usage
9-
10-
Parse JSON once with `Json.parse(...)`, compile the JsonPath once with `JsonPath.parse(...)`, then query multiple documents:
8+
## Quick Start
119

1210
```java
1311
import jdk.sandbox.java.util.json.*;
1412
import json.java21.jsonpath.JsonPath;
1513

1614
JsonValue doc = Json.parse("""
17-
{"store": {"book": [{"author": "A"}, {"author": "B"}]}}
15+
{"store": {"book": [{"title": "A", "price": 8.95}, {"title": "B", "price": 12.99}]}}
1816
""");
1917

20-
JsonPath path = JsonPath.parse("$.store.book[*].author");
21-
var authors = path.query(doc);
22-
23-
// If you want a static call site:
24-
var sameAuthors = JsonPath.query(path, doc);
18+
var titles = JsonPath.parse("$.store.book[*].title").query(doc);
19+
var cheap = JsonPath.parse("$.store.book[?(@.price < 10)].title").query(doc);
2520
```
2621

27-
Notes:
28-
- Prefer `JsonPath.parse(String)` + `query(JsonValue)` to avoid repeatedly parsing the same path.
29-
- `JsonPath.query(String, JsonValue)` is intended for one-off usage.
22+
## Syntax At A Glance
23+
24+
Operator | Example | What it selects
25+
---|---|---
26+
root | `$` | the whole document
27+
property | `$.store.book` | a nested object property
28+
bracket property | `$['store']['book']` | same as dot notation, but allows escaping
29+
wildcard | `$.store.*` | all direct children
30+
recursive descent | `$..price` | any matching member anywhere under the document
31+
array index | `$.store.book[0]` / `[-1]` | element by index (negative from end)
32+
slice | `$.store.book[:2]` / `[0:4:2]` / `[::-1]` | slice by start:end:step
33+
union | `$.store['book','bicycle']` / `[0,1]` | select multiple names/indices
34+
filter exists | `$.store.book[?(@.isbn)]` | elements where a member exists
35+
filter compare | `$.store.book[?(@.price < 10)]` | elements matching a comparison
36+
filter logic | `$.store.book[?(@.isbn && (@.price < 10 || @.price > 20))]` | compound boolean logic
37+
script (limited) | `$.store.book[(@.length-1)]` | last element via `length-1`
38+
39+
## Examples
40+
41+
Expression | What it selects
42+
---|---
43+
`$.store.book[*].title` | all book titles
44+
`$.store.book[?(@.price < 10)].title` | titles of books cheaper than 10
45+
`$.store.book[?(@.isbn && (@.price < 10 || @.price > 20))].title` | books with an ISBN and price outside the mid-range
46+
`$..price` | every `price` anywhere under the document
47+
`$.store.book[-1]` | the last book
48+
`$.store.book[0:4:2]` | every other book from the first four
3049

3150
## Supported Syntax
3251

@@ -43,7 +62,7 @@ This implementation follows Goessner-style JSONPath operators, including:
4362

4463
## Stream-Based Functions (Aggregations)
4564

46-
Some JsonPath implementations for older versions of Java provided aggregation functions such as `$.numbers.avg()`.
65+
Some JsonPath implementations include aggregation functions such as `$.numbers.avg()`.
4766
In this implementation we provide first class stream support so you can use standard JDK aggregation functions on `JsonPath.query(...)` results.
4867

4968
The `query()` method returns a standard `List<JsonValue>`. You can stream, filter, map, and reduce these results using standard Java APIs. To make this easier, we provide the `JsonPathStreams` utility class with predicate and conversion methods.

json-java21-jsonpath/pom.xml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
<plugin>
6565
<groupId>org.apache.maven.plugins</groupId>
6666
<artifactId>maven-compiler-plugin</artifactId>
67-
<version>3.11.0</version>
6867
<configuration>
6968
<release>21</release>
7069
<compilerArgs>

json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
///
1313
/// Usage examples:
1414
/// ```java
15-
/// // Fluent API (preferred)
15+
/// // Fluent API
1616
/// JsonValue json = Json.parse(jsonString);
1717
/// List<JsonValue> results = JsonPath.parse("$.store.book[*].author").query(json);
1818
///
@@ -46,8 +46,8 @@ public static JsonPath parse(String path) {
4646

4747
/// Queries matching values from a JSON document.
4848
///
49-
/// This is the preferred instance API: compile once via `parse(String)`, then call `query(JsonValue)`
50-
/// for each already-parsed JSON document.
49+
/// Instance API: compile once via `parse(String)`, then call `query(JsonValue)` for each already-parsed
50+
/// JSON document.
5151
///
5252
/// @param json the JSON document to query
5353
/// @return a list of matching JsonValue instances (maybe empty)
@@ -74,6 +74,21 @@ public static List<JsonValue> query(JsonPath path, JsonValue json) {
7474
return path.query(json);
7575
}
7676

77+
/// Evaluates a JsonPath expression against a JSON document.
78+
///
79+
/// Intended for one-off usage; for hot paths, prefer caching the compiled `JsonPath` via `parse(String)`.
80+
///
81+
/// @param path the JsonPath expression to parse
82+
/// @param json the JSON document to query
83+
/// @return a list of matching JsonValue instances (maybe empty)
84+
/// @throws NullPointerException if path or JSON is null
85+
/// @throws JsonPathParseException if the path is invalid
86+
public static List<JsonValue> query(String path, JsonValue json) {
87+
Objects.requireNonNull(path, "path must not be null");
88+
Objects.requireNonNull(json, "json must not be null");
89+
return parse(path).query(json);
90+
}
91+
7792
/// Evaluates a pre-parsed JsonPath AST against a JSON document.
7893
/// @param ast the parsed JsonPath AST
7994
/// @param json the JSON document to query
@@ -166,24 +181,28 @@ private static void evaluateArraySlice(
166181
final var elements = array.elements();
167182
final int size = elements.size();
168183

169-
// Resolve start, end, step with defaults
170-
int start = slice.start() != null ? normalizeIndex(slice.start(), size) : 0;
171-
int end = slice.end() != null ? normalizeIndex(slice.end(), size) : size;
172-
int step = slice.step() != null ? slice.step() : 1;
184+
final int step = slice.step() != null ? slice.step() : 1;
173185

174186
if (step == 0) {
175187
return; // Invalid step
176188
}
177189

178-
// Clamp values
179-
start = Math.max(0, Math.min(start, size));
180-
end = Math.max(0, Math.min(end, size));
181-
182190
if (step > 0) {
191+
int start = slice.start() != null ? normalizeIndex(slice.start(), size) : 0;
192+
int end = slice.end() != null ? normalizeIndex(slice.end(), size) : size;
193+
194+
start = Math.max(0, Math.min(start, size));
195+
end = Math.max(0, Math.min(end, size));
196+
183197
for (int i = start; i < end; i += step) {
184198
evaluateSegments(segments, index + 1, elements.get(i), root, results);
185199
}
186200
} else {
201+
int start = slice.start() != null ? normalizeIndex(slice.start(), size) : size - 1;
202+
final int end = slice.end() != null ? normalizeIndex(slice.end(), size) : -1;
203+
204+
start = Math.max(0, Math.min(start, size - 1));
205+
187206
for (int i = start; i > end; i += step) {
188207
evaluateSegments(segments, index + 1, elements.get(i), root, results);
189208
}

json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,8 @@ private JsonPathAst.Segment parseNumberOrSliceOrUnion() {
524524
// After initial ':', check if there's a number for end
525525
if (pos < path.length() && (Character.isDigit(path.charAt(pos)) || path.charAt(pos) == '-')) {
526526
elements.add(parseInteger());
527+
} else {
528+
elements.add(null);
527529
}
528530
} else {
529531
elements.add(parseInteger());

json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathStreams.java

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,54 +2,35 @@
22

33
import jdk.sandbox.java.util.json.*;
44

5-
/// Utility class for stream-based processing of JsonPath query results.
6-
///
7-
/// This module intentionally does not embed aggregate functions (avg/sum/min/max)
8-
/// in JsonPath syntax; use Java Streams on `JsonPath.query(...)` results instead.
5+
/// Helpers for stream-based processing of `JsonPath.query(...)` results.
96
public final class JsonPathStreams {
107

118
private JsonPathStreams() {}
129

13-
// =================================================================================
14-
// Predicates
15-
// =================================================================================
16-
17-
/// @return true if the value is a `JsonNumber`
1810
public static boolean isNumber(JsonValue v) {
1911
return v instanceof JsonNumber;
2012
}
2113

22-
/// @return true if the value is a `JsonString`
2314
public static boolean isString(JsonValue v) {
2415
return v instanceof JsonString;
2516
}
2617

27-
/// @return true if the value is a `JsonBoolean`
2818
public static boolean isBoolean(JsonValue v) {
2919
return v instanceof JsonBoolean;
3020
}
3121

32-
/// @return true if the value is a `JsonArray`
3322
public static boolean isArray(JsonValue v) {
3423
return v instanceof JsonArray;
3524
}
3625

37-
/// @return true if the value is a `JsonObject`
3826
public static boolean isObject(JsonValue v) {
3927
return v instanceof JsonObject;
4028
}
4129

42-
/// @return true if the value is a `JsonNull`
4330
public static boolean isNull(JsonValue v) {
4431
return v instanceof JsonNull;
4532
}
4633

47-
// =================================================================================
48-
// Strict Converters (throw ClassCastException if type mismatch)
49-
// =================================================================================
50-
51-
/// Converts a `JsonNumber` to a `double`.
52-
///
5334
/// @throws ClassCastException if the value is not a `JsonNumber`
5435
public static double asDouble(JsonValue v) {
5536
if (v instanceof JsonNumber n) {
@@ -58,8 +39,6 @@ public static double asDouble(JsonValue v) {
5839
throw new ClassCastException("Expected JsonNumber but got " + v.getClass().getSimpleName());
5940
}
6041

61-
/// Converts a `JsonNumber` to a `long`.
62-
///
6342
/// @throws ClassCastException if the value is not a `JsonNumber`
6443
public static long asLong(JsonValue v) {
6544
if (v instanceof JsonNumber n) {
@@ -68,8 +47,6 @@ public static long asLong(JsonValue v) {
6847
throw new ClassCastException("Expected JsonNumber but got " + v.getClass().getSimpleName());
6948
}
7049

71-
/// Converts a `JsonString` to a `String`.
72-
///
7350
/// @throws ClassCastException if the value is not a `JsonString`
7451
public static String asString(JsonValue v) {
7552
if (v instanceof JsonString s) {
@@ -78,8 +55,6 @@ public static String asString(JsonValue v) {
7855
throw new ClassCastException("Expected JsonString but got " + v.getClass().getSimpleName());
7956
}
8057

81-
/// Converts a `JsonBoolean` to a `boolean`.
82-
///
8358
/// @throws ClassCastException if the value is not a `JsonBoolean`
8459
public static boolean asBoolean(JsonValue v) {
8560
if (v instanceof JsonBoolean b) {
@@ -88,26 +63,18 @@ public static boolean asBoolean(JsonValue v) {
8863
throw new ClassCastException("Expected JsonBoolean but got " + v.getClass().getSimpleName());
8964
}
9065

91-
// =================================================================================
92-
// Lax Converters (return null if type mismatch)
93-
// =================================================================================
94-
95-
/// Converts to `Double` if the value is a `JsonNumber`, otherwise returns null.
9666
public static Double asDoubleOrNull(JsonValue v) {
9767
return (v instanceof JsonNumber n) ? n.toDouble() : null;
9868
}
9969

100-
/// Converts to `Long` if the value is a `JsonNumber`, otherwise returns null.
10170
public static Long asLongOrNull(JsonValue v) {
10271
return (v instanceof JsonNumber n) ? n.toLong() : null;
10372
}
10473

105-
/// Converts to `String` if the value is a `JsonString`, otherwise returns null.
10674
public static String asStringOrNull(JsonValue v) {
10775
return (v instanceof JsonString s) ? s.string() : null;
10876
}
10977

110-
/// Converts to `Boolean` if the value is a `JsonBoolean`, otherwise returns null.
11178
public static Boolean asBooleanOrNull(JsonValue v) {
11279
return (v instanceof JsonBoolean b) ? b.bool() : null;
11380
}

0 commit comments

Comments
 (0)