Skip to content
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
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
34 changes: 17 additions & 17 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
81 changes: 74 additions & 7 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 62 additions & 7 deletions src/JsonPath.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
10 changes: 6 additions & 4 deletions src/types/node.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions src/types/path-result.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Loilo\JsonPath;

class PathResult
{
public function __construct(public mixed $value, public string $path) {}
}
14 changes: 14 additions & 0 deletions src/types/path-segments-result.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Loilo\JsonPath;

class PathSegmentsResult
{
public function __construct(
public mixed $value,
/**
* @var string[]
*/
public array $segments
) {}
}
49 changes: 49 additions & 0 deletions tests/PathSegmentsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

use Loilo\JsonPath\JsonPath;

$book_1 = [
'category' => '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([]);
});
Loading