Skip to content

Commit 7a55c02

Browse files
committed
Test "extra" JSONPath syntax
1 parent b4cb9c2 commit 7a55c02

File tree

3 files changed

+305
-3
lines changed

3 files changed

+305
-3
lines changed

jsonpath/parse.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ def parse_path(self, stream: TokenStream) -> Iterable[JSONPathSegment]:
352352
else:
353353
break
354354

355-
def parse_selector(self, stream: TokenStream) -> tuple[JSONPathSelector, ...]:
355+
def parse_selector(self, stream: TokenStream) -> tuple[JSONPathSelector, ...]: # noqa: PLR0911
356356
token = stream.next()
357357

358358
if token.kind == TOKEN_NAME:
@@ -382,6 +382,15 @@ def parse_selector(self, stream: TokenStream) -> tuple[JSONPathSelector, ...]:
382382
)
383383

384384
if token.kind == TOKEN_KEYS:
385+
if stream.current().kind == TOKEN_NAME:
386+
return (
387+
KeySelector(
388+
env=self.env,
389+
token=token,
390+
key=self._decode_string_literal(stream.next()),
391+
),
392+
)
393+
385394
return (
386395
KeysSelector(
387396
env=self.env,
@@ -442,8 +451,21 @@ def parse_bracketed_selection(self, stream: TokenStream) -> List[JSONPathSelecto
442451
selectors.append(WildSelector(env=self.env, token=token))
443452
stream.next()
444453
elif token.kind == TOKEN_KEYS:
445-
selectors.append(KeysSelector(env=self.env, token=token))
446-
stream.next()
454+
stream.eat(TOKEN_KEYS)
455+
if stream.current().kind in (
456+
TOKEN_DOUBLE_QUOTE_STRING,
457+
TOKEN_SINGLE_QUOTE_STRING,
458+
):
459+
selectors.append(
460+
KeySelector(
461+
env=self.env,
462+
token=token,
463+
key=self._decode_string_literal(stream.next()),
464+
)
465+
)
466+
else:
467+
selectors.append(KeysSelector(env=self.env, token=token))
468+
447469
elif token.kind == TOKEN_FILTER:
448470
selectors.append(self.parse_filter_selector(stream))
449471
elif token.kind == TOKEN_KEYS_FILTER:

tests/test_find_extra.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import asyncio
2+
import dataclasses
3+
import operator
4+
from typing import Any
5+
from typing import List
6+
from typing import Mapping
7+
from typing import Sequence
8+
from typing import Union
9+
10+
import pytest
11+
12+
from jsonpath import JSONPathEnvironment
13+
14+
15+
@dataclasses.dataclass
16+
class Case:
17+
description: str
18+
path: str
19+
data: Union[Sequence[Any], Mapping[str, Any]]
20+
want: Union[Sequence[Any], Mapping[str, Any]]
21+
22+
23+
TEST_CASES = [
24+
Case(
25+
description="keys from an object",
26+
path="$.some[~]",
27+
data={"some": {"other": "foo", "thing": "bar"}},
28+
want=["other", "thing"],
29+
),
30+
Case(
31+
description="shorthand keys from an object",
32+
path="$.some.~",
33+
data={"some": {"other": "foo", "thing": "bar"}},
34+
want=["other", "thing"],
35+
),
36+
Case(
37+
description="keys from an array",
38+
path="$.some[~]",
39+
data={"some": ["other", "thing"]},
40+
want=[],
41+
),
42+
Case(
43+
description="shorthand keys from an array",
44+
path="$.some.~",
45+
data={"some": ["other", "thing"]},
46+
want=[],
47+
),
48+
Case(
49+
description="recurse object keys",
50+
path="$..~",
51+
data={"some": {"thing": "else", "foo": {"bar": "baz"}}},
52+
want=["some", "thing", "foo", "bar"],
53+
),
54+
Case(
55+
description="current key of an object",
56+
path="$.some[?match(#, '^b.*')]",
57+
data={"some": {"foo": "a", "bar": "b", "baz": "c", "qux": "d"}},
58+
want=["b", "c"],
59+
),
60+
Case(
61+
description="current key of an array",
62+
path="$.some[?# > 1]",
63+
data={"some": ["other", "thing", "foo", "bar"]},
64+
want=["foo", "bar"],
65+
),
66+
Case(
67+
description="filter keys from an object",
68+
path="$.some[~?match(@, '^b.*')]",
69+
data={"some": {"other": "foo", "thing": "bar"}},
70+
want=["thing"],
71+
),
72+
Case(
73+
description="singular key from an object",
74+
path="$.some[~'other']",
75+
data={"some": {"other": "foo", "thing": "bar"}},
76+
want=["other"],
77+
),
78+
Case(
79+
description="singular key from an object, does not exist",
80+
path="$.some[~'else']",
81+
data={"some": {"other": "foo", "thing": "bar"}},
82+
want=[],
83+
),
84+
Case(
85+
description="singular key from an array",
86+
path="$.some[~'1']",
87+
data={"some": ["foo", "bar"]},
88+
want=[],
89+
),
90+
Case(
91+
description="singular key from an object, shorthand",
92+
path="$.some.~other",
93+
data={"some": {"other": "foo", "thing": "bar"}},
94+
want=["other"],
95+
),
96+
Case(
97+
description="recursive key from an object",
98+
path="$.some..[~'other']",
99+
data={"some": {"other": "foo", "thing": "bar", "else": {"other": "baz"}}},
100+
want=["other", "other"],
101+
),
102+
Case(
103+
description="recursive key from an object, shorthand",
104+
path="$.some..~other",
105+
data={"some": {"other": "foo", "thing": "bar", "else": {"other": "baz"}}},
106+
want=["other", "other"],
107+
),
108+
Case(
109+
description="recursive key from an object, does not exist",
110+
path="$.some..[~'nosuchthing']",
111+
data={"some": {"other": "foo", "thing": "bar", "else": {"other": "baz"}}},
112+
want=[],
113+
),
114+
]
115+
116+
117+
@pytest.fixture()
118+
def env() -> JSONPathEnvironment:
119+
return JSONPathEnvironment()
120+
121+
122+
@pytest.mark.parametrize("case", TEST_CASES, ids=operator.attrgetter("description"))
123+
def test_find_extra(env: JSONPathEnvironment, case: Case) -> None:
124+
path = env.compile(case.path)
125+
assert path.findall(case.data) == case.want
126+
127+
128+
@pytest.mark.parametrize("case", TEST_CASES, ids=operator.attrgetter("description"))
129+
def test_find_extra_async(env: JSONPathEnvironment, case: Case) -> None:
130+
path = env.compile(case.path)
131+
132+
async def coro() -> List[object]:
133+
return await path.findall_async(case.data)
134+
135+
assert asyncio.run(coro()) == case.want

tests/test_find_extra_examples.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import asyncio
2+
import dataclasses
3+
import operator
4+
from typing import Any
5+
from typing import List
6+
from typing import Mapping
7+
from typing import Sequence
8+
from typing import Union
9+
10+
import pytest
11+
12+
from jsonpath import JSONPathEnvironment
13+
14+
15+
@dataclasses.dataclass
16+
class Case:
17+
description: str
18+
path: str
19+
data: Union[Sequence[Any], Mapping[str, Any]]
20+
want: Union[Sequence[Any], Mapping[str, Any]]
21+
want_paths: List[str]
22+
23+
24+
TEST_CASES = [
25+
Case(
26+
description="key selector, key of nested object",
27+
path="$.a[0].~c",
28+
data={
29+
"a": [{"b": "x", "c": "z"}, {"b": "y"}],
30+
},
31+
want=["c"],
32+
want_paths=["$['a'][0][~'c']"],
33+
),
34+
Case(
35+
description="key selector, key does not exist",
36+
path="$.a[1].~c",
37+
data={
38+
"a": [{"b": "x", "c": "z"}, {"b": "y"}],
39+
},
40+
want=[],
41+
want_paths=[],
42+
),
43+
Case(
44+
description="key selector, descendant, single quoted key",
45+
path="$..[~'b']",
46+
data={
47+
"a": [{"b": "x", "c": "z"}, {"b": "y"}],
48+
},
49+
want=["b", "b"],
50+
want_paths=["$['a'][0][~'b']", "$['a'][1][~'b']"],
51+
),
52+
Case(
53+
description="key selector, descendant, double quoted key",
54+
path='$..[~"b"]',
55+
data={
56+
"a": [{"b": "x", "c": "z"}, {"b": "y"}],
57+
},
58+
want=["b", "b"],
59+
want_paths=["$['a'][0][~'b']", "$['a'][1][~'b']"],
60+
),
61+
Case(
62+
description="keys selector, object key",
63+
path="$.a[0].~",
64+
data={
65+
"a": [{"b": "x", "c": "z"}, {"b": "y"}],
66+
},
67+
want=["b", "c"],
68+
want_paths=["$['a'][0][~'b']", "$['a'][0][~'c']"],
69+
),
70+
Case(
71+
description="keys selector, array key",
72+
path="$.a.~",
73+
data={
74+
"a": [{"b": "x", "c": "z"}, {"b": "y"}],
75+
},
76+
want=[],
77+
want_paths=[],
78+
),
79+
Case(
80+
description="keys selector, descendant keys",
81+
path="$..[~]",
82+
data={
83+
"a": [{"b": "x", "c": "z"}, {"b": "y"}],
84+
},
85+
want=["a", "b", "c", "b"],
86+
want_paths=["$[~'a']", "$['a'][0][~'b']", "$['a'][0][~'c']", "$['a'][1][~'b']"],
87+
),
88+
Case(
89+
description="keys filter selector, conditionally select object keys",
90+
path="$.*[~?length(@) > 2]",
91+
data=[{"a": [1, 2, 3], "b": [4, 5]}, {"c": {"x": [1, 2]}}, {"d": [1, 2, 3]}],
92+
want=["a", "d"],
93+
want_paths=["$[0][~'a']", "$[2][~'d']"],
94+
),
95+
Case(
96+
description="keys filter selector, existence test",
97+
path="$.*[~?@.x]",
98+
data=[{"a": [1, 2, 3], "b": [4, 5]}, {"c": {"x": [1, 2]}}, {"d": [1, 2, 3]}],
99+
want=["c"],
100+
want_paths=["$[1][~'c']"],
101+
),
102+
Case(
103+
description="keys filter selector, keys from an array",
104+
path="$[~?(true == true)]",
105+
data=[{"a": [1, 2, 3], "b": [4, 5]}, {"c": {"x": [1, 2]}}, {"d": [1, 2, 3]}],
106+
want=[],
107+
want_paths=[],
108+
),
109+
Case(
110+
description="current key identifier, match on object names",
111+
path="$[?match(#, '^ab.*') && length(@) > 0 ]",
112+
data={"abc": [1, 2, 3], "def": [4, 5], "abx": [6], "aby": []},
113+
want=[[1, 2, 3], [6]],
114+
want_paths=["$['abc']", "$['abx']"],
115+
),
116+
Case(
117+
description="current key identifier, compare current array index",
118+
path="$.abc[?(# >= 1)]",
119+
data={"abc": [1, 2, 3], "def": [4, 5], "abx": [6], "aby": []},
120+
want=[2, 3],
121+
want_paths=["$['abc'][1]", "$['abc'][2]"],
122+
),
123+
]
124+
125+
126+
@pytest.fixture()
127+
def env() -> JSONPathEnvironment:
128+
return JSONPathEnvironment()
129+
130+
131+
@pytest.mark.parametrize("case", TEST_CASES, ids=operator.attrgetter("description"))
132+
def test_find_extra_examples(env: JSONPathEnvironment, case: Case) -> None:
133+
path = env.compile(case.path)
134+
assert path.findall(case.data) == case.want
135+
assert list(path.query(case.data).locations()) == case.want_paths
136+
137+
138+
@pytest.mark.parametrize("case", TEST_CASES, ids=operator.attrgetter("description"))
139+
def test_find_extra_async(env: JSONPathEnvironment, case: Case) -> None:
140+
path = env.compile(case.path)
141+
142+
async def coro() -> List[object]:
143+
return await path.findall_async(case.data)
144+
145+
assert asyncio.run(coro()) == case.want

0 commit comments

Comments
 (0)