Skip to content

Commit f3dbe1a

Browse files
committed
Issue #127 JsonPath logical filters and parse error tests
Extend filter parsing to support !, &&, || (with parentheses) and treat bare @ as the current node. Add targeted parser/integration tests plus JsonPathParseException formatting assertions.\n\nVerify: ./mvnw -pl json-java21-jsonpath -am clean test -Djava.util.logging.ConsoleHandler.level=INFO
1 parent ab99e23 commit f3dbe1a

File tree

4 files changed

+192
-8
lines changed

4 files changed

+192
-8
lines changed

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

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,29 +200,83 @@ private JsonPathAst.Filter parseFilterExpression() {
200200
}
201201

202202
private JsonPathAst.FilterExpression parseFilterContent() {
203+
return parseLogicalOr();
204+
}
205+
206+
private JsonPathAst.FilterExpression parseLogicalOr() {
207+
var left = parseLogicalAnd();
208+
skipWhitespace();
209+
210+
while (pos + 1 < path.length() && path.substring(pos).startsWith("||")) {
211+
pos += 2;
212+
final var right = parseLogicalAnd();
213+
skipWhitespace();
214+
left = new JsonPathAst.LogicalFilter(left, JsonPathAst.LogicalOp.OR, right);
215+
}
216+
217+
return left;
218+
}
219+
220+
private JsonPathAst.FilterExpression parseLogicalAnd() {
221+
var left = parseLogicalUnary();
222+
skipWhitespace();
223+
224+
while (pos + 1 < path.length() && path.substring(pos).startsWith("&&")) {
225+
pos += 2;
226+
final var right = parseLogicalUnary();
227+
skipWhitespace();
228+
left = new JsonPathAst.LogicalFilter(left, JsonPathAst.LogicalOp.AND, right);
229+
}
230+
231+
return left;
232+
}
233+
234+
private JsonPathAst.FilterExpression parseLogicalUnary() {
235+
skipWhitespace();
236+
237+
if (pos < path.length() && path.charAt(pos) == '!') {
238+
pos++;
239+
final var operand = parseLogicalUnary();
240+
return new JsonPathAst.LogicalFilter(operand, JsonPathAst.LogicalOp.NOT, null);
241+
}
242+
243+
return parseLogicalPrimary();
244+
}
245+
246+
private JsonPathAst.FilterExpression parseLogicalPrimary() {
247+
skipWhitespace();
203248
if (pos >= path.length()) {
204249
throw new JsonPathParseException("Unexpected end of filter expression", path, pos);
205250
}
206251

207-
// Parse the left side (usually @.property)
208-
final var left = parseFilterAtom();
252+
if (path.charAt(pos) == '(') {
253+
pos++;
254+
final var expr = parseLogicalOr();
255+
skipWhitespace();
256+
expectChar(')');
257+
return expr;
258+
}
209259

260+
// Atom (maybe part of a comparison)
261+
final var left = parseFilterAtom();
210262
skipWhitespace();
211263

212-
// Check if there's a comparison operator
213-
if (pos < path.length() && path.charAt(pos) != ')') {
264+
if (pos < path.length() && isComparisonOpStart(path.charAt(pos))) {
214265
final var op = parseComparisonOp();
215266
skipWhitespace();
216267
final var right = parseFilterAtom();
217268
return new JsonPathAst.ComparisonFilter(left, op, right);
218269
}
219270

220-
// No operator means existence check
221271
if (left instanceof JsonPathAst.PropertyPath pp) {
222272
return new JsonPathAst.ExistsFilter(pp);
223273
}
224274

225-
throw new JsonPathParseException("Invalid filter expression - expected property path", path, pos);
275+
return left;
276+
}
277+
278+
private boolean isComparisonOpStart(char c) {
279+
return c == '=' || c == '!' || c == '<' || c == '>';
226280
}
227281

228282
private JsonPathAst.FilterExpression parseFilterAtom() {
@@ -268,7 +322,7 @@ private JsonPathAst.FilterExpression parseFilterAtom() {
268322
throw new JsonPathParseException("Unexpected token in filter expression", path, pos);
269323
}
270324

271-
private JsonPathAst.PropertyPath parseCurrentNodePath() {
325+
private JsonPathAst.FilterExpression parseCurrentNodePath() {
272326
pos++; // skip @
273327

274328
final var properties = new ArrayList<String>();
@@ -294,7 +348,7 @@ private JsonPathAst.PropertyPath parseCurrentNodePath() {
294348

295349
if (properties.isEmpty()) {
296350
// Just @ with no properties
297-
return new JsonPathAst.PropertyPath(List.of("@"));
351+
return new JsonPathAst.CurrentNode();
298352
}
299353

300354
return new JsonPathAst.PropertyPath(properties);

json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathGoessnerTest.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,35 @@ void testFilterLessOrEqual() {
332332
assertThat(results).hasSize(2);
333333
}
334334

335+
@Test
336+
void testFilterCurrentNodeAlwaysTrue() {
337+
LOG.info(() -> "TEST: testFilterCurrentNodeAlwaysTrue - $.store.book[?(@)]");
338+
final var results = JsonPath.parse("$.store.book[?(@)]").query(storeJson);
339+
assertThat(results).hasSize(4);
340+
}
341+
342+
@Test
343+
void testFilterLogicalNot() {
344+
LOG.info(() -> "TEST: testFilterLogicalNot - $.store.book[?(!@.isbn)]");
345+
final var results = JsonPath.parse("$.store.book[?(!@.isbn)]").query(storeJson);
346+
assertThat(results).hasSize(2);
347+
}
348+
349+
@Test
350+
void testFilterLogicalAndOr() {
351+
LOG.info(() -> "TEST: testFilterLogicalAndOr - $.store.book[?(@.isbn && (@.price<10 || @.price>20))]");
352+
final var results = JsonPath.parse("$.store.book[?(@.isbn && (@.price<10 || @.price>20))]").query(storeJson);
353+
assertThat(results).hasSize(2);
354+
}
355+
356+
@Test
357+
void testFilterLogicalAnd() {
358+
LOG.info(() -> "TEST: testFilterLogicalAnd - $.store.book[?(@.isbn && @.price>20)]");
359+
final var results = JsonPath.parse("$.store.book[?(@.isbn && @.price>20)]").query(storeJson);
360+
assertThat(results).hasSize(1);
361+
assertThat(((JsonObject) results.getFirst()).members().get("title").string()).isEqualTo("The Lord of the Rings");
362+
}
363+
335364
// ========== Fluent API tests ==========
336365

337366
@Test
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package json.java21.jsonpath;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.util.logging.Logger;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
9+
10+
/// Unit tests for JsonPathParseException formatting and details.
11+
class JsonPathParseExceptionTest extends JsonPathLoggingConfig {
12+
13+
private static final Logger LOG = Logger.getLogger(JsonPathParseExceptionTest.class.getName());
14+
15+
@Test
16+
void testMessageWithPathAndPositionIncludesNearChar() {
17+
LOG.info(() -> "TEST: testMessageWithPathAndPositionIncludesNearChar");
18+
19+
final var path = "store.book";
20+
assertThatThrownBy(() -> JsonPath.parse(path))
21+
.isInstanceOf(JsonPathParseException.class)
22+
.satisfies(e -> {
23+
final var ex = (JsonPathParseException) e;
24+
assertThat(ex.path()).isEqualTo(path);
25+
assertThat(ex.position()).isEqualTo(0);
26+
assertThat(ex.getMessage()).contains("at position 0");
27+
assertThat(ex.getMessage()).contains("in path: " + path);
28+
assertThat(ex.getMessage()).contains("near 's'");
29+
});
30+
}
31+
32+
@Test
33+
void testMessageWithPositionAtEndDoesNotIncludeNearChar() {
34+
LOG.info(() -> "TEST: testMessageWithPositionAtEndDoesNotIncludeNearChar");
35+
36+
final var path = "$.";
37+
assertThatThrownBy(() -> JsonPath.parse(path))
38+
.isInstanceOf(JsonPathParseException.class)
39+
.satisfies(e -> {
40+
final var ex = (JsonPathParseException) e;
41+
assertThat(ex.path()).isEqualTo(path);
42+
assertThat(ex.position()).isEqualTo(path.length());
43+
assertThat(ex.getMessage()).contains("at position " + path.length());
44+
assertThat(ex.getMessage()).contains("in path: " + path);
45+
assertThat(ex.getMessage()).doesNotContain("near '");
46+
});
47+
}
48+
49+
@Test
50+
void testSimpleConstructorHasNoPositionOrPath() {
51+
LOG.info(() -> "TEST: testSimpleConstructorHasNoPositionOrPath");
52+
53+
final var ex = new JsonPathParseException("boom");
54+
assertThat(ex.position()).isEqualTo(-1);
55+
assertThat(ex.path()).isNull();
56+
assertThat(ex.getMessage()).isEqualTo("boom");
57+
}
58+
}

json-java21-jsonpath/src/test/java/json/java21/jsonpath/JsonPathParserTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,49 @@ void testParseFilterComparisonWithEquals() {
259259
assertThat(comparison.op()).isEqualTo(JsonPathAst.ComparisonOp.EQ);
260260
}
261261

262+
@Test
263+
void testParseFilterCurrentNode() {
264+
LOG.info(() -> "TEST: testParseFilterCurrentNode - parse $..book[?(@)]");
265+
final var ast = JsonPathParser.parse("$..book[?(@)]");
266+
assertThat(ast.segments()).hasSize(2);
267+
assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class);
268+
final var filter = (JsonPathAst.Filter) ast.segments().get(1);
269+
assertThat(filter.expression()).isInstanceOf(JsonPathAst.CurrentNode.class);
270+
}
271+
272+
@Test
273+
void testParseFilterLogicalNot() {
274+
LOG.info(() -> "TEST: testParseFilterLogicalNot - parse $..book[?(!@.isbn)]");
275+
final var ast = JsonPathParser.parse("$..book[?(!@.isbn)]");
276+
assertThat(ast.segments()).hasSize(2);
277+
assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class);
278+
final var filter = (JsonPathAst.Filter) ast.segments().get(1);
279+
assertThat(filter.expression()).isInstanceOf(JsonPathAst.LogicalFilter.class);
280+
final var logical = (JsonPathAst.LogicalFilter) filter.expression();
281+
assertThat(logical.op()).isEqualTo(JsonPathAst.LogicalOp.NOT);
282+
assertThat(logical.left()).isInstanceOf(JsonPathAst.ExistsFilter.class);
283+
}
284+
285+
@Test
286+
void testParseFilterLogicalAndOrWithParentheses() {
287+
LOG.info(() -> "TEST: testParseFilterLogicalAndOrWithParentheses - parse $..book[?(@.isbn && (@.price<10 || @.price>20))]");
288+
final var ast = JsonPathParser.parse("$..book[?(@.isbn && (@.price<10 || @.price>20))]");
289+
assertThat(ast.segments()).hasSize(2);
290+
assertThat(ast.segments().get(1)).isInstanceOf(JsonPathAst.Filter.class);
291+
final var filter = (JsonPathAst.Filter) ast.segments().get(1);
292+
293+
assertThat(filter.expression()).isInstanceOf(JsonPathAst.LogicalFilter.class);
294+
final var andExpr = (JsonPathAst.LogicalFilter) filter.expression();
295+
assertThat(andExpr.op()).isEqualTo(JsonPathAst.LogicalOp.AND);
296+
assertThat(andExpr.left()).isInstanceOf(JsonPathAst.ExistsFilter.class);
297+
assertThat(andExpr.right()).isInstanceOf(JsonPathAst.LogicalFilter.class);
298+
299+
final var orExpr = (JsonPathAst.LogicalFilter) andExpr.right();
300+
assertThat(orExpr.op()).isEqualTo(JsonPathAst.LogicalOp.OR);
301+
assertThat(orExpr.left()).isInstanceOf(JsonPathAst.ComparisonFilter.class);
302+
assertThat(orExpr.right()).isInstanceOf(JsonPathAst.ComparisonFilter.class);
303+
}
304+
262305
// ========== Script expression parsing ==========
263306

264307
@Test

0 commit comments

Comments
 (0)