Skip to content

Commit 71a43ba

Browse files
committed
More tests and refactor parser.parse_query
1 parent 9a73434 commit 71a43ba

File tree

5 files changed

+229
-64
lines changed

5 files changed

+229
-64
lines changed

jsonpath/parse.py

Lines changed: 55 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -338,100 +338,91 @@ def parse_query(self, stream: TokenStream) -> Iterable[JSONPathSegment]:
338338
This method assumes the root, current or pseudo root identifier has
339339
already been consumed.
340340
"""
341+
if not self.env.strict and stream.current().kind in {
342+
TOKEN_NAME,
343+
TOKEN_WILD,
344+
TOKEN_KEYS,
345+
TOKEN_KEY_NAME,
346+
}:
347+
# A non-standard "bare" path. One that starts with a shorthand selector
348+
# without a leading identifier (`$`, `@`, `^` or `_`).
349+
#
350+
# When no identifier is given, a root query (`$`) is assumed.
351+
token = stream.current()
352+
selector = self.parse_shorthand_selector(stream)
353+
yield JSONPathChildSegment(env=self.env, token=token, selectors=(selector,))
354+
341355
while True:
342356
stream.skip_whitespace()
343-
_token = stream.current()
344-
if _token.kind == TOKEN_DOT:
345-
stream.eat(TOKEN_DOT)
346-
# Assert that dot is followed by shorthand selector without whitespace.
347-
stream.expect(TOKEN_NAME, TOKEN_WILD, TOKEN_KEYS, TOKEN_KEY_NAME)
348-
token = stream.current()
349-
selectors = self.parse_selector(stream)
357+
token = stream.next()
358+
359+
if token.kind == TOKEN_DOT:
360+
selector = self.parse_shorthand_selector(stream)
350361
yield JSONPathChildSegment(
351-
env=self.env, token=token, selectors=selectors
362+
env=self.env, token=token, selectors=(selector,)
352363
)
353-
elif _token.kind == TOKEN_DDOT:
354-
token = stream.eat(TOKEN_DDOT)
355-
selectors = self.parse_selector(stream)
356-
if not selectors:
357-
raise JSONPathSyntaxError(
358-
"missing selector for recursive descent segment",
359-
token=stream.current(),
360-
)
364+
elif token.kind == TOKEN_DDOT:
365+
if stream.current().kind == TOKEN_LBRACKET:
366+
selectors = tuple(self.parse_bracketed_selection(stream))
367+
else:
368+
selectors = (self.parse_shorthand_selector(stream),)
369+
361370
yield JSONPathRecursiveDescentSegment(
362371
env=self.env, token=token, selectors=selectors
363372
)
364-
elif _token.kind == TOKEN_LBRACKET:
365-
selectors = self.parse_selector(stream)
366-
yield JSONPathChildSegment(
367-
env=self.env, token=_token, selectors=selectors
368-
)
369-
elif _token.kind in {TOKEN_NAME, TOKEN_WILD, TOKEN_KEYS, TOKEN_KEY_NAME}:
370-
# A non-standard "bare" path. One without a leading identifier (`$`,
371-
# `@`, `^` or `_`).
372-
token = stream.current()
373-
selectors = self.parse_selector(stream)
373+
elif token.kind == TOKEN_LBRACKET:
374+
stream.pos -= 1
374375
yield JSONPathChildSegment(
375-
env=self.env, token=token, selectors=selectors
376+
env=self.env,
377+
token=token,
378+
selectors=tuple(self.parse_bracketed_selection(stream)),
376379
)
380+
elif token.kind == TOKEN_EOF:
381+
break
377382
else:
383+
# An embedded query. Put the token back on the stream.
384+
stream.pos -= 1
378385
break
379386

380-
def parse_selector(self, stream: TokenStream) -> tuple[JSONPathSelector, ...]: # noqa: PLR0911
387+
def parse_shorthand_selector(self, stream: TokenStream) -> JSONPathSelector:
381388
token = stream.next()
382389

383390
if token.kind == TOKEN_NAME:
384-
return (
385-
NameSelector(
386-
env=self.env,
387-
token=token,
388-
name=token.value,
389-
),
391+
return NameSelector(
392+
env=self.env,
393+
token=token,
394+
name=token.value,
390395
)
391396

392397
if token.kind == TOKEN_KEY_NAME:
393-
return (
394-
KeySelector(
395-
env=self.env,
396-
token=token,
397-
key=token.value,
398-
),
398+
return KeySelector(
399+
env=self.env,
400+
token=token,
401+
key=token.value,
399402
)
400403

401404
if token.kind == TOKEN_WILD:
402-
return (
403-
WildcardSelector(
404-
env=self.env,
405-
token=token,
406-
),
405+
return WildcardSelector(
406+
env=self.env,
407+
token=token,
407408
)
408409

409410
if token.kind == TOKEN_KEYS:
410411
if stream.current().kind == TOKEN_NAME:
411-
return (
412-
KeySelector(
413-
env=self.env,
414-
token=token,
415-
key=self._decode_string_literal(stream.next()),
416-
),
417-
)
418-
419-
return (
420-
KeysSelector(
412+
return KeySelector(
421413
env=self.env,
422414
token=token,
423-
),
424-
)
415+
key=self._decode_string_literal(stream.next()),
416+
)
425417

426-
if token.kind == TOKEN_LBRACKET:
427-
stream.pos -= 1
428-
return tuple(self.parse_bracketed_selection(stream))
418+
return KeysSelector(
419+
env=self.env,
420+
token=token,
421+
)
429422

430-
stream.pos -= 1
431-
return ()
423+
raise JSONPathSyntaxError("expected a shorthand selector", token=token)
432424

433425
def parse_bracketed_selection(self, stream: TokenStream) -> List[JSONPathSelector]: # noqa: PLR0912, PLR0915
434-
"""Parse a comma separated list of JSONPath selectors."""
435426
segment_token = stream.eat(TOKEN_LBRACKET)
436427
selectors: List[JSONPathSelector] = []
437428

@@ -704,7 +695,7 @@ def parse_grouped_expression(self, stream: TokenStream) -> BaseExpression:
704695
return expr
705696

706697
def parse_absolute_query(self, stream: TokenStream) -> BaseExpression:
707-
root = stream.next()
698+
root = stream.next() # Could be TOKEN_ROOT or TOKEN_PSEUDO_ROOT
708699
return RootFilterQuery(
709700
JSONPath(
710701
env=self.env,

tests/membership_operators.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,30 @@
5959
],
6060
"result_paths": ["$[0]"],
6161
"tags": ["extra"]
62+
},
63+
{
64+
"name": "embedded query in list literal",
65+
"selector": "$[?(@.a in ['bar', 'baz'])]",
66+
"document": [{ "a": "foo" }, { "a": "bar" }],
67+
"result": [
68+
{
69+
"a": "bar"
70+
}
71+
],
72+
"result_paths": ["$[1]"],
73+
"tags": ["extra"]
74+
},
75+
{
76+
"name": "list literal contains embedded query",
77+
"selector": "$[?(['bar', 'baz'] contains @.a)]",
78+
"document": [{ "a": "foo" }, { "a": "bar" }],
79+
"result": [
80+
{
81+
"a": "bar"
82+
}
83+
],
84+
"result_paths": ["$[1]"],
85+
"tags": ["extra"]
6286
}
6387
]
6488
}

tests/test_strictness.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ def test_alternative_or(env: JSONPathEnvironment) -> None:
3838
assert env.findall(query, data) == [{"a": True, "b": False}, {"c": 99}]
3939

4040

41+
def test_alternative_null(env: JSONPathEnvironment) -> None:
42+
query = "$[[email protected]==Null]"
43+
data = [{"a": None, "d": "e"}, {"a": "c", "d": "f"}]
44+
assert env.findall(query, data) == [{"a": None, "d": "e"}]
45+
46+
47+
def test_none(env: JSONPathEnvironment) -> None:
48+
query = "$[[email protected]==None]"
49+
data = [{"a": None, "d": "e"}, {"a": "c", "d": "f"}]
50+
assert env.findall(query, data) == [{"a": None, "d": "e"}]
51+
52+
4153
def test_implicit_root_identifier(
4254
env: JSONPathEnvironment,
4355
) -> None:

tests/test_undefined.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import asyncio
2+
import json
3+
import operator
4+
5+
import pytest
6+
7+
from jsonpath import JSONPathEnvironment
8+
from jsonpath import JSONPathSyntaxError
9+
from jsonpath import NodeList
10+
11+
from ._cts_case import Case
12+
13+
14+
@pytest.fixture()
15+
def env() -> JSONPathEnvironment:
16+
return JSONPathEnvironment(strict=False)
17+
18+
19+
with open("tests/undefined.json", encoding="utf8") as fd:
20+
data = [Case(**case) for case in json.load(fd)["tests"]]
21+
22+
23+
@pytest.mark.parametrize("case", data, ids=operator.attrgetter("name"))
24+
def test_undefined_keyword(env: JSONPathEnvironment, case: Case) -> None:
25+
assert case.document is not None
26+
nodes = NodeList(env.finditer(case.selector, case.document))
27+
case.assert_nodes(nodes)
28+
29+
30+
@pytest.mark.parametrize("case", data, ids=operator.attrgetter("name"))
31+
def test_undefined_keyword_async(env: JSONPathEnvironment, case: Case) -> None:
32+
async def coro() -> NodeList:
33+
assert case.document is not None
34+
it = await env.finditer_async(case.selector, case.document)
35+
return NodeList([node async for node in it])
36+
37+
nodes = asyncio.run(coro())
38+
case.assert_nodes(nodes)
39+
40+
41+
@pytest.mark.parametrize("case", data, ids=operator.attrgetter("name"))
42+
def test_comparison_to_undefined_fails_in_strict_mode(case: Case) -> None:
43+
env = JSONPathEnvironment(strict=True)
44+
45+
with pytest.raises(JSONPathSyntaxError):
46+
env.compile(case.selector)

tests/undefined.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"tests": [
3+
{
4+
"name": "explicit comparison to undefined",
5+
"selector": "$[[email protected] == undefined]",
6+
"document": [
7+
{
8+
"a": "b",
9+
"d": "e"
10+
},
11+
{
12+
"b": "c",
13+
"d": "f"
14+
}
15+
],
16+
"result": [
17+
{
18+
"b": "c",
19+
"d": "f"
20+
}
21+
],
22+
"result_paths": ["$[1]"],
23+
"tags": ["extra"]
24+
},
25+
{
26+
"name": "explicit comparison to missing",
27+
"selector": "$[[email protected] == missing]",
28+
"document": [
29+
{
30+
"a": "b",
31+
"d": "e"
32+
},
33+
{
34+
"b": "c",
35+
"d": "f"
36+
}
37+
],
38+
"result": [
39+
{
40+
"b": "c",
41+
"d": "f"
42+
}
43+
],
44+
"result_paths": ["$[1]"],
45+
"tags": ["extra"]
46+
},
47+
{
48+
"name": "explicit undefined is on the left",
49+
"selector": "$[?undefined == @.a]",
50+
"document": [
51+
{
52+
"a": "b",
53+
"d": "e"
54+
},
55+
{
56+
"b": "c",
57+
"d": "f"
58+
}
59+
],
60+
"result": [
61+
{
62+
"b": "c",
63+
"d": "f"
64+
}
65+
],
66+
"result_paths": ["$[1]"],
67+
"tags": ["extra"]
68+
},
69+
{
70+
"name": "not equal to undefined",
71+
"selector": "$[[email protected] != undefined]",
72+
"document": [
73+
{
74+
"a": "b",
75+
"d": "e"
76+
},
77+
{
78+
"b": "c",
79+
"d": "f"
80+
}
81+
],
82+
"result": [
83+
{
84+
"a": "b",
85+
"d": "e"
86+
}
87+
],
88+
"result_paths": ["$[0]"],
89+
"tags": ["extra"]
90+
}
91+
]
92+
}

0 commit comments

Comments
 (0)