Skip to content

Commit 3eaf512

Browse files
committed
Issue #129 Add JsonPathStreams helpers and stream aggregation docs
Add JsonPathStreams predicates/converters (strict and OrNull), document stream-based aggregation in json-java21-jsonpath/README.md, and bump AssertJ minimum to 3.27.7 in parent pom. How to test: ./mvnw test -pl json-java21-jsonpath -am -Djava.util.logging.ConsoleHandler.level=INFO
1 parent 75f9388 commit 3eaf512

File tree

5 files changed

+378
-1
lines changed

5 files changed

+378
-1
lines changed

json-java21-jsonpath/README.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,78 @@ This implementation follows Goessner-style JSONPath operators, including:
4141
- `[?(@.prop)]` and `[?(@.prop op value)]` basic filters
4242
- `[(@.length-1)]` limited script support
4343

44+
## Stream-Based Functions (Aggregations)
45+
46+
Some JsonPath implementations for older versions of Java provided aggregation functions such as `$.numbers.avg()`.
47+
In this implementation we provide first class stream support so you can use standard JDK aggregation functions on `JsonPath.query(...)` results.
48+
49+
The `query()` method returns a standard `List<JsonValue>`. You can stream, filter, map, and reduce these results using standard Java APIs. To make this easier, we provide the `JsonPathStreams` utility class with predicate and conversion methods.
50+
51+
### Strict vs. Lax Conversions
52+
53+
We follow a pattern of "Strict" (`asX`) vs "Lax" (`asXOrNull`) converters:
54+
- **Strict (`asX`)**: Throws `ClassCastException` (or similar) if the value is not the expected type. Use this when you are certain of the schema.
55+
- **Lax (`asXOrNull`)**: Returns `null` if the value is not the expected type. Use this with `.filter(Objects::nonNull)` for robust processing of messy data.
56+
57+
### Examples
58+
59+
**Summing Numbers (Lax - safe against bad data)**
60+
```java
61+
import json.java21.jsonpath.JsonPathStreams;
62+
import java.util.Objects;
63+
64+
// Calculate sum of all 'price' fields, ignoring non-numbers
65+
double total = path.query(doc).stream()
66+
.map(JsonPathStreams::asDoubleOrNull) // Convert to Double or null
67+
.filter(Objects::nonNull) // Remove non-numbers
68+
.mapToDouble(Double::doubleValue) // Unbox
69+
.sum();
70+
```
71+
72+
**Average (Strict - expects valid data)**
73+
```java
74+
import java.util.OptionalDouble;
75+
76+
// Calculate average, fails if any value is not a number
77+
OptionalDouble avg = path.query(doc).stream()
78+
.map(JsonPathStreams::asDouble) // Throws if not a number
79+
.mapToDouble(Double::doubleValue)
80+
.average();
81+
```
82+
83+
**Filtering by Type**
84+
```java
85+
import java.util.List;
86+
87+
// Get all strings
88+
List<String> strings = path.query(doc).stream()
89+
.filter(JsonPathStreams::isString)
90+
.map(JsonPathStreams::asString)
91+
.toList();
92+
```
93+
94+
### Available Helpers (`JsonPathStreams`)
95+
96+
**Predicates:**
97+
- `isNumber(JsonValue)`
98+
- `isString(JsonValue)`
99+
- `isBoolean(JsonValue)`
100+
- `isArray(JsonValue)`
101+
- `isObject(JsonValue)`
102+
- `isNull(JsonValue)`
103+
104+
**Converters (Strict):**
105+
- `asDouble(JsonValue)` -> `double`
106+
- `asLong(JsonValue)` -> `long`
107+
- `asString(JsonValue)` -> `String`
108+
- `asBoolean(JsonValue)` -> `boolean`
109+
110+
**Converters (Lax):**
111+
- `asDoubleOrNull(JsonValue)` -> `Double`
112+
- `asLongOrNull(JsonValue)` -> `Long`
113+
- `asStringOrNull(JsonValue)` -> `String`
114+
- `asBooleanOrNull(JsonValue)` -> `Boolean`
115+
44116
## Testing
45117

46118
```bash
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package json.java21.jsonpath;
2+
3+
import jdk.sandbox.java.util.json.*;
4+
5+
/// Utility class for stream-based processing of JsonPath query results.
6+
///
7+
/// This module intentionally does not embed aggregate functions (avg/sum/min/max)
8+
/// in JsonPath syntax; use Java Streams on `JsonPath.query(...)` results instead.
9+
public final class JsonPathStreams {
10+
11+
private JsonPathStreams() {}
12+
13+
// =================================================================================
14+
// Predicates
15+
// =================================================================================
16+
17+
/// @return true if the value is a `JsonNumber`
18+
public static boolean isNumber(JsonValue v) {
19+
return v instanceof JsonNumber;
20+
}
21+
22+
/// @return true if the value is a `JsonString`
23+
public static boolean isString(JsonValue v) {
24+
return v instanceof JsonString;
25+
}
26+
27+
/// @return true if the value is a `JsonBoolean`
28+
public static boolean isBoolean(JsonValue v) {
29+
return v instanceof JsonBoolean;
30+
}
31+
32+
/// @return true if the value is a `JsonArray`
33+
public static boolean isArray(JsonValue v) {
34+
return v instanceof JsonArray;
35+
}
36+
37+
/// @return true if the value is a `JsonObject`
38+
public static boolean isObject(JsonValue v) {
39+
return v instanceof JsonObject;
40+
}
41+
42+
/// @return true if the value is a `JsonNull`
43+
public static boolean isNull(JsonValue v) {
44+
return v instanceof JsonNull;
45+
}
46+
47+
// =================================================================================
48+
// Strict Converters (throw ClassCastException if type mismatch)
49+
// =================================================================================
50+
51+
/// Converts a `JsonNumber` to a `double`.
52+
///
53+
/// @throws ClassCastException if the value is not a `JsonNumber`
54+
public static double asDouble(JsonValue v) {
55+
if (v instanceof JsonNumber n) {
56+
return n.toDouble();
57+
}
58+
throw new ClassCastException("Expected JsonNumber but got " + v.getClass().getSimpleName());
59+
}
60+
61+
/// Converts a `JsonNumber` to a `long`.
62+
///
63+
/// @throws ClassCastException if the value is not a `JsonNumber`
64+
public static long asLong(JsonValue v) {
65+
if (v instanceof JsonNumber n) {
66+
return n.toLong();
67+
}
68+
throw new ClassCastException("Expected JsonNumber but got " + v.getClass().getSimpleName());
69+
}
70+
71+
/// Converts a `JsonString` to a `String`.
72+
///
73+
/// @throws ClassCastException if the value is not a `JsonString`
74+
public static String asString(JsonValue v) {
75+
if (v instanceof JsonString s) {
76+
return s.string();
77+
}
78+
throw new ClassCastException("Expected JsonString but got " + v.getClass().getSimpleName());
79+
}
80+
81+
/// Converts a `JsonBoolean` to a `boolean`.
82+
///
83+
/// @throws ClassCastException if the value is not a `JsonBoolean`
84+
public static boolean asBoolean(JsonValue v) {
85+
if (v instanceof JsonBoolean b) {
86+
return b.bool();
87+
}
88+
throw new ClassCastException("Expected JsonBoolean but got " + v.getClass().getSimpleName());
89+
}
90+
91+
// =================================================================================
92+
// Lax Converters (return null if type mismatch)
93+
// =================================================================================
94+
95+
/// Converts to `Double` if the value is a `JsonNumber`, otherwise returns null.
96+
public static Double asDoubleOrNull(JsonValue v) {
97+
return (v instanceof JsonNumber n) ? n.toDouble() : null;
98+
}
99+
100+
/// Converts to `Long` if the value is a `JsonNumber`, otherwise returns null.
101+
public static Long asLongOrNull(JsonValue v) {
102+
return (v instanceof JsonNumber n) ? n.toLong() : null;
103+
}
104+
105+
/// Converts to `String` if the value is a `JsonString`, otherwise returns null.
106+
public static String asStringOrNull(JsonValue v) {
107+
return (v instanceof JsonString s) ? s.string() : null;
108+
}
109+
110+
/// Converts to `Boolean` if the value is a `JsonBoolean`, otherwise returns null.
111+
public static Boolean asBooleanOrNull(JsonValue v) {
112+
return (v instanceof JsonBoolean b) ? b.bool() : null;
113+
}
114+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package json.java21.jsonpath;
2+
3+
import jdk.sandbox.java.util.json.*;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.util.List;
7+
import java.util.Objects;
8+
import java.util.OptionalDouble;
9+
import java.util.logging.Logger;
10+
11+
import static org.assertj.core.api.Assertions.assertThat;
12+
import static org.assertj.core.api.Assertions.within;
13+
14+
public class FunctionsReadmeDemo extends JsonPathLoggingConfig {
15+
16+
private static final Logger LOG = Logger.getLogger(FunctionsReadmeDemo.class.getName());
17+
18+
public static void main(String[] args) {
19+
final var demo = new FunctionsReadmeDemo();
20+
demo.testSummingNumbersLax();
21+
demo.testAverageStrict();
22+
demo.testFilteringByType();
23+
}
24+
25+
@Test
26+
void testSummingNumbersLax() {
27+
LOG.info(() -> "FunctionsReadmeDemo#testSummingNumbersLax");
28+
JsonValue doc = Json.parse("""
29+
{
30+
"store": {
31+
"book": [
32+
{ "category": "reference", "price": 8.95 },
33+
{ "category": "fiction", "price": 12.99 },
34+
{ "category": "fiction", "price": "Not For Sale" },
35+
{ "category": "fiction", "price": 22.99 }
36+
]
37+
}
38+
}
39+
""");
40+
41+
JsonPath path = JsonPath.parse("$.store.book[*].price");
42+
43+
// Calculate sum of all 'price' fields, ignoring non-numbers
44+
double total = path.query(doc).stream()
45+
.map(JsonPathStreams::asDoubleOrNull) // Convert to Double or null
46+
.filter(Objects::nonNull) // Remove non-numbers
47+
.mapToDouble(Double::doubleValue) // Unbox
48+
.sum();
49+
50+
assertThat(total).isCloseTo(8.95 + 12.99 + 22.99, within(0.001));
51+
}
52+
53+
@Test
54+
void testAverageStrict() {
55+
LOG.info(() -> "FunctionsReadmeDemo#testAverageStrict");
56+
JsonValue doc = Json.parse("""
57+
{
58+
"temperatures": [ 98.6, 99.1, 98.4 ]
59+
}
60+
""");
61+
62+
JsonPath path = JsonPath.parse("$.temperatures[*]");
63+
64+
// Calculate average, fails if any value is not a number
65+
OptionalDouble avg = path.query(doc).stream()
66+
.map(JsonPathStreams::asDouble) // Throws if not a number
67+
.mapToDouble(Double::doubleValue)
68+
.average();
69+
70+
assertThat(avg).isPresent();
71+
assertThat(avg.getAsDouble()).isBetween(98.6, 98.8); // 98.7 approx
72+
}
73+
74+
@Test
75+
void testFilteringByType() {
76+
LOG.info(() -> "FunctionsReadmeDemo#testFilteringByType");
77+
JsonValue doc = Json.parse("""
78+
[ "apple", 100, "banana", true, "cherry", null ]
79+
""");
80+
81+
JsonPath path = JsonPath.parse("$[*]");
82+
83+
// Get all strings
84+
List<String> strings = path.query(doc).stream()
85+
.filter(JsonPathStreams::isString)
86+
.map(JsonPathStreams::asString)
87+
.toList();
88+
89+
assertThat(strings).containsExactly("apple", "banana", "cherry");
90+
}
91+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package json.java21.jsonpath;
2+
3+
import jdk.sandbox.java.util.json.*;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.util.logging.Logger;
7+
8+
import static org.assertj.core.api.Assertions.*;
9+
10+
class JsonPathStreamsTest extends JsonPathLoggingConfig {
11+
12+
private static final Logger LOG = Logger.getLogger(JsonPathStreamsTest.class.getName());
13+
14+
@Test
15+
void testPredicates() {
16+
LOG.info(() -> "JsonPathStreamsTest#testPredicates");
17+
JsonValue num = JsonNumber.of(1);
18+
JsonValue str = JsonString.of("s");
19+
JsonValue bool = JsonBoolean.of(true);
20+
JsonValue arr = Json.parse("[]");
21+
JsonValue obj = Json.parse("{}");
22+
JsonValue nul = JsonNull.of();
23+
24+
assertThat(JsonPathStreams.isNumber(num)).isTrue();
25+
assertThat(JsonPathStreams.isNumber(str)).isFalse();
26+
27+
assertThat(JsonPathStreams.isString(str)).isTrue();
28+
assertThat(JsonPathStreams.isString(num)).isFalse();
29+
30+
assertThat(JsonPathStreams.isBoolean(bool)).isTrue();
31+
assertThat(JsonPathStreams.isBoolean(str)).isFalse();
32+
33+
assertThat(JsonPathStreams.isArray(arr)).isTrue();
34+
assertThat(JsonPathStreams.isArray(obj)).isFalse();
35+
36+
assertThat(JsonPathStreams.isObject(obj)).isTrue();
37+
assertThat(JsonPathStreams.isObject(arr)).isFalse();
38+
39+
assertThat(JsonPathStreams.isNull(nul)).isTrue();
40+
assertThat(JsonPathStreams.isNull(str)).isFalse();
41+
}
42+
43+
@Test
44+
void testStrictConverters() {
45+
LOG.info(() -> "JsonPathStreamsTest#testStrictConverters");
46+
JsonValue num = JsonNumber.of(123.45);
47+
JsonValue numInt = JsonNumber.of(100);
48+
JsonValue str = JsonString.of("foo");
49+
JsonValue bool = JsonBoolean.of(true);
50+
51+
// asDouble
52+
assertThat(JsonPathStreams.asDouble(num)).isEqualTo(123.45);
53+
assertThatThrownBy(() -> JsonPathStreams.asDouble(str))
54+
.isInstanceOf(ClassCastException.class)
55+
.hasMessageContaining("Expected JsonNumber");
56+
57+
// asLong
58+
assertThat(JsonPathStreams.asLong(numInt)).isEqualTo(100L);
59+
assertThatThrownBy(() -> JsonPathStreams.asLong(str))
60+
.isInstanceOf(ClassCastException.class)
61+
.hasMessageContaining("Expected JsonNumber");
62+
63+
// asString
64+
assertThat(JsonPathStreams.asString(str)).isEqualTo("foo");
65+
assertThatThrownBy(() -> JsonPathStreams.asString(num))
66+
.isInstanceOf(ClassCastException.class)
67+
.hasMessageContaining("Expected JsonString");
68+
69+
// asBoolean
70+
assertThat(JsonPathStreams.asBoolean(bool)).isTrue();
71+
assertThatThrownBy(() -> JsonPathStreams.asBoolean(str))
72+
.isInstanceOf(ClassCastException.class)
73+
.hasMessageContaining("Expected JsonBoolean");
74+
}
75+
76+
@Test
77+
void testLaxConverters() {
78+
LOG.info(() -> "JsonPathStreamsTest#testLaxConverters");
79+
JsonValue num = JsonNumber.of(123.45);
80+
JsonValue numInt = JsonNumber.of(100);
81+
JsonValue str = JsonString.of("foo");
82+
JsonValue bool = JsonBoolean.of(true);
83+
84+
// asDoubleOrNull
85+
assertThat(JsonPathStreams.asDoubleOrNull(num)).isEqualTo(123.45);
86+
assertThat(JsonPathStreams.asDoubleOrNull(str)).isNull();
87+
88+
// asLongOrNull
89+
assertThat(JsonPathStreams.asLongOrNull(numInt)).isEqualTo(100L);
90+
assertThat(JsonPathStreams.asLongOrNull(str)).isNull();
91+
92+
// asStringOrNull
93+
assertThat(JsonPathStreams.asStringOrNull(str)).isEqualTo("foo");
94+
assertThat(JsonPathStreams.asStringOrNull(num)).isNull();
95+
96+
// asBooleanOrNull
97+
assertThat(JsonPathStreams.asBooleanOrNull(bool)).isTrue();
98+
assertThat(JsonPathStreams.asBooleanOrNull(str)).isNull();
99+
}
100+
}

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
4949
<maven.compiler.release>21</maven.compiler.release>
5050
<junit.jupiter.version>5.10.2</junit.jupiter.version>
51-
<assertj.version>3.25.3</assertj.version>
51+
<assertj.version>3.27.7</assertj.version>
5252

5353
<!-- Plugin Versions -->
5454
<maven-clean-plugin.version>3.4.0</maven-clean-plugin.version>

0 commit comments

Comments
 (0)