Skip to content

Commit 7665105

Browse files
authored
Merge pull request #54 from jg-rp/fix-precedence
Fix operator precedence and selector list order
2 parents 5b1514b + 77a0002 commit 7665105

File tree

8 files changed

+33
-20
lines changed

8 files changed

+33
-20
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Version 1.1.0 (unreleased)
44

5+
**Fixes**
6+
7+
- Fixed logical operator precedence in JSONPath filter expressions. Previously, logical _or_ (`||`) logical _and_ (`&&`) had equal precedence. Now `&&` binds more tightly than `||`, as per RFC 9535.
8+
- Fixed bracketed selector list evaluation order. Previously we were iterating nodes for every list item, now we exhaust all matches for the first item before moving on to the next item.
9+
510
**Features**
611

712
- Added the "query API", a fluent, chainable API for manipulating `JSONPathMatch` iterators.

docs/syntax.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ And this is a list of areas where we deviate from [RFC 9535](https://datatracker
209209
- We don't require the recursive descent segment to have a selector. `$..` is equivalent to `$..*`.
210210
- We support explicit comparisons to `undefined` as well as implicit existence tests.
211211
- Float literals without a fractional digit are OK. `1.` is equivalent to `1.0`.
212+
- We treat literals (such as `true` and `false`) as valid "basic" expressions. So `$[?true || false]` does not raise a syntax error, which is and invalid query according to RFC 9535.
212213

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

jsonpath/filter.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""Filter expression nodes."""
2+
23
from __future__ import annotations
34

45
import copy

jsonpath/parse.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""The default JSONPath parser."""
2+
23
from __future__ import annotations
34

45
import json
@@ -142,14 +143,15 @@ class Parser:
142143
"""A JSONPath parser bound to a JSONPathEnvironment."""
143144

144145
PRECEDENCE_LOWEST = 1
145-
PRECEDENCE_LOGICALRIGHT = 3
146-
PRECEDENCE_LOGICAL = 4
146+
PRECEDENCE_LOGICALRIGHT = 2
147+
PRECEDENCE_LOGICAL_OR = 3
148+
PRECEDENCE_LOGICAL_AND = 4
147149
PRECEDENCE_RELATIONAL = 5
148150
PRECEDENCE_MEMBERSHIP = 6
149151
PRECEDENCE_PREFIX = 7
150152

151153
PRECEDENCES = {
152-
TOKEN_AND: PRECEDENCE_LOGICAL,
154+
TOKEN_AND: PRECEDENCE_LOGICAL_AND,
153155
TOKEN_CONTAINS: PRECEDENCE_MEMBERSHIP,
154156
TOKEN_EQ: PRECEDENCE_RELATIONAL,
155157
TOKEN_GE: PRECEDENCE_RELATIONAL,
@@ -160,7 +162,7 @@ class Parser:
160162
TOKEN_LT: PRECEDENCE_RELATIONAL,
161163
TOKEN_NE: PRECEDENCE_RELATIONAL,
162164
TOKEN_NOT: PRECEDENCE_PREFIX,
163-
TOKEN_OR: PRECEDENCE_LOGICAL,
165+
TOKEN_OR: PRECEDENCE_LOGICAL_OR,
164166
TOKEN_RE: PRECEDENCE_RELATIONAL,
165167
TOKEN_RPAREN: PRECEDENCE_LOWEST,
166168
}
@@ -563,9 +565,9 @@ def parse_filter_context_path(self, stream: TokenStream) -> FilterExpression:
563565

564566
def parse_regex(self, stream: TokenStream) -> FilterExpression:
565567
pattern = stream.current.value
568+
flags = 0
566569
if stream.peek.kind == TOKEN_RE_FLAGS:
567570
stream.next_token()
568-
flags = 0
569571
for flag in set(stream.current.value):
570572
flags |= self.RE_FLAG_MAP[flag]
571573
return RegexLiteral(value=re.compile(pattern, flags))

jsonpath/selectors.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -541,17 +541,17 @@ def __hash__(self) -> int:
541541
return hash((self.items, self.token))
542542

543543
def resolve(self, matches: Iterable[JSONPathMatch]) -> Iterable[JSONPathMatch]:
544-
_matches = list(matches)
545-
for item in self.items:
546-
yield from item.resolve(_matches)
544+
for match_ in matches:
545+
for item in self.items:
546+
yield from item.resolve([match_])
547547

548548
async def resolve_async(
549549
self, matches: AsyncIterable[JSONPathMatch]
550550
) -> AsyncIterable[JSONPathMatch]:
551-
_matches = [m async for m in matches]
552-
for item in self.items:
553-
async for match in item.resolve_async(_alist(_matches)):
554-
yield match
551+
async for match_ in matches:
552+
for item in self.items:
553+
async for m in item.resolve_async(_alist([match_])):
554+
yield m
555555

556556

557557
class Filter(JSONPathSelector):

tests/test_compliance.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import asyncio
77
import json
88
import operator
9-
import unittest
109
from dataclasses import dataclass
1110
from typing import Any
1211
from typing import List
@@ -26,6 +25,7 @@ class Case:
2625
selector: str
2726
document: Union[Mapping[str, Any], Sequence[Any], None] = None
2827
result: Any = None
28+
results: Optional[List[Any]] = None
2929
invalid_selector: Optional[bool] = None
3030

3131

@@ -69,10 +69,12 @@ def test_compliance(case: Case) -> None:
6969
pytest.skip(reason=SKIP[case.name])
7070

7171
assert case.document is not None
72-
73-
test_case = unittest.TestCase()
7472
rv = jsonpath.findall(case.selector, case.document)
75-
test_case.assertCountEqual(rv, case.result) # noqa: PT009
73+
74+
if case.results is not None:
75+
assert rv in case.results
76+
else:
77+
assert rv == case.result
7678

7779

7880
@pytest.mark.parametrize("case", valid_cases(), ids=operator.attrgetter("name"))
@@ -84,8 +86,10 @@ async def coro() -> List[object]:
8486
assert case.document is not None
8587
return await jsonpath.findall_async(case.selector, case.document)
8688

87-
test_case = unittest.TestCase()
88-
test_case.assertCountEqual(asyncio.run(coro()), case.result) # noqa: PT009
89+
if case.results is not None:
90+
assert asyncio.run(coro()) in case.results
91+
else:
92+
assert asyncio.run(coro()) == case.result
8993

9094

9195
@pytest.mark.parametrize("case", invalid_cases(), ids=operator.attrgetter("name"))

tests/test_ietf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ class Case:
279279
description=("descendant segment - Multiple segments"),
280280
path="$.a..[0, 1]",
281281
data={"o": {"j": 1, "k": 2}, "a": [5, 3, [{"j": 4}, {"k": 6}]]},
282-
want=[5, {"j": 4}, 3, {"k": 6}],
282+
want=[5, 3, {"j": 4}, {"k": 6}],
283283
),
284284
Case(
285285
description=("null semantics - Object value"),

0 commit comments

Comments
 (0)