diff --git a/composer.json b/composer.json index 46ef62b..857a20b 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,8 @@ "src/parsers/filter-selector.php", "src/types/nothing.php", "src/types/node.php", + "src/types/path-result.php", + "src/types/path-segments-result.php", "src/PeggyParser.php", "src/parser.php", "src/JsonPath.php", diff --git a/package-lock.json b/package-lock.json index 533a499..ff8b4f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { - "name": "php-jsonpath", + "name": "jsonpath-php", "lockfileVersion": 3, "requires": true, "packages": { "": { "dependencies": { - "@prettier/plugin-php": "^0.22.4", + "@prettier/plugin-php": "^0.24.0", "peggy": "^3.0.2", "phpeggy": "^2.0.1", - "prettier": "^3.5.1" + "prettier": "^3.8.1" } }, "node_modules/@prettier/plugin-php": { - "version": "0.22.4", - "resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.22.4.tgz", - "integrity": "sha512-uZWqfyrwsxScIYkmVcfnoQGFmKVMXTHD5pqYT4l8fxzm5P3XY94hTPbf8X6TFCi2QTZBIot7GS8lfIjQjldc2g==", + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.24.0.tgz", + "integrity": "sha512-x9l65fCE/pgoET6RQowgdgG8Xmzs44z6j6Hhg3coINCyCw9JBGJ5ZzMR2XHAM2jmAdbJAIgqB6cUn4/3W3XLTA==", "license": "MIT", "dependencies": { - "linguist-languages": "^7.27.0", - "php-parser": "^3.1.5" + "linguist-languages": "^8.0.0", + "php-parser": "^3.2.5" }, "peerDependencies": { "prettier": "^3.0.0" @@ -34,9 +34,9 @@ } }, "node_modules/linguist-languages": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/linguist-languages/-/linguist-languages-7.27.0.tgz", - "integrity": "sha512-Wzx/22c5Jsv2ag+uKy+ITanGA5hzvBZngrNGDXLTC7ZjGM6FLCYGgomauTkxNJeP9of353OM0pWqngYA180xgw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/linguist-languages/-/linguist-languages-8.2.0.tgz", + "integrity": "sha512-KCUUH9x97QWYU0SXOCGxUrZR6cSfuQrMhABB7L/0I8N0LXOeaKe7+RZs7FAwvWCV2qKfZ4Wv1luLq4OfMezSJg==", "license": "MIT" }, "node_modules/peggy": { @@ -56,9 +56,9 @@ } }, "node_modules/php-parser": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.2.2.tgz", - "integrity": "sha512-voj3rzCJmEbwHwH3QteON28wA6K+JbcaJEofyUZkUXmcViiXofjbSbcE5PtqtjX6nstnnAEYCFoRq0mkjP5/cg==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.2.5.tgz", + "integrity": "sha512-M1ZYlALFFnESbSdmRtTQrBFUHSriHgPhgqtTF/LCbZM4h7swR5PHtUceB2Kzby5CfqcsYwBn7OXTJ0+8Sajwkw==", "license": "BSD-3-Clause" }, "node_modules/phpeggy": { @@ -71,9 +71,9 @@ } }, "node_modules/prettier": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", - "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" diff --git a/package.json b/package.json index 0d9b329..5eb356d 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "type": "module", "dependencies": { - "@prettier/plugin-php": "^0.22.4", + "@prettier/plugin-php": "^0.24.0", "peggy": "^3.0.2", "phpeggy": "^2.0.1", - "prettier": "^3.5.1" + "prettier": "^3.8.1" }, "scripts": { "generate-parser": "node src/grammar/generate.js", diff --git a/readme.md b/readme.md index 914a95e..7a50e6c 100644 --- a/readme.md +++ b/readme.md @@ -38,18 +38,85 @@ $result = $query->find([ ], ]); -var_dump($result); +print_r($result); /* Prints: -array(2) { - [0]=> - string(8) "John Doe" - [1]=> - string(8) "Jane Doe" -} +Array +( + [0] => John Doe + [1] => Jane Doe +) */ + +$path_result = $query->paths([ + 'users' => [ + [ 'name' => 'John Doe' ], + [ 'name' => 'Jane Doe' ], + ], +]); + +print_r($path_result); + +/* +Prints: + +Array +( + [0] => Loilo\JsonPath\PathResult Object + ( + [value] => John Doe + [path] => $['users'][0]['name'] + ) + + [1] => Loilo\JsonPath\PathResult Object + ( + [value] => Jane Doe + [path] => $['users'][1]['name'] + ) +) +*/ + + +$path_segments_result = $query->path_segments([ + 'users' => [ + [ 'name' => 'John Doe' ], + [ 'name' => 'Jane Doe' ], + ], +]); + +print_r($path_segments_result); + +/* +Prints: + +Array +( + [0] => Loilo\JsonPath\PathSegmentsResult Object + ( + [value] => John Doe + [segments] => Array + ( + [0] => users + [1] => 0 + [2] => name + ) + ) + + [1] => Loilo\JsonPath\PathSegmentsResult Object + ( + [value] => Jane Doe + [segments] => Array + ( + [0] => users + [1] => 1 + [2] => name + ) + ) +) +*/ + ``` ## Development diff --git a/src/JsonPath.php b/src/JsonPath.php index de4d5bc..080c83c 100644 --- a/src/JsonPath.php +++ b/src/JsonPath.php @@ -2,31 +2,86 @@ namespace Loilo\JsonPath; +/** + * A JSONPath query engine for executing JSONPath queries against JSON data. + * Fully implements the RFC 9535 JSONPath specification. + */ class JsonPath { private $root_node; - public function __construct(private $query) + /** + * Creates a new JSONPath query instance. + * @param string $query The JSONPath query string to parse + * @throws \Exception Throws an error if the query string is invalid + */ + public function __construct(private string $query) { $parser = new PeggyParser(); $parse_result = $parser->parse($query); $this->root_node = $parse_result; } - public function find($json) + /** + * Executes the JSONPath query and returns only the matching values. + * @param mixed $json The JSON data to query against + * @return array An array of matching values + */ + public function find($json): array { $result_node_list = run($json, $this->root_node); return array_map(fn(Node $node) => $node->value, $result_node_list); } - public function paths($json) + protected function convert_path_segment_to_string($segment) + { + if (is_string($segment)) { + if ($segment === '$') { + return $segment; + } + + return "['{$segment}']"; + } + + return "[{$segment}]"; + } + + /** + * Executes the JSONPath query and returns both matching values and their JSONPath strings. + * @param mixed $json The JSON data to query against + * @return array An array of objects containing the matching value and its JSONPath string + */ + public function paths($json): array + { + $path_segments = $this->path_segments($json); + return array_map( + fn($result) => new PathResult( + $result->value, + '$' . + join( + '', + array_map( + fn($result) => $this->convert_path_segment_to_string($result), + $result->segments, + ), + ), + ), + $path_segments, + ); + } + + /** + * Executes the JSONPath query and returns both matching values and their path segments as arrays. + * Path segments are returned as arrays containing strings (for object keys) and numbers (for array indices). + * The root segment $ is not included in path segments. + * @param mixed $json The JSON data to query against + * @return PathSegmentsResult[] An array of objects containing the matching value and its path segments as an array + */ + public function path_segments($json): array { $result_node_list = run($json, $this->root_node); return array_map( - fn(Node $node) => [ - 'value' => $node->value, - 'path' => $node->path, - ], + fn(Node $node) => new PathSegmentsResult($node->value, array_slice($node->path, 1)), $result_node_list, ); } diff --git a/src/parser.php b/src/parser.php index f732e88..b56eefe 100644 --- a/src/parser.php +++ b/src/parser.php @@ -4,6 +4,6 @@ function run($json, $query): array { - $root_node = create_node($json, '$'); + $root_node = create_node($json, ['$']); return apply_root($query, $root_node); } diff --git a/src/types/node.php b/src/types/node.php index f116422..7b7c939 100644 --- a/src/types/node.php +++ b/src/types/node.php @@ -6,10 +6,12 @@ class Node { - public function __construct(public mixed $value, public string $path) {} + public function __construct(public mixed $value, public array $path) + { + } } -function create_node(mixed $json, string $path): Node +function create_node(mixed $json, array $path): Node { return new Node($json, $path); } @@ -18,12 +20,12 @@ function add_member_path(Node $base, mixed $new_value, string $member_name): Nod { $escaped_member_name = escape_member_name($member_name); - return create_node($new_value, "{$base->path}['{$escaped_member_name}']"); + return create_node($new_value, [...$base->path, $escaped_member_name]); } function add_index_path(Node $base, mixed $new_value, int $index): Node { - return create_node($new_value, "{$base->path}[{$index}]"); + return create_node($new_value, [...$base->path, $index]); } function is_node($node) diff --git a/src/types/path-result.php b/src/types/path-result.php new file mode 100644 index 0000000..5443698 --- /dev/null +++ b/src/types/path-result.php @@ -0,0 +1,8 @@ + 'reference', + 'author' => 'Nigel Rees', + 'title' => 'Sayings of the Century', + 'price' => 8.95, +]; + +$book_2 = [ + 'category' => 'fiction', + 'author' => 'Evelyn Waugh', + 'title' => 'Sword of Honour', + 'price' => 12.99, +]; + +$json = [ + 'store' => [ + 'book' => [$book_1, $book_2], + ], +]; + +it( + 'should return path segments as arrays of strings and numbers', + function () use ($json) { + $path = new JsonPath("$.store.book[*].author"); + $path_segments_list = array_map( + fn($result) => $result->segments, + $path->path_segments($json), + ); + + expect($path_segments_list[0])->toEqual(['store', 'book', 0, 'author']); + expect($path_segments_list[1])->toEqual(['store', 'book', 1, 'author']); + }, +); + +it('should return empty segments for root segment', function () use ($json) { + $path = new JsonPath("$"); + $path_segments_list = array_map( + fn($result) => $result->segments, + $path->path_segments($json), + ); + + expect($path_segments_list[0])->toEqual([]); +}); diff --git a/tests/utils.php b/tests/utils.php index 4c8809a..3235378 100644 --- a/tests/utils.php +++ b/tests/utils.php @@ -1,6 +1,7 @@ path; }, $path->paths($json)); expect($paths)->toEqual($expected); diff --git a/tests/utils/TraverseDescendantTest.php b/tests/utils/TraverseDescendantTest.php index c53f1e2..7ee3567 100644 --- a/tests/utils/TraverseDescendantTest.php +++ b/tests/utils/TraverseDescendantTest.php @@ -3,25 +3,26 @@ declare(strict_types=1); use Loilo\JsonPath\Node; + use function Loilo\JsonPath\create_node; use function Loilo\JsonPath\traverse_descendant; describe('traverseDescendant', function () { test('empty object traverses empty', function () { - $node = create_node((object) [], ''); + $node = create_node((object) [], []); expect( array_map( - fn(Node $node) => $node->value, + fn (Node $node) => $node->value, traverse_descendant($node), ), )->toEqual([(object) []]); }); test('nested arrays traverse correctly', function () { - $node = create_node([[[1]], [2]], ''); + $node = create_node([[[1]], [2]], []); expect( array_map( - fn(Node $node) => $node->value, + fn (Node $node) => $node->value, traverse_descendant($node), ), )->toEqual([[[[1]], [2]], [[1]], [1], 1, [2], 2]);