refactor: Cleanup git state - commit all staged changes

Major refactoring cleanup:
- Add new controller architecture (class-controller-*.php)
- Add new settings-v2 UI (views/settings-v2/)
- Add new CSS architecture (agentic-sidebar.css, tokens)
- Add esbuild build pipeline (scripts/build.js, package.json)
- Add composer dependencies (vendor/)
- Add frontend src directory (assets/js/src/index.jsx)
- Add documentation files
- Remove old/obsolete files (class-settings.php, old CSS)

This commits all pending changes from previous refactoring efforts.
This commit is contained in:
Dwindi Ramadhana
2026-06-17 05:27:58 +07:00
parent d3f142222c
commit 690991c526
7963 changed files with 941566 additions and 67372 deletions

View File

@@ -0,0 +1,4 @@
vendor/*
.phpunit.cache
build
/.phpunit.result.cache

21
vendor/phpdocumentor/json-path/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2010 Mike van Riel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,32 @@
# JSON-path
JSON-path is a simple JSON path parser and evaluator for PHP. It is based on the JSONPath
implementation in [Goessner's JSONPath](http://goessner.net/articles/JsonPath/).
The code allows you to parse json paths and evaluate them on php objects. Which makes it a query language for
php object structures.
It's propably not the fastest solution to query php objects, but as the paths are stored as plain strings, it's
easy to use them in configuration files or databases. This makes is a good solution for tools that need to query
a php object structure based on user input.
## Installation
The recommended way to install JSON-path is through [Composer](http://getcomposer.org).
```bash
composer require phpdocumentor/json-path
```
## Usage
```php
$parser = \phpDocumentor\JsonPath\Parser::createInstance();
$query = $parser->parse('.store.book[*].author');
$executor = new \phpDocumentor\JsonPath\Executor();
foreach ($executor->execute($query, $json) as $result) {
var_dump($result);
}
```

View File

@@ -0,0 +1,30 @@
{
"name": "phpdocumentor/json-path",
"description": "Access php objects with json path expressions",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"phpDocumentor\\JsonPath\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"phpDocumentor\\JsonPath\\": "tests/unit"
}
},
"authors": [
{
"name": "jaapio",
"email": "jaap@phpdoc.org"
}
],
"require": {
"php": "8.1.*|8.2.*|8.3.*|8.4.*",
"parsica-php/parsica": "^0.8.3",
"symfony/property-access": "^5.4|^6.4|^7.1"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
}
}

2431
vendor/phpdocumentor/json-path/composer.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" colors="true" bootstrap="vendor/autoload.php" beStrictAboutOutputDuringTests="true" beStrictAboutChangesToGlobalState="true" cacheDirectory=".phpunit.cache">
<coverage>
<report>
<clover outputFile="build/logs/clover.xml"/>
<html outputDirectory="build/coverage" lowUpperBound="35" highLowerBound="70"/>
</report>
</coverage>
<testsuites>
<testsuite name="unit">
<directory>./tests/unit</directory>
</testsuite>
</testsuites>
<logging/>
<source>
<include>
<directory suffix=".php">./src/</directory>
</include>
</source>
</phpunit>

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use InvalidArgumentException;
use phpDocumentor\JsonPath\Executor;
final class Comparison implements Expression
{
public function __construct(
private readonly QueryNode $left,
private readonly string $operator,
private readonly QueryNode $right,
) {
}
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root): bool
{
return match ($this->operator) {
'==' => $param->evaluateEqualsComparison($root, $currentObject, $this->left, $this->right),
'!=' => $param->evaluateNotEqualsComparison($root, $currentObject, $this->left, $this->right),
'starts_with' => $param->evaluateStartsWithComparison($root, $currentObject, $this->left, $this->right),
'contains' => $param->evaluateContainsComparison($root, $currentObject, $this->left, $this->right),
default => throw new InvalidArgumentException(),
};
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
final class CurrentNode implements PathNode
{
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root)
{
return $currentObject;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
interface Expression
{
/**
* @param mixed $currentObject
* @param mixed $root
*/
public function visit(Executor $param, $currentObject, $root): bool;
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
class FieldAccess implements PathNode
{
public function __construct(private readonly FieldName|Expression $fieldName)
{
}
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root)
{
return $param->evaluateFieldAccess($currentObject, $this->fieldName);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
class FieldName
{
public function __construct(private readonly string $name)
{
}
public function getName(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use InvalidArgumentException;
use phpDocumentor\JsonPath\Executor;
use function is_iterable;
final class FilterNode implements PathNode
{
public function __construct(private readonly Expression $expression)
{
}
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root)
{
if (is_iterable($currentObject) === false) {
throw new InvalidArgumentException('Can only filter iterable values %s given');
}
foreach ($currentObject as $current) {
if (! $this->expression->visit($param, $current, $root)) {
continue;
}
yield $current;
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
final class FunctionCall implements QueryNode
{
/** @var QueryNode[] */
private readonly array $arguments;
public function __construct(private readonly string $name, QueryNode ...$arguments)
{
$this->arguments = $arguments;
}
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root)
{
return $param->evaluateFunctionCall($root, $currentObject, $this->name, ...$this->arguments);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
class Path implements QueryNode
{
/** @param non-empty-list<PathNode> $nodes */
public function __construct(private readonly array $nodes)
{
}
/** @return non-empty-list<PathNode>*/
public function getNodes(): array
{
return $this->nodes;
}
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root)
{
return $param->evaluatePath($root, $currentObject, ...$this->nodes);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
interface PathNode extends QueryNode
{
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
interface QueryNode
{
/**
* @param mixed $currentObject
* @param mixed $root
*
* @return mixed
*/
public function visit(Executor $param, $currentObject, $root);
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
class RootNode implements PathNode
{
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root)
{
return $root ?? $currentObject;
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
class Value implements QueryNode
{
public function __construct(private readonly mixed $value)
{
}
/**
* @param mixed $currentObject
* @param mixed $root
*
* @return mixed
*/
public function visit(Executor $param, $currentObject, $root)
{
return $this->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace phpDocumentor\JsonPath\AST;
use phpDocumentor\JsonPath\Executor;
final class Wildcard implements Expression
{
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root): bool
{
return true;
}
public function getName(): string
{
return '*';
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath;
use ArrayAccess;
use Generator;
use phpDocumentor\JsonPath\AST\Expression;
use phpDocumentor\JsonPath\AST\FieldName;
use phpDocumentor\JsonPath\AST\PathNode;
use phpDocumentor\JsonPath\AST\QueryNode;
use phpDocumentor\JsonPath\AST\Wildcard;
use Symfony\Component\PropertyAccess\PropertyAccess;
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\PropertyAccess\PropertyPath;
use function array_merge;
use function current;
use function is_array;
use function is_iterable;
use function is_object;
use function is_string;
use function iterator_to_array;
use function str_starts_with;
use function strrpos;
use function substr;
final class Executor
{
private readonly PropertyAccessor $propertyAccessor;
public function __construct()
{
$this->propertyAccessor = PropertyAccess::createPropertyAccessor();
}
/** @return mixed */
public function evaluate(QueryNode $query, mixed $currentElement, mixed $rootElement = null)
{
return $query->visit($this, $currentElement, $rootElement);
}
public function evaluateEqualsComparison(
mixed $root,
mixed $currentObject,
QueryNode $left,
QueryNode $right,
): bool {
$leftValue = $this->toValue($this->evaluate($left, $currentObject, $root));
$rightValue = $this->toValue($this->evaluate($right, $currentObject, $root));
if (is_string($rightValue)) {
return ((string) $leftValue) === $rightValue;
}
return $leftValue === $rightValue;
}
public function evaluateNotEqualsComparison(
mixed $root,
mixed $currentObject,
QueryNode $left,
QueryNode $right,
): bool {
return ! $this->evaluateEqualsComparison($root, $currentObject, $left, $right);
}
public function evaluateStartsWithComparison(
mixed $root,
mixed $currentObject,
QueryNode $left,
QueryNode $right,
): bool {
$leftValue = $this->toValue($this->evaluate($left, $currentObject, $root));
$rightValue = $this->toValue($this->evaluate($right, $currentObject, $root));
return str_starts_with((string) $leftValue, (string) $rightValue);
}
public function evaluateContainsComparison(
mixed $root,
mixed $currentObject,
QueryNode $left,
QueryNode $right,
): bool {
$leftValue = $this->toValue($this->evaluate($left, $currentObject, $root));
$rightValue = $this->toValue($this->evaluate($right, $currentObject, $root));
if (is_iterable($leftValue)) {
foreach ($leftValue as $value) {
if (is_string($rightValue) && ((string) $value) === $rightValue) {
return true;
}
if ($value === $rightValue) {
return true;
}
}
return false;
}
return false;
}
/**
* @param Generator<mixed>|mixed $value
*
* @return mixed
*/
private function toValue($value)
{
if ($value instanceof Generator) {
$result = iterator_to_array($value, false);
return current($result);
}
return $value;
}
/** @return mixed */
public function evaluatePath(mixed $root, mixed $currentElement, PathNode ...$nodes)
{
$result = $currentElement;
foreach ($nodes as $node) {
$result = $this->evaluate($node, $result, $root);
}
return $result;
}
/** @return mixed */
public function evaluateFunctionCall(
mixed $root,
mixed $currentElement,
string $functionName,
QueryNode ...$arguments,
) {
switch ($functionName) {
case 'type':
$class = $this->evaluate($arguments[0], $currentElement, $root)::class;
$isNamespacedClass = strrpos($class, '\\');
if ($isNamespacedClass !== false) {
return substr($class, $isNamespacedClass + 1);
}
return $class;
}
}
/** @return Generator<mixed> */
public function evaluateFieldAccess(mixed $currentElement, FieldName|Expression $fieldName): Generator
{
if ($fieldName instanceof Wildcard && is_iterable($currentElement)) {
foreach ($currentElement as $element) {
foreach ($element as $value) {
yield $value;
}
}
return;
}
if ($currentElement instanceof Generator) {
foreach ($currentElement as $element) {
foreach ($this->evaluateFieldAccess($element, $fieldName) as $result) {
yield $result;
}
}
return;
}
if (
(is_array($currentElement) || $currentElement instanceof ArrayAccess) &&
isset($currentElement[$fieldName->getName()])
) {
yield $currentElement[$fieldName->getName()];
} elseif (is_iterable($currentElement)) {
$result = [];
foreach ($currentElement as $element) {
foreach ($this->evaluateFieldAccess($element, $fieldName) as $row) {
if (is_iterable($row)) {
$result = array_merge(
$result,
is_array($row) ? $row : iterator_to_array($row, false),
);
continue;
}
$result[] = $row;
}
}
yield from $result;
} else {
if (is_object($currentElement) === false) {
return;
}
if (
$this->propertyAccessor->isReadable(
$currentElement,
new PropertyPath($fieldName->getName()),
) === false
) {
return;
}
yield $this->propertyAccessor->getValue($currentElement, new PropertyPath($fieldName->getName()));
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath;
use Parsica\Parsica\Parser as InnerParser;
use phpDocumentor\JsonPath\AST\Path;
use phpDocumentor\JsonPath\Parser\ParserBuilder;
final class Parser
{
/** @param InnerParser<Path> $innerParser*/
private function __construct(private readonly InnerParser $innerParser)
{
}
public static function createInstance(): self
{
return new self(
(new ParserBuilder())->build(),
);
}
public function parse(string $query): Query
{
return new Query(
$this->innerParser->tryString($query)->output(),
);
}
}

View File

@@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\Parser;
use Parsica\Parsica\Parser;
use phpDocumentor\JsonPath\AST\Comparison;
use phpDocumentor\JsonPath\AST\CurrentNode;
use phpDocumentor\JsonPath\AST\FieldAccess;
use phpDocumentor\JsonPath\AST\FieldName;
use phpDocumentor\JsonPath\AST\FilterNode;
use phpDocumentor\JsonPath\AST\FunctionCall;
use phpDocumentor\JsonPath\AST\Path;
use phpDocumentor\JsonPath\AST\RootNode;
use phpDocumentor\JsonPath\AST\Value;
use phpDocumentor\JsonPath\AST\Wildcard;
use function is_array;
use function Parsica\Parsica\alphaNumChar;
use function Parsica\Parsica\any;
use function Parsica\Parsica\atLeastOne;
use function Parsica\Parsica\between;
use function Parsica\Parsica\char;
use function Parsica\Parsica\choice;
use function Parsica\Parsica\collect;
use function Parsica\Parsica\keepSecond;
use function Parsica\Parsica\noneOfS;
use function Parsica\Parsica\optional;
use function Parsica\Parsica\recursive;
use function Parsica\Parsica\sepBy;
use function Parsica\Parsica\skipHSpace;
use function Parsica\Parsica\some;
use function Parsica\Parsica\string;
use function Parsica\Parsica\whitespace;
final class ParserBuilder
{
/** @return Parser<RootNode> */
private static function rootNode(): Parser
{
return char('$')->map(static fn () => new RootNode())->label('$');
}
/** @return Parser<CurrentNode> */
private static function currentNode(): Parser
{
return char('@')->map(static fn () => new CurrentNode());
}
/** @return Parser<FieldAccess> */
private static function fieldAccess(): Parser
{
$fieldName = self::fieldName();
return choice(
keepSecond(char('.'), any($fieldName, self::wildcard())),
between(string("['"), string("']"), $fieldName),
)->map(static fn ($args) => new FieldAccess($args));
}
/** @return Parser<Wildcard> */
private static function wildcard(): Parser
{
return string('*')->label('Wildcard')->map(static fn () => new Wildcard());
}
/** @return Parser<FilterNode> */
private static function filter(): Parser
{
return choice(
between(
string('['),
string(']'),
self::wildcard(),
)->map(static fn ($wildcard) => new FilterNode($wildcard)),
between(
string('[?('),
string(')]'),
self::expression(),
)->map((static fn ($expression) => new FilterNode($expression))),
);
}
/** @return Parser<Comparison> */
private static function expression(): Parser
{
$operator = choice(
string('=='),
string('!='),
string('starts_with'),
string('contains'),
);
$value = choice(
between(char('"'), char('"'), atLeastOne(noneOfS('"')))
->map(static fn ($value) => new Value($value)),
between(char("'"), char("'"), atLeastOne(noneOfS("'")))
->map(static fn ($value) => new Value($value)),
)->label('VALUE');
return collect(
choice(
self::currentNodeFollowUp(),
self::functionCall(),
),
optional(whitespace())->followedBy($operator),
optional(whitespace())->followedBy($value),
)->map(static fn ($args) => new Comparison($args[0], $args[1], $args[2]));
}
/** @return Parser<Path> */
private static function currentNodeFollowUp(): Parser
{
$inner = choice(
self::fieldAccess(),
);
return self::currentNode()->followedBy(
some($inner)->map(static fn ($args) => is_array($args) ? $args : []),
)->map(static fn ($args) => new Path([new CurrentNode(), ...$args]));
}
/** @return Parser<FunctionCall> */
private static function functionCall(): Parser
{
return collect(
atLeastOne(alphaNumChar()),
skipHSpace()->followedBy(
between(
char('('),
char(')'),
optional(self::arguments()),
),
),
)->map(static fn ($a) => new FunctionCall($a[0], ...$a[1]));
}
/** @return Parser<list<mixed>> */
private static function arguments(): Parser
{
return sepBy(
char(','),
choice(self::currentNodeFollowUp(), self::currentNode()),
);
}
/** @return Parser<FieldName> */
private static function fieldName(): Parser
{
return atLeastOne(
alphaNumChar()->or(char('_')),
)->label('NODE_NAME')->map(static fn ($name) => new FieldName($name));
}
/** @return Parser<Path> */
private static function rootFollowUp(): Parser
{
$inner = choice(
self::fieldAccess(),
self::filter(),
);
$path = recursive();
$path->recurse(collect($inner, $path));
return collect(
self::rootNode(),
some($inner),
)->map(
static fn ($args) => new Path([$args[0], ...$args[1]]),
);
}
/** @return Parser<Path> */
public function build(): Parser
{
return choice(
self::rootFollowUp(),
self::currentNodeFollowUp(),
self::rootNode(),
self::currentNode(),
)->thenEof()->label('End of Query');
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath;
use phpDocumentor\JsonPath\AST\QueryNode;
final class Query implements QueryNode
{
public function __construct(private readonly QueryNode $node)
{
}
/** @inheritDoc */
public function visit(Executor $param, $currentObject, $root)
{
return $param->evaluate($this->node, $currentObject, $root);
}
}

View File

@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath;
use phpDocumentor\JsonPath\AST\Comparison;
use phpDocumentor\JsonPath\AST\CurrentNode;
use phpDocumentor\JsonPath\AST\FieldAccess;
use phpDocumentor\JsonPath\AST\FieldName;
use phpDocumentor\JsonPath\AST\FilterNode;
use phpDocumentor\JsonPath\AST\FunctionCall;
use phpDocumentor\JsonPath\AST\Path;
use phpDocumentor\JsonPath\AST\RootNode;
use phpDocumentor\JsonPath\AST\Value;
use phpDocumentor\JsonPath\AST\Wildcard;
use phpDocumentor\JsonPath\Fixtures\Book;
use phpDocumentor\JsonPath\Fixtures\Commic;
use phpDocumentor\JsonPath\Fixtures\Store;
use PHPUnit\Framework\TestCase;
use stdClass;
use function iterator_to_array;
final class ExecutorTest extends TestCase
{
public function testQueryRootSource(): void
{
$store = new Store();
$executor = new Executor();
$result = $executor->evaluate(
new Path(
[
new RootNode(),
new FieldAccess(new FieldName('store')),
],
),
['store' => $store],
);
self::assertSame([$store], iterator_to_array($result, false));
}
public function testQueryRootSourceObject(): void
{
$root = new stdClass();
$store = new Store();
$root->store = $store;
$executor = new Executor();
$result = $executor->evaluate(
new Path(
[
new RootNode(),
new FieldAccess(new FieldName('store')),
],
),
$root,
);
self::assertSame([$store], iterator_to_array($result));
}
public function testQuerySubProperty(): void
{
$root = new stdClass();
$store = new Store();
$store->addBook(new Book('First book'));
$store->addBook(new Book('Second book'));
$root->store = $store;
$executor = new Executor();
$result = $executor->evaluate(
new Path(
[
new RootNode(),
new FieldAccess(new FieldName('store')),
new FieldAccess(new FieldName('books')),
new FieldAccess(new Wildcard()),
],
),
$root,
);
self::assertSame($store->getBooks(), iterator_to_array($result, false));
}
public function testQuerySubPropertyByFilter(): void
{
$book = new Book('phpDoc');
$root = new stdClass();
$store = new Store();
$store->addBook(new Book('First book'));
$store->addBook($book);
$store->addBook(new Book('Second book'));
$root->store = $store;
$executor = new Executor();
$result = $executor->evaluate(
new Path(
[
new RootNode(),
new FieldAccess(new FieldName('store')),
new FieldAccess(new FieldName('books')),
new FieldAccess(new Wildcard()),
new FilterNode(
new Comparison(
new Path([
new CurrentNode(),
new FieldAccess(new FieldName('title')),
]),
'==',
new Value(
'phpDoc',
),
),
),
],
),
$root,
);
self::assertSame([$book], iterator_to_array($result, false));
}
public function testQuerySubPropertyByFilterFunctionCall(): void
{
$book = new Commic('phpDoc');
$root = new stdClass();
$store = new Store();
$store->addBook(new Book('First book'));
$store->addBook($book);
$store->addBook(new Book('Second book'));
$root->store = $store;
$executor = new Executor();
$result = $executor->evaluate(
new Path(
[
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books'),
),
new FieldAccess(
new Wildcard(),
),
new FilterNode(
new Comparison(
new FunctionCall(
'type',
new Path([
new CurrentNode(),
]),
),
'==',
new Value(
'Commic',
),
),
),
],
),
$root,
);
self::assertSame([$book], iterator_to_array($result, false));
}
public function testQueryWithWildcard(): void
{
$books = [
'phpDoc',
'First book',
'Second book',
];
$root = new stdClass();
$root->store = $this->createStore($books);
$executor = new Executor();
$result = $executor->evaluate(
new Path(
[
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books'),
),
new FilterNode(
new Wildcard(),
),
new FieldAccess(
new FieldName('title'),
),
],
),
$root,
);
self::assertSame($books, iterator_to_array($result, false));
}
public function testQueryCollectionInCollection(): void
{
$books = [
'phpDoc',
'First book',
'Second book',
];
$root = new stdClass();
$root->stores = [];
$root->stores[] = $this->createStore($books);
$root->stores[] = $this->createStore(['foo', 'bar']);
$root->stores[] = $this->createStore($books);
$executor = new Executor();
$result = $executor->evaluate(
new Path(
[
new RootNode(),
new FieldAccess(
new FieldName('stores'),
),
new FilterNode(
new Wildcard(),
),
new FieldAccess(
new FieldName('books'),
),
new FilterNode(
new Comparison(
new Path([
new CurrentNode(),
new FieldAccess(new FieldName('title')),
]),
'==',
new Value(
'phpDoc',
),
),
),
new FieldAccess(
new FieldName('title'),
),
],
),
$root,
);
self::assertEquals(['phpDoc', 'phpDoc'], iterator_to_array($result, false));
}
private function createStore(array $books): Store
{
$store = new Store();
foreach ($books as $title) {
$store->addBook(new Book($title));
}
return $store;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\Fixtures;
class Book
{
public function __construct(private readonly string $title)
{
}
public function getTitle(): string
{
return $this->title;
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\Fixtures;
class Commic extends Book
{
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\Fixtures;
class Store
{
/** @var Book[] */
private array $books = [];
public function __construct()
{
}
public function getAddress(): string
{
return 'My Address';
}
public function addBook(Book $book): void
{
$this->books[] = $book;
}
public function getBooks(): array
{
return $this->books;
}
}

View File

@@ -0,0 +1,282 @@
<?php
declare(strict_types=1);
/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/
namespace phpDocumentor\JsonPath\Parser;
use Generator;
use Parsica\Parsica\Parser;
use phpDocumentor\JsonPath\AST\Comparison;
use phpDocumentor\JsonPath\AST\CurrentNode;
use phpDocumentor\JsonPath\AST\FieldAccess;
use phpDocumentor\JsonPath\AST\FieldName;
use phpDocumentor\JsonPath\AST\FilterNode;
use phpDocumentor\JsonPath\AST\FunctionCall;
use phpDocumentor\JsonPath\AST\Path;
use phpDocumentor\JsonPath\AST\RootNode;
use phpDocumentor\JsonPath\AST\Value;
use phpDocumentor\JsonPath\AST\Wildcard;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class ParserBuilderTest extends TestCase
{
private Parser $parser;
protected function setUp(): void
{
$this->parser = (new ParserBuilder())->build();
}
public function testRootNodeIsParsed(): void
{
$result = $this->parser->tryString('$');
self::assertEquals(new RootNode(), $result->output());
}
public function testCurrentNodeIsParsed(): void
{
$result = $this->parser->tryString('@');
self::assertEquals(new CurrentNode(), $result->output());
}
public function testCurrentNodeChildren(): void
{
$result = $this->parser->tryString('@.*');
self::assertEquals(new Path([new CurrentNode(), new FieldAccess(new Wildcard())]), $result->output());
}
public function testRootFieldAccess(): void
{
$result = $this->parser->tryString('$.store');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
]),
$result->output(),
);
}
public function testRootFieldAccessArrayLike(): void
{
$result = $this->parser->tryString('$[\'store\']');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
]),
$result->output(),
);
}
public function testRootFieldChildAccess(): void
{
$result = $this->parser->tryString('$.store.address');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('address'),
),
]),
$result->output(),
);
}
#[DataProvider('operatorProvider')]
public function testFilterExpression(string $operator): void
{
$result = $this->parser->tryString('$.store.books[?(@.title ' . $operator . ' "phpDoc")]');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books'),
),
new FilterNode(
new Comparison(
new Path([
new CurrentNode(),
new FieldAccess(new FieldName('title')),
]),
$operator,
new Value(
'phpDoc',
),
),
),
]),
$result->output(),
);
}
/** @return Generator<string, string> */
public static function operatorProvider(): Generator
{
$operators = [
'==',
'!=',
'starts_with',
];
foreach ($operators as $operator) {
yield $operator => [$operator];
}
}
public function testFilterExpressionCurrentObjectProperyWildCard(): void
{
$result = $this->parser->tryString('$.store.books[?(@.* == "phpDoc")]');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books'),
),
new FilterNode(
new Comparison(
new Path([
new CurrentNode(),
new FieldAccess(new Wildcard()),
]),
'==',
new Value(
'phpDoc',
),
),
),
]),
$result->output(),
);
}
public function testFilterExpressionCurrentObjectTypeEquals(): void
{
$result = $this->parser->tryString('$.store.books[?(type(@) == "api")]');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books'),
),
new FilterNode(
new Comparison(
new FunctionCall(
'type',
new CurrentNode(),
),
'==',
new Value(
'api',
),
),
),
]),
$result->output(),
);
}
public function testFilterExpressionCurrentObjectChildrenTypeEquals(): void
{
$result = $this->parser->tryString('$.store.books[?(type(@.*) == "api")]');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books'),
),
new FilterNode(
new Comparison(
new FunctionCall(
'type',
new Path([
new CurrentNode(),
new FieldAccess(new Wildcard()),
]),
),
'==',
new Value(
'api',
),
),
),
]),
$result->output(),
);
}
public function testFilterExpressionWildcard(): void
{
$result = $this->parser->tryString('$.store.books_title[*]');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books_title'),
),
new FilterNode(
new Wildcard(),
),
]),
$result->output(),
);
}
public function testFilterExpressionWildcardNested(): void
{
$result = $this->parser->tryString('$.store.books_title.*[*]');
self::assertEquals(
new Path([
new RootNode(),
new FieldAccess(
new FieldName('store'),
),
new FieldAccess(
new FieldName('books_title'),
),
new FieldAccess(
new Wildcard(),
),
new FilterNode(
new Wildcard(),
),
]),
$result->output(),
);
}
}