Skip to content

Commit 078280c

Browse files
committed
Change filter behavior when applied to JSON objects.
1 parent 20a8fd7 commit 078280c

10 files changed

Lines changed: 120 additions & 102 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Python JSONPath Change Log
22

3+
## Version 0.4.0
4+
5+
**IETF JSONPath Draft compliance**
6+
7+
- **Behavioral change.** When applied to a JSON object, filters now have an implicit preceding wildcard selector. This is now consistent with applying filters to arrays and adheres to the IETF JSONPath Internet Draft.
8+
39
## Version 0.3.0
410

511
**IETF JSONPath Draft compliance**

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ And this is a list of areas where we deviate from the [IETF JSONPath draft](http
290290
- The root token (default `$`) is optional.
291291
- Paths starting with a dot (`.`) are OK. `.thing` is the same as `$.thing`, as is `thing`, `$[thing]` and `$["thing"]`.
292292
- Nested filters are not supported.
293-
- We don't treat filter expressions without a comparison as existence test, but an "is truthy" test. See the "Existence of non-singular queries" example in the IETF JSONPath draft.
293+
- We don't treat filter expressions without a comparison as existence test, but an "is truthy" test.
294294

295295
And this is a list of features that are uncommon or unique to Python JSONPath.
296296

jsonpath/filter.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Filter expression nodes."""
22
from __future__ import annotations
33

4+
import json
45
import re
56
from abc import ABC
67
from abc import abstractmethod
@@ -262,25 +263,15 @@ def __eq__(self, other: object) -> bool:
262263
def evaluate(self, context: FilterContext) -> bool:
263264
if isinstance(self.left, Undefined) and isinstance(self.right, Undefined):
264265
return True
265-
266266
left = self.left.evaluate(context)
267267
right = self.right.evaluate(context)
268-
269-
if left is UNDEFINED and right is UNDEFINED:
270-
return False
271-
272268
return context.env.compare(left, self.operator, right)
273269

274270
async def evaluate_async(self, context: FilterContext) -> bool:
275271
if isinstance(self.left, Undefined) and isinstance(self.right, Undefined):
276272
return True
277-
278273
left = await self.left.evaluate_async(context)
279274
right = await self.right.evaluate_async(context)
280-
281-
if left is UNDEFINED and right is UNDEFINED:
282-
return False
283-
284275
return context.env.compare(left, self.operator, right)
285276

286277

@@ -328,7 +319,11 @@ def evaluate(self, context: FilterContext) -> object:
328319
return UNDEFINED
329320
return context.current
330321

331-
matches = self.path.findall(context.current)
322+
try:
323+
matches = self.path.findall(context.current)
324+
except json.JSONDecodeError:
325+
return UNDEFINED
326+
332327
if not matches:
333328
return UNDEFINED
334329
if len(matches) == 1:
@@ -341,7 +336,11 @@ async def evaluate_async(self, context: FilterContext) -> object:
341336
return UNDEFINED
342337
return context.current
343338

344-
matches = await self.path.findall_async(context.current)
339+
try:
340+
matches = await self.path.findall_async(context.current)
341+
except json.JSONDecodeError:
342+
return UNDEFINED
343+
345344
if not matches:
346345
return UNDEFINED
347346
if len(matches) == 1:

jsonpath/path.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def findall(
7676
an incompatible way.
7777
"""
7878
if isinstance(data, str):
79+
# TODO: catch JSONDecodeError?
7980
_data = json.loads(data)
8081
elif isinstance(data, TextIO):
8182
_data = json.loads(data.read())
@@ -140,6 +141,7 @@ async def findall_async(
140141
) -> List[object]:
141142
"""An async version of `findall()`."""
142143
if isinstance(data, str):
144+
# TODO: catch JSONDecodeError
143145
_data = json.loads(data)
144146
elif isinstance(data, TextIO):
145147
_data = json.loads(data.read())

jsonpath/selectors.py

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -447,22 +447,34 @@ def __init__(
447447
def __str__(self) -> str:
448448
return f"[?({self.expression})]"
449449

450-
def resolve(self, matches: Iterable[JSONPathMatch]) -> Iterable[JSONPathMatch]:
450+
def resolve( # noqa: PLR0912
451+
self, matches: Iterable[JSONPathMatch]
452+
) -> Iterable[JSONPathMatch]:
451453
for match in matches:
452454
if isinstance(match.obj, Mapping):
453-
context = FilterContext(
454-
env=self.env,
455-
current=match.obj,
456-
root=match.root,
457-
extra_context=match.filter_context(),
458-
)
459-
try:
460-
if self.expression.evaluate(context):
461-
yield match
462-
except JSONPathTypeError as err:
463-
if not err.token:
464-
err.token = self.token
465-
raise
455+
for key, val in match.obj.items():
456+
context = FilterContext(
457+
env=self.env,
458+
current=val,
459+
root=match.root,
460+
extra_context=match.filter_context(),
461+
)
462+
try:
463+
if self.expression.evaluate(context):
464+
_match = JSONPathMatch(
465+
filter_context=match.filter_context(),
466+
obj=val,
467+
parent=match,
468+
parts=match.parts + (key,),
469+
path=match.path + f"['{key}']",
470+
root=match.root,
471+
)
472+
match.add_child(_match)
473+
yield _match
474+
except JSONPathTypeError as err:
475+
if not err.token:
476+
err.token = self.token
477+
raise
466478

467479
elif isinstance(match.obj, Sequence) and not isinstance(match.obj, str):
468480
for i, obj in enumerate(match.obj):
@@ -489,25 +501,37 @@ def resolve(self, matches: Iterable[JSONPathMatch]) -> Iterable[JSONPathMatch]:
489501
err.token = self.token
490502
raise
491503

492-
async def resolve_async(
504+
async def resolve_async( # noqa: PLR0912
493505
self, matches: AsyncIterable[JSONPathMatch]
494506
) -> AsyncIterable[JSONPathMatch]:
495507
async for match in matches:
496508
if isinstance(match.obj, Mapping):
497-
context = FilterContext(
498-
env=self.env,
499-
current=match.obj,
500-
root=match.root,
501-
extra_context=match.filter_context(),
502-
)
509+
for key, val in match.obj.items():
510+
context = FilterContext(
511+
env=self.env,
512+
current=val,
513+
root=match.root,
514+
extra_context=match.filter_context(),
515+
)
516+
517+
try:
518+
result = await self.expression.evaluate_async(context)
519+
except JSONPathTypeError as err:
520+
if not err.token:
521+
err.token = self.token
522+
raise
503523

504-
try:
505-
if await self.expression.evaluate_async(context):
506-
yield match
507-
except JSONPathTypeError as err:
508-
if not err.token:
509-
err.token = self.token
510-
raise
524+
if result:
525+
_match = JSONPathMatch(
526+
filter_context=match.filter_context(),
527+
obj=val,
528+
parent=match,
529+
parts=match.parts + (key,),
530+
path=match.path + f"['{key}']",
531+
root=match.root,
532+
)
533+
match.add_child(_match)
534+
yield _match
511535

512536
elif isinstance(match.obj, Sequence) and not isinstance(match.obj, str):
513537
for i, obj in enumerate(match.obj):
@@ -517,22 +541,24 @@ async def resolve_async(
517541
root=match.root,
518542
extra_context=match.filter_context(),
519543
)
544+
520545
try:
521-
if await self.expression.evaluate_async(context):
522-
_match = JSONPathMatch(
523-
filter_context=match.filter_context(),
524-
obj=obj,
525-
parent=match,
526-
parts=match.parts + (i,),
527-
path=f"{match.path}[{i}]",
528-
root=match.root,
529-
)
530-
match.add_child(_match)
531-
yield _match
546+
result = await self.expression.evaluate_async(context)
532547
except JSONPathTypeError as err:
533548
if not err.token:
534549
err.token = self.token
535550
raise
551+
if result:
552+
_match = JSONPathMatch(
553+
filter_context=match.filter_context(),
554+
obj=obj,
555+
parent=match,
556+
parts=match.parts + (i,),
557+
path=f"{match.path}[{i}]",
558+
root=match.root,
559+
)
560+
match.add_child(_match)
561+
yield _match
536562

537563

538564
class FilterContext:

tests/compliance.py

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,9 @@ def cases() -> List[Case]:
4545

4646

4747
def valid_cases() -> List[Case]:
48-
def mangle_filter(case: Case) -> Case:
49-
# XXX: Insert wildcard in front of root :(
50-
if (
51-
case.name.startswith("filter")
52-
and case.selector.startswith("$[?")
53-
and isinstance(case.document, list)
54-
):
55-
case.selector = case.selector.replace("$[?", "$.*[?")
56-
return case
57-
5848
# TODO: skipping filter functions. Not supported.
5949
return [
60-
mangle_filter(case)
50+
case
6151
for case in cases()
6252
if not case.invalid_selector and "function" not in case.name
6353
]

tests/consensus.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,6 @@ class Query:
4747
"bracket_notation_with_number_on_object": "Bad consensus",
4848
"bracket_notation_with_number_on_string": "Invalid document",
4949
"dot_notation_with_number_-1": "Unexpected token",
50-
"filter_expression_with_equals_on_object_with_key_matching_query": "Bad consensus",
51-
(
52-
"filter_expression_with_value_after_"
53-
"dot_notation_with_wildcard_on_array_of_objects"
54-
): "Bad consensus",
5550
}
5651

5752

tests/test_env.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_find_all_with_extra_filter_context(env: JSONPathEnvironment) -> None:
2828
"""Test that we can pass extra filter context to findall."""
2929
rv = env.findall(
3030
"$[?(@.some == #.other)]",
31-
{"some": 1, "thing": 2},
31+
{"foo": {"some": 1, "thing": 2}},
3232
filter_context={"other": 1},
3333
)
3434
assert rv == [{"some": 1, "thing": 2}]
@@ -50,7 +50,7 @@ def test_find_iter_with_extra_filter_context(env: JSONPathEnvironment) -> None:
5050
"""Test that we can pass extra filter context to finditer."""
5151
matches = env.finditer(
5252
"$[?(@.some == #.other)]",
53-
{"some": 1, "thing": 2},
53+
{"foo": {"some": 1, "thing": 2}},
5454
filter_context={"other": 1},
5555
)
5656
assert [match.obj for match in matches] == [{"some": 1, "thing": 2}]
@@ -80,7 +80,7 @@ def test_find_all_async_with_extra_filter_context(env: JSONPathEnvironment) -> N
8080
async def coro() -> List[object]:
8181
return await env.findall_async(
8282
"$[?(@.some == #.other)]",
83-
{"some": 1, "thing": 2},
83+
{"foo": {"some": 1, "thing": 2}},
8484
filter_context={"other": 1},
8585
)
8686

@@ -113,7 +113,7 @@ def test_find_iter_async_with_extra_filter_context(env: JSONPathEnvironment) ->
113113
async def coro() -> List[object]:
114114
matches = await env.finditer_async(
115115
"$[?(@.some == #.other)]",
116-
{"some": 1, "thing": 2},
116+
{"foo": {"some": 1, "thing": 2}},
117117
filter_context={"other": 1},
118118
)
119119
return [match.obj async for match in matches]

tests/test_ietf.py

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,15 @@ class Case:
157157
data=FILTER_SELECTOR_DATA,
158158
want=[{"b": "j"}, {"b": "k"}, {"b": {}}, {"b": "kilo"}],
159159
),
160-
# Case(
161-
# description="filter selector - Existence of non-singular queries",
162-
# path="$[?(@.*)]",
163-
# data=FILTER_SELECTOR_DATA,
164-
# want=[
165-
# [3, 5, 1, 2, 4, 6, {"b": "j"}, {"b": "k"}, {"b": {}}, {"b": "kilo"}],
166-
# {"p": 1, "q": 2, "r": 3, "s": 5, "t": {"u": 6}},
167-
# ],
168-
# ),
160+
Case(
161+
description="filter selector - Existence of non-singular queries",
162+
path="$[?(@.*)]",
163+
data=FILTER_SELECTOR_DATA,
164+
want=[
165+
[3, 5, 1, 2, 4, 6, {"b": "j"}, {"b": "k"}, {"b": {}}, {"b": "kilo"}],
166+
{"p": 1, "q": 2, "r": 3, "s": 5, "t": {"u": 6}},
167+
],
168+
),
169169
# Case(
170170
# description="filter selector - Nested filters",
171171
# path="$[?(@[?(@.b)])] ",
@@ -190,24 +190,24 @@ class Case:
190190
# data=FILTER_SELECTOR_DATA,
191191
# want=[{"b": "j"}, {"b": "k"}, {"b": "kilo"}],
192192
# ),
193-
# Case(
194-
# description="filter selector - Object value logical AND",
195-
# path="$.o[?(@>1 && @<4)]",
196-
# data=FILTER_SELECTOR_DATA,
197-
# want=[2, 3],
198-
# ),
199-
# Case(
200-
# description="filter selector - Object value logical OR",
201-
# path="$.o[?(@.u || @.x)]",
202-
# data=FILTER_SELECTOR_DATA,
203-
# want=[{"u": 6}],
204-
# ),
205-
# Case(
206-
# description="filter selector - Comparison of queries with no values",
207-
# path="$.a[?(@.b == $.x)]",
208-
# data=FILTER_SELECTOR_DATA,
209-
# want=[3, 5, 1, 2, 4, 6],
210-
# ),
193+
Case(
194+
description="filter selector - Object value logical AND",
195+
path="$.o[?(@>1 && @<4)]",
196+
data=FILTER_SELECTOR_DATA,
197+
want=[2, 3],
198+
),
199+
Case(
200+
description="filter selector - Object value logical OR",
201+
path="$.o[?(@.u || @.x)]",
202+
data=FILTER_SELECTOR_DATA,
203+
want=[{"u": 6}],
204+
),
205+
Case(
206+
description="filter selector - Comparison of queries with no values",
207+
path="$.a[?(@.b == $.x)]",
208+
data=FILTER_SELECTOR_DATA,
209+
want=[3, 5, 1, 2, 4, 6],
210+
),
211211
Case(
212212
description=(
213213
"filter selector - Comparisons of primitive and of structured values"

tests/test_re.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,19 @@ class Case:
2424
Case(
2525
description="match a regex",
2626
path="$.some[?(@.thing =~ /fo[a-z]/)]",
27-
data={"some": {"thing": "foo"}},
27+
data={"some": [{"thing": "foo"}]},
2828
want=[{"thing": "foo"}],
2929
),
3030
Case(
3131
description="regex with no match",
3232
path="$.some[?(@.thing =~ /fo[a-z]/)]",
33-
data={"some": {"thing": "foO"}},
33+
data={"some": [{"thing": "foO"}]},
3434
want=[],
3535
),
3636
Case(
3737
description="case insensitive match",
3838
path="$.some[?(@.thing =~ /fo[a-z]/i)]",
39-
data={"some": {"thing": "foO"}},
39+
data={"some": [{"thing": "foO"}]},
4040
want=[{"thing": "foO"}],
4141
),
4242
]

0 commit comments

Comments
 (0)