Skip to content

Commit d6ccedf

Browse files
committed
[Release] 1.0.0
- 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. - Achieved and enforced 100% code coverage across AccessHelper, all filters, lexer, tokens, and JSONPath core while keeping phpstan and coding standards clean. - Added a lightweight manual query runner with curated examples to exercise selectors quickly without external datasets. - Major compatibility push toward the unofficial JSONPath standard: unions support slices/queries/wildcards, trailing commas parse correctly, negative indexes and bracket-escaped keys (quotes, brackets, wildcards, special chars) are honored, filters compare path-to-path and root references, equality/deep-equality/regex/in/nin semantics align with expectations, and null existence/value handling follows RFC behavior. - New feature highlights from this cycle: - Multi-key unions with and without quotes: `$[name,year]` and `$["name","year"]`. - Robust bracket notation for special/escaped keys, including `']'`, `'*'`, `$`, backslashes, and mixed punctuation. - Trailing comma support in unions/slices (e.g. `$..books[0,1,2,]`). - Negative index handling aligned with spec (short arrays return empty; -1 works where valid). - Filter improvements: path-to-path/root comparisons, deep equality across scalars/objects/arrays/null/empties, regex matching, `in`/`nin`/`!in`, tautological expressions, and `?@` existence behavior per RFC. - Unions combining slices/queries/wildcards now return complete results (e.g. `$[1:3,4]`, `$[*,1]`). This fixes #72, fixes #61, fixes #60, fixes #59, fixes #58, fixes #51, fixes #44, fixes #41, fixes #40, fixes #39, fixes #38, fixes #37, fixes #36, fixes #35, fixes #34, fixes #33, fixes #32, fixes #31, fixes #30, fixes #29, fixes #9, closes #3 Signed-off-by: Sascha Greuel <[email protected]>
1 parent af17176 commit d6ccedf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1499
-3972
lines changed

.editorconfig

Lines changed: 0 additions & 450 deletions
This file was deleted.

CHANGELOG.md

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,35 @@
11
# Changelog
22

3-
### 0.11.0
4-
🔻 Breaking changes ahead:
5-
6-
- Dropped support for PHP < 8.5
7-
- `JSONPathToken` now uses a `TokenType` enum and the constructor signature changed accordingly.
8-
- `JSONPath` options flag is now an `int` bitmask (was `bool`), requiring callers to pass integer flags.
9-
- `SliceFilter` returns an empty result for non-positive step values (previously iterated indefinitely).
10-
- `QueryResultFilter` now throws a `JSONPathException` for unsupported operators instead of silently proceeding.
11-
- Access helper behavior is stricter: `arrayValues` throws on invalid types; ArrayAccess lookups check `offsetExists` before reading; traversables and objects are handled distinctly.
12-
- Adopted PHP 8.5 features: `TokenType` enum, readonly value object for tokens, typed flags/options, and `#[\Override]` usage.
13-
- CI now runs on PHP 8.5 with required extensions; code style workflow updated accordingly.
14-
- Added coverage for AccessHelper edge cases (magic getters, ArrayAccess, traversables, negative indexes), QueryResultFilter arithmetic branches, and SliceFilter negative/null bounds.
15-
- Fixed empty-expression handling in lexer and improved safety in AccessHelper traversable lookups.
16-
- Added PHPStan static analysis to the toolchain and addressed its findings.
17-
18-
### 0.10.1
19-
- Fixed ignore whitespace after comparison value in filter expression
3+
### 1.0.0
4+
- 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.
5+
- Achieved and enforced 100% code coverage across AccessHelper, all filters, lexer, tokens, and JSONPath core while keeping phpstan and coding standards clean.
6+
- Added a lightweight manual query runner with curated examples to exercise selectors quickly without external datasets.
7+
- Major compatibility push toward the unofficial JSONPath standard: unions support slices/queries/wildcards, trailing commas parse correctly, negative indexes and bracket-escaped keys (quotes, brackets, wildcards, special chars) are honored, filters compare path-to-path and root references, equality/deep-equality/regex/in/nin semantics align with expectations, and null existence/value handling follows RFC behavior.
8+
- New feature highlights from this cycle:
9+
- Multi-key unions with and without quotes: `$[name,year]` and `$["name","year"]`.
10+
- Robust bracket notation for special/escaped keys, including `']'`, `'*'`, `$`, backslashes, and mixed punctuation.
11+
- Trailing comma support in unions/slices (e.g. `$..books[0,1,2,]`).
12+
- Negative index handling aligned with spec (short arrays return empty; -1 works where valid).
13+
- Filter improvements: path-to-path/root comparisons, deep equality across scalars/objects/arrays/null/empties, regex matching, `in`/`nin`/`!in`, tautological expressions, and `?@` existence behavior per RFC.
14+
- Unions combining slices/queries/wildcards now return complete results (e.g. `$[1:3,4]`, `$[*,1]`).
15+
16+
### 0.11.0
17+
🔻 Breaking changes ahead:
18+
19+
- Dropped support for PHP < 8.5
20+
- `JSONPathToken` now uses a `TokenType` enum and the constructor signature changed accordingly.
21+
- `JSONPath` options flag is now an `int` bitmask (was `bool`), requiring callers to pass integer flags.
22+
- `SliceFilter` returns an empty result for non-positive step values (previously iterated indefinitely).
23+
- `QueryResultFilter` now throws a `JSONPathException` for unsupported operators instead of silently proceeding.
24+
- Access helper behavior is stricter: `arrayValues` throws on invalid types; ArrayAccess lookups check `offsetExists` before reading; traversables and objects are handled distinctly.
25+
- Adopted PHP 8.5 features: `TokenType` enum, readonly value object for tokens, typed flags/options, and `#[\Override]` usage.
26+
- CI now runs on PHP 8.5 with required extensions; code style workflow updated accordingly.
27+
- Added coverage for AccessHelper edge cases (magic getters, ArrayAccess, traversables, negative indexes), QueryResultFilter arithmetic branches, and SliceFilter negative/null bounds.
28+
- Fixed empty-expression handling in lexer and improved safety in AccessHelper traversable lookups.
29+
- Added PHPStan static analysis to the toolchain and addressed its findings.
30+
31+
### 0.10.1
32+
- Fixed ignore whitespace after comparison value in filter expression
2033

2134
### 0.10.0
2235
- Fixed query/selector Filter Expression With Current Object

README.md

Lines changed: 42 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,32 @@
44
[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Plant Tree](https://img.shields.io/badge/dynamic/json?color=brightgreen&label=Plant%20Tree&query=%24.total&url=https%3A%2F%2Fpublic.ecologi.com%2Fusers%2Fsoftcreatr%2Ftrees)](https://ecologi.com/softcreatr?r=61212ab3fc69b8eb8a2014f4)
55
[![Codecov branch](https://img.shields.io/codecov/c/github/SoftCreatR/JSONPath)](https://codecov.io/gh/SoftCreatR/JSONPath)
66

7-
This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for PHP based on Stefan Goessner's JSONPath script.
7+
This is a [JSONPath](http://goessner.net/articles/JsonPath/) implementation for PHP that targets the de facto comparison suite/RFC semantics while keeping the API small, cached, and `eval`-free.
88

9-
JSONPath is an XPath-like expression language for filtering, flattening and extracting data.
9+
## Highlights
1010

11-
This project aims to be a clean and simple implementation with the following goals:
12-
13-
- Object-oriented code (should be easier to manage or extend in future)
14-
- Expressions are parsed into tokens using code inspired by the Doctrine Lexer. The tokens are cached internally to avoid re-parsing the expressions.
15-
- There is no `eval()` in use
16-
- Any combination of objects/arrays/ArrayAccess-objects can be used as the data input which is great if you're de-serializing JSON in to objects or if you want to process your own data structures.
11+
- PHP 8.5+ only, with enums/readonly tokens and no `eval`.
12+
- Works with arrays, objects, and `ArrayAccess`/traversables in any combination.
13+
- 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.
15+
- Tokenized parsing with internal caching; lightweight manual runner to try bundled examples quickly.
1716

1817
## Installation
1918

2019
Requires PHP 8.5 or newer.
2120

2221
```bash
23-
composer require softcreatr/jsonpath:"^0.11"
22+
composer require softcreatr/jsonpath:"^1.0"
2423
```
2524

2625
## Development
2726

28-
Static analysis is done with PHPStan.
27+
Useful commands:
2928

3029
```bash
31-
composer require --dev phpstan/phpstan
32-
./vendor/bin/phpstan analyse --no-progress
30+
composer exec phpunit
31+
composer phpstan
32+
composer cs
3333
```
3434

3535
## JSONPath Examples
@@ -43,6 +43,7 @@ JSONPath | Result
4343
`$..books[(@.length-1)]` | the last book in order.
4444
`$..books[-1:]` | the last book in order.
4545
`$..books[0,1]` | the first two books
46+
`$..books[title,year]` | multiple keys in a union
4647
`$..books[:2]` | the first two books
4748
`$..books[::2]` | every second book starting from first one
4849
`$..books[1:6:3]` | every third book starting from 1 till 6
@@ -64,8 +65,8 @@ Symbol | Description
6465
`*` | Wildcard. All child elements regardless their index.
6566
`[,]` | Array indices as a set
6667
`[start:end:step]` | Array slice operator borrowed from ES4/Python.
67-
`?()` | Filters a result set by a script expression
68-
`()` | Uses the result of a script expression as the index
68+
`?()` | Filters a result set by a comparison expression
69+
`()` | Uses the result of a comparison expression as the index
6970

7071
## PHP Usage
7172

@@ -116,8 +117,6 @@ stdClass Object
116117
*/
117118
```
118119

119-
More examples can be found in the [Wiki](https://github.com/SoftCreatR/JSONPath/wiki/Queries)
120-
121120
### Magic method access
122121

123122
The options flag `JSONPath::ALLOW_MAGIC` will instruct JSONPath when retrieving a value to first check if an object
@@ -127,40 +126,47 @@ not very predictable as:
127126
- wildcard and recursive features will only look at public properties and can't smell which properties are magically accessible
128127
- there is no `property_exists` check for magic methods so an object with a magic `__get()` will always return `true` when checking
129128
if the property exists
130-
- any errors thrown or unpredictable behaviour caused by fetching via `__get()` is your own problem to deal with
129+
- any errors thrown or unpredictable behavior caused by fetching via `__get()` is your own problem to deal with
131130

132131
```php
132+
<?php
133+
133134
use Flow\JSONPath\JSONPath;
134135

135136
$myObject = (new Foo())->get('bar');
136137
$jsonPath = new JSONPath($myObject, JSONPath::ALLOW_MAGIC);
137138
```
138139

139-
For more examples, check the JSONPathTest.php tests file.
140-
141140
## Script expressions
142141

143-
Script expressions are not supported as the original author intended because:
142+
Script execution is intentionally **not** supported:
144143

145-
- This would only be achievable through `eval` (boo).
146-
- Using the script engine from different languages defeats the purpose of having a single expression evaluate the same way in different
147-
languages which seems like a bit of a flaw if you're creating an abstract expression syntax.
144+
- It would require `eval`, which we avoid.
145+
- Behavior would diverge across languages and defeat having a portable expression syntax.
148146

149-
So here are the types of query expressions that are supported:
147+
Supported filter/query patterns (200+ cases covered in the comparison suite):
150148

151-
[?(@._KEY_ _OPERATOR_ _VALUE_)] // <, >, <=, >=, !=, ==, =~, in and nin
152-
e.g.
153-
[?(@.title == "A string")] //
154-
[?(@.title = "A string")]
155-
// A single equals is not an assignment but the SQL-style of '=='
156-
[?(@.title =~ /^a(nother)? string$/i)]
157-
[?(@.title in ["A string", "Another string"])]
158-
[?(@.title nin ["A string", "Another string"])]
159-
160-
## Known issues
161-
162-
- This project has not implemented multiple string indexes e.g. `$[name,year]` or `$["name","year"]`. I have no ETA on that feature, and it would require some re-writing of the parser that uses a very basic regex implementation.
149+
```
150+
[?(@._KEY_ _OPERATOR_ _VALUE_)]
151+
Operators: ==, =, !=, <>, !==, <, >, <=, >=, =~, in, nin, !in
152+
153+
Examples:
154+
[?(@.title == "A string")] // equality
155+
[?(@.title = "A string")] // SQL-style equals
156+
[?(@.price < 10)] // numeric comparisons
157+
[?(@.title =~ /^a(nother)?/i)] // regex
158+
[?(@.title in ["A","B"])] // membership
159+
[?(@.title nin ["A"])] // not in
160+
[?(@.title !in ["A"])] // alternate not in
161+
[?(@.key == @.other)] // path-to-path comparison
162+
[?(@.key == $.rootValue)] // root reference
163+
[?(@)] or [?(@==@)] // truthy/tautology
164+
[?(@.length)] // existence checks
165+
[?(@['weird-key']=="ok")] // bracket-escaped keys and negative indexes
166+
```
163167

168+
A full list of (un)supported filter/query patterns can be found in the [JSONPath Comparison Cheatsheet](https://cburgmer.github.io/json-path-comparison/).
169+
164170
## Similar projects
165171

166172
[FlowCommunications/JSONPath](https://github.com/FlowCommunications/JSONPath) is the predecessor of this library by Stephen Frank

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": "0.11.0",
5+
"version": "1.0.0",
66
"authors": [
77
{
88
"name": "Stephen Frank",

src/AccessHelper.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,14 @@ public static function keyExists(mixed $collection, int|string|null $key, bool $
3838
return true;
3939
}
4040

41-
if (\is_int($key) && $key < 0) {
42-
$key = \abs($key);
43-
}
44-
4541
if (\is_array($collection)) {
42+
if (\is_int($key) && $key < 0) {
43+
$keys = \array_keys($collection);
44+
$index = \count($keys) + $key;
45+
46+
return $index >= 0 && \array_key_exists($index, $keys);
47+
}
48+
4649
return \array_key_exists($key ?? '', $collection);
4750
}
4851

@@ -76,7 +79,8 @@ public static function getValue(mixed $collection, int|string|null $key, bool $m
7679
$return = $collection->offsetExists($key) ? $collection->offsetGet($key) : null;
7780
} elseif (\is_array($collection)) {
7881
if (\is_int($key) && $key < 0) {
79-
$return = \array_slice($collection, $key, 1)[0] ?? null;
82+
$index = \count($collection) + $key;
83+
$return = $index >= 0 && \array_key_exists($index, $collection) ? $collection[$index] : null;
8084
} else {
8185
$return = $collection[$key] ?? null;
8286
}
@@ -128,7 +132,6 @@ public static function setValue(mixed &$collection, int|string|null $key, mixed
128132
}
129133

130134
if ($collection instanceof ArrayAccess) {
131-
/** @noinspection PhpVoidFunctionResultUsedInspection */
132135
$collection->offsetSet($key, $value);
133136

134137
return $value;

src/Filters/AbstractFilter.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,18 @@ abstract class AbstractFilter
1717
{
1818
protected bool $magicIsAllowed;
1919

20+
protected mixed $rootData = null;
21+
2022
public function __construct(protected JSONPathToken $token, int $options = 0)
2123
{
2224
$this->magicIsAllowed = ($options & JSONPath::ALLOW_MAGIC) === JSONPath::ALLOW_MAGIC;
2325
}
2426

27+
public function setRootData(mixed $root): void
28+
{
29+
$this->rootData = $root;
30+
}
31+
2532
/**
2633
* @param array<array-key, mixed>|object $collection
2734
* @return array<array-key, mixed>

src/Filters/IndexFilter.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public function filter(array|object $collection): array
2323
{
2424
if (\is_array($this->token->value)) {
2525
$result = [];
26+
2627
foreach ($this->token->value as $value) {
2728
if (AccessHelper::keyExists($collection, $value, $this->magicIsAllowed)) {
2829
$result[] = AccessHelper::getValue($collection, $value, $this->magicIsAllowed);
@@ -38,7 +39,7 @@ public function filter(array|object $collection): array
3839
];
3940
}
4041

41-
if ($this->token->value === '*') {
42+
if ($this->token->value === '*' && !$this->token->quoted) {
4243
return AccessHelper::arrayValues($collection);
4344
}
4445

src/Filters/IndexesFilter.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,55 @@
1111
namespace Flow\JSONPath\Filters;
1212

1313
use Flow\JSONPath\AccessHelper;
14+
use Flow\JSONPath\JSONPath;
15+
use Flow\JSONPath\JSONPathException;
16+
use Flow\JSONPath\JSONPathToken;
17+
use Flow\JSONPath\TokenType;
1418

1519
class IndexesFilter extends AbstractFilter
1620
{
1721
/**
1822
* @inheritDoc
23+
*
24+
* @throws JSONPathException
1925
*/
2026
public function filter(array|object $collection): array
2127
{
2228
$return = [];
2329

2430
foreach ($this->token->value as $index) {
31+
if (\is_array($index) && ($index['type'] ?? null) === 'slice') {
32+
$sliceToken = new JSONPathToken(TokenType::Slice, $index['value']);
33+
$sliceFilter = new SliceFilter(
34+
$sliceToken,
35+
$this->magicIsAllowed ? JSONPath::ALLOW_MAGIC : 0
36+
);
37+
$sliceFilter->setRootData($this->rootData ?? $collection);
38+
39+
$return = \array_merge($return, $sliceFilter->filter($collection));
40+
41+
continue;
42+
}
43+
44+
if (\is_array($index) && ($index['type'] ?? null) === 'query') {
45+
$queryToken = new JSONPathToken(TokenType::QueryMatch, $index['value']);
46+
$queryFilter = new QueryMatchFilter(
47+
$queryToken,
48+
$this->magicIsAllowed ? JSONPath::ALLOW_MAGIC : 0
49+
);
50+
$queryFilter->setRootData($this->rootData ?? $collection);
51+
52+
$return = \array_merge($return, $queryFilter->filter($collection));
53+
54+
continue;
55+
}
56+
57+
if ($index === '*' && !$this->token->quoted) {
58+
$return = \array_merge($return, AccessHelper::arrayValues($collection));
59+
60+
continue;
61+
}
62+
2563
if (AccessHelper::keyExists($collection, $index, $this->magicIsAllowed)) {
2664
$return[] = AccessHelper::getValue($collection, $index, $this->magicIsAllowed);
2765
}

0 commit comments

Comments
 (0)