Thanks to visit codestin.com
Credit goes to github.com

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.

@alexandre-daubois alexandre-daubois force-pushed the jp-functions-provider branch 2 times, most recently from b5b5819 to 3147335 Compare August 28, 2025 10:16
@alexandre-daubois
Copy link
Member Author

Thanks, I addressed all your comments. I introduced a locator approach which should address your concerns in b5b5819

@snoob
Copy link
Contributor

snoob commented Sep 1, 2025

question: Instead of introducting a new way to define function can't we use the expression language as has already been done for the components routing and security ?

@alexandre-daubois
Copy link
Member Author

This was actually my first idea. However, the RFC defines that you can add custom functions, not necessarily custom expressions. The way it's done currently allows to easily plug on the existing logic. Introducing expression language would bring many challenges (for example, binding JSON data and the path to the expression in some way). I think it's a better choice to not add the dependency and keep the component as standalone as possible (and it's also easier I think). However, nothing would stop one from defining an expr() function and implement the logic 🙂

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

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

Partial review, I only now realize #61132 should be merged first 😬

@alexandre-daubois alexandre-daubois force-pushed the jp-functions-provider branch 3 times, most recently from 323351e to 27495a8 Compare September 12, 2025 14:14
public function assertIsQuery(string $arg): void
{
if (preg_match('/^(true|false|null|\d+(\.\d+)?([eE][+-]?\d+)?|\'[^\']*\'|"[^"]*")$/', $arg)) {
throw new InvalidArgumentException('count() function requires a query argument, not a literal.');
Copy link
Member

Choose a reason for hiding this comment

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

count here, length above, this looks specific, not fit for a trait

@alexandre-daubois alexandre-daubois force-pushed the jp-functions-provider branch 2 times, most recently from b66d9a0 to cbf332a Compare September 12, 2025 14:23
}
}

public function addFunction(JsonPathFunctionInterface $function): void
Copy link
Member

Choose a reason for hiding this comment

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

I'd rather remove this method, it makes the service mutable

/**
* @param list<string> $args
*/
public function executeFunction(string $functionName, array $args, mixed $context): mixed
Copy link
Member

Choose a reason for hiding this comment

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

I'd rather remove this method, it's a needless helper with lots of repetition with getFunction

return $function($args, $context);
}

public function isComparable(string $functionName): bool
Copy link
Member

Choose a reason for hiding this comment

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

I'd rather remove this method, it's a needless helper with lots of repetition with getFunction

$value = \is_array($results) ? ($results[0] ?? null) : $results;

return match (true) {
\is_string($value) => mb_strlen($value),
Copy link
Member

Choose a reason for hiding this comment

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

this is JSON, it's always utf-8:

Suggested change
\is_string($value) => mb_strlen($value),
\is_string($value) => mb_strlen($value, 'UTF-8'),

*
* @internal
*/
#[AsJsonPathFunction(name: 'match')]
Copy link
Member

Choose a reason for hiding this comment

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

I don't think these attributes are needed. They should be removed IMHO

Comment on lines +34 to +37
return match (true) {
\is_string($value) && \is_string($pattern) => (bool) @preg_match(\sprintf('/^%s$/u', JsonPathUtils::normalizeRegex($pattern)), $value),
default => false,
};
Copy link
Member

Choose a reason for hiding this comment

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

please consider changing to this CS everywhere mach is used:

Suggested change
return match (true) {
\is_string($value) && \is_string($pattern) => (bool) @preg_match(\sprintf('/^%s$/u', JsonPathUtils::normalizeRegex($pattern)), $value),
default => false,
};
return \is_string($value) && \is_string($pattern) && @preg_match(\sprintf('/^%s$/Du', JsonPathUtils::normalizeRegex($pattern)), $value);

'value' => ValueFunction::class,
] as $name => $function) {
if (!$this->locator?->has($name)) {
$this->addFunction(new $function());
Copy link
Member

Choose a reason for hiding this comment

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

this force instantiating the functions even if ther's not used. I'd rather do the instantiation on demand

@@ -26,9 +25,11 @@ final class JsonPathTokenizer
private const BARE_LITERAL_REGEX = '(true|false|null|\d+(\.\d+)?([eE][+-]?\d+)?|\'[^\']*\'|"[^"]*")';

/**
* @param callable(string $functionName, list<string> $args): void|null $functionValidator
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @param callable(string $functionName, list<string> $args): void|null $functionValidator
* @param callable(string $functionName, list<string> $args): void $functionValidator

voisd|null does not make sense

*/
public function validate(array $args): void;

public function getName(): string;
Copy link
Member

Choose a reason for hiding this comment

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

Remove addFunction in the functions provider also allows removing this method, as that's the only place using that method.
Custom functions are expected to be registered in the service locator using the name as id, which is what would be the source of truth anyway (and which won't be implemented based on this getter in any lazy-loading implementation)

@@ -5,6 +5,8 @@ CHANGELOG
---

* The component is not marked as `@experimental` anymore
* Add `JsonPathFunctionsProviderInterface`, `JsonPathFunctionInterface` and `JsonPathFunctionProviderTrait` to allow registering custom JsonPath functions
Copy link
Member

Choose a reason for hiding this comment

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

there is no JsonPathFunctionProviderTrait

Copy link
Member

Choose a reason for hiding this comment

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

maybe you meant JsonPathFunctionArgumentTrait

/**
* @return bool Whether the function returns a comparable value or a boolean test result
*/
public function isComparable(string $functionName): bool;
Copy link
Member

Choose a reason for hiding this comment

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

the functions provider should only provide functions. Executing them or checking whether the function implement JsonPathComparableFunctionInterface should be implemented in our JsonCrawler consuming that interface

/**
* @author Alexandre Daubois <[email protected]>
*/
abstract class AbstractJsonPathFunction implements JsonPathFunctionInterface
Copy link
Member

Choose a reason for hiding this comment

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

Once getName is dropped, I suggest removing this abstract class. Its only remaining code would be the no-op validate, and I expect that all functions should at least validate their argument count, making the no-op implementation useless.

try {
return $this->functionsProvider->executeFunction($name, $argList, $context);
} catch (\InvalidArgumentException $e) {
throw new JsonCrawlerException($name, $e->getMessage(), previous: $e);
Copy link
Member

Choose a reason for hiding this comment

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

using the function name as path being evaluated is wrong.

@alexandre-daubois
Copy link
Member Author

To match the RFC and implement custom functions, Nothing must actually be exposed to the user (compared to 7.3 where it's hidden). I'll take care of your comments in the next few days.

@stof
Copy link
Member

stof commented Sep 12, 2025

If we need to expose Nothing as a non-internal API, I suggest reverting the change that replaced the single-case enum with an stdClass object stored in a private property that could be used as a marker object.
the single-case enum will provide a typesafe clean API for that use case.

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.

5 participants