Skip to content

Commit 23d04a3

Browse files
committed
- Aligned the query runner and lexer with the JSONPath comparison suite: JSON documents are now decoded as objects to preserve {} vs [], unsupported selectors no longer abort the runner, and dot-notation now accepts quoted keys with dots/spaces/leading @.
- Hardened filter parsing: boolean-only filters (`?(true|false|null)`), literal short-circuiting (`&& false`, `|| true`), and empty filters now return the expected collections instead of throwing. - Slice filters gracefully skip non-countable objects. Signed-off-by: Sascha Greuel <[email protected]>
1 parent d6ccedf commit 23d04a3

File tree

9 files changed

+202
-41
lines changed

9 files changed

+202
-41
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
### 1.0.1
4+
- Aligned the query runner and lexer with the JSONPath comparison suite: JSON documents are now decoded as objects to preserve `{}` vs `[]`, unsupported selectors no longer abort the runner, and dot-notation now accepts quoted keys with dots/spaces/leading `@`.
5+
- Hardened filter parsing: boolean-only filters (`?(true|false|null)`), literal short-circuiting (`&& false`, `|| true`), and empty filters now return the expected collections instead of throwing.
6+
- Slice filters gracefully skip non-countable objects.
7+
38
### 1.0.0
49
- Rebuilt the test suite from scratch: removed bulky baseline fixtures and added compact unit/integration coverage for every filter (index, union, query, recursive, slice), lexer edge cases, and JSONPath core helpers. Runs reflection-free and deprecation-free.
510
- Achieved and enforced 100% code coverage across AccessHelper, all filters, lexer, tokens, and JSONPath core while keeping phpstan and coding standards clean.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for
1111
- PHP 8.5+ only, with enums/readonly tokens and no `eval`.
1212
- Works with arrays, objects, and `ArrayAccess`/traversables in any combination.
1313
- Unions cover slices/queries/wildcards/multi-key strings (quoted or unquoted); negative indexes and escaped bracket notation are supported.
14-
- Filters support path-to-path/root comparisons, regex, `in`/`nin`/`!in`, deep equality, and RFC-style null existence/value handling.
14+
- Filters support path-to-path/root comparisons, regex, `in`/`nin`/`!in`, deep equality, RFC-style null existence/value handling, and literal-only short-circuiting (e.g., `?(true)`, `?(false)`, `&& false`, `|| true`).
1515
- Tokenized parsing with internal caching; lightweight manual runner to try bundled examples quickly.
1616

1717
## Installation
@@ -65,7 +65,7 @@ Symbol | Description
6565
`*` | Wildcard. All child elements regardless their index.
6666
`[,]` | Array indices as a set
6767
`[start:end:step]` | Array slice operator borrowed from ES4/Python.
68-
`?()` | Filters a result set by a comparison expression
68+
`?()` | Filters a result set by a comparison expression (constant expressions like `?(true)`/`?(false)` are allowed; unsupported/empty filters evaluate to an empty result)
6969
`()` | Uses the result of a comparison expression as the index
7070

7171
## PHP Usage

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "softcreatr/jsonpath",
33
"description": "JSONPath implementation for parsing, searching and flattening arrays",
44
"license": "MIT",
5-
"version": "1.0.0",
5+
"version": "1.0.1",
66
"authors": [
77
{
88
"name": "Stephen Frank",

src/Filters/QueryMatchFilter.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,18 @@ public function filter(array|object $collection): array
5858
$filterExpression = $negationMatches['logicalexpr'];
5959
}
6060

61+
$literalResult = $this->evaluateLiteralExpression($filterExpression, $collection);
62+
63+
if ($literalResult !== null) {
64+
return $literalResult;
65+
}
66+
67+
$shortCircuitResult = $this->evaluateExpressionWithTrailingLiteral($filterExpression, $collection);
68+
69+
if ($shortCircuitResult !== null) {
70+
return $shortCircuitResult;
71+
}
72+
6173
$filterGroups = [];
6274

6375
if (
@@ -315,6 +327,60 @@ private function evaluateConstantExpression(string $expression): ?bool
315327
};
316328
}
317329

330+
/**
331+
* @param array<int, mixed>|object $collection
332+
* @return array<int, mixed>|null
333+
* @throws JSONPathException
334+
*/
335+
private function evaluateLiteralExpression(string $expression, array|object $collection): ?array
336+
{
337+
$trimmed = \trim($expression);
338+
339+
if ($trimmed === '') {
340+
return [];
341+
}
342+
343+
$literalValue = $this->decodeLiteral($trimmed);
344+
$literalIsBool = \is_bool($literalValue);
345+
346+
if (!$literalIsBool && $literalValue !== null) {
347+
return null;
348+
}
349+
350+
return $this->isTruthy($literalValue) ? AccessHelper::arrayValues($collection) : [];
351+
}
352+
353+
/**
354+
* @param array<int, mixed>|object $collection
355+
* @return array<int, mixed>|null
356+
* @throws JSONPathException
357+
*/
358+
private function evaluateExpressionWithTrailingLiteral(
359+
string $expression,
360+
array|object $collection
361+
): ?array {
362+
if (
363+
!\preg_match(
364+
'/^(?<left>.+?)\s*(?<op>&&|\|\|)\s*(?<literal>true|false|null)\s*$/i',
365+
$expression,
366+
$matches
367+
)
368+
) {
369+
return null;
370+
}
371+
372+
$leftFilter = '$[?(' . $matches['left'] . ')]';
373+
$leftResult = new JSONPath($collection)->find($leftFilter)->getData();
374+
$literalValue = $this->decodeLiteral($matches['literal']);
375+
$literalIsTrue = $this->isTruthy($literalValue);
376+
377+
return match ($matches['op']) {
378+
'&&' => $literalIsTrue ? $leftResult : [],
379+
'||' => $literalIsTrue ? AccessHelper::arrayValues($collection) : $leftResult,
380+
default => [],
381+
};
382+
}
383+
318384
private function decodeLiteral(string $literal): mixed
319385
{
320386
$literal = \trim($literal);

src/Filters/SliceFilter.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ class SliceFilter extends AbstractFilter
1919
*/
2020
public function filter(array|object $collection): array
2121
{
22+
if (
23+
!\is_array($collection)
24+
&& !$collection instanceof \Countable
25+
&& !$collection instanceof \ArrayAccess
26+
) {
27+
return [];
28+
}
29+
2230
$length = \count($collection);
2331
$start = $this->token->value['start'];
2432
$end = $this->token->value['end'];

src/JSONPathLexer.php

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public function __construct(string $expression)
5454
return;
5555
}
5656

57-
if ($expression[0] === '$') {
57+
if ($expression[0] === '$' || $expression[0] === '@') {
5858
$expression = \substr($expression, 1);
5959
}
6060

@@ -83,11 +83,17 @@ public function parseExpressionTokens(): array
8383
$tokenValue = '';
8484
$tokens = [];
8585
$inBracketQuote = null;
86+
$inQuote = null;
8687

8788
for ($i = 0; $i < $this->expressionLength; $i++) {
8889
$char = $this->expression[$i];
8990

90-
if (($squareBracketDepth === 0) && $char === '.') {
91+
if ($squareBracketDepth === 0 && ($char === "'" || $char === '"')) {
92+
$escaped = $this->isEscaped($tokenValue);
93+
$inQuote = $inQuote === $char && !$escaped ? null : ($inQuote ?? $char);
94+
}
95+
96+
if (($squareBracketDepth === 0) && $inQuote === null && $char === '.') {
9197
if ($this->lookAhead($i) === '.') {
9298
$tokens[] = new JSONPathToken(TokenType::Recursive, null);
9399
}
@@ -140,7 +146,10 @@ public function parseExpressionTokens(): array
140146
*/
141147
$tokenValue .= $char;
142148

143-
if ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true)) {
149+
if (
150+
$inQuote === null
151+
&& ($this->atEnd($i) || \in_array($this->lookAhead($i), ['.', '['], true))
152+
) {
144153
$tokens[] = $this->createToken($tokenValue);
145154
$tokenValue = '';
146155
}
@@ -179,7 +188,7 @@ protected function createToken(string $value): JSONPathToken
179188
{
180189
// The IDE doesn't like, what we do with $value, so let's
181190
// move it to a separate variable, to get rid of any IDE warnings
182-
$tokenValue = $value;
191+
$tokenValue = \trim($value);
183192

184193
/** @var JSONPathToken|null $ret */
185194
$ret = null;
@@ -197,6 +206,15 @@ protected function createToken(string $value): JSONPathToken
197206
$hasQuery = false;
198207

199208
foreach ($parts as $part) {
209+
if (
210+
\preg_match('/^' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '$/xu', $part, $matches)
211+
|| \preg_match('/^' . static::MATCH_INDEX_IN_DOUBLE_QUOTES . '$/xu', $part, $matches)
212+
) {
213+
$union[] = $this->decodeQuotedIndex($matches[1] ?? '', $matches[0][0]);
214+
215+
continue;
216+
}
217+
200218
if (\preg_match('/^-\\d+$/', $part)) {
201219
$union[] = (int)$part;
202220

@@ -228,8 +246,21 @@ protected function createToken(string $value): JSONPathToken
228246
}
229247
}
230248

231-
if (($hasSlice || $hasQuery) && \count($union) === \count($parts)) {
232-
return new JSONPathToken(TokenType::Indexes, $union);
249+
if (\count($union) === \count($parts)) {
250+
$quotedPattern = '/^(' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '|'
251+
. static::MATCH_INDEX_IN_DOUBLE_QUOTES . ')$/xu';
252+
253+
$quotedCallback = static function (string $part) use ($quotedPattern): bool {
254+
return \preg_match($quotedPattern, $part) === 1;
255+
};
256+
257+
$quotedParts = \array_filter($parts, $quotedCallback);
258+
259+
$allQuoted = \count($quotedParts) === \count($parts);
260+
261+
$tokenType = ($hasSlice || $hasQuery || !$allQuoted) ? TokenType::Indexes : TokenType::Index;
262+
263+
return new JSONPathToken($tokenType, $union, $allQuoted);
233264
}
234265
}
235266
}
@@ -238,21 +269,25 @@ protected function createToken(string $value): JSONPathToken
238269
return new JSONPathToken(TokenType::Index, (int)$tokenValue);
239270
}
240271

272+
if ($tokenValue === '') {
273+
return new JSONPathToken(TokenType::Indexes, []);
274+
}
275+
276+
if (
277+
($tokenValue[0] === "'" || $tokenValue[0] === '"')
278+
&& $tokenValue[\strlen($tokenValue) - 1] === $tokenValue[0]
279+
) {
280+
$tokenValue = $this->decodeQuotedIndex(\substr($tokenValue, 1, -1), $tokenValue[0]);
281+
282+
return new JSONPathToken(TokenType::Index, $tokenValue, true);
283+
}
284+
241285
if (\preg_match('/^(' . static::MATCH_INDEX . ')$/xu', $tokenValue, $matches)) {
242286
if (\preg_match('/^-?\d+$/', $tokenValue)) {
243287
$tokenValue = (int)$tokenValue;
244288
}
245289

246290
$ret = new JSONPathToken(TokenType::Index, $tokenValue);
247-
} elseif (\preg_match('/^' . static::MATCH_INDEXES . '$/xu', $tokenValue, $matches)) {
248-
$tokenValue = \explode(',', \trim($tokenValue, ','));
249-
250-
foreach ($tokenValue as $i => $v) {
251-
$v = \trim($v);
252-
$tokenValue[$i] = $v === '*' ? '*' : (int)$v;
253-
}
254-
255-
$ret = new JSONPathToken(TokenType::Indexes, $tokenValue);
256291
} elseif (\preg_match('/^' . static::MATCH_SLICE . '$/xu', $tokenValue, $matches)) {
257292
$tokenValue = $this->parseSlice($tokenValue);
258293

@@ -261,6 +296,8 @@ protected function createToken(string $value): JSONPathToken
261296
$tokenValue = \substr($tokenValue, 1, -1);
262297

263298
$ret = new JSONPathToken(TokenType::QueryResult, $tokenValue);
299+
} elseif ($tokenValue === '?()') {
300+
$ret = new JSONPathToken(TokenType::QueryMatch, '', shorthand: false);
264301
} elseif ($tokenValue === '?') {
265302
$ret = new JSONPathToken(TokenType::QueryMatch, '@', shorthand: true);
266303
} elseif (\preg_match('/^\\?@/', $tokenValue)) {
@@ -272,27 +309,6 @@ protected function createToken(string $value): JSONPathToken
272309
$tokenValue = \substr($tokenValue, 2, -1);
273310

274311
$ret = new JSONPathToken(TokenType::QueryMatch, $tokenValue);
275-
} elseif (
276-
\preg_match('/^' . static::MATCH_INDEX_IN_SINGLE_QUOTES . '$/xu', $tokenValue, $matches)
277-
|| \preg_match('/^' . static::MATCH_INDEX_IN_DOUBLE_QUOTES . '$/xu', $tokenValue, $matches)
278-
) {
279-
if (isset($matches[1])) {
280-
$tokenValue = $this->decodeQuotedIndex($matches[1], $matches[0][0]);
281-
282-
$possibleArray = false;
283-
if ($matches[0][0] === '"') {
284-
$possibleArray = \explode('","', $tokenValue);
285-
} elseif ($matches[0][0] === "'") {
286-
$possibleArray = \explode("','", $tokenValue);
287-
}
288-
if ($possibleArray !== false && \count($possibleArray) > 1) {
289-
$tokenValue = $possibleArray;
290-
}
291-
} else {
292-
$tokenValue = '';
293-
}
294-
295-
$ret = new JSONPathToken(TokenType::Index, $tokenValue, true);
296312
}
297313

298314
if ($ret !== null) {

tests/JSONPathLexerTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,27 @@ public static function expressionProvider(): iterable
198198
],
199199
];
200200

201+
yield 'quoted index in dot notation preserves dots' => [
202+
"$.'some.key'",
203+
[
204+
['type' => TokenType::Index, 'value' => 'some.key', 'quoted' => true],
205+
],
206+
];
207+
208+
yield 'empty bracket notation yields empty index list' => [
209+
'$[]',
210+
[
211+
['type' => TokenType::Indexes, 'value' => []],
212+
],
213+
];
214+
215+
yield 'empty filter expression tokenizes to empty query match' => [
216+
'$[?()]',
217+
[
218+
['type' => TokenType::QueryMatch, 'value' => '', 'shorthand' => false],
219+
],
220+
];
221+
201222
yield 'query result expression' => [
202223
'[(@.foo + 2)]',
203224
[

tests/QueryMatchFilterTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,45 @@ public function testMalformedFilterThrowsRuntimeException(): void
243243
new JSONPath([1])->find('$[?(foo)]');
244244
}
245245

246+
/**
247+
* @throws JSONPathException
248+
*/
249+
public function testLiteralOnlyFilterExpressionsReturnWholeCollectionOrEmpty(): void
250+
{
251+
$data = [1, 2, 3];
252+
253+
self::assertSame($data, new JSONPath($data)->find('$[?(true)]')->getData());
254+
self::assertSame([], new JSONPath($data)->find('$[?(false)]')->getData());
255+
}
256+
257+
/**
258+
* @throws JSONPathException
259+
*/
260+
public function testLogicalExpressionsWithLiteralRightOperand(): void
261+
{
262+
$data = [
263+
['key' => 1],
264+
['key' => -1],
265+
];
266+
267+
self::assertSame(
268+
[],
269+
new JSONPath($data)->find('$[?(@.key>0 && false)]')->getData()
270+
);
271+
self::assertSame(
272+
$data,
273+
new JSONPath($data)->find('$[?(@.key>0 || true)]')->getData()
274+
);
275+
}
276+
277+
/**
278+
* @throws JSONPathException
279+
*/
280+
public function testEmptyFilterExpressionReturnsEmpty(): void
281+
{
282+
self::assertSame([], new JSONPath([1, 2])->find('$[?()]')->getData());
283+
}
284+
246285
/**
247286
* @throws JSONPathException
248287
*/

tests/SliceFilterTest.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public function testFilterHandlesNegativeAndNullBounds(array $slice, array|objec
3636
}
3737

3838
/**
39-
* @return iterable<string, array{array<string, int|null>, array<array-key, mixed>, array<int, mixed>}>
39+
* @return iterable<string, array{array<string, int|null>, array<array-key, mixed>|object, array<int, mixed>}>
4040
*/
4141
public static function edgeCaseProvider(): iterable
4242
{
@@ -97,6 +97,12 @@ public static function edgeCaseProvider(): iterable
9797
['a', 'b', 'c'],
9898
['b', 'a'],
9999
];
100+
101+
yield 'non countable object yields empty' => [
102+
['start' => 0, 'end' => null, 'step' => 1],
103+
(object)['a' => 1],
104+
[],
105+
];
100106
}
101107

102108
/**
@@ -105,7 +111,7 @@ public static function edgeCaseProvider(): iterable
105111
* @param array<int, mixed> $expected
106112
*/
107113
#[DataProvider('edgeCaseProvider')]
108-
public function testEdgeCases(array $slice, array $input, array $expected): void
114+
public function testEdgeCases(array $slice, array|object $input, array $expected): void
109115
{
110116
$token = new JSONPathToken(TokenType::Slice, $slice);
111117
$filter = new SliceFilter($token);

0 commit comments

Comments
 (0)