Skip to content

Conversation

alexandre-daubois
Copy link
Member

@alexandre-daubois alexandre-daubois commented Aug 25, 2025

Q A
Branch? 7.4
Bug fix? no
New feature? yes
Deprecations? no
Issues -
License MIT

Requires #61132. Only second commit (1927fa4) should be reviewed.

This PR allows to create custom JsonPath functions thanks to the #[AsJsonPathFunction] attribute:

<?php

namespace App\JsonPath;

use Symfony\Component\JsonPath\Functions\AbstractJsonPathFunction;
use Symfony\Component\JsonPath\Functions\AsJsonPathFunction;
use Symfony\Component\JsonPath\Functions\JsonPathFunctionArgumentTrait;

#[AsJsonPathFunction(name: 'upper')]
final class UpperFunction extends AbstractJsonPathFunction
{
    use JsonPathFunctionArgumentTrait;

    public function __invoke(array $args, mixed $context): ?string
    {
        $results = $args[0] ?? [];
        $value = \is_array($results) ? ($results[0] ?? null) : $results;

        return \is_string($value) ? strtoupper($value) : null;
    }

    public function validate(array $args): void
    {
        self::assertArgumentsCount($args, 1);
    }
}

Then, it's used like this:

<?php

namespace App\Command;

// ...

#[AsCommand(name: 'cmd', description: 'Hello PhpStorm')]
class MyCommand extends Command
{
    public function __construct(
        private JsonPathFunctionsProviderInterface $jsonPathFunctionsProvider,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $crawler = new JsonCrawler(<<<JSON
            {"name": "test", "items": [{"title": "hello"}, {"title": "world"}]}
        JSON, $this->jsonPathFunctionsProvider); // <-- provide the functions provider

        $result = $crawler->find('$.items[?upper(@.title) == "HELLO"]');

        dump($result);

        return Command::SUCCESS;
    }
}

All RFC built-in functions are converted to use the new AbstractJsonPathFunction to avoid special case handling in the crawler code. This uses the approach detailed here: #60624 (comment). What's also nice is that built-in functions now have their very own unit tests as well.

abstract class AbstractJsonPathFunction implements JsonPathFunctionInterface
{
public function __construct(
private ?string $name = null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not use a promoted property, to decouple the parameter type (nullable) from the private property type (not nullable)

*/
trait JsonPathFunctionArgumentTrait
{
public static function assertArgumentsCount(array $args, int $expectedCount): void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why making the public static methods if this is in a trait ?

Either we should have public static utilities in a dedicated class expected to be called statically, or we should have a trait defining private instance methods (so that using the trait does not expend the public API of the class implementing a function)

interface JsonPathFunctionInterface
{
/**
* @param array<string> $args Array of argument strings as they appear in the expression
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would even be list<string> to be more precise, as they are always provided as a list.

*
* @param array<string> $args Array of argument strings as they appear in the expression
*
* @throws \InvalidArgumentException When arguments are invalid
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this use an exception class of the component instead ?

public function __construct(iterable $functions = [])
{
foreach ($functions as $function) {
$this->addFunction($function);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of injecting an iterable that is used in the constructor (breaking any lazy-loading of the iterator argument provided by the DI component), I suggest injecting a service locator instead (which would require knowing the name statically, which could be done by always specifying the name in the attribute instead of the getName method in the instance)

* @author Alexandre Daubois <alex.daubois@gmail.com>
*/
#[AsJsonPathFunction(name: 'count')]
final class CountFunction extends AbstractJsonPathFunction implements JsonPathComparableFunctionInterface
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should these core functions be tagged as @internal as the JsonCrawler is registering them on its own and so there is no reason for ever accessing them directly outside the component ?

*/
trait JsonPathFunctionArgumentTrait
{
public static function assertArgumentsCount(array $args, int $expectedCount): void
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type of $args must be specified in phpdoc

@@ -28,7 +27,7 @@ final class JsonPathTokenizer
/**
* @return JsonPathToken[]
*/
public static function tokenize(JsonPath $query): array
public static function tokenize(JsonPath $query, ?callable $functionValidator = null): array
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suggest documenting the signature of that callable. It will make it easier to contributors reading that code in the future (same in other methods where it is forwarded).

try {
$functionValidator($functionName, $args);
} catch (\InvalidArgumentException $e) {
throw new InvalidJsonPathException($e->getMessage(), $position);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$e should be attached as previous exception.

}

$nodelistSize = \count($results);
if (1 < $nodelistSize) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could remove this check, using only return 1 === $nodelistSize ? $results[0] : null; for the same result.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants