diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index 55db773..33e7e3e 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -9,7 +9,9 @@ assignees: "" (Optional) When submitting an Issue, please consider using a "deep research" tool to sanity check your proposal. Then **before** submission, run your draft through a strong model with a prompt such as: -> "Please review the AGENTS.md and README.md along with this draft Issue and check that it does not have any gaps — why it might be insufficient, incomplete, lacking a concrete example, duplicating prior issues or PRs, or not be aligned with the project goals or non‑goals." +> "Please review README.md along with this draft Issue and check that it does not have any gaps — why it might be insufficient, incomplete, lacking a concrete example, duplicating prior issues or PRs, or not be aligned with the project goals or non‑goals." + +If you used an AI assistant while preparing this Issue, ensure it followed the contributor/agent workflow rules in `AGENTS.md`. (Optional) Please then attach both the prompt and the model's review to the bottom of this template under "Augmented Intelligence Review". diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 42a59a9..f54ca8d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -9,7 +9,9 @@ assignees: '' (Optional) When submitting a bug report, please consider using an AI assistant to help create a minimal test case that demonstrates the issue. Then **before** submission, run your bug description through a strong model with a prompt such as: -> "Please review the AGENTS.md and README.md along with this bug report and check that it includes: a clear description of the problem, steps to reproduce, expected vs actual behavior, and a minimal test case that demonstrates the bug." +> "Please review README.md along with this bug report and check that it includes: a clear description of the problem, steps to reproduce, expected vs actual behavior, and a minimal test case that demonstrates the bug." + +If you used an AI assistant while preparing this report, ensure it followed the contributor/agent workflow rules in `AGENTS.md`. (Optional) Please then attach both the prompt and the model's review to the bottom of this template under "Augmented Intelligence Review". diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md index dde2411..6e067a5 100644 --- a/.github/ISSUE_TEMPLATE/custom.md +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -9,8 +9,9 @@ assignees: '' (Optional) When submitting this issue, please consider using an AI assistant to help analyze and articulate the problem. Then **before** submission, run your issue description through a strong model with a prompt such as: -> "Please review the AGENTS.md and README.md along with this issue description and check that it: clearly explains the problem or request, provides sufficient context, includes relevant details, and aligns with project standards." +> "Please review README.md along with this issue description and check that it: clearly explains the problem or request, provides sufficient context, includes relevant details, and aligns with project standards." -(Optional) Please then attach both the prompt and the model's review to the bottom of this template under "Augmented Intelligence Review". +If you used an AI assistant while preparing this issue, ensure it followed the contributor/agent workflow rules in `AGENTS.md`. +(Optional) Please then attach both the prompt and the model's review to the bottom of this template under "Augmented Intelligence Review". diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 4c321d2..93518a2 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -9,7 +9,9 @@ assignees: '' (Optional) When submitting a feature request, please consider using an AI assistant to validate your implementation approach. Then **before** submission, run your feature description through a strong model with a prompt such as: -> "Please review the AGENTS.md and README.md along with this feature request and check that it: aligns with project goals, doesn't duplicate existing functionality, includes concrete use cases, and suggests a reasonable implementation approach." +> "Please review README.md along with this feature request and check that it: aligns with project goals, doesn't duplicate existing functionality, includes concrete use cases, and suggests a reasonable implementation approach." + +If you used an AI assistant while preparing this request, ensure it followed the contributor/agent workflow rules in `AGENTS.md`. (Optional) Please then attach both the prompt and the model's review to the bottom of this template under "Augmented Intelligence Review". diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 53906c9..df5b044 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,9 @@ -(Optional) When submitting an PR, please consider using a "deep research" tool to sanity check your proposal. Then **before** submission, run your draft through a strong model with a prompt such as: +(Optional) When submitting a PR, please consider using a "deep research" tool to sanity check your proposal. Then **before** submission, run your draft through a strong model with a prompt such as: -> "Please review the AGENTS.md and README.md along with this draft PR and check that it does not have any gaps — why it might be insufficient, incomplete, lacking a concrete example, duplicating prior issues or PRs, or not be aligned with the project goals or non‑goals." +> "Please review README.md along with this draft PR and check that it does not have any gaps — why it might be insufficient, incomplete, lacking a concrete example, duplicating prior issues or PRs, or not be aligned with the project goals or non‑goals." + +If you used an AI assistant while preparing this PR, ensure it followed the contributor/agent workflow rules in `AGENTS.md`. (Optional) Please then attach both the prompt and the model's review to the bottom of this template under "Augmented Intelligence Review". @@ -28,4 +30,3 @@ **(Optional) Augmented Intelligence Review**: *Both prompt and model out, asking a strong model to double-check your submission, from the perspective of a maintainer of this repo* - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96e5cf7..eefb80d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: for k in totals: totals[k]+=int(r.get(k,'0')) except Exception: pass - exp_tests=511 + exp_tests=611 exp_skipped=0 if totals['tests']!=exp_tests or totals['skipped']!=exp_skipped: print(f"Unexpected test totals: {totals} != expected tests={exp_tests}, skipped={exp_skipped}") diff --git a/AGENTS.md b/AGENTS.md index 97bf630..0fa6e44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,11 +3,13 @@ ## Purpose & Scope - Operational guidance for human and AI agents working in this repository. This revision preserves all existing expectations while improving structure and wording in line with agents.md best practices. +User-facing documentation lives in `README.md`. Keep this file focused on contributor/agent workflow, debugging, and coding standards. + ## Operating Principles - Follow the sequence plan → implement → verify; do not pivot without restating the plan. - Stop immediately on unexpected failures and ask before changing approach. - Keep edits atomic and avoid leaving mixed partial states. -- Propose jsonSchemaOptions with trade-offs before invasive changes. +- You SHOULD discuss trade-offs before making invasive changes to existing code. - Prefer mechanical, reversible transforms (especially when syncing upstream sources). - Validate that outputs are non-empty before overwriting files. - Minimal shims are acceptable only when needed to keep backports compiling. @@ -39,13 +41,13 @@ When making changes, always update documentation files before modifying code. ### Non-Negotiable Rules - You MUST NOT ever filter test output; debugging relies on observing the unknown. -- You MUST restrict the amount of tokens by adding logging at INFO, FINE, FINER, and FINEST. Focus runs on the narrowest model/test/method that exposes the issue. +- You MUST keep debug output actionable by using JUL levels (INFO/FINE/FINER/FINEST) and running the narrowest test/class/module that reproduces the issue. - You MUST NOT add ad-hoc "temporary logging"; only the defined JUL levels above are acceptable. - You SHOULD NOT delete logging. Adjust levels downward (finer granularity) instead of removing statements. - You MUST add a JUL log statement at INFO level at the top of every test method announcing execution. - You MUST have all new tests extend a helper such as `JsonSchemaLoggingConfig` so environment variables configure JUL levels compatibly with `$(command -v mvnd || command -v mvn || command -v ./mvnw)`. - You MUST NOT guess root causes; add targeted logging or additional tests. Treat observability as the path to the fix. -- YOU MUST Use exactly one logger for the JSON Schema subsystem and use appropriate logging to debug as below. +- Use a single shared logger per subsystem/package and use appropriate JUL levels to debug. - YOU MUST honour official JUL logging levels: - SEVERE (1000): Critical errors—application will likely abort. - WARNING (900): Indications of potential problems or recoverable issues. @@ -57,7 +59,8 @@ When making changes, always update documentation files before modifying code. ### Run Tests With Valid Logging -- You MUST prefer the `$(command -v mvnd || command -v mvn || command -v ./mvnw)` wrapper for every Maven invocation. +- For agent/local development, prefer `$(command -v mvnd || command -v mvn || command -v ./mvnw)` to automatically pick the fastest available tool. +- User-facing documentation MUST only use the top-level `./mvnw` command. - You MUST pass in a `java.util.logging.ConsoleHandler.level` of INFO or more low-level. - You SHOULD run all tests in all models or a given `-pl mvn_moduue` passing `-Djava.util.logging.ConsoleHandler.level=INFO` to see which tests run and which tests might hang - You SHOULD run a single test class using `-Dtest=BlahTest -Djava.util.logging.ConsoleHandler.level=FINE` as fine will show you basic debug info @@ -79,21 +82,21 @@ $(command -v mvnd || command -v mvn || command -v ./mvnw) -Dtest=BlahTest#testSo $(command -v mvnd || command -v mvn || command -v ./mvnw) -pl json-java21-api-tracker -Dtest=ApiTrackerTest -Djava.util.logging.ConsoleHandler.level=FINE ``` -IMPORTANT: Fix the method with FINEST logging, then fix the test class with FINER logging, then fix the module with FINE logging, then run the whole suite with INFO logging. THERE IS NO TRIVIAL LOGIC LEFT IN THIS PROJECT TO BE SYSTEMATIC. +IMPORTANT: Fix the method with FINEST logging, then fix the test class with FINER logging, then fix the module with FINE logging, then run the whole suite with INFO logging. ### Output Visibility Requirements - You MUST NEVER pipe build or test output to tools (head, tail, grep, etc.) that reduce visibility. Logging uncovers the unexpected; piping hides it. Use the instructions above to zoom in on what you want to see using `-Dtest=BlahTest` and `-Dtest=BlahTest#testSomething` passing the appropriate `Djava.util.logging.ConsoleHandler.level=XXX` to avoid too much outputs - You MAY log full data structures at FINEST for deep tracing. Run a single test method at that granularity. - If output volume becomes unbounded (for example, due to inadvertent infinite loops), this is the only time head/tail is allowed. Even then, you MUST inspect a sufficiently large sample (thousands of lines) to capture the real issue and avoid focusing on Maven startup noise. -- My time is far more precious than yours do not error on the side of less information and thrash around guessing. You MUST add more logging and look harder! +- Avoid guessing. Add targeted logging or additional tests until the failing case is fully observable. - Deep debugging employs the same FINE/FINEST discipline: log data structures at FINEST for one test method at a time and expand coverage with additional logging or tests instead of guessing. ### Logging Practices - JUL logging is used for safety and performance. Many consumers rely on SLF4J bridges and search for the literal `ERROR`, not `SEVERE`. When logging at `SEVERE`, prefix the message with `ERROR` to keep cloud log filters effective: ```java -LOG.severe(() -> "ERROR: Remote references disabled but computeIfAbsent called for: " + key); +LOG.severe(() -> "ERROR: " + message); ``` - Only tag true errors (pre-exception logging, validation failures, and similar) with the `ERROR` prefix. Do not downgrade log semantics. @@ -138,14 +141,8 @@ throw new IllegalArgumentException("bad value"); // No specifics Use `Json.toDisplayString(value, depth)` to render JSON fragments in error messages, and include relevant context like schema paths, actual vs expected values, and specific constraint violations. ## JSON Compatibility Suite -```bash -# Build and run compatibility report -mvn clean compile generate-test-resources -pl json-compatibility-suite -mvn exec:java -pl json-compatibility-suite -# Run JSON output (dogfoods the API) -mvn exec:java -pl json-compatibility-suite -Dexec.args="--json" -``` +See `README.md` for user-facing commands. When running locally as an agent, use the Maven wrapper described in this file. ## Architecture Overview @@ -154,6 +151,11 @@ mvn exec:java -pl json-compatibility-suite -Dexec.args="--json" - `json-java21-api-tracker`: API evolution tracking utilities. - `json-compatibility-suite`: JSON Test Suite compatibility validation. - `json-java21-jtd`: JSON Type Definition (JTD) validator based on RFC 8927. +- `json-java21-jsonpath`: JsonPath query engine over `jdk.sandbox.java.util.json` values. + +Only when you are asked to work on a specific module, start by reading that module's `README.md`, then its `AGENTS.md`. + +These modules are treated as separate subsystems; do not read their docs unless you are actively working on them. They are not interlinked and each depends only on the core `json-java21` API. ### Core Components @@ -174,7 +176,9 @@ IMPORTANT: This API **MUST NOT** deviate from the upstream jdk.sandbox repo whic - `Json*Impl`: Immutable implementations of `Json*` types. - `Utils`: Internal utilities and factory methods. -IMPORTANT: Bugs in the main logic this code cannot be fixed in this repo they **MUST** be fixed upstream. Only bugs in any backporting machinery such as the double-check-locking class that is a polyfill for a future JDK `@StableValue` feature may be fixed in this repo. +IMPORTANT: Bugs in upstream-derived core logic MUST be fixed upstream. Do not patch `jdk.sandbox.*` sources in this repo unless the user explicitly agrees (for example, to carry a temporary local backport while the upstream fix is in progress). + +Only bugs in local, non-upstream code (for example, backporting shims/polyfills or other modules in this repo) should be fixed here using normal TDD. ### Design Patterns - Algebraic Data Types: Sealed interfaces enable exhaustive pattern matching. @@ -202,35 +206,7 @@ IMPORTANT: Bugs in the main logic this code cannot be fixed in this repo they ** ## Common Workflows -### API Compatibility Testing -1. Run the compatibility suite: `mvn exec:java -pl json-compatibility-suite`. -2. Inspect reports for regressions relative to upstream expectations. -3. Validate outcomes against the official JSON Test Suite. - -## Module Reference - -### json-java21 -- Main library delivering the core JSON API. -- Maven coordinates: `io.github.simbo1905.json:json-java21:0.X.Y`. -- Requires Java 21 or newer. - -### json-compatibility-suite -- Automatically downloads the JSON Test Suite from GitHub. -- Surfaces known vulnerabilities (for example, StackOverflowError under deep nesting). -- Intended for education and testing, not production deployment. - -### json-java21-api-tracker -- Tracks API evolution and compatibility changes. -- Uses Java 24 preview features (`--enable-preview`). -- Runner: `io.github.simbo1905.tracker.ApiTrackerRunner` compares the public JSON API (`jdk.sandbox.java.util.json`) with upstream `java.util.json`. -- Workflow fetches upstream sources, parses both codebases with the Java compiler API, and reports matching/different/missing elements across modifiers, inheritance, methods, fields, and constructors. -- Continuous integration prints the report daily. It does not fail or open issues on differences; to trigger notifications, either make the runner exit non-zero when `differentApi > 0` or parse the report and call `core.setFailed()` within CI. - -### json-java21-jtd (JTD Validator) -- JSON Type Definition validator implementing RFC 8927 specification. -- Provides eight mutually-exclusive schema forms for simple, predictable validation. -- Uses stack-based validation with comprehensive error reporting. -- Includes full RFC 8927 compliance test suite. +Prefer linking to `README.md` for stable, user-facing workflows and module descriptions. Keep this file focused on agent execution details. #### Debugging Exhaustive Property Tests diff --git a/README.md b/README.md index 0e9328b..6fe065a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ We welcome contributions to the JTD Validator incubating within this repo. To try the examples from this README, build the project and run the standalone example class: ```bash -mvn package +./mvnw package java -cp ./json-java21/target/java.util.json-*.jar:./json-java21/target/test-classes \ jdk.sandbox.java.util.json.examples.ReadmeExamples ``` @@ -257,10 +257,10 @@ The test data is bundled as ZIP files and extracted automatically at runtime: ```bash # Run human-readable report -mvn exec:java -pl json-compatibility-suite +./mvnw exec:java -pl json-compatibility-suite # Run JSON output (dogfoods the API) -mvn exec:java -pl json-compatibility-suite -Dexec.args="--json" +./mvnw exec:java -pl json-compatibility-suite -Dexec.args="--json" ``` @@ -368,10 +368,10 @@ The validator provides full RFC 8927 compliance with comprehensive test coverage ```bash # Run all JTD compliance tests -$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest=JtdSpecIT +./mvnw test -pl json-java21-jtd -Dtest=JtdSpecIT # Run with detailed logging -$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Djava.util.logging.ConsoleHandler.level=FINE +./mvnw test -pl json-java21-jtd -Djava.util.logging.ConsoleHandler.level=FINE ``` Features: @@ -388,20 +388,61 @@ Features: Requires JDK 21 or later. Build with Maven: ```bash -mvn clean package +./mvnw clean package ``` -See AGENTS.md for detailed guidance including logging configuration. +## JsonPath -## Augmented Intelligence (AI) Welcomed +This repo also includes a JsonPath query engine (module `json-java21-jsonpath`), based on the original Goessner JSONPath article: +https://goessner.net/articles/JsonPath/ -AI as **Augmented Intelligence** is most welcome here. Contributions that enhance *human + agent collaboration* are encouraged. If you want to suggest new agent‑workflows, prompt patterns, or improvements in tooling / validation / introspection, please submit amendments to **AGENTS.md** via standalone PRs. Your ideas make the difference. +```java +import jdk.sandbox.java.util.json.*; +import json.java21.jsonpath.JsonPath; +import json.java21.jsonpath.JsonPathStreams; + +JsonValue doc = Json.parse(""" + {"store": {"book": [ + {"author": "Nora Quill", "title": "Signal Lake", "price": 8.95}, + {"author": "Jae Moreno", "title": "Copper Atlas", "price": 12.99}, + {"author": "Marek Ilyin", "title": "Paper Comet", "price": 22.99} + ]}} + """); + +var authors = JsonPath.parse("$.store.book[*].author") + .query(doc) + .stream() + .map(JsonValue::string) + .toList(); + +System.out.println("Authors count: " + authors.size()); // prints '3' +System.out.println("First author: " + authors.getFirst()); // prints 'Nora Quill' +System.out.println("Last author: " + authors.getLast()); // prints 'Marek Ilyin' + +var cheapTitles = JsonPath.parse("$.store.book[?(@.price < 10)].title") + .query(doc) + .stream() + .map(JsonValue::string) + .toList(); + +var priceStats = JsonPath.parse("$.store.book[*].price") + .query(doc) + .stream() + .filter(JsonPathStreams::isNumber) + .mapToDouble(JsonPathStreams::asDouble) + .summaryStatistics(); + +System.out.println("Total price: " + priceStats.getSum()); +System.out.println("Min price: " + priceStats.getMin()); +System.out.println("Max price: " + priceStats.getMax()); +System.out.println("Avg price: " + priceStats.getAverage()); +``` -When submitting Issues or PRs, please use a "deep research" tool to sanity check your proposal. Then **before** submission un your submission through a strong model with a prompt such as: +See `json-java21-jsonpath/README.md` for JsonPath operators and more examples. -> "Please review the AGENTS.md and README.md along with this draft PR/Issue and check that it does not have any gaps and why it might be insufficient, incomplete, lacking a concrete example, duplicating prior issues or PRs, or not be aligned with the project goals or non‑goals." +## Contributing -Please attach the output of that model’s review to your Issue or PR. +If you use an AI assistant while contributing, ensure it follows the contributor/agent workflow rules in `AGENTS.md`. ## License diff --git a/json-java21-jsonpath/AGENTS.md b/json-java21-jsonpath/AGENTS.md new file mode 100644 index 0000000..b8d9ba7 --- /dev/null +++ b/json-java21-jsonpath/AGENTS.md @@ -0,0 +1,17 @@ +# json-java21-jsonpath/AGENTS.md + +This file is for contributor/agent operational notes. Read `json-java21-jsonpath/README.md` for purpose, supported syntax, and user-facing examples. + +- 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. +- Add parser + evaluation tests; new tests should extend `JsonPathLoggingConfig`. diff --git a/json-java21-jsonpath/README.md b/json-java21-jsonpath/README.md new file mode 100644 index 0000000..f38d36f --- /dev/null +++ b/json-java21-jsonpath/README.md @@ -0,0 +1,143 @@ +# JsonPath + +This module provides a JSONPath-style query engine for JSON documents parsed with `jdk.sandbox.java.util.json`. + +It is based on the original Stefan Goessner JSONPath article: +https://goessner.net/articles/JsonPath/ + +## Quick Start + +```java +import jdk.sandbox.java.util.json.*; +import json.java21.jsonpath.JsonPath; + +JsonValue doc = Json.parse(""" + {"store": {"book": [{"title": "A", "price": 8.95}, {"title": "B", "price": 12.99}]}} + """); + +var titles = JsonPath.parse("$.store.book[*].title").query(doc); +var cheap = JsonPath.parse("$.store.book[?(@.price < 10)].title").query(doc); +``` + +## Syntax At A Glance + +Operator | Example | What it selects +---|---|--- +root | `$` | the whole document +property | `$.store.book` | a nested object property +bracket property | `$['store']['book']` | same as dot notation, but allows escaping +wildcard | `$.store.*` | all direct children +recursive descent | `$..price` | any matching member anywhere under the document +array index | `$.store.book[0]` / `[-1]` | element by index (negative from end) +slice | `$.store.book[:2]` / `[0:4:2]` / `[::-1]` | slice by start:end:step +union | `$.store['book','bicycle']` / `[0,1]` | select multiple names/indices +filter exists | `$.store.book[?(@.isbn)]` | elements where a member exists +filter compare | `$.store.book[?(@.price < 10)]` | elements matching a comparison +filter logic | `$.store.book[?(@.isbn && (@.price < 10 || @.price > 20))]` | compound boolean logic +script (limited) | `$.store.book[(@.length-1)]` | last element via `length-1` + +## Examples + +Expression | What it selects +---|--- +`$.store.book[*].title` | all book titles +`$.store.book[?(@.price < 10)].title` | titles of books cheaper than 10 +`$.store.book[?(@.isbn && (@.price < 10 || @.price > 20))].title` | books with an ISBN and price outside the mid-range +`$..price` | every `price` anywhere under the document +`$.store.book[-1]` | the last book +`$.store.book[0:4:2]` | every other book from the first four + +## Supported Syntax + +This implementation follows Goessner-style JSONPath operators, including: +- `$` root +- `.name` / `['name']` property access +- `[n]` array index (including negative indices) +- `[start:end:step]` slices +- `*` wildcards +- `..` recursive descent +- `[n,m]` and `['a','b']` unions +- `[?(@.prop)]` and `[?(@.prop op value)]` basic filters +- `[(@.length-1)]` limited script support + +## Stream-Based Functions (Aggregations) + +Some JsonPath implementations include aggregation functions such as `$.numbers.avg()`. +In this implementation we provide first class stream support so you can use standard JDK aggregation functions on `JsonPath.query(...)` results. + +The `query()` method returns a standard `List`. 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. + +### Strict vs. Lax Conversions + +We follow a pattern of "Strict" (`asX`) vs "Lax" (`asXOrNull`) converters: +- **Strict (`asX`)**: Throws `ClassCastException` (or similar) if the value is not the expected type. Use this when you are certain of the schema. +- **Lax (`asXOrNull`)**: Returns `null` if the value is not the expected type. Use this with `.filter(Objects::nonNull)` for robust processing of messy data. + +### Examples + +**Summing Numbers (Lax - safe against bad data)** +```java +import json.java21.jsonpath.JsonPathStreams; +import java.util.Objects; + +// Calculate sum of all 'price' fields, ignoring non-numbers +double total = path.query(doc).stream() + .map(JsonPathStreams::asDoubleOrNull) // Convert to Double or null + .filter(Objects::nonNull) // Remove non-numbers + .mapToDouble(Double::doubleValue) // Unbox + .sum(); +``` + +**Average (Strict - expects valid data)** +```java +import java.util.OptionalDouble; + +// Calculate average, fails if any value is not a number +OptionalDouble avg = path.query(doc).stream() + .map(JsonPathStreams::asDouble) // Throws if not a number + .mapToDouble(Double::doubleValue) + .average(); +``` + +**Filtering by Type** +```java +import java.util.List; + +// Get all strings +List strings = path.query(doc).stream() + .filter(JsonPathStreams::isString) + .map(JsonPathStreams::asString) + .toList(); +``` + +### Available Helpers (`JsonPathStreams`) + +**Predicates:** +- `isNumber(JsonValue)` +- `isString(JsonValue)` +- `isBoolean(JsonValue)` +- `isArray(JsonValue)` +- `isObject(JsonValue)` +- `isNull(JsonValue)` + +**Converters (Strict):** +- `asDouble(JsonValue)` -> `double` +- `asLong(JsonValue)` -> `long` +- `asString(JsonValue)` -> `String` +- `asBoolean(JsonValue)` -> `boolean` + +**Converters (Lax):** +- `asDoubleOrNull(JsonValue)` -> `Double` +- `asLongOrNull(JsonValue)` -> `Long` +- `asStringOrNull(JsonValue)` -> `String` +- `asBooleanOrNull(JsonValue)` -> `Boolean` + +## Testing + +```bash +./mvnw test -pl json-java21-jsonpath -am -Djava.util.logging.ConsoleHandler.level=INFO +``` + +```bash +./mvnw test -pl json-java21-jsonpath -am -Dtest=JsonPathGoessnerTest -Djava.util.logging.ConsoleHandler.level=FINE +``` diff --git a/json-java21-jsonpath/pom.xml b/json-java21-jsonpath/pom.xml new file mode 100644 index 0000000..aa4b366 --- /dev/null +++ b/json-java21-jsonpath/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + io.github.simbo1905.json + parent + 0.1.9 + + + java.util.json.jsonpath + jar + java.util.json Java21 Backport JsonPath + https://simbo1905.github.io/java.util.json.Java21/ + + scm:git:https://github.com/simbo1905/java.util.json.Java21.git + scm:git:git@github.com:simbo1905/java.util.json.Java21.git + https://github.com/simbo1905/java.util.json.Java21 + HEAD + + JsonPath implementation for the java.util.json Java 21 backport; parses JsonPath expressions to AST and matches against JSON documents. + + + UTF-8 + 21 + + + + + io.github.simbo1905.json + java.util.json + ${project.version} + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.assertj + assertj-core + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 21 + + -Xlint:all + -Werror + -Xdiags:verbose + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -ea + + + + + diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java new file mode 100644 index 0000000..a18225a --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPath.java @@ -0,0 +1,616 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.logging.Logger; + +/// JsonPath query evaluator for JSON documents. +/// Parses JsonPath expressions and evaluates them against JsonValue instances. +/// +/// Usage examples: +/// ```java +/// // Fluent API +/// JsonValue json = Json.parse(jsonString); +/// List results = JsonPath.parse("$.store.book[*].author").query(json); +/// +/// // Compiled + static call site (also reusable) +/// JsonPath path = JsonPath.parse("$.store.book[*].author"); +/// List results = JsonPath.query(path, json); +/// ``` +/// +/// Based on the JSONPath specification from [...](https://goessner.net/articles/JsonPath/) +public final class JsonPath { + + private static final Logger LOG = Logger.getLogger(JsonPath.class.getName()); + + private final JsonPathAst.Root ast; + + private 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); + } + + /// Queries matching values from a JSON document. + /// + /// Instance API: compile once via `parse(String)`, then call `query(JsonValue)` for each already-parsed + /// JSON document. + /// + /// @param json the JSON document to query + /// @return a list of matching JsonValue instances (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); + } + + /// Reconstructs the JsonPath expression from the AST. + @Override + public String toString() { + return reconstruct(ast); + } + + /// Evaluates a compiled JsonPath against a JSON document. + /// @param path a compiled JsonPath (typically cached) + /// @param json the JSON document to query + /// @return a list of matching JsonValue instances (maybe empty) + /// @throws NullPointerException if path or JSON is null + public static List query(JsonPath path, JsonValue json) { + Objects.requireNonNull(path, "path must not be null"); + return path.query(json); + } + + /// Evaluates a JsonPath expression against a JSON document. + /// + /// Intended for one-off usage; for hot paths, prefer caching the compiled `JsonPath` via `parse(String)`. + /// + /// @param path the JsonPath expression to parse + /// @param json the JSON document to query + /// @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) { + 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); + } + } + + private static void evaluatePropertyAccess( + JsonPathAst.PropertyAccess prop, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonObject obj) { + final var value = obj.members().get(prop.name()); + if (value != null) { + evaluateSegments(segments, index + 1, value, root, results); + } + } + } + + private static void evaluateArrayIndex( + JsonPathAst.ArrayIndex arr, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonArray array) { + final var elements = array.elements(); + int idx = arr.index(); + + // Handle negative indices (from end) + if (idx < 0) { + idx = elements.size() + idx; + } + + if (idx >= 0 && idx < elements.size()) { + evaluateSegments(segments, index + 1, elements.get(idx), root, results); + } + } + } + + private static void evaluateArraySlice( + JsonPathAst.ArraySlice slice, + List segments, + int index, + JsonValue current, + JsonValue root, + List results) { + + if (current instanceof JsonArray array) { + final var elements = array.elements(); + final int size = elements.size(); + + 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; + }; + } + + 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/JsonPathAst.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java new file mode 100644 index 0000000..4f83225 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathAst.java @@ -0,0 +1,187 @@ +package json.java21.jsonpath; + +import java.util.List; +import java.util.Objects; + +/// AST representation for JsonPath expressions. +/// Based on the JSONPath specification from [...](https://goessner.net/articles/JsonPath/) +/// +/// A JsonPath expression is a sequence of path segments starting from root ($). +/// Each segment can be: +/// - PropertyAccess: access a named property (e.g., .store or \['store'\]) +/// - ArrayIndex: access array element by index (e.g., \[0\] or \[-1\]) +/// - ArraySlice: slice array with start:end:step (e.g., \[0:2\] or \[::2\]) +/// - Wildcard: match all children (e.g., .* or \[*\]) +/// - RecursiveDescent: search all descendants (e.g., ..author) +/// - Filter: filter by predicate (e.g., \[?(@.isbn)\] or \[?(@.price<10)\]) +/// - Union: multiple indices or names (e.g., \[0,1\] or \['a','b'\]) +/// - ScriptExpression: computed index (e.g., \[(@.length-1)\]) +sealed interface JsonPathAst { + + /// Root element ($) - the starting point of all JsonPath expressions + record Root(List segments) implements JsonPathAst { + public Root { + Objects.requireNonNull(segments, "segments must not be null"); + segments = List.copyOf(segments); // defensive copy + } + } + + /// A single segment in a JsonPath expression + sealed interface Segment permits + PropertyAccess, + ArrayIndex, + ArraySlice, + Wildcard, + RecursiveDescent, + Filter, + Union, + ScriptExpression {} + + /// Access a named property: .name or ['name'] + record PropertyAccess(String name) implements Segment { + public PropertyAccess { + Objects.requireNonNull(name, "name must not be null"); + } + } + + /// Access array element by index: \[n\] where n can be negative for reverse indexing + record ArrayIndex(int index) implements Segment {} + + /// Slice array: \[start:end:step\] + /// All fields are optional (null means not specified) + record ArraySlice(Integer start, Integer end, Integer step) implements Segment {} + + /// Wildcard: * matches all children (both object properties and array elements) + record Wildcard() implements Segment {} + + /// Recursive descent: .. searches all descendants + /// The property field specifies what to search for (can be null for ..[*] or ..*) + record RecursiveDescent(Segment target) implements Segment { + public RecursiveDescent { + Objects.requireNonNull(target, "target must not be null"); + } + } + + /// Filter expression: [?(@.isbn)] or [?(@.price < 10)] + record Filter(FilterExpression expression) implements Segment { + public Filter { + Objects.requireNonNull(expression, "expression must not be null"); + } + } + + /// Union of multiple selectors: [0,1] or ['a','b'] + record Union(List selectors) implements Segment { + public Union { + Objects.requireNonNull(selectors, "selectors must not be null"); + if (selectors.size() < 2) { + throw new IllegalArgumentException("Union must have at least 2 selectors"); + } + selectors = List.copyOf(selectors); // defensive copy + } + } + + /// Script expression for computed index: \[(@.length-1)\] + record ScriptExpression(String script) implements Segment { + public ScriptExpression { + Objects.requireNonNull(script, "script must not be null"); + } + } + + /// Filter expressions used in [?(...)] predicates + sealed interface FilterExpression permits + ExistsFilter, + ComparisonFilter, + LogicalFilter, + CurrentNode, + PropertyPath, + LiteralValue {} + + /// Check if property exists: [?(@.isbn)] + record ExistsFilter(PropertyPath path) implements FilterExpression { + public ExistsFilter { + Objects.requireNonNull(path, "path must not be null"); + } + } + + /// Comparison filter: [?(@.price < 10)] + record ComparisonFilter( + FilterExpression left, + ComparisonOp op, + FilterExpression right + ) implements FilterExpression { + public ComparisonFilter { + Objects.requireNonNull(left, "left must not be null"); + Objects.requireNonNull(op, "op must not be null"); + Objects.requireNonNull(right, "right must not be null"); + } + } + + /// Logical combination of filters: "&&", "||", "!" + record LogicalFilter( + FilterExpression left, + LogicalOp op, + FilterExpression right + ) implements FilterExpression { + public LogicalFilter { + Objects.requireNonNull(left, "left must not be null"); + Objects.requireNonNull(op, "op must not be null"); + // right can be null for NOT operator + } + } + + /// Current node reference: @ in filter expressions + record CurrentNode() implements FilterExpression {} + + /// Property path in filter expressions: @.price or @.store.book + record PropertyPath(List properties) implements FilterExpression { + public PropertyPath { + Objects.requireNonNull(properties, "properties must not be null"); + if (properties.isEmpty()) { + throw new IllegalArgumentException("PropertyPath must have at least one property"); + } + properties = List.copyOf(properties); // defensive copy + } + } + + /// Literal value in filter expressions + record LiteralValue(Object value) implements FilterExpression { + // value can be null (for JSON null), String, Number, or Boolean + } + + /// Comparison operators + enum ComparisonOp { + EQ("=="), // equals + NE("!="), // not equals + LT("<"), // less than + LE("<="), // less than or equal + GT(">"), // greater than + GE(">="); // greater than or equal + + private final String symbol; + + ComparisonOp(String symbol) { + this.symbol = symbol; + } + + public String symbol() { + return symbol; + } + } + + /// Logical operators + enum LogicalOp { + AND("&&"), + OR("||"), + NOT("!"); + + private final String symbol; + + LogicalOp(String symbol) { + this.symbol = symbol; + } + + public String symbol() { + return symbol; + } + } +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java new file mode 100644 index 0000000..532e1c9 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParseException.java @@ -0,0 +1,59 @@ +package json.java21.jsonpath; + +import java.io.Serial; + +/// Exception thrown when a JsonPath expression cannot be parsed. +/// This is a runtime exception as JsonPath parsing failures are typically programming errors. +public class JsonPathParseException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final int position; + private final String path; + + /// Creates a new parse exception with the given message. + public JsonPathParseException(String message) { + super(message); + this.position = -1; + this.path = null; + } + + /// Creates a new parse exception with position information. + public JsonPathParseException(String message, String path, int position) { + super(formatMessage(message, path, position)); + this.position = position; + this.path = path; + } + + /// Creates a new parse exception with a cause. + public JsonPathParseException(String message, Throwable cause) { + super(message, cause); + this.position = -1; + this.path = null; + } + + /// Returns the position in the path where the error occurred, or -1 if unknown. + public int position() { + return position; + } + + /// Returns the path that was being parsed, or null if unknown. + public String path() { + return path; + } + + private static String formatMessage(String message, String path, int position) { + if (path == null || position < 0) { + return message; + } + final var sb = new StringBuilder(); + sb.append(message); + sb.append(" at position ").append(position); + sb.append(" in path: ").append(path); + if (position < path.length()) { + sb.append(" (near '").append(path.charAt(position)).append("')"); + } + return sb.toString(); + } +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java new file mode 100644 index 0000000..5d22c30 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathParser.java @@ -0,0 +1,616 @@ +package json.java21.jsonpath; + +import java.util.ArrayList; +import java.util.Objects; +import java.util.logging.Logger; + +/// Parser for JsonPath expressions into AST. +/// Implements a recursive descent parser for JsonPath syntax. +/// +/// Supported syntax based on [...](https://goessner.net/articles/JsonPath/): +/// - $ : root element +/// - .name : child property access +/// - \['name'\] or \["name"\] : bracket notation property access +/// - \[n\] : array index (supports negative indices) +/// - \[start:end:step\] : array slice +/// - \[*\] or .* : wildcard +/// - .. : recursive descent +/// - \[n,m\] : union of indices +/// - \['a','b'\] : union of properties +/// - \[?(@.prop)\] : filter expression for existence +/// - \[?(@.prop op value)\] : filter expression with comparison +/// - \[(@.length-1)\] : script expression +final class JsonPathParser { + + private static final Logger LOG = Logger.getLogger(JsonPathParser.class.getName()); + + private final String path; + private int pos; + + private JsonPathParser(String path) { + this.path = path; + this.pos = 0; + } + + /// Parses a JsonPath expression string into an AST. + /// @param path the JsonPath expression to parse + /// @return the parsed AST + /// @throws NullPointerException if path is null + /// @throws JsonPathParseException if the path is invalid + public static JsonPathAst.Root parse(String path) { + Objects.requireNonNull(path, "path must not be null"); + LOG.fine(() -> "Parsing JsonPath: " + path); + return new JsonPathParser(path).parseRoot(); + } + + private JsonPathAst.Root parseRoot() { + if (path.isEmpty() || path.charAt(0) != '$') { + throw new JsonPathParseException("JsonPath must start with $", path, 0); + } + pos = 1; // skip $ + + final var segments = new ArrayList(); + + while (pos < path.length()) { + final var segment = parseSegment(); + if (segment != null) { + segments.add(segment); + LOG.finer(() -> "Parsed segment: " + segment); + } + } + + return new JsonPathAst.Root(segments); + } + + private JsonPathAst.Segment parseSegment() { + if (pos >= path.length()) { + return null; + } + + final char c = path.charAt(pos); + + return switch (c) { + case '.' -> parseDotNotation(); + case '[' -> parseBracketNotation(); + default -> throw new JsonPathParseException("Unexpected character", path, pos); + }; + } + + private JsonPathAst.Segment parseDotNotation() { + pos++; // skip . + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path after '.'", path, pos); + } + + final char c = path.charAt(pos); + + // Check for recursive descent (..) + if (c == '.') { + pos++; // skip second . + return parseRecursiveDescent(); + } + + // Check for wildcard (.*) + if (c == '*') { + pos++; // skip * + return new JsonPathAst.Wildcard(); + } + + // Property name + return parsePropertyName(); + } + + private JsonPathAst.RecursiveDescent parseRecursiveDescent() { + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path after '..'", path, pos); + } + + final char c = path.charAt(pos); + + // Check for ..* (recursive wildcard) + if (c == '*') { + pos++; + return new JsonPathAst.RecursiveDescent(new JsonPathAst.Wildcard()); + } + + // Check for ..[ + if (c == '[') { + // Parse the bracket notation but wrap the target + final var segment = parseBracketNotation(); + return new JsonPathAst.RecursiveDescent(segment); + } + + // Property name after .. + final var property = parsePropertyName(); + return new JsonPathAst.RecursiveDescent(property); + } + + private JsonPathAst.PropertyAccess parsePropertyName() { + final int start = pos; + + // Parse until we hit a special character or end + while (pos < path.length()) { + final char c = path.charAt(pos); + if (c == '.' || c == '[') { + break; + } + pos++; + } + + if (pos == start) { + throw new JsonPathParseException("Expected property name", path, pos); + } + + final var name = path.substring(start, pos); + return new JsonPathAst.PropertyAccess(name); + } + + private JsonPathAst.Segment parseBracketNotation() { + pos++; // skip [ + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path after '['", path, pos); + } + + final char c = path.charAt(pos); + + // Wildcard [*] + if (c == '*') { + pos++; // skip * + expectChar(']'); + return new JsonPathAst.Wildcard(); + } + + // Filter expression [?(...)] + if (c == '?') { + return parseFilterExpression(); + } + + // Script expression [(...)], but not (?...) + if (c == '(') { + return parseScriptExpression(); + } + + // Quoted property name or union + if (c == '\'' || c == '"') { + return parseQuotedPropertyOrUnion(); + } + + // Number, slice, or union + if (c == '-' || c == ':' || Character.isDigit(c)) { + return parseNumberOrSliceOrUnion(); + } + + throw new JsonPathParseException("Unexpected character in bracket notation", path, pos); + } + + private JsonPathAst.Filter parseFilterExpression() { + pos++; // skip ? + + expectChar('('); + + final var expression = parseFilterContent(); + + expectChar(')'); + expectChar(']'); + + return new JsonPathAst.Filter(expression); + } + + private JsonPathAst.FilterExpression parseFilterContent() { + return parseLogicalOr(); + } + + private JsonPathAst.FilterExpression parseLogicalOr() { + var left = parseLogicalAnd(); + skipWhitespace(); + + while (pos + 1 < path.length() && path.substring(pos).startsWith("||")) { + pos += 2; + final var right = parseLogicalAnd(); + skipWhitespace(); + left = new JsonPathAst.LogicalFilter(left, JsonPathAst.LogicalOp.OR, right); + } + + return left; + } + + private JsonPathAst.FilterExpression parseLogicalAnd() { + var left = parseLogicalUnary(); + skipWhitespace(); + + while (pos + 1 < path.length() && path.substring(pos).startsWith("&&")) { + pos += 2; + final var right = parseLogicalUnary(); + skipWhitespace(); + left = new JsonPathAst.LogicalFilter(left, JsonPathAst.LogicalOp.AND, right); + } + + return left; + } + + private JsonPathAst.FilterExpression parseLogicalUnary() { + skipWhitespace(); + + if (pos < path.length() && path.charAt(pos) == '!') { + pos++; + final var operand = parseLogicalUnary(); + return new JsonPathAst.LogicalFilter(operand, JsonPathAst.LogicalOp.NOT, null); + } + + return parseLogicalPrimary(); + } + + private JsonPathAst.FilterExpression parseLogicalPrimary() { + skipWhitespace(); + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of filter expression", path, pos); + } + + if (path.charAt(pos) == '(') { + pos++; + final var expr = parseLogicalOr(); + skipWhitespace(); + expectChar(')'); + return expr; + } + + // Atom (maybe part of a comparison) + final var left = parseFilterAtom(); + skipWhitespace(); + + if (pos < path.length() && isComparisonOpStart(path.charAt(pos))) { + final var op = parseComparisonOp(); + skipWhitespace(); + final var right = parseFilterAtom(); + return new JsonPathAst.ComparisonFilter(left, op, right); + } + + if (left instanceof JsonPathAst.PropertyPath pp) { + return new JsonPathAst.ExistsFilter(pp); + } + + return left; + } + + private boolean isComparisonOpStart(char c) { + return c == '=' || c == '!' || c == '<' || c == '>'; + } + + private JsonPathAst.FilterExpression parseFilterAtom() { + skipWhitespace(); + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of filter expression", path, pos); + } + + final char c = path.charAt(pos); + + // Current element reference @ + if (c == '@') { + return parseCurrentNodePath(); + } + + // String literal + if (c == '\'' || c == '"') { + return parseLiteralString(); + } + + // Number literal + if (c == '-' || Character.isDigit(c)) { + return parseLiteralNumber(); + } + + // Boolean literal + if (path.substring(pos).startsWith("true")) { + pos += 4; + return new JsonPathAst.LiteralValue(true); + } + if (path.substring(pos).startsWith("false")) { + pos += 5; + return new JsonPathAst.LiteralValue(false); + } + + // Null literal + if (path.substring(pos).startsWith("null")) { + pos += 4; + return new JsonPathAst.LiteralValue(null); + } + + throw new JsonPathParseException("Unexpected token in filter expression", path, pos); + } + + private JsonPathAst.FilterExpression parseCurrentNodePath() { + pos++; // skip @ + + final var properties = new ArrayList(); + + while (pos < path.length() && path.charAt(pos) == '.') { + pos++; // skip . + final int start = pos; + + while (pos < path.length()) { + final char c = path.charAt(pos); + if (!Character.isLetterOrDigit(c) && c != '_') { + break; + } + pos++; + } + + if (pos == start) { + throw new JsonPathParseException("Expected property name after '.'", path, pos); + } + + properties.add(path.substring(start, pos)); + } + + if (properties.isEmpty()) { + // Just @ with no properties + return new JsonPathAst.CurrentNode(); + } + + return new JsonPathAst.PropertyPath(properties); + } + + private JsonPathAst.LiteralValue parseLiteralString() { + final char quote = path.charAt(pos); + pos++; // skip opening quote + + final int start = pos; + while (pos < path.length() && path.charAt(pos) != quote) { + if (path.charAt(pos) == '\\' && pos + 1 < path.length()) { + pos++; // skip escape character + } + pos++; + } + + if (pos >= path.length()) { + throw new JsonPathParseException("Unterminated string literal", path, start); + } + + final var value = path.substring(start, pos); + pos++; // skip closing quote + + return new JsonPathAst.LiteralValue(value); + } + + private JsonPathAst.LiteralValue parseLiteralNumber() { + final int start = pos; + + if (path.charAt(pos) == '-') { + pos++; + } + + while (pos < path.length() && Character.isDigit(path.charAt(pos))) { + pos++; + } + + // Check for decimal point + if (pos < path.length() && path.charAt(pos) == '.') { + do { + pos++; + } while (pos < path.length() && Character.isDigit(path.charAt(pos))); + } + + final var numStr = path.substring(start, pos); + if (numStr.contains(".")) { + return new JsonPathAst.LiteralValue(Double.parseDouble(numStr)); + } else { + return new JsonPathAst.LiteralValue(Long.parseLong(numStr)); + } + } + + private JsonPathAst.ComparisonOp parseComparisonOp() { + if (path.substring(pos).startsWith("==")) { + pos += 2; + return JsonPathAst.ComparisonOp.EQ; + } + if (path.substring(pos).startsWith("!=")) { + pos += 2; + return JsonPathAst.ComparisonOp.NE; + } + if (path.substring(pos).startsWith("<=")) { + pos += 2; + return JsonPathAst.ComparisonOp.LE; + } + if (path.substring(pos).startsWith(">=")) { + pos += 2; + return JsonPathAst.ComparisonOp.GE; + } + if (path.charAt(pos) == '<') { + pos++; + return JsonPathAst.ComparisonOp.LT; + } + if (path.charAt(pos) == '>') { + pos++; + return JsonPathAst.ComparisonOp.GT; + } + + throw new JsonPathParseException("Expected comparison operator", path, pos); + } + + private JsonPathAst.ScriptExpression parseScriptExpression() { + pos++; // skip ( + + final int start = pos; + int depth = 1; + + while (pos < path.length() && depth > 0) { + final char c = path.charAt(pos); + if (c == '(') depth++; + else if (c == ')') depth--; + pos++; + } + + if (depth != 0) { + throw new JsonPathParseException("Unmatched parenthesis in script expression", path, start); + } + + // pos is now past the closing ) + final var script = path.substring(start, pos - 1); + + expectChar(']'); + + return new JsonPathAst.ScriptExpression(script); + } + + private JsonPathAst.Segment parseQuotedPropertyOrUnion() { + final var properties = new ArrayList(); + + while (true) { + skipWhitespace(); + final var prop = parseQuotedProperty(); + properties.add(prop); + skipWhitespace(); + + if (pos >= path.length()) { + throw new JsonPathParseException("Unexpected end of path in bracket notation", path, pos); + } + + if (path.charAt(pos) == ']') { + pos++; // skip ] + break; + } + + if (path.charAt(pos) == ',') { + pos++; // skip , + continue; + } + + throw new JsonPathParseException("Expected ',' or ']' in bracket notation", path, pos); + } + + if (properties.size() == 1) { + return properties.getFirst(); + } + + return new JsonPathAst.Union(properties); + } + + private JsonPathAst.PropertyAccess parseQuotedProperty() { + final char quote = path.charAt(pos); + pos++; // skip opening quote + + final int start = pos; + while (pos < path.length() && path.charAt(pos) != quote) { + if (path.charAt(pos) == '\\' && pos + 1 < path.length()) { + pos++; // skip escape character + } + pos++; + } + + if (pos >= path.length()) { + throw new JsonPathParseException("Unterminated string in bracket notation", path, start); + } + + final var name = path.substring(start, pos); + pos++; // skip closing quote + + return new JsonPathAst.PropertyAccess(name); + } + + private JsonPathAst.Segment parseNumberOrSliceOrUnion() { + // Collect all the numbers and operators to determine what we have + final var elements = new ArrayList(); // Integer values (null for missing) + boolean hasColon = false; + boolean hasComma = false; + + // Parse first element (maybe empty for [:end]) + if (path.charAt(pos) == ':') { + elements.add(null); // empty start + hasColon = true; + pos++; + // After initial ':', check if there's a number for end + if (pos < path.length() && (Character.isDigit(path.charAt(pos)) || path.charAt(pos) == '-')) { + elements.add(parseInteger()); + } else { + elements.add(null); + } + } else { + elements.add(parseInteger()); + } + + // Continue parsing + while (pos < path.length()) { + final char c = path.charAt(pos); + + if (c == ']') { + pos++; + break; + } + + if (c == ':') { + hasColon = true; + pos++; + // Parse next element or leave as null + if (pos < path.length() && (Character.isDigit(path.charAt(pos)) || path.charAt(pos) == '-')) { + elements.add(parseInteger()); + } else if (pos < path.length() && path.charAt(pos) != ':' && path.charAt(pos) != ']') { + // Not a digit, not another colon, not end bracket - unexpected + throw new JsonPathParseException("Unexpected character after ':' in slice", path, pos); + } else { + elements.add(null); + } + } else if (c == ',') { + hasComma = true; + pos++; + skipWhitespace(); + elements.add(parseInteger()); + } else { + throw new JsonPathParseException("Unexpected character in array subscript", path, pos); + } + } + + // Determine what we parsed + if (hasColon) { + // It's a slice [start:end:step] + final Integer start = !elements.isEmpty() ? elements.get(0) : null; + final Integer end = elements.size() > 1 ? elements.get(1) : null; + final Integer step = elements.size() > 2 ? elements.get(2) : null; + return new JsonPathAst.ArraySlice(start, end, step); + } + + if (hasComma) { + // It's a union [n,m,...] + final var indices = new ArrayList(); + for (final var elem : elements) { + indices.add(new JsonPathAst.ArrayIndex(elem)); + } + return new JsonPathAst.Union(indices); + } + + // Single index + return new JsonPathAst.ArrayIndex(elements.getFirst()); + } + + private int parseInteger() { + final int start = pos; + if (pos < path.length() && path.charAt(pos) == '-') { + pos++; + } + while (pos < path.length() && Character.isDigit(path.charAt(pos))) { + pos++; + } + if (pos == start || (pos == start + 1 && path.charAt(start) == '-')) { + throw new JsonPathParseException("Expected integer", path, pos); + } + return Integer.parseInt(path.substring(start, pos)); + } + + private void expectChar(char expected) { + if (pos >= path.length()) { + throw new JsonPathParseException("Expected '" + expected + "' but reached end of path", path, pos); + } + if (path.charAt(pos) != expected) { + throw new JsonPathParseException("Expected '" + expected + "'", path, pos); + } + pos++; + } + + private void skipWhitespace() { + while (pos < path.length() && Character.isWhitespace(path.charAt(pos))) { + pos++; + } + } +} diff --git a/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathStreams.java b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathStreams.java new file mode 100644 index 0000000..28e0f61 --- /dev/null +++ b/json-java21-jsonpath/src/main/java/json/java21/jsonpath/JsonPathStreams.java @@ -0,0 +1,81 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; + +/// Helpers for stream-based processing of `JsonPath.query(...)` results. +public final class JsonPathStreams { + + private JsonPathStreams() {} + + public static boolean isNumber(JsonValue v) { + return v instanceof JsonNumber; + } + + public static boolean isString(JsonValue v) { + return v instanceof JsonString; + } + + public static boolean isBoolean(JsonValue v) { + return v instanceof JsonBoolean; + } + + public static boolean isArray(JsonValue v) { + return v instanceof JsonArray; + } + + public static boolean isObject(JsonValue v) { + return v instanceof JsonObject; + } + + public static boolean isNull(JsonValue v) { + return v instanceof JsonNull; + } + + /// @throws ClassCastException if the value is not a `JsonNumber` + public static double asDouble(JsonValue v) { + if (v instanceof JsonNumber n) { + return n.toDouble(); + } + throw new ClassCastException("Expected JsonNumber but got " + v.getClass().getSimpleName()); + } + + /// @throws ClassCastException if the value is not a `JsonNumber` + public static long asLong(JsonValue v) { + if (v instanceof JsonNumber n) { + return n.toLong(); + } + throw new ClassCastException("Expected JsonNumber but got " + v.getClass().getSimpleName()); + } + + /// @throws ClassCastException if the value is not a `JsonString` + public static String asString(JsonValue v) { + if (v instanceof JsonString s) { + return s.string(); + } + throw new ClassCastException("Expected JsonString but got " + v.getClass().getSimpleName()); + } + + /// @throws ClassCastException if the value is not a `JsonBoolean` + public static boolean asBoolean(JsonValue v) { + if (v instanceof JsonBoolean b) { + return b.bool(); + } + throw new ClassCastException("Expected JsonBoolean but got " + v.getClass().getSimpleName()); + } + + public static Double asDoubleOrNull(JsonValue v) { + return (v instanceof JsonNumber n) ? n.toDouble() : null; + } + + public static Long asLongOrNull(JsonValue v) { + return (v instanceof JsonNumber n) ? n.toLong() : null; + } + + public static String asStringOrNull(JsonValue v) { + return (v instanceof JsonString s) ? s.string() : null; + } + + public static Boolean asBooleanOrNull(JsonValue v) { + return (v instanceof JsonBoolean b) ? b.bool() : null; + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/FunctionsReadmeDemo.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/FunctionsReadmeDemo.java new file mode 100644 index 0000000..05842dd --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/FunctionsReadmeDemo.java @@ -0,0 +1,82 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Objects; +import java.util.OptionalDouble; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +public class FunctionsReadmeDemo extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(FunctionsReadmeDemo.class.getName()); + + @Test + void testSummingNumbersLax() { + LOG.info(() -> "FunctionsReadmeDemo#testSummingNumbersLax"); + JsonValue doc = Json.parse(""" + { + "store": { + "book": [ + { "category": "reference", "price": 8.95 }, + { "category": "fiction", "price": 12.99 }, + { "category": "fiction", "price": "Not For Sale" }, + { "category": "fiction", "price": 22.99 } + ] + } + } + """); + + JsonPath path = JsonPath.parse("$.store.book[*].price"); + + double total = path.query(doc).stream() + .map(JsonPathStreams::asDoubleOrNull) + .filter(Objects::nonNull) + .mapToDouble(Double::doubleValue) + .sum(); + + assertThat(total).isCloseTo(8.95 + 12.99 + 22.99, within(0.001)); + } + + @Test + void testAverageStrict() { + LOG.info(() -> "FunctionsReadmeDemo#testAverageStrict"); + JsonValue doc = Json.parse(""" + { + "temperatures": [ 98.6, 99.1, 98.4 ] + } + """); + + JsonPath path = JsonPath.parse("$.temperatures[*]"); + + OptionalDouble avg = path.query(doc).stream() + .map(JsonPathStreams::asDouble) + .mapToDouble(Double::doubleValue) + .average(); + + assertThat(avg).isPresent(); + assertThat(avg.getAsDouble()).isBetween(98.6, 98.8); // 98.7 approx + } + + @Test + void testFilteringByType() { + LOG.info(() -> "FunctionsReadmeDemo#testFilteringByType"); + JsonValue doc = Json.parse(""" + [ "apple", 100, "banana", true, "cherry", null ] + """); + + JsonPath path = JsonPath.parse("$[*]"); + + // Get all strings + List strings = path.query(doc).stream() + .filter(JsonPathStreams::isString) + .map(JsonPathStreams::asString) + .toList(); + + assertThat(strings).containsExactly("apple", "banana", "cherry"); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java new file mode 100644 index 0000000..b2a7823 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathAstTest.java @@ -0,0 +1,166 @@ +package json.java21.jsonpath; + +import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// Unit tests for JsonPathAst record types +class JsonPathAstTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathAstTest.class.getName()); + + @Test + void testRootCreation() { + LOG.info(() -> "TEST: testRootCreation"); + final var root = new JsonPathAst.Root(List.of()); + assertThat(root.segments()).isEmpty(); + } + + @Test + void testRootWithSegments() { + LOG.info(() -> "TEST: testRootWithSegments"); + final var segments = List.of( + new JsonPathAst.PropertyAccess("store"), + new JsonPathAst.PropertyAccess("book") + ); + final var root = new JsonPathAst.Root(segments); + assertThat(root.segments()).hasSize(2); + } + + @Test + void testRootDefensiveCopy() { + LOG.info(() -> "TEST: testRootDefensiveCopy"); + final var mutableList = new java.util.ArrayList(); + mutableList.add(new JsonPathAst.PropertyAccess("store")); + final var root = new JsonPathAst.Root(mutableList); + mutableList.add(new JsonPathAst.PropertyAccess("book")); + // Root should have defensive copy, so adding to original list doesn't affect it + assertThat(root.segments()).hasSize(1); + } + + @Test + void testPropertyAccess() { + LOG.info(() -> "TEST: testPropertyAccess"); + final var prop = new JsonPathAst.PropertyAccess("author"); + assertThat(prop.name()).isEqualTo("author"); + } + + @Test + void testPropertyAccessNullThrows() { + LOG.info(() -> "TEST: testPropertyAccessNullThrows"); + assertThatThrownBy(() -> new JsonPathAst.PropertyAccess(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void testArrayIndex() { + LOG.info(() -> "TEST: testArrayIndex"); + final var index = new JsonPathAst.ArrayIndex(0); + assertThat(index.index()).isEqualTo(0); + + final var negIndex = new JsonPathAst.ArrayIndex(-1); + assertThat(negIndex.index()).isEqualTo(-1); + } + + @Test + void testArraySlice() { + LOG.info(() -> "TEST: testArraySlice"); + final var slice = new JsonPathAst.ArraySlice(0, 2, null); + assertThat(slice.start()).isEqualTo(0); + assertThat(slice.end()).isEqualTo(2); + assertThat(slice.step()).isNull(); + } + + @Test + void testWildcard() { + LOG.info(() -> "TEST: testWildcard"); + final var wildcard = new JsonPathAst.Wildcard(); + assertThat(wildcard).isNotNull(); + } + + @Test + void testRecursiveDescent() { + LOG.info(() -> "TEST: testRecursiveDescent"); + final var descent = new JsonPathAst.RecursiveDescent( + new JsonPathAst.PropertyAccess("author") + ); + assertThat(descent.target()).isInstanceOf(JsonPathAst.PropertyAccess.class); + } + + @Test + void testFilter() { + LOG.info(() -> "TEST: testFilter"); + final var filter = new JsonPathAst.Filter( + new JsonPathAst.ExistsFilter( + new JsonPathAst.PropertyPath(List.of("ISBN")) + ) + ); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ExistsFilter.class); + } + + @Test + void testUnion() { + LOG.info(() -> "TEST: testUnion"); + final var union = new JsonPathAst.Union(List.of( + new JsonPathAst.ArrayIndex(0), + new JsonPathAst.ArrayIndex(1) + )); + assertThat(union.selectors()).hasSize(2); + } + + @Test + void testUnionRequiresAtLeastTwoSelectors() { + LOG.info(() -> "TEST: testUnionRequiresAtLeastTwoSelectors"); + assertThatThrownBy(() -> new JsonPathAst.Union(List.of(new JsonPathAst.ArrayIndex(0)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least 2"); + } + + @Test + void testComparisonFilter() { + LOG.info(() -> "TEST: testComparisonFilter"); + final var filter = new JsonPathAst.ComparisonFilter( + new JsonPathAst.PropertyPath(List.of("price")), + JsonPathAst.ComparisonOp.LT, + new JsonPathAst.LiteralValue(10) + ); + assertThat(filter.op()).isEqualTo(JsonPathAst.ComparisonOp.LT); + } + + @Test + void testPropertyPath() { + LOG.info(() -> "TEST: testPropertyPath"); + final var path = new JsonPathAst.PropertyPath(List.of("store", "book", "price")); + assertThat(path.properties()).containsExactly("store", "book", "price"); + } + + @Test + void testPropertyPathRequiresAtLeastOneProperty() { + LOG.info(() -> "TEST: testPropertyPathRequiresAtLeastOneProperty"); + assertThatThrownBy(() -> new JsonPathAst.PropertyPath(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least one property"); + } + + @Test + void testComparisonOps() { + LOG.info(() -> "TEST: testComparisonOps"); + assertThat(JsonPathAst.ComparisonOp.EQ.symbol()).isEqualTo("=="); + assertThat(JsonPathAst.ComparisonOp.NE.symbol()).isEqualTo("!="); + assertThat(JsonPathAst.ComparisonOp.LT.symbol()).isEqualTo("<"); + assertThat(JsonPathAst.ComparisonOp.LE.symbol()).isEqualTo("<="); + assertThat(JsonPathAst.ComparisonOp.GT.symbol()).isEqualTo(">"); + assertThat(JsonPathAst.ComparisonOp.GE.symbol()).isEqualTo(">="); + } + + @Test + void testLogicalOps() { + LOG.info(() -> "TEST: testLogicalOps"); + assertThat(JsonPathAst.LogicalOp.AND.symbol()).isEqualTo("&&"); + assertThat(JsonPathAst.LogicalOp.OR.symbol()).isEqualTo("||"); + assertThat(JsonPathAst.LogicalOp.NOT.symbol()).isEqualTo("!"); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathFilterEvaluationTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathFilterEvaluationTest.java new file mode 100644 index 0000000..54db45d --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathFilterEvaluationTest.java @@ -0,0 +1,166 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Dedicated evaluation tests for logical operators in filters (!, &&, ||, parens). +/// Verifies truth tables and precedence on controlled documents. +class JsonPathFilterEvaluationTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathFilterEvaluationTest.class.getName()); + + @Test + void testLogicalAnd() { + LOG.info(() -> "TEST: testLogicalAnd (&&)"); + // Truth table for AND + // Items: + // 1. T && T -> Match + // 2. T && F -> No + // 3. F && T -> No + // 4. F && F -> No + var json = Json.parse(""" + [ + {"id": 1, "a": true, "b": true}, + {"id": 2, "a": true, "b": false}, + {"id": 3, "a": false, "b": true}, + {"id": 4, "a": false, "b": false} + ] + """); + + var results = JsonPath.parse("$[?(@.a == true && @.b == true)]").query(json); + + assertThat(results).hasSize(1); + assertThat(asInt(results.getFirst(), "id")).isEqualTo(1); + } + + @Test + void testLogicalOr() { + LOG.info(() -> "TEST: testLogicalOr (||)"); + // Truth table for OR + // Items: + // 1. T || T -> Match + // 2. T || F -> Match + // 3. F || T -> Match + // 4. F || F -> No + var json = Json.parse(""" + [ + {"id": 1, "a": true, "b": true}, + {"id": 2, "a": true, "b": false}, + {"id": 3, "a": false, "b": true}, + {"id": 4, "a": false, "b": false} + ] + """); + + var results = JsonPath.parse("$[?(@.a == true || @.b == true)]").query(json); + + assertThat(results).hasSize(3); + assertThat(results.stream().map(v -> asInt(v, "id")).toList()) + .containsExactly(1, 2, 3); + } + + @Test + void testLogicalNot() { + LOG.info(() -> "TEST: testLogicalNot (!)"); + var json = Json.parse(""" + [ + {"id": 1, "active": true}, + {"id": 2, "active": false}, + {"id": 3} + ] + """); + + // !@.active should match where active is false or null/missing (if treated as falsy? strictly missing check is different) + // In this implementation: + // !@.active implies we invert the truthiness of @.active. + // 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). + // 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); + assertThat(missingResults).hasSize(1); + assertThat(asInt(missingResults.getFirst(), "id")).isEqualTo(3); + + // Case B: Value comparison negation + // [?(!(@.active == true))] + var notTrueResults = JsonPath.parse("$[?(!(@.active == true))]").query(json); + // id 1: active=true -> EQ is true -> !T -> F + // id 2: active=false -> EQ is false -> !F -> T + // id 3: active missing -> EQ is false (null != true) -> !F -> T + assertThat(notTrueResults).hasSize(2); + assertThat(notTrueResults.stream().map(v -> asInt(v, "id")).toList()) + .containsExactlyInAnyOrder(2, 3); + } + + @Test + void testParenthesesPrecedence() { + LOG.info(() -> "TEST: testParenthesesPrecedence"); + // Logic: A && (B || C) vs (A && B) || C + // 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} + ] + """); + + // Case 1: A && (B || C) -> Expect Empty + var results1 = JsonPath.parse("$[?(@.A == true && (@.B == true || @.C == true))]").query(json); + assertThat(results1).isEmpty(); + + // Case 2: (A && B) || C -> Expect Match (since C is true) + // Note: The parser must respect precedence. AND usually binds tighter than OR, but we use parens to force order. + // Standard precedence: && before ||. + // So @.A && @.B || @.C means (@.A && @.B) || @.C. + // Let's verify explicit parens first. + var results2 = JsonPath.parse("$[?((@.A == true && @.B == true) || @.C == true)]").query(json); + assertThat(results2).hasSize(1); + } + + @Test + void testComplexNestedLogic() { + LOG.info(() -> "TEST: testComplexNestedLogic"); + // (Price < 10 OR (Category == 'fiction' AND Published is false)) + // Note: !@.published would mean "published does not exist". + // To check for false value, use @.published == false. + var json = Json.parse(""" + [ + {"id": 1, "price": 5, "category": "ref", "published": true}, + {"id": 2, "price": 20, "category": "fiction", "published": false}, + {"id": 3, "price": 20, "category": "fiction", "published": true}, + {"id": 4, "price": 20, "category": "ref", "published": false} + ] + """); + + var results = JsonPath.parse("$[?(@.price < 10 || (@.category == 'fiction' && @.published == false))]").query(json); + + assertThat(results).hasSize(2); + assertThat(results.stream().map(v -> asInt(v, "id")).toList()) + .containsExactlyInAnyOrder(1, 2); + } + + // Helper to extract integer field for assertions + private int asInt(JsonValue v, @SuppressWarnings("SameParameterValue") String key) { + if (v instanceof jdk.sandbox.java.util.json.JsonObject obj) { + return (int) obj.members().get(key).toLong(); + } + throw new IllegalArgumentException("Not an object"); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java new file mode 100644 index 0000000..3fcd3a8 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java @@ -0,0 +1,431 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; + +/// Tests for JsonPath based on examples from [...](https://goessner.net/articles/JsonPath/) +/// This test class uses the sample JSON document from the article. +class JsonPathGoessnerTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathGoessnerTest.class.getName()); + + /// Sample JSON from Goessner article + private static final String STORE_JSON = """ + { + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + ], + "bicycle": { + "color": "red", + "price": 19.95 + } + } + } + """; + + private static JsonValue storeJson; + + @BeforeAll + static void parseJson() { + storeJson = Json.parse(STORE_JSON); + LOG.info(() -> "Parsed store JSON for Goessner tests"); + } + + // Basic path queries + + @Test + void testRootOnly() { + LOG.info(() -> "TEST: testRootOnly - $ returns the root document"); + final var results = JsonPath.parse("$").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isEqualTo(storeJson); + } + + @Test + void testSingleProperty() { + LOG.info(() -> "TEST: testSingleProperty - $.store returns the store object"); + final var results = JsonPath.parse("$.store").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonObject.class); + } + + @Test + void testNestedProperty() { + LOG.info(() -> "TEST: testNestedProperty - $.store.bicycle returns the bicycle object"); + final var results = JsonPath.parse("$.store.bicycle").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonObject.class); + final var bicycle = (JsonObject) results.getFirst(); + assertThat(bicycle.members().get("color")).isInstanceOf(JsonString.class); + assertThat(bicycle.members().get("color").string()).isEqualTo("red"); + } + + // Goessner Article Examples + + @Test + void testAuthorsOfAllBooks() { + LOG.info(() -> "TEST: testAuthorsOfAllBooks - $.store.book[*].author"); + final var results = JsonPath.parse("$.store.book[*].author").query(storeJson); + assertThat(results).hasSize(4); + final var authors = results.stream() + .map(JsonValue::string) + .toList(); + assertThat(authors).containsExactly( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void testAllBooks() { + LOG.info(() -> "TEST: testAllBooks - $.store.book"); + final var results = JsonPath.parse("$.store.book").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonArray.class); + assertThat(((JsonArray) results.getFirst()).elements()).hasSize(4); + } + + @Test + void testAllAuthorsRecursive() { + LOG.info(() -> "TEST: testAllAuthorsRecursive - $..author"); + final var results = JsonPath.parse("$..author").query(storeJson); + assertThat(results).hasSize(4); + final var authors = results.stream() + .map(JsonValue::string) + .toList(); + assertThat(authors).containsExactlyInAnyOrder( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void testAllThingsInStore() { + LOG.info(() -> "TEST: testAllThingsInStore - $.store.*"); + final var results = JsonPath.parse("$.store.*").query(storeJson); + assertThat(results).hasSize(2); // book array and bicycle object + } + + @Test + void testAllPricesInStore() { + LOG.info(() -> "TEST: testAllPricesInStore - $.store..price"); + final var results = JsonPath.parse("$.store..price").query(storeJson); + assertThat(results).hasSize(5); // 4 book prices + 1 bicycle price + final var prices = results.stream() + .map(JsonValue::toDouble) + .toList(); + assertThat(prices).containsExactlyInAnyOrder(8.95, 12.99, 8.99, 22.99, 19.95); + } + + @Test + void testThirdBook() { + LOG.info(() -> "TEST: testThirdBook - $..book[2]"); + final var results = JsonPath.parse("$..book[2]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("Moby Dick"); + } + + @Test + void testLastBookScriptExpression() { + LOG.info(() -> "TEST: testLastBookScriptExpression - $..book[(@.length-1)]"); + final var results = JsonPath.parse("$..book[(@.length-1)]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void testLastBookSlice() { + LOG.info(() -> "TEST: testLastBookSlice - $..book[-1:]"); + final var results = JsonPath.parse("$..book[-1:]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void testFirstTwoBooksUnion() { + LOG.info(() -> "TEST: testFirstTwoBooksUnion - $..book[0,1]"); + final var results = JsonPath.parse("$..book[0,1]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactly("Sayings of the Century", "Sword of Honour"); + } + + @Test + void testFirstTwoBooksSlice() { + LOG.info(() -> "TEST: testFirstTwoBooksSlice - $..book[:2]"); + final var results = JsonPath.parse("$..book[:2]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactly("Sayings of the Century", "Sword of Honour"); + } + + @Test + void testBooksWithIsbn() { + LOG.info(() -> "TEST: testBooksWithIsbn - $..book[?(@.isbn)]"); + final var results = JsonPath.parse("$..book[?(@.isbn)]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactlyInAnyOrder("Moby Dick", "The Lord of the Rings"); + } + + @Test + void testBooksCheaperThan10() { + LOG.info(() -> "TEST: testBooksCheaperThan10 - $..book[?(@.price<10)]"); + final var results = JsonPath.parse("$..book[?(@.price<10)]").query(storeJson); + assertThat(results).hasSize(2); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactlyInAnyOrder("Sayings of the Century", "Moby Dick"); + } + + @Test + void testAllMembersRecursive() { + LOG.info(() -> "TEST: testAllMembersRecursive - $..*"); + final var results = JsonPath.parse("$..*").query(storeJson); + assertThat(results).isNotEmpty(); + LOG.fine(() -> "Found " + results.size() + " members recursively"); + } + + // Additional edge cases + + @Test + void testArrayIndexFirst() { + LOG.info(() -> "TEST: testArrayIndexFirst - $.store.book[0]"); + final var results = JsonPath.parse("$.store.book[0]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("author").string()).isEqualTo("Nigel Rees"); + } + + @Test + void testArrayIndexNegative() { + LOG.info(() -> "TEST: testArrayIndexNegative - $.store.book[-1]"); + final var results = JsonPath.parse("$.store.book[-1]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("author").string()).isEqualTo("J. R. R. Tolkien"); + } + + @Test + void testBracketNotationProperty() { + LOG.info(() -> "TEST: testBracketNotationProperty - $['store']['book'][0]"); + final var results = JsonPath.parse("$['store']['book'][0]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("author").string()).isEqualTo("Nigel Rees"); + } + + @Test + void testFilterEquality() { + LOG.info(() -> "TEST: testFilterEquality - $..book[?(@.category=='fiction')]"); + final var results = JsonPath.parse("$..book[?(@.category=='fiction')]").query(storeJson); + assertThat(results).hasSize(3); // 3 fiction books + } + + @Test + void testPropertyNotFound() { + LOG.info(() -> "TEST: testPropertyNotFound - $.nonexistent"); + final var results = JsonPath.parse("$.nonexistent").query(storeJson); + assertThat(results).isEmpty(); + } + + @Test + void testArrayIndexOutOfBounds() { + LOG.info(() -> "TEST: testArrayIndexOutOfBounds - $.store.book[100]"); + final var results = JsonPath.parse("$.store.book[100]").query(storeJson); + assertThat(results).isEmpty(); + } + + @Test + void testSliceWithStep() { + LOG.info(() -> "TEST: testSliceWithStep - $.store.book[0:4:2] (every other book)"); + final var results = JsonPath.parse("$.store.book[0:4:2]").query(storeJson); + assertThat(results).hasSize(2); // books at index 0 and 2 + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactly("Sayings of the Century", "Moby Dick"); + } + + @Test + void testSliceReverse() { + LOG.info(() -> "TEST: testSliceReverse - $.store.book[::-1]"); + final var results = JsonPath.parse("$.store.book[::-1]").query(storeJson); + assertThat(results).hasSize(4); + final var titles = results.stream() + .map(v -> v.members().get("title").string()) + .toList(); + assertThat(titles).containsExactly( + "The Lord of the Rings", + "Moby Dick", + "Sword of Honour", + "Sayings of the Century" + ); + } + + @Test + void testDeepNestedAccess() { + LOG.info(() -> "TEST: testDeepNestedAccess - $.store.book[0].title"); + final var results = JsonPath.parse("$.store.book[0].title").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst().string()).isEqualTo("Sayings of the Century"); + } + + @Test + void testRecursiveDescentOnArray() { + LOG.info(() -> "TEST: testRecursiveDescentOnArray - $..book"); + final var results = JsonPath.parse("$..book").query(storeJson); + assertThat(results).hasSize(1); + assertThat(results.getFirst()).isInstanceOf(JsonArray.class); + } + + @Test + void testPropertyUnion() { + LOG.info(() -> "TEST: testPropertyUnion - $.store['book','bicycle']"); + final var results = JsonPath.parse("$.store['book','bicycle']").query(storeJson); + assertThat(results).hasSize(2); + } + + @Test + void testFilterGreaterThan() { + LOG.info(() -> "TEST: testFilterGreaterThan - $..book[?(@.price>20)]"); + final var results = JsonPath.parse("$..book[?(@.price>20)]").query(storeJson); + assertThat(results).hasSize(1); + final var book = (JsonObject) results.getFirst(); + assertThat(book.members().get("title").string()).isEqualTo("The Lord of the Rings"); + } + + @Test + void testFilterLessOrEqual() { + LOG.info(() -> "TEST: testFilterLessOrEqual - $..book[?(@.price<=8.99)]"); + final var results = JsonPath.parse("$..book[?(@.price<=8.99)]").query(storeJson); + assertThat(results).hasSize(2); + } + + @Test + void testFilterCurrentNodeAlwaysTrue() { + LOG.info(() -> "TEST: testFilterCurrentNodeAlwaysTrue - $.store.book[?(@)]"); + final var results = JsonPath.parse("$.store.book[?(@)]").query(storeJson); + assertThat(results).hasSize(4); + } + + @Test + void testFilterLogicalNot() { + LOG.info(() -> "TEST: testFilterLogicalNot - $.store.book[?(!@.isbn)]"); + final var results = JsonPath.parse("$.store.book[?(!@.isbn)]").query(storeJson); + assertThat(results).hasSize(2); + } + + @Test + void testFilterLogicalAndOr() { + LOG.info(() -> "TEST: testFilterLogicalAndOr - $.store.book[?(@.isbn && (@.price<10 || @.price>20))]"); + final var results = JsonPath.parse("$.store.book[?(@.isbn && (@.price<10 || @.price>20))]").query(storeJson); + assertThat(results).hasSize(2); + } + + @Test + void testFilterLogicalAnd() { + LOG.info(() -> "TEST: testFilterLogicalAnd - $.store.book[?(@.isbn && @.price>20)]"); + final var results = JsonPath.parse("$.store.book[?(@.isbn && @.price>20)]").query(storeJson); + assertThat(results).hasSize(1); + assertThat(((JsonObject) results.getFirst()).members().get("title").string()).isEqualTo("The Lord of the Rings"); + } + + // Fluent API tests + + @Test + void testFluentApiParseAndSelect() { + LOG.info(() -> "TEST: testFluentApiParseAndSelect - JsonPath.parse(...).query(...)"); + final var matches = JsonPath.parse("$.store.book").query(storeJson); + assertThat(matches).hasSize(1); + assertThat(matches.getFirst()).isInstanceOf(JsonArray.class); + final var bookArray = (JsonArray) matches.getFirst(); + assertThat(bookArray.elements()).hasSize(4); // 4 books in the array + } + + @Test + void testStaticQueryWithCompiledPath() { + LOG.info(() -> "TEST: testStaticQueryWithCompiledPath - JsonPath.query(JsonPath, JsonValue) does not re-parse"); + final var compiled = JsonPath.parse("$.store.book[*].author"); + final var results = JsonPath.query(compiled, storeJson); + assertThat(results).hasSize(4); + assertThat(results.stream().map(JsonValue::string).toList()).containsExactly( + "Nigel Rees", + "Evelyn Waugh", + "Herman Melville", + "J. R. R. Tolkien" + ); + } + + @Test + void testFluentApiReusable() { + LOG.info(() -> "TEST: testFluentApiReusable - compiled path can be reused"); + final var compiledPath = JsonPath.parse("$..price"); + + // Use on store doc + final var storeResults = compiledPath.query(storeJson); + assertThat(storeResults).hasSize(5); // 4 book prices + 1 bicycle price + + // Use on a different doc + final var simpleDoc = Json.parse(""" + {"item": {"price": 99.99}} + """); + final var simpleResults = compiledPath.query(simpleDoc); + assertThat(simpleResults).hasSize(1); + assertThat(simpleResults.getFirst().toDouble()).isEqualTo(99.99); + } + + @Test + void testFluentApiExpressionAccessor() { + LOG.info(() -> "TEST: testFluentApiExpressionAccessor - toString() reconstructs path"); + final var path = JsonPath.parse("$.store.book[*].author"); + // Reconstructed path might vary slightly (e.g. .* vs [*]), but should be valid and equivalent + // Our implementation uses .* for Wildcard + assertThat(path.toString()).isEqualTo("$.store.book.*.author"); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathLoggingConfig.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathLoggingConfig.java new file mode 100644 index 0000000..6ba3cfc --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathLoggingConfig.java @@ -0,0 +1,46 @@ +package json.java21.jsonpath; + +import org.junit.jupiter.api.BeforeAll; +import java.util.Locale; +import java.util.logging.*; + +/// Base class for JsonPath tests that configures JUL logging from system properties. +/// All test classes should extend this class to enable consistent logging behavior. +public class JsonPathLoggingConfig { + @BeforeAll + static void enableJulDebug() { + final var log = Logger.getLogger(JsonPathLoggingConfig.class.getName()); + Logger root = Logger.getLogger(""); + String levelProp = System.getProperty("java.util.logging.ConsoleHandler.level"); + Level targetLevel = Level.INFO; + if (levelProp != null) { + try { + targetLevel = Level.parse(levelProp.trim()); + } catch (IllegalArgumentException ex) { + try { + targetLevel = Level.parse(levelProp.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + log.warning(() -> "Unrecognized logging level from 'java.util.logging.ConsoleHandler.level': " + levelProp); + } + } + } + // Ensure the root logger honors the most verbose configured level + if (root.getLevel() == null || root.getLevel().intValue() > targetLevel.intValue()) { + root.setLevel(targetLevel); + } + for (Handler handler : root.getHandlers()) { + Level handlerLevel = handler.getLevel(); + if (handlerLevel == null || handlerLevel.intValue() > targetLevel.intValue()) { + handler.setLevel(targetLevel); + } + } + + // Ensure test resource base is absolute and portable across CI and local runs + String prop = System.getProperty("jsonpath.test.resources"); + if (prop == null || prop.isBlank()) { + java.nio.file.Path base = java.nio.file.Paths.get("src", "test", "resources").toAbsolutePath(); + System.setProperty("jsonpath.test.resources", base.toString()); + log.config(() -> "jsonpath.test.resources set to " + base); + } + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParseExceptionTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParseExceptionTest.java new file mode 100644 index 0000000..5210315 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParseExceptionTest.java @@ -0,0 +1,58 @@ +package json.java21.jsonpath; + +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// Unit tests for JsonPathParseException formatting and details. +class JsonPathParseExceptionTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathParseExceptionTest.class.getName()); + + @Test + void testMessageWithPathAndPositionIncludesNearChar() { + LOG.info(() -> "TEST: testMessageWithPathAndPositionIncludesNearChar"); + + final var path = "store.book"; + assertThatThrownBy(() -> JsonPath.parse(path)) + .isInstanceOf(JsonPathParseException.class) + .satisfies(e -> { + final var ex = (JsonPathParseException) e; + assertThat(ex.path()).isEqualTo(path); + assertThat(ex.position()).isEqualTo(0); + assertThat(ex.getMessage()).contains("at position 0"); + assertThat(ex.getMessage()).contains("in path: " + path); + assertThat(ex.getMessage()).contains("near 's'"); + }); + } + + @Test + void testMessageWithPositionAtEndDoesNotIncludeNearChar() { + LOG.info(() -> "TEST: testMessageWithPositionAtEndDoesNotIncludeNearChar"); + + final var path = "$."; + assertThatThrownBy(() -> JsonPath.parse(path)) + .isInstanceOf(JsonPathParseException.class) + .satisfies(e -> { + final var ex = (JsonPathParseException) e; + assertThat(ex.path()).isEqualTo(path); + assertThat(ex.position()).isEqualTo(path.length()); + assertThat(ex.getMessage()).contains("at position " + path.length()); + assertThat(ex.getMessage()).contains("in path: " + path); + assertThat(ex.getMessage()).doesNotContain("near '"); + }); + } + + @Test + void testSimpleConstructorHasNoPositionOrPath() { + LOG.info(() -> "TEST: testSimpleConstructorHasNoPositionOrPath"); + + final var ex = new JsonPathParseException("boom"); + assertThat(ex.position()).isEqualTo(-1); + assertThat(ex.path()).isNull(); + assertThat(ex.getMessage()).isEqualTo("boom"); + } +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParserTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParserTest.java new file mode 100644 index 0000000..a84fa26 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParserTest.java @@ -0,0 +1,341 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.Json; +import jdk.sandbox.java.util.json.JsonValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/// Unit tests for JsonPathParser - tests parsing of JsonPath strings to AST +/// Based on examples from [...](https://goessner.net/articles/JsonPath/) +class JsonPathParserTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathParserTest.class.getName()); + + @Test + void testParseRootOnly() { + LOG.info(() -> "TEST: testParseRootOnly - parse $"); + final var ast = JsonPathParser.parse("$"); + assertThat(ast).isInstanceOf(JsonPathAst.Root.class); + assertThat(ast.segments()).isEmpty(); + } + + @Test + void testParseSingleProperty() { + LOG.info(() -> "TEST: testParseSingleProperty - parse $.store"); + final var ast = JsonPathParser.parse("$.store"); + assertThat(ast.segments()).hasSize(1); + assertThat(ast.segments().getFirst()).isInstanceOf(JsonPathAst.PropertyAccess.class); + final var prop = (JsonPathAst.PropertyAccess) ast.segments().getFirst(); + assertThat(prop.name()).isEqualTo("store"); + } + + @Test + void testParseNestedProperties() { + LOG.info(() -> "TEST: testParseNestedProperties - parse $.store.book"); + final var ast = JsonPathParser.parse("$.store.book"); + assertThat(ast.segments()).hasSize(2); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(1)).name()).isEqualTo("book"); + } + + @Test + void testParseBracketNotation() { + LOG.info(() -> "TEST: testParseBracketNotation - parse $['store']"); + final var ast = JsonPathParser.parse("$['store']"); + assertThat(ast.segments()).hasSize(1); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().getFirst()).name()).isEqualTo("store"); + } + + @Test + void testParseBracketNotationWithDoubleQuotes() { + LOG.info(() -> "TEST: testParseBracketNotationWithDoubleQuotes - parse $[\"store\"]"); + final var ast = JsonPathParser.parse("$[\"store\"]"); + assertThat(ast.segments()).hasSize(1); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().getFirst()).name()).isEqualTo("store"); + } + + @Test + void testParseArrayIndex() { + LOG.info(() -> "TEST: testParseArrayIndex - parse $.store.book[0]"); + final var ast = JsonPathParser.parse("$.store.book[0]"); + assertThat(ast.segments()).hasSize(3); + assertThat(ast.segments().get(2)).isInstanceOf(JsonPathAst.ArrayIndex.class); + final var index = (JsonPathAst.ArrayIndex) ast.segments().get(2); + assertThat(index.index()).isEqualTo(0); + } + + @Test + void testParseNegativeArrayIndex() { + LOG.info(() -> "TEST: testParseNegativeArrayIndex - parse $..book[-1]"); + final var ast = JsonPathParser.parse("$..book[-1]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArrayIndex.class); + final var index = (JsonPathAst.ArrayIndex) ast.segments().get(1); + assertThat(index.index()).isEqualTo(-1); + } + + @Test + void testParseThirdBook() { + LOG.info(() -> "TEST: testParseThirdBook - parse $..book[2] (third book)"); + final var ast = JsonPathParser.parse("$..book[2]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(0)).isInstanceOf(JsonPathAst.RecursiveDescent.class); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArrayIndex.class); + assertThat(((JsonPathAst.ArrayIndex) ast.segments().get(1)).index()).isEqualTo(2); + } + + @Test + void testParseWildcard() { + LOG.info(() -> "TEST: testParseWildcard - parse $.store.*"); + final var ast = JsonPathParser.parse("$.store.*"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Wildcard.class); + } + + @Test + void testParseWildcardInBrackets() { + LOG.info(() -> "TEST: testParseWildcardInBrackets - parse $.store.book[*]"); + final var ast = JsonPathParser.parse("$.store.book[*]"); + assertThat(ast.segments()).hasSize(3); + assertThat(ast.segments().get(2)).isInstanceOf(JsonPathAst.Wildcard.class); + } + + @Test + void testParseAllAuthors() { + LOG.info(() -> "TEST: testParseAllAuthors - parse $.store.book[*].author"); + final var ast = JsonPathParser.parse("$.store.book[*].author"); + assertThat(ast.segments()).hasSize(4); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(1)).name()).isEqualTo("book"); + assertThat(ast.segments().get(2)).isInstanceOf(JsonPathAst.Wildcard.class); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(3)).name()).isEqualTo("author"); + } + + @Test + void testParseRecursiveDescent() { + LOG.info(() -> "TEST: testParseRecursiveDescent - parse $..author"); + final var ast = JsonPathParser.parse("$..author"); + assertThat(ast.segments()).hasSize(1); + assertThat(ast.segments().getFirst()).isInstanceOf(JsonPathAst.RecursiveDescent.class); + final var descent = (JsonPathAst.RecursiveDescent) ast.segments().getFirst(); + assertThat(descent.target()).isInstanceOf(JsonPathAst.PropertyAccess.class); + assertThat(((JsonPathAst.PropertyAccess) descent.target()).name()).isEqualTo("author"); + } + + @Test + void testParseRecursiveDescentWithWildcard() { + LOG.info(() -> "TEST: testParseRecursiveDescentWithWildcard - parse $..*"); + final var ast = JsonPathParser.parse("$..*"); + assertThat(ast.segments()).hasSize(1); + assertThat(ast.segments().getFirst()).isInstanceOf(JsonPathAst.RecursiveDescent.class); + final var descent = (JsonPathAst.RecursiveDescent) ast.segments().getFirst(); + assertThat(descent.target()).isInstanceOf(JsonPathAst.Wildcard.class); + } + + @Test + void testParseStorePrice() { + LOG.info(() -> "TEST: testParseStorePrice - parse $.store..price"); + final var ast = JsonPathParser.parse("$.store..price"); + assertThat(ast.segments()).hasSize(2); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.RecursiveDescent.class); + final var descent = (JsonPathAst.RecursiveDescent) ast.segments().get(1); + assertThat(((JsonPathAst.PropertyAccess) descent.target()).name()).isEqualTo("price"); + } + + @Test + void testParseSlice() { + LOG.info(() -> "TEST: testParseSlice - parse $..book[:2] (first two books)"); + final var ast = JsonPathParser.parse("$..book[:2]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArraySlice.class); + final var slice = (JsonPathAst.ArraySlice) ast.segments().get(1); + assertThat(slice.start()).isNull(); + assertThat(slice.end()).isEqualTo(2); + assertThat(slice.step()).isNull(); + } + + @Test + void testParseSliceFromEnd() { + LOG.info(() -> "TEST: testParseSliceFromEnd - parse $..book[-1:] (last book)"); + final var ast = JsonPathParser.parse("$..book[-1:]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArraySlice.class); + final var slice = (JsonPathAst.ArraySlice) ast.segments().get(1); + assertThat(slice.start()).isEqualTo(-1); + assertThat(slice.end()).isNull(); + assertThat(slice.step()).isNull(); + } + + @Test + void testParseSliceWithStep() { + LOG.info(() -> "TEST: testParseSliceWithStep - parse $.book[0:10:2] (every other book)"); + final var ast = JsonPathParser.parse("$.book[0:10:2]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ArraySlice.class); + final var slice = (JsonPathAst.ArraySlice) ast.segments().get(1); + assertThat(slice.start()).isEqualTo(0); + assertThat(slice.end()).isEqualTo(10); + assertThat(slice.step()).isEqualTo(2); + } + + @Test + void testParseUnionIndices() { + LOG.info(() -> "TEST: testParseUnionIndices - parse $..book[0,1] (first two books)"); + final var ast = JsonPathParser.parse("$..book[0,1]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Union.class); + final var union = (JsonPathAst.Union) ast.segments().get(1); + assertThat(union.selectors()).hasSize(2); + assertThat(((JsonPathAst.ArrayIndex) union.selectors().get(0)).index()).isEqualTo(0); + assertThat(((JsonPathAst.ArrayIndex) union.selectors().get(1)).index()).isEqualTo(1); + } + + @Test + void testParseUnionProperties() { + LOG.info(() -> "TEST: testParseUnionProperties - parse $.store['book','bicycle']"); + final var ast = JsonPathParser.parse("$.store['book','bicycle']"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Union.class); + final var union = (JsonPathAst.Union) ast.segments().get(1); + assertThat(union.selectors()).hasSize(2); + assertThat(((JsonPathAst.PropertyAccess) union.selectors().get(0)).name()).isEqualTo("book"); + assertThat(((JsonPathAst.PropertyAccess) union.selectors().get(1)).name()).isEqualTo("bicycle"); + } + + @Test + void testParseFilterExists() { + LOG.info(() -> "TEST: testParseFilterExists - parse $..book[?(@.isbn)] (books with ISBN)"); + final var ast = JsonPathParser.parse("$..book[?(@.isbn)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ExistsFilter.class); + } + + @Test + void testParseFilterComparison() { + LOG.info(() -> "TEST: testParseFilterComparison - parse $..book[?(@.price<10)] (books cheaper than 10)"); + final var ast = JsonPathParser.parse("$..book[?(@.price<10)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ComparisonFilter.class); + final var comparison = (JsonPathAst.ComparisonFilter) filter.expression(); + assertThat(comparison.op()).isEqualTo(JsonPathAst.ComparisonOp.LT); + } + + @Test + void testParseFilterComparisonWithEquals() { + LOG.info(() -> "TEST: testParseFilterComparisonWithEquals - parse $..book[?(@.category=='fiction')]"); + final var ast = JsonPathParser.parse("$..book[?(@.category=='fiction')]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.ComparisonFilter.class); + final var comparison = (JsonPathAst.ComparisonFilter) filter.expression(); + assertThat(comparison.op()).isEqualTo(JsonPathAst.ComparisonOp.EQ); + } + + @Test + void testParseFilterCurrentNode() { + LOG.info(() -> "TEST: testParseFilterCurrentNode - parse $..book[?(@)]"); + final var ast = JsonPathParser.parse("$..book[?(@)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.CurrentNode.class); + } + + @Test + void testParseFilterLogicalNot() { + LOG.info(() -> "TEST: testParseFilterLogicalNot - parse $..book[?(!@.isbn)]"); + final var ast = JsonPathParser.parse("$..book[?(!@.isbn)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + assertThat(filter.expression()).isInstanceOf(JsonPathAst.LogicalFilter.class); + final var logical = (JsonPathAst.LogicalFilter) filter.expression(); + assertThat(logical.op()).isEqualTo(JsonPathAst.LogicalOp.NOT); + assertThat(logical.left()).isInstanceOf(JsonPathAst.ExistsFilter.class); + } + + @Test + void testParseFilterLogicalAndOrWithParentheses() { + LOG.info(() -> "TEST: testParseFilterLogicalAndOrWithParentheses - parse $..book[?(@.isbn && (@.price<10 || @.price>20))]"); + final var ast = JsonPathParser.parse("$..book[?(@.isbn && (@.price<10 || @.price>20))]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class); + final var filter = (JsonPathAst.Filter) ast.segments().get(1); + + assertThat(filter.expression()).isInstanceOf(JsonPathAst.LogicalFilter.class); + final var andExpr = (JsonPathAst.LogicalFilter) filter.expression(); + assertThat(andExpr.op()).isEqualTo(JsonPathAst.LogicalOp.AND); + assertThat(andExpr.left()).isInstanceOf(JsonPathAst.ExistsFilter.class); + assertThat(andExpr.right()).isInstanceOf(JsonPathAst.LogicalFilter.class); + + final var orExpr = (JsonPathAst.LogicalFilter) andExpr.right(); + assertThat(orExpr.op()).isEqualTo(JsonPathAst.LogicalOp.OR); + assertThat(orExpr.left()).isInstanceOf(JsonPathAst.ComparisonFilter.class); + assertThat(orExpr.right()).isInstanceOf(JsonPathAst.ComparisonFilter.class); + } + + @Test + void testParseScriptExpression() { + LOG.info(() -> "TEST: testParseScriptExpression - parse $..book[(@.length-1)] (last book)"); + final var ast = JsonPathParser.parse("$..book[(@.length-1)]"); + assertThat(ast.segments()).hasSize(2); + assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.ScriptExpression.class); + final var script = (JsonPathAst.ScriptExpression) ast.segments().get(1); + assertThat(script.script()).isEqualTo("@.length-1"); + } + + @Test + void testParsePropertyAfterArrayIndex() { + LOG.info(() -> "TEST: testParsePropertyAfterArrayIndex - parse $.store.book[0].title"); + final var ast = JsonPathParser.parse("$.store.book[0].title"); + assertThat(ast.segments()).hasSize(4); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(0)).name()).isEqualTo("store"); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(1)).name()).isEqualTo("book"); + assertThat(((JsonPathAst.ArrayIndex) ast.segments().get(2)).index()).isEqualTo(0); + assertThat(((JsonPathAst.PropertyAccess) ast.segments().get(3)).name()).isEqualTo("title"); + } + + @Test + void testParseEmptyStringThrows() { + LOG.info(() -> "TEST: testParseEmptyStringThrows"); + assertThatThrownBy(() -> JsonPathParser.parse("")) + .isInstanceOf(JsonPathParseException.class) + .hasMessageContaining("must start with $"); + } + + @Test + void testParseNullThrows() { + LOG.info(() -> "TEST: testParseNullThrows"); + assertThatThrownBy(() -> JsonPathParser.parse(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void testParseMissingRootThrows() { + LOG.info(() -> "TEST: testParseMissingRootThrows"); + assertThatThrownBy(() -> JsonPathParser.parse("store.book")) + .isInstanceOf(JsonPathParseException.class) + .hasMessageContaining("must start with $"); + } + + @ParameterizedTest + @ValueSource(strings = {"$.", "$[", "$...", "$.store[", "$.store."}) + void testParseIncompletePathThrows(String path) { + LOG.info(() -> "TEST: testParseIncompletePathThrows - " + path); + assertThatThrownBy(() -> JsonPathParser.parse(path)) + .isInstanceOf(JsonPathParseException.class); + } + +} diff --git a/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathStreamsTest.java b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathStreamsTest.java new file mode 100644 index 0000000..edb0672 --- /dev/null +++ b/json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathStreamsTest.java @@ -0,0 +1,100 @@ +package json.java21.jsonpath; + +import jdk.sandbox.java.util.json.*; +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.*; + +class JsonPathStreamsTest extends JsonPathLoggingConfig { + + private static final Logger LOG = Logger.getLogger(JsonPathStreamsTest.class.getName()); + + @Test + void testPredicates() { + LOG.info(() -> "JsonPathStreamsTest#testPredicates"); + JsonValue num = JsonNumber.of(1); + JsonValue str = JsonString.of("s"); + JsonValue bool = JsonBoolean.of(true); + JsonValue arr = Json.parse("[]"); + JsonValue obj = Json.parse("{}"); + JsonValue nul = JsonNull.of(); + + assertThat(JsonPathStreams.isNumber(num)).isTrue(); + assertThat(JsonPathStreams.isNumber(str)).isFalse(); + + assertThat(JsonPathStreams.isString(str)).isTrue(); + assertThat(JsonPathStreams.isString(num)).isFalse(); + + assertThat(JsonPathStreams.isBoolean(bool)).isTrue(); + assertThat(JsonPathStreams.isBoolean(str)).isFalse(); + + assertThat(JsonPathStreams.isArray(arr)).isTrue(); + assertThat(JsonPathStreams.isArray(obj)).isFalse(); + + assertThat(JsonPathStreams.isObject(obj)).isTrue(); + assertThat(JsonPathStreams.isObject(arr)).isFalse(); + + assertThat(JsonPathStreams.isNull(nul)).isTrue(); + assertThat(JsonPathStreams.isNull(str)).isFalse(); + } + + @Test + void testStrictConverters() { + LOG.info(() -> "JsonPathStreamsTest#testStrictConverters"); + JsonValue num = JsonNumber.of(123.45); + JsonValue numInt = JsonNumber.of(100); + JsonValue str = JsonString.of("foo"); + JsonValue bool = JsonBoolean.of(true); + + // asDouble + assertThat(JsonPathStreams.asDouble(num)).isEqualTo(123.45); + assertThatThrownBy(() -> JsonPathStreams.asDouble(str)) + .isInstanceOf(ClassCastException.class) + .hasMessageContaining("Expected JsonNumber"); + + // asLong + assertThat(JsonPathStreams.asLong(numInt)).isEqualTo(100L); + assertThatThrownBy(() -> JsonPathStreams.asLong(str)) + .isInstanceOf(ClassCastException.class) + .hasMessageContaining("Expected JsonNumber"); + + // asString + assertThat(JsonPathStreams.asString(str)).isEqualTo("foo"); + assertThatThrownBy(() -> JsonPathStreams.asString(num)) + .isInstanceOf(ClassCastException.class) + .hasMessageContaining("Expected JsonString"); + + // asBoolean + assertThat(JsonPathStreams.asBoolean(bool)).isTrue(); + assertThatThrownBy(() -> JsonPathStreams.asBoolean(str)) + .isInstanceOf(ClassCastException.class) + .hasMessageContaining("Expected JsonBoolean"); + } + + @Test + void testLaxConverters() { + LOG.info(() -> "JsonPathStreamsTest#testLaxConverters"); + JsonValue num = JsonNumber.of(123.45); + JsonValue numInt = JsonNumber.of(100); + JsonValue str = JsonString.of("foo"); + JsonValue bool = JsonBoolean.of(true); + + // asDoubleOrNull + assertThat(JsonPathStreams.asDoubleOrNull(num)).isEqualTo(123.45); + assertThat(JsonPathStreams.asDoubleOrNull(str)).isNull(); + + // asLongOrNull + assertThat(JsonPathStreams.asLongOrNull(numInt)).isEqualTo(100L); + assertThat(JsonPathStreams.asLongOrNull(str)).isNull(); + + // asStringOrNull + assertThat(JsonPathStreams.asStringOrNull(str)).isEqualTo("foo"); + assertThat(JsonPathStreams.asStringOrNull(num)).isNull(); + + // asBooleanOrNull + assertThat(JsonPathStreams.asBooleanOrNull(bool)).isTrue(); + assertThat(JsonPathStreams.asBooleanOrNull(str)).isNull(); + } +} diff --git a/json-java21-jtd/README.md b/json-java21-jtd/README.md index 67b357f..f034c7a 100644 --- a/json-java21-jtd/README.md +++ b/json-java21-jtd/README.md @@ -174,16 +174,16 @@ Validation errors include standardized information: ```bash # Build the module -$(command -v mvnd || command -v mvn || command -v ./mvnw) compile -pl json-java21-jtd +./mvnw compile -pl json-java21-jtd -am # Run tests -$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd +./mvnw test -pl json-java21-jtd -am # Run RFC compliance tests -$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Dtest=JtdSpecIT +./mvnw test -pl json-java21-jtd -am -Dtest=JtdSpecIT # Run with detailed logging -$(command -v mvnd || command -v mvn || command -v ./mvnw) test -pl json-java21-jtd -Djava.util.logging.ConsoleHandler.level=FINE +./mvnw test -pl json-java21-jtd -am -Djava.util.logging.ConsoleHandler.level=FINE ``` ## Architecture @@ -217,4 +217,4 @@ This implementation is fully compliant with RFC 8927: ## License -This project is part of the OpenJDK JSON API implementation and follows the same licensing terms. \ No newline at end of file +This project is part of the OpenJDK JSON API implementation and follows the same licensing terms. diff --git a/pom.xml b/pom.xml index 175d1eb..16d8439 100644 --- a/pom.xml +++ b/pom.xml @@ -41,13 +41,14 @@ json-java21-api-tracker json-compatibility-suite json-java21-jtd + json-java21-jsonpath UTF-8 21 5.10.2 - 3.25.3 + 3.27.7 3.4.0