Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix operator precedence and selector list order #54

Merged
merged 1 commit into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Version 1.1.0 (unreleased)

**Fixes**

- 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.
- 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.

**Features**

- Added the "query API", a fluent, chainable API for manipulating `JSONPathMatch` iterators.
Expand Down
1 change: 1 addition & 0 deletions docs/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ And this is a list of areas where we deviate from [RFC 9535](https://datatracker
- We don't require the recursive descent segment to have a selector. `$..` is equivalent to `$..*`.
- We support explicit comparisons to `undefined` as well as implicit existence tests.
- Float literals without a fractional digit are OK. `1.` is equivalent to `1.0`.
- 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.

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

Expand Down
1 change: 1 addition & 0 deletions jsonpath/filter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Filter expression nodes."""

from __future__ import annotations

import copy
Expand Down
12 changes: 7 additions & 5 deletions jsonpath/parse.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""The default JSONPath parser."""

from __future__ import annotations

import json
Expand Down Expand Up @@ -142,14 +143,15 @@ class Parser:
"""A JSONPath parser bound to a JSONPathEnvironment."""

PRECEDENCE_LOWEST = 1
PRECEDENCE_LOGICALRIGHT = 3
PRECEDENCE_LOGICAL = 4
PRECEDENCE_LOGICALRIGHT = 2
PRECEDENCE_LOGICAL_OR = 3
PRECEDENCE_LOGICAL_AND = 4
PRECEDENCE_RELATIONAL = 5
PRECEDENCE_MEMBERSHIP = 6
PRECEDENCE_PREFIX = 7

PRECEDENCES = {
TOKEN_AND: PRECEDENCE_LOGICAL,
TOKEN_AND: PRECEDENCE_LOGICAL_AND,
TOKEN_CONTAINS: PRECEDENCE_MEMBERSHIP,
TOKEN_EQ: PRECEDENCE_RELATIONAL,
TOKEN_GE: PRECEDENCE_RELATIONAL,
Expand All @@ -160,7 +162,7 @@ class Parser:
TOKEN_LT: PRECEDENCE_RELATIONAL,
TOKEN_NE: PRECEDENCE_RELATIONAL,
TOKEN_NOT: PRECEDENCE_PREFIX,
TOKEN_OR: PRECEDENCE_LOGICAL,
TOKEN_OR: PRECEDENCE_LOGICAL_OR,
TOKEN_RE: PRECEDENCE_RELATIONAL,
TOKEN_RPAREN: PRECEDENCE_LOWEST,
}
Expand Down Expand Up @@ -563,9 +565,9 @@ def parse_filter_context_path(self, stream: TokenStream) -> FilterExpression:

def parse_regex(self, stream: TokenStream) -> FilterExpression:
pattern = stream.current.value
flags = 0
if stream.peek.kind == TOKEN_RE_FLAGS:
stream.next_token()
flags = 0
for flag in set(stream.current.value):
flags |= self.RE_FLAG_MAP[flag]
return RegexLiteral(value=re.compile(pattern, flags))
Expand Down
14 changes: 7 additions & 7 deletions jsonpath/selectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,17 +541,17 @@ def __hash__(self) -> int:
return hash((self.items, self.token))

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

async def resolve_async(
self, matches: AsyncIterable[JSONPathMatch]
) -> AsyncIterable[JSONPathMatch]:
_matches = [m async for m in matches]
for item in self.items:
async for match in item.resolve_async(_alist(_matches)):
yield match
async for match_ in matches:
for item in self.items:
async for m in item.resolve_async(_alist([match_])):
yield m


class Filter(JSONPathSelector):
Expand Down
16 changes: 10 additions & 6 deletions tests/test_compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import asyncio
import json
import operator
import unittest
from dataclasses import dataclass
from typing import Any
from typing import List
Expand All @@ -26,6 +25,7 @@ class Case:
selector: str
document: Union[Mapping[str, Any], Sequence[Any], None] = None
result: Any = None
results: Optional[List[Any]] = None
invalid_selector: Optional[bool] = None


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

assert case.document is not None

test_case = unittest.TestCase()
rv = jsonpath.findall(case.selector, case.document)
test_case.assertCountEqual(rv, case.result) # noqa: PT009

if case.results is not None:
assert rv in case.results
else:
assert rv == case.result


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

test_case = unittest.TestCase()
test_case.assertCountEqual(asyncio.run(coro()), case.result) # noqa: PT009
if case.results is not None:
assert asyncio.run(coro()) in case.results
else:
assert asyncio.run(coro()) == case.result


@pytest.mark.parametrize("case", invalid_cases(), ids=operator.attrgetter("name"))
Expand Down
2 changes: 1 addition & 1 deletion tests/test_ietf.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ class Case:
description=("descendant segment - Multiple segments"),
path="$.a..[0, 1]",
data={"o": {"j": 1, "k": 2}, "a": [5, 3, [{"j": 4}, {"k": 6}]]},
want=[5, {"j": 4}, 3, {"k": 6}],
want=[5, 3, {"j": 4}, {"k": 6}],
),
Case(
description=("null semantics - Object value"),
Expand Down
Loading