From f34f4c491dd47f57fce20c0cdfb3fc3643a1113f Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Tue, 28 Jan 2025 15:38:26 +0100 Subject: [PATCH] [JsonPath] Add the component as experimental --- composer.json | 1 + src/Symfony/Component/JsonPath/.gitattributes | 3 + .../JsonPath/.github/PULL_REQUEST_TEMPLATE.md | 8 + .../.github/workflows/close-pull-request.yml | 20 + src/Symfony/Component/JsonPath/.gitignore | 3 + src/Symfony/Component/JsonPath/CHANGELOG.md | 7 + .../JsonPath/Exception/ExceptionInterface.php | 21 + .../Exception/InvalidArgumentException.php | 21 + .../Exception/InvalidJsonPathException.php | 25 + .../InvalidJsonStringInputException.php | 27 ++ .../Exception/JsonCrawlerException.php | 25 + .../Component/JsonPath/JsonCrawler.php | 418 ++++++++++++++++ .../JsonPath/JsonCrawlerInterface.php | 31 ++ src/Symfony/Component/JsonPath/JsonPath.php | 73 +++ .../Component/JsonPath/JsonPathUtils.php | 88 ++++ src/Symfony/Component/JsonPath/LICENSE | 19 + src/Symfony/Component/JsonPath/README.md | 42 ++ .../JsonPath/Tests/JsonCrawlerTest.php | 456 ++++++++++++++++++ .../Component/JsonPath/Tests/JsonPathTest.php | 38 ++ .../JsonPath/Tests/JsonPathUtilsTest.php | 186 +++++++ .../Tests/Tokenizer/JsonPathTokenizerTest.php | 365 ++++++++++++++ .../JsonPath/Tokenizer/JsonPathToken.php | 26 + .../JsonPath/Tokenizer/JsonPathTokenizer.php | 172 +++++++ .../JsonPath/Tokenizer/TokenType.php | 24 + src/Symfony/Component/JsonPath/composer.json | 32 ++ .../Component/JsonPath/phpunit.xml.dist | 31 ++ .../Component/JsonStreamer/Read/Splitter.php | 2 +- 27 files changed, 2163 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/JsonPath/.gitattributes create mode 100644 src/Symfony/Component/JsonPath/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 src/Symfony/Component/JsonPath/.github/workflows/close-pull-request.yml create mode 100644 src/Symfony/Component/JsonPath/.gitignore create mode 100644 src/Symfony/Component/JsonPath/CHANGELOG.md create mode 100644 src/Symfony/Component/JsonPath/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/JsonPath/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/JsonPath/Exception/InvalidJsonPathException.php create mode 100644 src/Symfony/Component/JsonPath/Exception/InvalidJsonStringInputException.php create mode 100644 src/Symfony/Component/JsonPath/Exception/JsonCrawlerException.php create mode 100644 src/Symfony/Component/JsonPath/JsonCrawler.php create mode 100644 src/Symfony/Component/JsonPath/JsonCrawlerInterface.php create mode 100644 src/Symfony/Component/JsonPath/JsonPath.php create mode 100644 src/Symfony/Component/JsonPath/JsonPathUtils.php create mode 100644 src/Symfony/Component/JsonPath/LICENSE create mode 100644 src/Symfony/Component/JsonPath/README.md create mode 100644 src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php create mode 100644 src/Symfony/Component/JsonPath/Tests/JsonPathTest.php create mode 100644 src/Symfony/Component/JsonPath/Tests/JsonPathUtilsTest.php create mode 100644 src/Symfony/Component/JsonPath/Tests/Tokenizer/JsonPathTokenizerTest.php create mode 100644 src/Symfony/Component/JsonPath/Tokenizer/JsonPathToken.php create mode 100644 src/Symfony/Component/JsonPath/Tokenizer/JsonPathTokenizer.php create mode 100644 src/Symfony/Component/JsonPath/Tokenizer/TokenType.php create mode 100644 src/Symfony/Component/JsonPath/composer.json create mode 100644 src/Symfony/Component/JsonPath/phpunit.xml.dist diff --git a/composer.json b/composer.json index 67a1cfbafe582..3cfbe70ae68d8 100644 --- a/composer.json +++ b/composer.json @@ -83,6 +83,7 @@ "symfony/http-foundation": "self.version", "symfony/http-kernel": "self.version", "symfony/intl": "self.version", + "symfony/json-path": "self.version", "symfony/json-streamer": "self.version", "symfony/ldap": "self.version", "symfony/lock": "self.version", diff --git a/src/Symfony/Component/JsonPath/.gitattributes b/src/Symfony/Component/JsonPath/.gitattributes new file mode 100644 index 0000000000000..14c3c35940427 --- /dev/null +++ b/src/Symfony/Component/JsonPath/.gitattributes @@ -0,0 +1,3 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.git* export-ignore diff --git a/src/Symfony/Component/JsonPath/.github/PULL_REQUEST_TEMPLATE.md b/src/Symfony/Component/JsonPath/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000000000..4689c4dad430e --- /dev/null +++ b/src/Symfony/Component/JsonPath/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/src/Symfony/Component/JsonPath/.github/workflows/close-pull-request.yml b/src/Symfony/Component/JsonPath/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000000000..e55b47817e69a --- /dev/null +++ b/src/Symfony/Component/JsonPath/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/src/Symfony/Component/JsonPath/.gitignore b/src/Symfony/Component/JsonPath/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/JsonPath/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/JsonPath/CHANGELOG.md b/src/Symfony/Component/JsonPath/CHANGELOG.md new file mode 100644 index 0000000000000..0f29770616c5f --- /dev/null +++ b/src/Symfony/Component/JsonPath/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.3 +--- + + * Add the component as experimental diff --git a/src/Symfony/Component/JsonPath/Exception/ExceptionInterface.php b/src/Symfony/Component/JsonPath/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..55e189db85991 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Exception; + +/** + * @author Alexandre Daubois + * + * @experimental + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/JsonPath/Exception/InvalidArgumentException.php b/src/Symfony/Component/JsonPath/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..6b2697686b661 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Exception; + +/** + * @author Alexandre Daubois + * + * @experimental + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/JsonPath/Exception/InvalidJsonPathException.php b/src/Symfony/Component/JsonPath/Exception/InvalidJsonPathException.php new file mode 100644 index 0000000000000..8a62f411197eb --- /dev/null +++ b/src/Symfony/Component/JsonPath/Exception/InvalidJsonPathException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Exception; + +/** + * @author Alexandre Daubois + * + * @experimental + */ +class InvalidJsonPathException extends \LogicException implements ExceptionInterface +{ + public function __construct(string $message, ?int $position = null) + { + parent::__construct(\sprintf('JSONPath syntax error%s: %s', $position ? ' at position '.$position : '', $message)); + } +} diff --git a/src/Symfony/Component/JsonPath/Exception/InvalidJsonStringInputException.php b/src/Symfony/Component/JsonPath/Exception/InvalidJsonStringInputException.php new file mode 100644 index 0000000000000..013986349a8e2 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Exception/InvalidJsonStringInputException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Exception; + +/** + * Thrown when a string passed as an input is not a valid JSON string, e.g. in {@see JsonCrawler}. + * + * @author Alexandre Daubois + * + * @experimental + */ +class InvalidJsonStringInputException extends InvalidArgumentException +{ + public function __construct(string $message, ?\Throwable $previous = null) + { + parent::__construct(\sprintf('Invalid JSON input: %s.', $message), previous: $previous); + } +} diff --git a/src/Symfony/Component/JsonPath/Exception/JsonCrawlerException.php b/src/Symfony/Component/JsonPath/Exception/JsonCrawlerException.php new file mode 100644 index 0000000000000..222d3fb8da430 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Exception/JsonCrawlerException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Exception; + +/** + * @author Alexandre Daubois + * + * @experimental + */ +class JsonCrawlerException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(string $path, string $message, ?\Throwable $previous = null) + { + parent::__construct(\sprintf('Error while crawling JSON with JSON path "%s": %s.', $path, $message), previous: $previous); + } +} diff --git a/src/Symfony/Component/JsonPath/JsonCrawler.php b/src/Symfony/Component/JsonPath/JsonCrawler.php new file mode 100644 index 0000000000000..18a929571c47f --- /dev/null +++ b/src/Symfony/Component/JsonPath/JsonCrawler.php @@ -0,0 +1,418 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath; + +use Symfony\Component\JsonPath\Exception\InvalidArgumentException; +use Symfony\Component\JsonPath\Exception\InvalidJsonStringInputException; +use Symfony\Component\JsonPath\Exception\JsonCrawlerException; +use Symfony\Component\JsonPath\Tokenizer\JsonPathToken; +use Symfony\Component\JsonPath\Tokenizer\JsonPathTokenizer; +use Symfony\Component\JsonPath\Tokenizer\TokenType; +use Symfony\Component\JsonStreamer\Read\Splitter; + +/** + * Crawls a JSON document using a JSON Path as described in the RFC 9535. + * + * @see https://datatracker.ietf.org/doc/html/rfc9535 + * + * @author Alexandre Daubois + * + * @experimental + */ +final class JsonCrawler implements JsonCrawlerInterface +{ + private const RFC9535_FUNCTIONS = [ + 'length' => true, + 'count' => true, + 'match' => true, + 'search' => true, + 'value' => true, + ]; + + /** + * @param resource|string $raw + */ + public function __construct( + private readonly mixed $raw, + ) { + if (!\is_string($raw) && !\is_resource($raw)) { + throw new InvalidArgumentException(\sprintf('Expected string or resource, got "%s".', get_debug_type($raw))); + } + } + + public function find(string|JsonPath $query): array + { + return $this->evaluate(\is_string($query) ? new JsonPath($query) : $query); + } + + private function evaluate(JsonPath $query): array + { + try { + $tokens = JsonPathTokenizer::tokenize($query); + $json = $this->raw; + + if (\is_resource($this->raw)) { + if (!class_exists(Splitter::class)) { + throw new \LogicException('The JsonEncoder package is required to evaluate a path against a resource. Try running "composer require symfony/json-streamer".'); + } + + $simplified = JsonPathUtils::findSmallestDeserializableStringAndPath( + $tokens, + $this->raw, + ); + + $tokens = $simplified['tokens']; + $json = $simplified['json']; + } + + try { + $data = json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidJsonStringInputException($e->getMessage(), $e); + } + + $current = [$data]; + + foreach ($tokens as $token) { + $next = []; + foreach ($current as $value) { + $result = $this->evaluateToken($token, $value); + $next = array_merge($next, $result); + } + + $current = $next; + } + + return $current; + } catch (InvalidArgumentException $e) { + throw $e; + } catch (\Throwable $e) { + throw new JsonCrawlerException($query, $e->getMessage(), previous: $e); + } + } + + private function evaluateToken(JsonPathToken $token, mixed $value): array + { + return match ($token->type) { + TokenType::Name => $this->evaluateName($token->value, $value), + TokenType::Bracket => $this->evaluateBracket($token->value, $value), + TokenType::Recursive => $this->evaluateRecursive($value), + }; + } + + private function evaluateName(string $name, mixed $value): array + { + if (!\is_array($value)) { + return []; + } + + if ('*' === $name) { + return array_values($value); + } + + return \array_key_exists($name, $value) ? [$value[$name]] : []; + } + + private function evaluateBracket(string $expr, mixed $value): array + { + if (!\is_array($value)) { + return []; + } + + if ('*' === $expr) { + return array_values($value); + } + + // single negative index + if (preg_match('/^-\d+$/', $expr)) { + if (!array_is_list($value)) { + return []; + } + + $index = \count($value) + (int) $expr; + + return isset($value[$index]) ? [$value[$index]] : []; + } + + // start and end index + if (preg_match('/^-?\d+(?:\s*,\s*-?\d+)*$/', $expr)) { + if (!array_is_list($value)) { + return []; + } + + $result = []; + foreach (explode(',', $expr) as $index) { + $index = (int) trim($index); + if ($index < 0) { + $index = \count($value) + $index; + } + if (isset($value[$index])) { + $result[] = $value[$index]; + } + } + + return $result; + } + + // start, end and step + if (preg_match('/^(-?\d*):(-?\d*)(?::(-?\d+))?$/', $expr, $matches)) { + if (!array_is_list($value)) { + return []; + } + + $length = \count($value); + $start = '' !== $matches[1] ? (int) $matches[1] : null; + $end = '' !== $matches[2] ? (int) $matches[2] : null; + $step = isset($matches[3]) && '' !== $matches[3] ? (int) $matches[3] : 1; + + if (0 === $step || $start > $length) { + return []; + } + + if (null === $start) { + $start = $step > 0 ? 0 : $length - 1; + } else { + if ($start < 0) { + $start = $length + $start; + } + $start = max(0, min($start, $length - 1)); + } + + if (null === $end) { + $end = $step > 0 ? $length : -1; + } else { + if ($end < 0) { + $end = $length + $end; + } + if ($step > 0) { + $end = max(0, min($end, $length)); + } else { + $end = max(-1, min($end, $length - 1)); + } + } + + $result = []; + for ($i = $start; $step > 0 ? $i < $end : $i > $end; $i += $step) { + if (isset($value[$i])) { + $result[] = $value[$i]; + } + } + + return $result; + } + + // filter expressions + if (preg_match('/^\?(.*)$/', $expr, $matches)) { + $filterExpr = $matches[1]; + + if (preg_match('/^(\w+)\s*\([^()]*\)\s*([<>=!]+.*)?$/', $filterExpr)) { + $filterExpr = "($filterExpr)"; + } + + if (!str_starts_with($filterExpr, '(')) { + throw new JsonCrawlerException($expr, 'Invalid filter expression'); + } + + // remove outrer filter parentheses + $innerExpr = substr(substr($filterExpr, 1), 0, -1); + + return $this->evaluateFilter($innerExpr, $value); + } + + // quoted strings for object keys + if (preg_match('/^([\'"])(.*)\1$/', $expr, $matches)) { + $key = stripslashes($matches[2]); + + return \array_key_exists($key, $value) ? [$value[$key]] : []; + } + + throw new \LogicException(\sprintf('Unsupported bracket expression "%s".', $expr)); + } + + private function evaluateFilter(string $expr, mixed $value): array + { + if (!\is_array($value)) { + return []; + } + + $result = []; + foreach ($value as $item) { + if (!\is_array($item)) { + continue; + } + + if ($this->evaluateFilterExpression($expr, $item)) { + $result[] = $item; + } + } + + return $result; + } + + private function evaluateFilterExpression(string $expr, array $context): bool + { + $expr = trim($expr); + + if (str_contains($expr, '&&')) { + $parts = array_map('trim', explode('&&', $expr)); + foreach ($parts as $part) { + if (!$this->evaluateFilterExpression($part, $context)) { + return false; + } + } + + return true; + } + + if (str_contains($expr, '||')) { + $parts = array_map('trim', explode('||', $expr)); + $result = false; + foreach ($parts as $part) { + $result = $result || $this->evaluateFilterExpression($part, $context); + } + + return $result; + } + + $operators = ['!=', '==', '>=', '<=', '>', '<']; + foreach ($operators as $op) { + if (str_contains($expr, $op)) { + [$left, $right] = array_map('trim', explode($op, $expr, 2)); + $leftValue = $this->evaluateScalar($left, $context); + $rightValue = $this->evaluateScalar($right, $context); + + return $this->compare($leftValue, $rightValue, $op); + } + } + + if (str_starts_with($expr, '@.')) { + $path = substr($expr, 2); + + return \array_key_exists($path, $context); + } + + // function calls + if (preg_match('/^(\w+)\((.*)\)$/', $expr, $matches)) { + $functionName = $matches[1]; + if (!isset(self::RFC9535_FUNCTIONS[$functionName])) { + throw new JsonCrawlerException($expr, \sprintf('invalid function "%s"', $functionName)); + } + + $functionResult = $this->evaluateFunction($functionName, $matches[2], $context); + + return is_numeric($functionResult) ? $functionResult > 0 : (bool) $functionResult; + } + + return false; + } + + private function evaluateScalar(string $expr, array $context): mixed + { + if (is_numeric($expr)) { + return str_contains($expr, '.') ? (float) $expr : (int) $expr; + } + + if ('true' === $expr) { + return true; + } + + if ('false' === $expr) { + return false; + } + + if ('null' === $expr) { + return null; + } + + // string literals + if (preg_match('/^([\'"])(.*)\1$/', $expr, $matches)) { + return $matches[2]; + } + + // current node references + if (str_starts_with($expr, '@.')) { + $path = substr($expr, 2); + + return $context[$path] ?? null; + } + + // function calls + if (preg_match('/^(\w+)\((.*)\)$/', $expr, $matches)) { + $functionName = $matches[1]; + if (!isset(self::RFC9535_FUNCTIONS[$functionName])) { + throw new JsonCrawlerException($expr, \sprintf('invalid function "%s"', $functionName)); + } + + return $this->evaluateFunction($functionName, $matches[2], $context); + } + + return null; + } + + private function evaluateFunction(string $name, string $args, array $context): mixed + { + $args = array_map( + fn ($arg) => $this->evaluateScalar(trim($arg), $context), + explode(',', $args) + ); + + $value = $args[0] ?? null; + + return match ($name) { + 'length' => match (true) { + \is_string($value) => mb_strlen($value), + \is_array($value) => \count($value), + default => 0, + }, + 'count' => \is_array($value) ? \count($value) : 0, + 'match' => match (true) { + \is_string($value) && \is_string($args[1] ?? null) => (bool) @preg_match(\sprintf('/^%s$/', $args[1]), $value), + default => false, + }, + 'search' => match (true) { + \is_string($value) && \is_string($args[1] ?? null) => (bool) @preg_match("/$args[1]/", $value), + default => false, + }, + 'value' => $value, + default => null, + }; + } + + private function evaluateRecursive(mixed $value): array + { + if (!\is_array($value)) { + return []; + } + + $result = [$value]; + foreach ($value as $item) { + if (\is_array($item)) { + $result = array_merge($result, $this->evaluateRecursive($item)); + } + } + + return $result; + } + + private function compare(mixed $left, mixed $right, string $operator): bool + { + return match ($operator) { + '==' => $left === $right, + '!=' => $left !== $right, + '>' => $left > $right, + '>=' => $left >= $right, + '<' => $left < $right, + '<=' => $left <= $right, + default => false, + }; + } +} diff --git a/src/Symfony/Component/JsonPath/JsonCrawlerInterface.php b/src/Symfony/Component/JsonPath/JsonCrawlerInterface.php new file mode 100644 index 0000000000000..3e8a222f0ba8e --- /dev/null +++ b/src/Symfony/Component/JsonPath/JsonCrawlerInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath; + +use Symfony\Component\JsonPath\Exception\InvalidArgumentException; +use Symfony\Component\JsonPath\Exception\JsonCrawlerException; + +/** + * @author Alexandre Daubois + * + * @experimental + */ +interface JsonCrawlerInterface +{ + /** + * @return list + * + * @throws InvalidArgumentException When the JSON string provided to the crawler cannot be decoded + * @throws JsonCrawlerException When a syntax error occurs in the provided JSON path + */ + public function find(string|JsonPath $query): array; +} diff --git a/src/Symfony/Component/JsonPath/JsonPath.php b/src/Symfony/Component/JsonPath/JsonPath.php new file mode 100644 index 0000000000000..b44f35795793c --- /dev/null +++ b/src/Symfony/Component/JsonPath/JsonPath.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath; + +/** + * @author Alexandre Daubois + * + * @immutable + * + * @experimental + */ +final class JsonPath +{ + /** + * @param non-empty-string $path + */ + public function __construct( + private readonly string $path = '$', + ) { + } + + public function key(string $key): static + { + return new self($this->path.(str_ends_with($this->path, '..') ? '' : '.').$key); + } + + public function index(int $index): static + { + return new self($this->path.'['.$index.']'); + } + + public function deepScan(): static + { + return new self($this->path.'..'); + } + + public function anyIndex(): static + { + return new self($this->path.'[*]'); + } + + public function slice(int $start, ?int $end = null, ?int $step = null): static + { + $slice = $start; + if (null !== $end) { + $slice .= ':'.$end; + if (null !== $step) { + $slice .= ':'.$step; + } + } + + return new self($this->path.'['.$slice.']'); + } + + public function filter(string $expression): static + { + return new self($this->path.'[?('.$expression.')]'); + } + + public function __toString(): string + { + return $this->path; + } +} diff --git a/src/Symfony/Component/JsonPath/JsonPathUtils.php b/src/Symfony/Component/JsonPath/JsonPathUtils.php new file mode 100644 index 0000000000000..9d1e66a39f530 --- /dev/null +++ b/src/Symfony/Component/JsonPath/JsonPathUtils.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath; + +use Symfony\Component\JsonStreamer\Read\Splitter; +use Symfony\Component\JsonPath\Exception\InvalidArgumentException; +use Symfony\Component\JsonPath\Tokenizer\JsonPathToken; +use Symfony\Component\JsonPath\Tokenizer\TokenType; + +/** + * Get the smallest deserializable JSON string from a list of tokens that doesn't need any processing. + * + * @author Alexandre Daubois + * + * @internal + */ +final class JsonPathUtils +{ + /** + * @param JsonPathToken[] $tokens + * @param resource $json + * + * @return array{json: string, tokens: list} + */ + public static function findSmallestDeserializableStringAndPath(array $tokens, mixed $json): array + { + if (!\is_resource($json)) { + throw new InvalidArgumentException('The JSON parameter must be a resource.'); + } + + $currentOffset = 0; + $currentLength = null; + + $remainingTokens = $tokens; + rewind($json); + + foreach ($tokens as $token) { + $boundaries = []; + + if (TokenType::Name === $token->type) { + foreach (Splitter::splitDict($json, $currentOffset, $currentLength) as $key => $bound) { + $boundaries[$key] = $bound; + if ($key === $token->value) { + break; + } + } + } elseif (TokenType::Bracket === $token->type && preg_match('/^\d+$/', $token->value)) { + foreach (Splitter::splitList($json, $currentOffset, $currentLength) as $key => $bound) { + $boundaries[$key] = $bound; + if ($key === $token->value) { + break; + } + } + } + + if (!$boundaries) { + // in case of a recursive descent or a filter, we can't reduce the JSON string + break; + } + + if (!\array_key_exists($token->value, $boundaries) || \count($remainingTokens) <= 1) { + // the key given in the path is not found by the splitter or there is no remaining token to shift + break; + } + + // boundaries for the current token are found, we can remove it from the list + // and substring the JSON string later + $currentOffset = $boundaries[$token->value][0]; + $currentLength = $boundaries[$token->value][1]; + + array_shift($remainingTokens); + } + + return [ + 'json' => stream_get_contents($json, $currentLength, $currentOffset ?: -1), + 'tokens' => $remainingTokens, + ]; + } +} diff --git a/src/Symfony/Component/JsonPath/LICENSE b/src/Symfony/Component/JsonPath/LICENSE new file mode 100644 index 0000000000000..bc38d714ef697 --- /dev/null +++ b/src/Symfony/Component/JsonPath/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025-present Fabien Potencier + +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. diff --git a/src/Symfony/Component/JsonPath/README.md b/src/Symfony/Component/JsonPath/README.md new file mode 100644 index 0000000000000..1a15a7111b2ed --- /dev/null +++ b/src/Symfony/Component/JsonPath/README.md @@ -0,0 +1,42 @@ +JsonPath Component +================== + +The JsonPath component eases JSON navigation using the JSONPath syntax as described in [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html). + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Getting Started +--------------- + +```bash +composer require symfony/json-path +``` + +```php +use Symfony\Component\JsonPath\JsonCrawler; + +$json = <<<'JSON' +{"store": {"book": [ + {"category": "reference", "author": "Nigel Rees", "title": "Sayings", "price": 8.95}, + {"category": "fiction", "author": "Evelyn Waugh", "title": "Sword", "price": 12.99} +]}} +JSON; + +$crawler = new JsonCrawler($json); + +$result = $crawler->find('$.store.book[0].title'); +$result = $crawler->find('$.store.book[?match(@.author, "[A-Z].*el.+")]'); +$result = $crawler->find("$.store.book[?(@.category == 'fiction')].title"); +``` + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/dom_crawler.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php b/src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php new file mode 100644 index 0000000000000..6871a56511890 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Tests/JsonCrawlerTest.php @@ -0,0 +1,456 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonPath\Exception\InvalidArgumentException; +use Symfony\Component\JsonPath\Exception\InvalidJsonStringInputException; +use Symfony\Component\JsonPath\Exception\JsonCrawlerException; +use Symfony\Component\JsonPath\JsonCrawler; +use Symfony\Component\JsonPath\JsonPath; + +class JsonCrawlerTest extends TestCase +{ + public function testNotStringOrResourceThrows() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected string or resource, got "int".'); + + new JsonCrawler(42); + } + + public function testInvalidInputJson() + { + $this->expectException(InvalidJsonStringInputException::class); + $this->expectExceptionMessage('Invalid JSON input: Syntax error.'); + + (new JsonCrawler('invalid'))->find('$..*'); + } + + public function testAllAuthors() + { + $result = self::getBookstoreCrawler()->find('$..author'); + + $this->assertCount(4, $result); + $this->assertSame([ + 'Nigel Rees', + 'Evelyn Waugh', + 'Herman Melville', + 'J. R. R. Tolkien', + ], $result); + } + + public function testAllThingsInStore() + { + $result = self::getBookstoreCrawler()->find('$.store.*'); + + $this->assertCount(2, $result); + $this->assertCount(4, $result[0]); + $this->assertArrayHasKey('color', $result[1]); + } + + public function testEscapedDoubleQuotesInFieldName() + { + $crawler = new JsonCrawler(<<find("$['a']['b\\\"c']"); + + $this->assertSame(42, $result[0]); + } + + public function testBasicNameSelector() + { + $result = self::getBookstoreCrawler()->find('$.store.book')[0]; + + $this->assertCount(4, $result); + $this->assertSame('Nigel Rees', $result[0]['author']); + } + + public function testAllPrices() + { + $result = self::getBookstoreCrawler()->find('$.store..price'); + + $this->assertCount(5, $result); + $this->assertSame([8.95, 12.99, 8.99, 22.99, 399], $result); + } + + public function testSpecificBookByIndex() + { + $result = self::getBookstoreCrawler()->find('$..book[2]'); + + $this->assertCount(1, $result); + $this->assertSame('Moby Dick', $result[0]['title']); + } + + public function testLastBookInOrder() + { + $result = self::getBookstoreCrawler()->find('$..book[-1]'); + + $this->assertCount(1, $result); + $this->assertSame('The Lord of the Rings', $result[0]['title']); + } + + public function testFirstTwoBooks() + { + $result = self::getBookstoreCrawler()->find('$..book[0,1]'); + + $this->assertCount(2, $result); + $this->assertSame('Sayings of the Century', $result[0]['title']); + $this->assertSame('Sword of Honour', $result[1]['title']); + } + + public function testBooksWithIsbn() + { + $result = self::getBookstoreCrawler()->find('$..book[?(@.isbn)]'); + + $this->assertCount(2, $result); + $this->assertSame([ + '0-553-21311-3', + '0-395-19395-8', + ], [$result[0]['isbn'], $result[1]['isbn']]); + } + + public function testBooksLessThanTenDollars() + { + $result = self::getBookstoreCrawler()->find('$..book[?(@.price < 10)]'); + + $this->assertCount(2, $result); + $this->assertSame([ + 'Sayings of the Century', + 'Moby Dick', + ], [$result[0]['title'], $result[1]['title']]); + } + + public function testRecursiveWildcard() + { + $result = self::getBookstoreCrawler()->find('$..*'); + + $this->assertNotEmpty($result); + } + + public function testSliceWithStep() + { + $crawler = new JsonCrawler(<<find('$.a[1:5:2]'); + $this->assertSame([5, 2], $result); + } + + public function testNegativeSlice() + { + $crawler = new JsonCrawler(<<find('$.a[-3:]'); + + $this->assertCount(3, $result); + } + + public function testBooleanAndNullValues() + { + $crawler = new JsonCrawler('{"a": true, "b": false, "c": null}'); + + $result = $crawler->find('$.*'); + $this->assertSame([true, false, null], $result); + } + + public function testFullArraySlice() + { + $crawler = self::getSimpleCollectionCrawler(); + + $result = $crawler->find('$.a[:]'); + $this->assertSame([3, 5, 1, 2, 4, 6], $result); + } + + public function testReverseArraySlice() + { + $crawler = self::getSimpleCollectionCrawler(); + + $result = $crawler->find('$.a[::-1]'); + $this->assertSame([6, 4, 2, 1, 5, 3], $result); + } + + public function testLastTwoElementsSlice() + { + $crawler = self::getSimpleCollectionCrawler(); + + $result = $crawler->find('$.a[-2:]'); + $this->assertSame([4, 6], $result); + } + + public function testAllButLastTwoElementsSlice() + { + $crawler = self::getSimpleCollectionCrawler(); + + $result = $crawler->find('$.a[:-2]'); + $this->assertSame([3, 5, 1, 2], $result); + } + + public function testEverySecondElementSlice() + { + $crawler = self::getSimpleCollectionCrawler(); + + $result = $crawler->find('$.a[::2]'); + $this->assertSame([3, 1, 4], $result); + } + + public function testEverySecondElementReverseSlice() + { + $crawler = self::getSimpleCollectionCrawler(); + + $result = $crawler->find('$.a[::-2]'); + $this->assertSame([6, 2, 5], $result); + } + + public function testEmptyResults() + { + $crawler = self::getSimpleCollectionCrawler(); + + $this->assertEmpty($crawler->find('$.a[::0]')); + $this->assertEmpty($crawler->find('$.a[10:20]')); + $this->assertEmpty($crawler->find('$.a[5:2]')); + } + + public function testNegativeIndicesEdgeCases() + { + $crawler = self::getSimpleCollectionCrawler(); + + $result = $crawler->find('$.a[-4:-2]'); + $this->assertSame([1, 2], $result); + + $result = $crawler->find('$.a[-3:5]'); + $this->assertSame([2, 4], $result); + + $result = $crawler->find('$.a[-2:-5:-1]'); + $this->assertSame([4, 2, 1], $result); + } + + public function testBoundaryConditions() + { + $crawler = new JsonCrawler(<<find('$.a[0:6]'); + $this->assertSame([3, 5, 1, 2, 4, 6], $result); + + $result = $crawler->find('$.a[-10:10]'); + $this->assertSame([3, 5, 1, 2, 4, 6], $result); + + $result = $crawler->find('$.a[2:3]'); + $this->assertSame([1], $result); + } + + public function testFilterByValue() + { + $crawler = new JsonCrawler(<<find("$.a[?(@.b == 'kilo')]"); + + $this->assertCount(1, $result); + $this->assertSame('kilo', $result[0]['b']); + } + + public function testMultipleConditions() + { + $result = self::getBookstoreCrawler()->find("$..book[?(@.price < 10 && @.category == 'reference')]"); + + $this->assertCount(1, $result); + $this->assertSame('Sayings of the Century', $result[0]['title']); + } + + public function testEmptyResult() + { + $result = self::getBookstoreCrawler()->find('$.store.book[?(@.price > 1000)]'); + + $this->assertEmpty($result); + } + + public function testDirectRecursion() + { + $result = self::getBookstoreCrawler()->find('$..price'); + + $this->assertCount(5, $result); + } + + public function testCombinedFilters() + { + $result = self::getBookstoreCrawler()->find("$..book[?(@.price > 20 && @.category == 'fiction')]"); + + $this->assertCount(1, $result); + $this->assertSame('The Lord of the Rings', $result[0]['title']); + } + + public function testMatchFunction() + { + $result = self::getBookstoreCrawler()->find("$.store.book[?match(@.title, 'Sw[a-z]rd of Honour')]"); + + $this->assertCount(1, $result); + $this->assertSame('Sword of Honour', $result[0]['title']); + } + + public function testMatchFunctionDoesNotMatchSubstring() + { + $result = self::getBookstoreCrawler()->find("$.store.book[?match(@.title, 'Sw[a-z]rd')]"); + + $this->assertCount(0, $result); + } + + public function testMatchFunctionWithOuterParentheses() + { + $result = self::getBookstoreCrawler()->find("$.store.book[?(match(@.title, 'Sw[a-z]rd of Honour'))]"); + + $this->assertCount(1, $result); + $this->assertSame('Sword of Honour', $result[0]['title']); + } + + public function testSearchFunctionMatchSubstring() + { + $result = self::getBookstoreCrawler()->find("$.store.book[?search(@.title, 'of H[ou]nour')]"); + + $this->assertCount(1, $result); + $this->assertSame('Sword of Honour', $result[0]['title']); + } + + public function testSearchFunctionWithOuterParentheses() + { + $result = self::getBookstoreCrawler()->find("$.store.book[?(search(@.title, 'of Hon.{2}r'))]"); + + $this->assertCount(1, $result); + $this->assertSame('Sword of Honour', $result[0]['title']); + } + + public function testValueFunction() + { + $result = self::getBookstoreCrawler()->find('$.store.book[?value(@.price) == 8.95]'); + + $this->assertCount(1, $result); + $this->assertSame('Sayings of the Century', $result[0]['title']); + } + + public function testValueFunctionWithOuterParentheses() + { + $result = self::getBookstoreCrawler()->find('$.store.book[?(value(@.price) == 8.95)]'); + + $this->assertCount(1, $result); + $this->assertSame('Sayings of the Century', $result[0]['title']); + } + + public function testLengthFunction() + { + $result = self::getBookstoreCrawler()->find('$.store.book[?length(@.author) > 12]'); + + $this->assertCount(2, $result); + $this->assertSame('Herman Melville', $result[0]['author']); + $this->assertSame('J. R. R. Tolkien', $result[1]['author']); + } + + public function testLengthFunctionWithOuterParentheses() + { + $result = self::getBookstoreCrawler()->find('$.store.book[?(length(@.author) > 12)]'); + + $this->assertCount(2, $result); + $this->assertSame('Herman Melville', $result[0]['author']); + $this->assertSame('J. R. R. Tolkien', $result[1]['author']); + } + + public function testCountFunction() + { + $result = self::getBookstoreCrawler()->find('$.store.book[?count(@.extra) != 0]'); + + $this->assertCount(1, $result); + $this->assertSame([42], $result[0]['extra']); + } + + public function testCountFunctionWithOuterParentheses() + { + $result = self::getBookstoreCrawler()->find('$.store.book[?(count(@.extra) != 0)]'); + + $this->assertCount(1, $result); + $this->assertSame([42], $result[0]['extra']); + } + + public function testUnknownFunction() + { + $this->expectException(JsonCrawlerException::class); + $this->expectExceptionMessage('invalid function "unknown"'); + + self::getBookstoreCrawler()->find('$.store.book[?unknown(@.extra) != 0]'); + } + + public function testAcceptsJsonPath() + { + $bicyclePath = new JsonPath('$.store.bicycle'); + + $result = self::getBookstoreCrawler()->find($bicyclePath); + + $this->assertCount(1, $result); + $this->assertSame('red', $result[0]['color']); + } + + private static function getBookstoreCrawler(): JsonCrawler + { + return new JsonCrawler(<< + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonPath\JsonPath; + +class JsonPathTest extends TestCase +{ + public function testBuildPath() + { + $path = new JsonPath(); + $path = $path->key('users') + ->index(0) + ->key('address'); + + $this->assertSame('$.users[0].address', (string) $path); + $this->assertSame('$.users[0].address..city', (string) $path->deepScan()->key('city')); + } + + public function testBuildWithFilter() + { + $path = new JsonPath(); + $path = $path->key('users') + ->filter('@.age > 18'); + + $this->assertSame('$.users[?(@.age > 18)]', (string) $path); + } +} diff --git a/src/Symfony/Component/JsonPath/Tests/JsonPathUtilsTest.php b/src/Symfony/Component/JsonPath/Tests/JsonPathUtilsTest.php new file mode 100644 index 0000000000000..75c1ccc8dfa56 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Tests/JsonPathUtilsTest.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonPath\JsonPath; +use Symfony\Component\JsonPath\JsonPathUtils; +use Symfony\Component\JsonPath\Tokenizer\JsonPathToken; +use Symfony\Component\JsonPath\Tokenizer\JsonPathTokenizer; +use Symfony\Component\JsonPath\Tokenizer\TokenType; + +class JsonPathUtilsTest extends TestCase +{ + public function testReduceWithArrayAccess() + { + $path = new JsonPath('$.store.book[0].title'); + $resource = self::provideJsonResource(); + + $reduced = JsonPathUtils::findSmallestDeserializableStringAndPath( + JsonPathTokenizer::tokenize($path), + $resource + ); + + fclose($resource); + + $this->assertSame('{"category": "reference", "author": "Nigel Rees", "title": "Sayings", "price": 8.95}', $reduced['json']); + $this->assertEquals([new JsonPathToken(TokenType::Name, 'title')], $reduced['tokens']); + } + + public function testReduceWithBasicProperty() + { + $path = new JsonPath('$.store.book'); + $resource = self::provideJsonResource(); + + $reduced = JsonPathUtils::findSmallestDeserializableStringAndPath( + JsonPathTokenizer::tokenize($path), + $resource + ); + + fclose($resource); + + $this->assertSame(<<assertEquals([new JsonPathToken(TokenType::Name, 'book')], $reduced['tokens']); + } + + public function testReduceUntilFilter() + { + $path = new JsonPath('$.store[?(@.book.author == "Nigel Rees")]'); + $resource = self::provideJsonResource(); + + $reduced = JsonPathUtils::findSmallestDeserializableStringAndPath( + JsonPathTokenizer::tokenize($path), + $resource + ); + + fclose($resource); + + $this->assertSame(<<assertEquals([new JsonPathToken(TokenType::Bracket, '?(@.book.author == "Nigel Rees")')], $reduced['tokens']); + } + + public function testDoesNotReduceOnRecursiveDescent() + { + $path = new JsonPath('$..book'); + $resource = self::provideJsonResource(); + + $reduced = JsonPathUtils::findSmallestDeserializableStringAndPath( + JsonPathTokenizer::tokenize($path), + $resource + ); + + rewind($resource); + $fullJson = stream_get_contents($resource); + fclose($resource); + + $this->assertSame($fullJson, $reduced['json']); + $this->assertEquals([ + new JsonPathToken(TokenType::Recursive, '..'), + new JsonPathToken(TokenType::Name, 'book'), + ], $reduced['tokens']); + } + + public function testDoesNotReduceOnArraySlice() + { + $path = new JsonPath('$.store.book[1:2]'); + $resource = self::provideJsonResource(); + + $reduced = JsonPathUtils::findSmallestDeserializableStringAndPath( + JsonPathTokenizer::tokenize($path), + $resource + ); + + fclose($resource); + + $this->assertSame(<<assertEquals([ + new JsonPathToken(TokenType::Bracket, '1:2'), + ], $reduced['tokens']); + } + + public function testDoesNotReduceOnUnknownProperty() + { + $path = new JsonPath('$.unknown'); + $resource = self::provideJsonResource(); + + $reduced = JsonPathUtils::findSmallestDeserializableStringAndPath( + JsonPathTokenizer::tokenize($path), + $resource + ); + + $fullJson = stream_get_contents($resource); + fclose($resource); + + $this->assertSame($fullJson, $reduced['json']); + $this->assertEquals([ + new JsonPathToken(TokenType::Name, 'unknown'), + ], $reduced['tokens']); + } + + public function testDoesNotReduceOnUnknownIndex() + { + $path = new JsonPath('$.store.book[123].title'); + $resource = self::provideJsonResource(); + + $reduced = JsonPathUtils::findSmallestDeserializableStringAndPath( + JsonPathTokenizer::tokenize($path), + $resource + ); + + fclose($resource); + + $this->assertSame(<<assertEquals([ + new JsonPathToken(TokenType::Bracket, '123'), + new JsonPathToken(TokenType::Name, 'title'), + ], $reduced['tokens']); + } + + /** + * @return resource + */ + private static function provideJsonResource(): mixed + { + $json = <<<'JSON' +{"store": {"book": [ + {"category": "reference", "author": "Nigel Rees", "title": "Sayings", "price": 8.95}, + {"category": "fiction", "author": "Evelyn Waugh", "title": "Sword", "price": 12.99} +]}} +JSON; + + $resource = fopen('php://memory', 'r+'); + fwrite($resource, $json); + rewind($resource); + + return $resource; + } +} diff --git a/src/Symfony/Component/JsonPath/Tests/Tokenizer/JsonPathTokenizerTest.php b/src/Symfony/Component/JsonPath/Tests/Tokenizer/JsonPathTokenizerTest.php new file mode 100644 index 0000000000000..9bef3fc1943ec --- /dev/null +++ b/src/Symfony/Component/JsonPath/Tests/Tokenizer/JsonPathTokenizerTest.php @@ -0,0 +1,365 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Tests\Tokenizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonPath\Exception\InvalidJsonPathException; +use Symfony\Component\JsonPath\JsonPath; +use Symfony\Component\JsonPath\Tokenizer\JsonPathTokenizer; +use Symfony\Component\JsonPath\Tokenizer\TokenType; + +class JsonPathTokenizerTest extends TestCase +{ + /** + * @dataProvider simplePathProvider + */ + public function testSimplePath(string $path, array $expectedTokens) + { + $jsonPath = new JsonPath($path); + $tokens = JsonPathTokenizer::tokenize($jsonPath); + + $this->assertCount(\count($expectedTokens), $tokens); + foreach ($tokens as $i => $token) { + $this->assertSame($expectedTokens[$i][0], $token->type); + $this->assertSame($expectedTokens[$i][1], $token->value); + } + } + + public function simplePathProvider(): array + { + return [ + 'root only' => [ + '$', + [], + ], + 'simple property' => [ + '$.store', + [[TokenType::Name, 'store']], + ], + 'nested property' => [ + '$.store.book', + [ + [TokenType::Name, 'store'], + [TokenType::Name, 'book'], + ], + ], + 'recursive descent' => [ + '$..book', + [ + [TokenType::Recursive, '..'], + [TokenType::Name, 'book'], + ], + ], + ]; + } + + /** + * @dataProvider bracketNotationProvider + */ + public function testBracketNotation(string $path, array $expectedTokens) + { + $jsonPath = new JsonPath($path); + $tokens = JsonPathTokenizer::tokenize($jsonPath); + + $this->assertCount(\count($expectedTokens), $tokens); + foreach ($tokens as $i => $token) { + $this->assertSame($expectedTokens[$i][0], $token->type); + $this->assertSame($expectedTokens[$i][1], $token->value); + } + } + + public function bracketNotationProvider(): array + { + return [ + 'bracket with quotes' => [ + "$['store']", + [[TokenType::Bracket, "'store'"]], + ], + 'multiple brackets' => [ + "$['store']['book']", + [ + [TokenType::Bracket, "'store'"], + [TokenType::Bracket, "'book'"], + ], + ], + 'mixed notation' => [ + "$.store['book'][0]", + [ + [TokenType::Name, 'store'], + [TokenType::Bracket, "'book'"], + [TokenType::Bracket, '0'], + ], + ], + ]; + } + + /** + * @dataProvider filterExpressionProvider + */ + public function testFilterExpressions(string $path, array $expectedTokens) + { + $jsonPath = new JsonPath($path); + $tokens = JsonPathTokenizer::tokenize($jsonPath); + + $this->assertCount(\count($expectedTokens), $tokens); + foreach ($tokens as $i => $token) { + $this->assertSame($expectedTokens[$i][0], $token->type); + $this->assertSame($expectedTokens[$i][1], $token->value); + } + } + + public function filterExpressionProvider(): array + { + return [ + 'simple filter' => [ + '$.store.book[?(@.price < 10)]', + [ + [TokenType::Name, 'store'], + [TokenType::Name, 'book'], + [TokenType::Bracket, '?(@.price < 10)'], + ], + ], + 'nested filter' => [ + '$.store.book[?(@.price < 10 && @.category == "fiction")]', + [ + [TokenType::Name, 'store'], + [TokenType::Name, 'book'], + [TokenType::Bracket, '?(@.price < 10 && @.category == "fiction")'], + ], + ], + 'filter with nested brackets' => [ + '$.store.book[?(@.authors[0] == "John Smith")]', + [ + [TokenType::Name, 'store'], + [TokenType::Name, 'book'], + [TokenType::Bracket, '?(@.authors[0] == "John Smith")'], + ], + ], + ]; + } + + /** + * @dataProvider complexPathProvider + */ + public function testComplexPaths(string $path, array $expectedTokens) + { + $jsonPath = new JsonPath($path); + $tokens = JsonPathTokenizer::tokenize($jsonPath); + + $this->assertCount(\count($expectedTokens), $tokens); + foreach ($tokens as $i => $token) { + $this->assertSame($expectedTokens[$i][0], $token->type); + $this->assertSame($expectedTokens[$i][1], $token->value); + } + } + + public function complexPathProvider(): array + { + return [ + 'mixed with recursive' => [ + '$..book[?(@.price < 10)].title', + [ + [TokenType::Recursive, '..'], + [TokenType::Name, 'book'], + [TokenType::Bracket, '?(@.price < 10)'], + [TokenType::Name, 'title'], + ], + ], + 'multiple filters' => [ + '$.store.book[?(@.price < 10)][?(@.category == "fiction")]', + [ + [TokenType::Name, 'store'], + [TokenType::Name, 'book'], + [TokenType::Bracket, '?(@.price < 10)'], + [TokenType::Bracket, '?(@.category == "fiction")'], + ], + ], + 'everything combined' => [ + '$..store[*].book[?(@.price < 10)].author["lastName"]', + [ + [TokenType::Recursive, '..'], + [TokenType::Name, 'store'], + [TokenType::Bracket, '*'], + [TokenType::Name, 'book'], + [TokenType::Bracket, '?(@.price < 10)'], + [TokenType::Name, 'author'], + [TokenType::Bracket, '"lastName"'], + ], + ], + ]; + } + + public function testTokenizeThrowsExceptionForEmptyExpression() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error: empty JSONPath expression.'); + + JsonPathTokenizer::tokenize(new JsonPath('')); + } + + public function testTokenizeThrowsExceptionWhenNotStartingWithDollar() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error: expression must start with $'); + + JsonPathTokenizer::tokenize(new JsonPath('store.book')); + } + + public function testTokenizeThrowsExceptionForUnmatchedClosingBracket() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 7: unmatched closing bracket.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store]')); + } + + public function testTokenizeThrowsExceptionForEmptyBrackets() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 8: empty brackets are not allowed.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store[]')); + } + + public function testTokenizeThrowsExceptionForUnexpectedCharsBeforeFilter() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 11: unexpected characters before filter expression.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store[abc?(@.price > 10)]')); + } + + public function testTokenizeThrowsExceptionForUnmatchedParenthesisInFilter() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 23: unmatched closing parenthesis in filter.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store[?(@.price > 10))]')); + } + + public function testTokenizeThrowsExceptionForPathEndingWithDot() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 7: path cannot end with a dot.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store.')); + } + + public function testTokenizeThrowsExceptionForUnclosedBracket() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 8: unclosed bracket.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store[0')); + } + + public function testTokenizeThrowsExceptionForUnclosedStringLiteral() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 16: unclosed string literal.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store["unclosed')); + } + + public function testTokenizeThrowsExceptionForUnclosedSingleQuotedString() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 16: unclosed string literal.'); + + JsonPathTokenizer::tokenize(new JsonPath("$.store['unclosed")); + } + + public function testTokenizeThrowsExceptionForNestedUnmatchedBrackets() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 10: unclosed bracket.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store[[0]')); + } + + public function testTokenizeThrowsExceptionForMultipleUnmatchedClosingBrackets() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 10: unmatched closing bracket.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store[0]]]')); + } + + public function testTokenizeThrowsExceptionForInvalidFilterSyntax() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 22: unclosed bracket.'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store[?(@.price > 10]')); + } + + public function testTokenizeThrowsExceptionForConsecutiveDotsWithoutRecursive() + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessage('JSONPath syntax error at position 9: invalid character "." in property name'); + + JsonPathTokenizer::tokenize(new JsonPath('$.store...name')); + } + + /** + * @dataProvider provideValidUtf8Chars + */ + public function testUtf8ValidChars(string $propertyName) + { + $jsonPath = new JsonPath(\sprintf('$.%s', $propertyName)); + $tokens = JsonPathTokenizer::tokenize($jsonPath); + + $this->assertCount(1, $tokens); + $this->assertSame(TokenType::Name, $tokens[0]->type); + $this->assertSame($propertyName, $tokens[0]->value); + } + + public static function provideValidUtf8Chars(): array + { + return [ + 'basic lowercase letter' => ['hello'], + 'basic uppercase letter' => ['Hello'], + 'underscore first' => ['_test123'], + 'numbers allowed after first char' => ['a123'], + 'asterisk alone' => ['*'], + 'french accents' => ['héllo'], + 'russian' => ['привет'], + 'chinese' => ['漢字'], + ]; + } + + /** + * @dataProvider provideInvalidUtf8PropertyName + */ + public function testUtf8InvalidPropertyName(string $propertyName) + { + $this->expectException(InvalidJsonPathException::class); + $this->expectExceptionMessageMatches('/JSONPath syntax error.*: invalid character in property name "(.*)"/'); + + $jsonPath = new JsonPath(\sprintf('$.%s', $propertyName)); + JsonPathTokenizer::tokenize($jsonPath); + } + + public static function provideInvalidUtf8PropertyName(): array + { + return [ + 'special char first' => ['#test'], + 'start with digit' => ['123test'], + 'asterisk' => ['test*test'], + 'space not allowed' => [' test'], + 'at sign not allowed' => ['@test'], + 'start control char' => ["\0test"], + 'ending control char' => ["test\xFF\xFA"], + 'dash sign' => ['-test'], + ]; + } +} diff --git a/src/Symfony/Component/JsonPath/Tokenizer/JsonPathToken.php b/src/Symfony/Component/JsonPath/Tokenizer/JsonPathToken.php new file mode 100644 index 0000000000000..266928e897be9 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Tokenizer/JsonPathToken.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Tokenizer; + +/** + * @author Alexandre Daubois + * + * @internal + */ +final class JsonPathToken +{ + public function __construct( + public TokenType $type, + public string $value, + ) { + } +} diff --git a/src/Symfony/Component/JsonPath/Tokenizer/JsonPathTokenizer.php b/src/Symfony/Component/JsonPath/Tokenizer/JsonPathTokenizer.php new file mode 100644 index 0000000000000..d7c5fe44457e7 --- /dev/null +++ b/src/Symfony/Component/JsonPath/Tokenizer/JsonPathTokenizer.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Tokenizer; + +use Symfony\Component\JsonPath\Exception\InvalidJsonPathException; +use Symfony\Component\JsonPath\JsonPath; + +/** + * @author Alexandre Daubois + * + * @internal + */ +final class JsonPathTokenizer +{ + /** + * @return JsonPathToken[] + */ + public static function tokenize(JsonPath $query): array + { + $tokens = []; + $current = ''; + $inBracket = false; + $bracketDepth = 0; + $inFilter = false; + $inQuote = false; + $quoteChar = ''; + $filterParenthesisDepth = 0; + + $chars = mb_str_split((string) $query); + $length = \count($chars); + + if (0 === $length) { + throw new InvalidJsonPathException('empty JSONPath expression.'); + } + + if ('$' !== $chars[0]) { + throw new InvalidJsonPathException('expression must start with $.'); + } + + for ($i = 0; $i < $length; ++$i) { + $char = $chars[$i]; + $position = $i; + + if (('"' === $char || "'" === $char) && !$inQuote) { + $inQuote = true; + $quoteChar = $char; + $current .= $char; + continue; + } + + if ($inQuote) { + $current .= $char; + if ($char === $quoteChar && '\\' !== $chars[$i - 1]) { + $inQuote = false; + } + if ($i === $length - 1 && $inQuote) { + throw new InvalidJsonPathException('unclosed string literal.', $position); + } + continue; + } + + if ('$' === $char && 0 === $i) { + continue; + } + + if ('[' === $char && !$inFilter) { + if ('' !== $current) { + $tokens[] = new JsonPathToken(TokenType::Name, $current); + $current = ''; + } + + $inBracket = true; + ++$bracketDepth; + continue; + } + + if (']' === $char) { + if ($inFilter && $filterParenthesisDepth > 0) { + $current .= $char; + continue; + } + + if (--$bracketDepth < 0) { + throw new InvalidJsonPathException('unmatched closing bracket.', $position); + } + + if (0 === $bracketDepth) { + if ('' === $current) { + throw new InvalidJsonPathException('empty brackets are not allowed.', $position); + } + + $tokens[] = new JsonPathToken(TokenType::Bracket, $current); + $current = ''; + $inBracket = false; + $inFilter = false; + $filterParenthesisDepth = 0; + continue; + } + } + + if ('?' === $char && $inBracket && !$inFilter) { + if ('' !== $current) { + throw new InvalidJsonPathException('unexpected characters before filter expression.', $position); + } + $inFilter = true; + $filterParenthesisDepth = 0; + } + + if ($inFilter) { + if ('(' === $char) { + ++$filterParenthesisDepth; + } elseif (')' === $char) { + if (--$filterParenthesisDepth < 0) { + throw new InvalidJsonPathException('unmatched closing parenthesis in filter.', $position); + } + } + } + + // recursive descent + if ('.' === $char && !$inBracket) { + if ('' !== $current) { + $tokens[] = new JsonPathToken(TokenType::Name, $current); + $current = ''; + } + + if ($i + 1 < $length && '.' === $chars[$i + 1]) { + // more than two consecutive dots? + if ($i + 2 < $length && '.' === $chars[$i + 2]) { + throw new InvalidJsonPathException('invalid character "." in property name.', $i + 2); + } + + $tokens[] = new JsonPathToken(TokenType::Recursive, '..'); + ++$i; + } elseif ($i + 1 >= $length) { + throw new InvalidJsonPathException('path cannot end with a dot.', $position); + } + + continue; + } + + $current .= $char; + } + + if ($inBracket) { + throw new InvalidJsonPathException('unclosed bracket.', $length - 1); + } + + if ($inQuote) { + throw new InvalidJsonPathException('unclosed string literal.', $length - 1); + } + + if ('' !== $current) { + // final validation of the whole name + if (!preg_match('/^(?:\*|[a-zA-Z_\x{0080}-\x{D7FF}\x{E000}-\x{10FFFF}][a-zA-Z0-9_\x{0080}-\x{D7FF}\x{E000}-\x{10FFFF}]*)$/u', $current)) { + throw new InvalidJsonPathException(\sprintf('invalid character in property name "%s"', $current)); + } + + $tokens[] = new JsonPathToken(TokenType::Name, $current); + } + + return $tokens; + } +} diff --git a/src/Symfony/Component/JsonPath/Tokenizer/TokenType.php b/src/Symfony/Component/JsonPath/Tokenizer/TokenType.php new file mode 100644 index 0000000000000..fec351d9a991b --- /dev/null +++ b/src/Symfony/Component/JsonPath/Tokenizer/TokenType.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonPath\Tokenizer; + +/** + * @author Alexandre Daubois + * + * @internal + */ +enum TokenType +{ + case Name; + case Bracket; + case Recursive; +} diff --git a/src/Symfony/Component/JsonPath/composer.json b/src/Symfony/Component/JsonPath/composer.json new file mode 100644 index 0000000000000..95b02675e7459 --- /dev/null +++ b/src/Symfony/Component/JsonPath/composer.json @@ -0,0 +1,32 @@ +{ + "name": "symfony/json-path", + "type": "library", + "description": "Eases JSON navigation using the JSONPath syntax as described in RFC 9535", + "keywords": [], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Alexandre Daubois", + "email": "alex.daubois@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.2", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/json-streamer": "^7.3" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\JsonPath\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/JsonPath/phpunit.xml.dist b/src/Symfony/Component/JsonPath/phpunit.xml.dist new file mode 100644 index 0000000000000..8bbef439050b7 --- /dev/null +++ b/src/Symfony/Component/JsonPath/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Resources + ./Tests + ./vendor + + + diff --git a/src/Symfony/Component/JsonStreamer/Read/Splitter.php b/src/Symfony/Component/JsonStreamer/Read/Splitter.php index 0230fd98f7859..671a1df69d644 100644 --- a/src/Symfony/Component/JsonStreamer/Read/Splitter.php +++ b/src/Symfony/Component/JsonStreamer/Read/Splitter.php @@ -18,7 +18,7 @@ * * @author Mathias Arlaud * - * @internal + * @experimental */ final class Splitter {