From 0e10ad12f685f6cb28c80bc1e0408a15bdfb7c55 Mon Sep 17 00:00:00 2001 From: Julien LARY <47776596+laryjulien@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:21:09 +0200 Subject: [PATCH 1/7] fix: allow mcp/sdk 0.7, composer install was broken symfony/mcp-bundle dev-main now requires mcp/sdk ^0.7 which conflicted with the root ^0.6 constraint, making the project impossible to install. Co-Authored-By: Claude Fable 5 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e1b841d153..882cedc2cd 100644 --- a/composer.json +++ b/composer.json @@ -142,7 +142,7 @@ "jangregor/phpstan-prophecy": "^2.1.11", "justinrainbow/json-schema": "^6.5.2", "laravel/framework": "^11.0 || ^12.0 || ^13.0", - "mcp/sdk": "^0.6", + "mcp/sdk": "^0.6 || ^0.7", "orchestra/testbench": "^10.9 || ^11.0", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", From a7aca389231d3e4788a1c12568715c7e2726f5b8 Mon Sep 17 00:00:00 2001 From: Julien LARY <47776596+laryjulien@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:21:09 +0200 Subject: [PATCH 2/7] fix: mark deleted PHPStan exclude path as optional tests/Fixtures/app/config/reference.php was removed with the Symfony 8 support commit but its excludePaths entry remained, breaking every local PHPStan run. Co-Authored-By: Claude Fable 5 --- phpstan.neon.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ee3f9221f4..8afc2a94ef 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -35,7 +35,7 @@ parameters: - src/*/vendor/* # Symfony 6 support - src/Symfony/Bundle/ArgumentResolver/CompatibleValueResolverInterface.php - - tests/Fixtures/app/config/reference.php + - tests/Fixtures/app/config/reference.php (?) earlyTerminatingMethodCalls: PHPUnit\Framework\Constraint\Constraint: - fail From 7b434c97c4e0feadf6631cbed704d40ef942c107 Mon Sep 17 00:00:00 2001 From: Julien LARY <47776596+laryjulien@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:21:46 +0200 Subject: [PATCH 3/7] feat!: drop Elasticsearch 7 client support Removes the legacy elasticsearch/elasticsearch v7 client (Elasticsearch\Client) from the providers, the client factory compiler pass and the bundle configuration. Elasticsearch 7 is end-of-life; the component now requires elasticsearch/elasticsearch ^8.4 || ^9.0 (OpenSearch support is unchanged). The CI matrix entry running against Elasticsearch 7.17 now runs against 8.4 with the lowest supported client version. BREAKING CHANGE: the Elasticsearch state providers no longer accept an Elasticsearch\Client (v7) instance. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 2 +- composer.json | 2 +- .../State/CollectionProvider.php | 6 +-- src/Elasticsearch/State/ItemProvider.php | 6 +-- src/Elasticsearch/composer.json | 2 +- .../ApiPlatformExtension.php | 6 +-- .../Compiler/ElasticsearchClientPass.php | 9 ---- .../DependencyInjection/Configuration.php | 4 +- .../Compiler/ElasticsearchClientPassTest.php | 43 +++++-------------- 9 files changed, 19 insertions(+), 61 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db8cdbb570..1bcac5cd1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -725,7 +725,7 @@ jobs: elasticsearch-package: 'elasticsearch/elasticsearch:^8.4' extensions: 'intl, bcmath, curl, openssl, mbstring' - php: '8.3' - elasticsearch-version: '7.17.0' + elasticsearch-version: '8.4.0' elasticsearch-package: 'elasticsearch/elasticsearch:prefer-lowest' extensions: 'intl, bcmath, curl, openssl, mbstring' fail-fast: false diff --git a/composer.json b/composer.json index 882cedc2cd..2c8e606a19 100644 --- a/composer.json +++ b/composer.json @@ -129,7 +129,7 @@ "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/orm": "^2.17 || ^3.0", - "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", + "elasticsearch/elasticsearch": "^8.4 || ^9.0", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", "illuminate/config": "^11.0 || ^12.0 || ^13.0", diff --git a/src/Elasticsearch/State/CollectionProvider.php b/src/Elasticsearch/State/CollectionProvider.php index 4e78be7fa6..08ee597825 100644 --- a/src/Elasticsearch/State/CollectionProvider.php +++ b/src/Elasticsearch/State/CollectionProvider.php @@ -24,8 +24,6 @@ use Elastic\Elasticsearch\Client; use Elastic\Elasticsearch\Exception\ClientResponseException; use Elastic\Elasticsearch\Response\Elasticsearch; -use Elasticsearch\Client as V7Client; -use Elasticsearch\Common\Exceptions\Missing404Exception as V7Missing404Exception; use OpenSearch\Client as OpenSearchClient; use OpenSearch\Common\Exceptions\Missing404Exception as OpenSearchMissing404Exception; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -42,7 +40,7 @@ final class CollectionProvider implements ProviderInterface * @param RequestBodySearchCollectionExtensionInterface[] $collectionExtensions */ public function __construct( - private readonly V7Client|Client|OpenSearchClient $client, // @phpstan-ignore-line + private readonly Client|OpenSearchClient $client, // @phpstan-ignore-line private readonly ?DenormalizerInterface $denormalizer = null, private readonly ?Pagination $pagination = null, private readonly iterable $collectionExtensions = [], @@ -78,7 +76,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $documents = $this->client->search($params); // @phpstan-ignore-line - } catch (V7Missing404Exception|OpenSearchMissing404Exception $e) { // @phpstan-ignore-line + } catch (OpenSearchMissing404Exception $e) { // @phpstan-ignore-line throw new Error(status: $e->getCode(), detail: $e->getMessage(), title: $e->getMessage(), originalTrace: $e->getTrace()); // @phpstan-ignore-line } catch (ClientResponseException $e) { $response = $e->getResponse(); diff --git a/src/Elasticsearch/State/ItemProvider.php b/src/Elasticsearch/State/ItemProvider.php index 88321d26fa..3c71c97a4f 100644 --- a/src/Elasticsearch/State/ItemProvider.php +++ b/src/Elasticsearch/State/ItemProvider.php @@ -22,8 +22,6 @@ use Elastic\Elasticsearch\Client; use Elastic\Elasticsearch\Exception\ClientResponseException; use Elastic\Elasticsearch\Response\Elasticsearch; -use Elasticsearch\Client as V7Client; -use Elasticsearch\Common\Exceptions\Missing404Exception as V7Missing404Exception; use OpenSearch\Client as OpenSearchClient; use OpenSearch\Common\Exceptions\Missing404Exception as OpenSearchMissing404Exception; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -38,7 +36,7 @@ final class ItemProvider implements ProviderInterface { public function __construct( - private readonly V7Client|Client|OpenSearchClient $client, // @phpstan-ignore-line + private readonly Client|OpenSearchClient $client, // @phpstan-ignore-line private readonly ?DenormalizerInterface $denormalizer = null, private readonly ?InflectorInterface $inflector = new Inflector(), ) { @@ -62,7 +60,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $document = $this->client->get($params); // @phpstan-ignore-line - } catch (V7Missing404Exception|OpenSearchMissing404Exception) { // @phpstan-ignore-line + } catch (OpenSearchMissing404Exception) { // @phpstan-ignore-line return null; } catch (ClientResponseException $e) { $response = $e->getResponse(); diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 3050b7eea2..51d078d1b8 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -27,7 +27,7 @@ "api-platform/metadata": "^5.0@alpha", "api-platform/serializer": "^5.0@alpha", "api-platform/state": "^5.0@alpha", - "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", + "elasticsearch/elasticsearch": "^8.4 || ^9.0", "symfony/cache": "^7.4 || ^8.0", "symfony/console": "^7.4 || ^8.0", "symfony/property-access": "^7.4 || ^8.0", diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 22d99e6c48..6719043c28 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -1031,12 +1031,8 @@ private function registerElasticsearchConfiguration(ContainerBuilder $container, if ('opensearch' === $config['elasticsearch']['client']) { $clientClass = \OpenSearch\Client::class; // @phpstan-ignore class.notFound - } elseif (!class_exists(\Elasticsearch\Client::class)) { - // ES v8 and up - $clientClass = \Elastic\Elasticsearch\Client::class; } else { - // ES v7 - $clientClass = \Elasticsearch\Client::class; + $clientClass = \Elastic\Elasticsearch\Client::class; } $clientDefinition = new Definition($clientClass); diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php index d676c46866..bd6ffb1452 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPass.php @@ -41,21 +41,12 @@ public function process(ContainerBuilder $container): void if ('opensearch' === $container->getParameter('api_platform.elasticsearch.client')) { $builderName = \OpenSearch\ClientBuilder::class; // @phpstan-ignore class.notFound - } elseif (class_exists(\Elasticsearch\ClientBuilder::class)) { - // ES v7 - $builderName = \Elasticsearch\ClientBuilder::class; } else { - // ES v8 and up $builderName = \Elastic\Elasticsearch\ClientBuilder::class; } if ($container->has('logger')) { $clientConfiguration['logger'] = new Reference('logger'); - - // @phpstan-ignore-next-line - if (\Elasticsearch\ClientBuilder::class === $builderName) { - $clientConfiguration['tracer'] = new Reference('logger'); - } } if ($sslCaBundle = $container->getParameter('api_platform.elasticsearch.ssl_ca_bundle')) { diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 012f0f1aa2..ef28f4bb4a 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -476,10 +476,8 @@ private function addElasticsearchSection(ArrayNodeDefinition $rootNode): void ->ifTrue() ->then(static function (bool $v): bool { if ( - // ES v7 - !class_exists(\Elasticsearch\Client::class) // ES v8 and up - && !class_exists(\Elastic\Elasticsearch\Client::class) + !class_exists(\Elastic\Elasticsearch\Client::class) // OpenSearch && !class_exists(\OpenSearch\Client::class) ) { diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php index 12ced7b366..a6b2aff4c4 100644 --- a/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/ElasticsearchClientPassTest.php @@ -36,26 +36,13 @@ public function testConstruct(): void public function testProcess(): void { - // ES v7 - if (class_exists(\Elasticsearch\ClientBuilder::class)) { - $clientBuilder = \Elasticsearch\ClientBuilder::class; - - $expectedArguments = [ - Argument::withEntry('hosts', ['http://localhost:9200']), - Argument::withEntry('logger', Argument::type(Reference::class)), - Argument::withEntry('tracer', Argument::type(Reference::class)), - Argument::size(3), - ]; - // ES v8 and up - } else { - $clientBuilder = \Elastic\Elasticsearch\ClientBuilder::class; - - $expectedArguments = [ - Argument::withEntry('hosts', ['http://localhost:9200']), - Argument::withEntry('logger', Argument::type(Reference::class)), - Argument::size(2), - ]; - } + $clientBuilder = \Elastic\Elasticsearch\ClientBuilder::class; + + $expectedArguments = [ + Argument::withEntry('hosts', ['http://localhost:9200']), + Argument::withEntry('logger', Argument::type(Reference::class)), + Argument::size(2), + ]; $clientDefinitionProphecy = $this->prophesize(Definition::class); $clientDefinitionProphecy->setFactory([$clientBuilder, 'fromConfig'])->willReturn($clientDefinitionProphecy->reveal())->shouldBeCalled(); @@ -127,11 +114,7 @@ public function testProcessWithOpenSearchClient(): void public function testProcessWithoutConfiguration(): void { - $clientBuilder = class_exists(\Elasticsearch\ClientBuilder::class) - // ES v7 - ? \Elasticsearch\ClientBuilder::class - // ES v8 and up - : \Elastic\Elasticsearch\ClientBuilder::class; + $clientBuilder = \Elastic\Elasticsearch\ClientBuilder::class; $clientDefinitionProphecy = $this->prophesize(Definition::class); $clientDefinitionProphecy->setFactory([$clientBuilder, 'build'])->willReturn($clientDefinitionProphecy->reveal())->shouldBeCalled(); @@ -158,11 +141,7 @@ public function testProcessWithElasticsearchDisabled(): void public function testProcessWithSslCaBundle(): void { - $clientBuilder = class_exists(\Elasticsearch\ClientBuilder::class) - // ES v7 - ? \Elasticsearch\ClientBuilder::class - // ES v8 and up - : \Elastic\Elasticsearch\ClientBuilder::class; + $clientBuilder = \Elastic\Elasticsearch\ClientBuilder::class; $clientDefinition = $this->createMock(Definition::class); $clientDefinition->expects($this->once()) @@ -209,9 +188,7 @@ public function testProcessWithSslCaBundle(): void public function testProcessWithSslVerificationDisabled(): void { - $clientBuilder = class_exists(\Elasticsearch\ClientBuilder::class) - ? \Elasticsearch\ClientBuilder::class - : \Elastic\Elasticsearch\ClientBuilder::class; + $clientBuilder = \Elastic\Elasticsearch\ClientBuilder::class; $clientDefinition = $this->createMock(Definition::class); $clientDefinition->expects($this->once()) From 96e29d2ab8d7564621960ef94a27e43c5fd406db Mon Sep 17 00:00:00 2001 From: Julien LARY <47776596+laryjulien@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:22:35 +0200 Subject: [PATCH 4/7] feat(elasticsearch): add experimental ES|QL support for collections Adds an ES|QL-based collection provider as an alternative to the classic _search Query DSL, selectable globally (api_platform.elasticsearch.query_language) or per operation (queryLanguage state option): - EsqlQuery: injection-safe query builder using named value (?p) and identifier (??f) parameters, with the full-text conditions always compiled into the first WHERE command after FROM - parameter filters aligned with the Doctrine bridges: ExactFilter, MatchFilter (full-text), ComparisonFilter (gt/gte/lt/lte/ne/between) and SortFilter - extension chain mirroring the Doctrine architecture, including a ParameterExtension bridging QueryParameter to filters (with an explicit error for "nested" fields, unsupported by ES|QL) and a PaginationExtension implemented as a result extension - partial pagination only (ES|QL has no offset and no total hit count): the paginator fetches LIMIT n+1 rows and exposes hasNextPage() - handleLinks state option for subresource support - items keep using the document GET API whatever the query language Requires Elasticsearch >= 8.14 at runtime; not available with OpenSearch (rejected at configuration time). The default query language remains "dsl". Co-Authored-By: Claude Fable 5 --- src/Elasticsearch/Esql/EsqlQuery.php | 206 ++++++++++++++++++ .../CollectionExtensionInterface.php | 32 +++ .../Esql/Extension/PaginationExtension.php | 98 +++++++++ .../Esql/Extension/ParameterExtension.php | 91 ++++++++ .../ResultCollectionExtensionInterface.php | 40 ++++ .../Esql/Extension/SortExtension.php | 64 ++++++ .../Esql/Filter/ComparisonFilter.php | 105 +++++++++ src/Elasticsearch/Esql/Filter/ExactFilter.php | 64 ++++++ .../Esql/Filter/FilterInterface.php | 37 ++++ src/Elasticsearch/Esql/Filter/MatchFilter.php | 62 ++++++ src/Elasticsearch/Esql/Filter/SortFilter.php | 64 ++++++ src/Elasticsearch/Esql/Paginator.php | 141 ++++++++++++ .../Esql/State/CollectionProvider.php | 97 +++++++++ .../Esql/State/LinksHandlerInterface.php | 36 +++ ...viderResourceMetadataCollectionFactory.php | 29 ++- src/Elasticsearch/State/Options.php | 31 +++ src/Elasticsearch/State/QueryLanguage.php | 32 +++ .../Tests/Esql/EsqlQueryTest.php | 131 +++++++++++ .../Extension/PaginationExtensionTest.php | 59 +++++ .../Esql/Extension/ParameterExtensionTest.php | 96 ++++++++ .../Esql/Extension/SortExtensionTest.php | 60 +++++ .../Tests/Esql/Filter/FilterTest.php | 159 ++++++++++++++ .../Tests/Esql/PaginatorTest.php | 97 +++++++++ .../Esql/State/CollectionProviderTest.php | 154 +++++++++++++ ...rResourceMetadataCollectionFactoryTest.php | 62 ++++++ .../ApiPlatformExtension.php | 4 + .../DependencyInjection/Configuration.php | 9 + .../Bundle/Resources/config/elasticsearch.php | 45 +++- .../DependencyInjection/ConfigurationTest.php | 1 + 29 files changed, 2101 insertions(+), 5 deletions(-) create mode 100644 src/Elasticsearch/Esql/EsqlQuery.php create mode 100644 src/Elasticsearch/Esql/Extension/CollectionExtensionInterface.php create mode 100644 src/Elasticsearch/Esql/Extension/PaginationExtension.php create mode 100644 src/Elasticsearch/Esql/Extension/ParameterExtension.php create mode 100644 src/Elasticsearch/Esql/Extension/ResultCollectionExtensionInterface.php create mode 100644 src/Elasticsearch/Esql/Extension/SortExtension.php create mode 100644 src/Elasticsearch/Esql/Filter/ComparisonFilter.php create mode 100644 src/Elasticsearch/Esql/Filter/ExactFilter.php create mode 100644 src/Elasticsearch/Esql/Filter/FilterInterface.php create mode 100644 src/Elasticsearch/Esql/Filter/MatchFilter.php create mode 100644 src/Elasticsearch/Esql/Filter/SortFilter.php create mode 100644 src/Elasticsearch/Esql/Paginator.php create mode 100644 src/Elasticsearch/Esql/State/CollectionProvider.php create mode 100644 src/Elasticsearch/Esql/State/LinksHandlerInterface.php create mode 100644 src/Elasticsearch/State/QueryLanguage.php create mode 100644 src/Elasticsearch/Tests/Esql/EsqlQueryTest.php create mode 100644 src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php create mode 100644 src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php create mode 100644 src/Elasticsearch/Tests/Esql/Extension/SortExtensionTest.php create mode 100644 src/Elasticsearch/Tests/Esql/Filter/FilterTest.php create mode 100644 src/Elasticsearch/Tests/Esql/PaginatorTest.php create mode 100644 src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php diff --git a/src/Elasticsearch/Esql/EsqlQuery.php b/src/Elasticsearch/Esql/EsqlQuery.php new file mode 100644 index 0000000000..efaef17f3f --- /dev/null +++ b/src/Elasticsearch/Esql/EsqlQuery.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; + +/** + * Builds an ES|QL query (sent to the Elasticsearch "_query" endpoint). + * + * Values and identifiers are bound as named parameters (`?name` for values, + * `??name` for identifiers such as field names) so the resulting query is + * safe against injection whatever the source of the bound data is. + * + * Full-text conditions (e.g. `MATCH(field, ?value)`) must be part of the first + * `WHERE` command following `FROM`: they are accumulated separately through + * {@see fullTextWhere()} and always compiled into that first `WHERE`. + * + * @see https://www.elastic.co/docs/reference/query-languages/esql/esql-syntax + * + * @experimental + */ +final class EsqlQuery +{ + private ?string $where = null; + + private ?string $fullTextWhere = null; + + /** + * @var list + */ + private array $sorts = []; + + private ?int $limit = null; + + /** + * @var list> + */ + private array $params = []; + + private int $paramCount = 0; + + /** + * @param list $metadataFields + */ + public function __construct( + private readonly string $index, + private readonly array $metadataFields = ['_id'], + ) { + } + + /** + * Binds a value and returns its placeholder (e.g. "?p1"). + */ + public function param(mixed $value): string + { + $name = 'p'.++$this->paramCount; + $this->params[] = [$name => $value]; + + return "?{$name}"; + } + + /** + * Binds an identifier (field name) and returns its placeholder (e.g. "??f2"). + */ + public function identifier(string $name): string + { + $paramName = 'f'.++$this->paramCount; + $this->params[] = [$paramName => $name]; + + return "??{$paramName}"; + } + + /** + * Adds a condition joined with a logical AND, like the Doctrine "andWhere" method. + */ + public function andWhere(string $condition): self + { + $this->where = null === $this->where ? $condition : "({$this->where}) AND ({$condition})"; + + return $this; + } + + /** + * Adds a condition joined with a logical OR, like the Doctrine "orWhere" method. + */ + public function orWhere(string $condition): self + { + $this->where = null === $this->where ? $condition : "({$this->where}) OR ({$condition})"; + + return $this; + } + + /** + * Adds a full-text condition (e.g. MATCH()), always compiled into the first WHERE command after FROM. + */ + public function fullTextWhere(string $condition, string $operator = 'AND'): self + { + if (!\in_array($operator = strtoupper($operator), ['AND', 'OR'], true)) { + throw new InvalidArgumentException(\sprintf('Invalid logical operator "%s".', $operator)); + } + + $this->fullTextWhere = null === $this->fullTextWhere ? $condition : "({$this->fullTextWhere}) {$operator} ({$condition})"; + + return $this; + } + + /** + * @param string $direction "ASC" or "DESC", case-insensitive (validated at runtime as it may come from user input) + */ + public function sort(string $field, string $direction = 'ASC'): self + { + // identifier parameters (??name) are not supported by the SORT command, validate the raw field name instead + if (!preg_match('/^[a-zA-Z0-9_.@-]+$/', $field)) { + throw new InvalidArgumentException(\sprintf('Invalid sort field "%s".', $field)); + } + + if (!\in_array($direction = strtoupper($direction), ['ASC', 'DESC'], true)) { + throw new InvalidArgumentException(\sprintf('Invalid sort direction "%s".', $direction)); + } + + $this->sorts[] = "{$field} {$direction}"; + + return $this; + } + + public function hasSort(): bool + { + return [] !== $this->sorts; + } + + public function hasSortOn(string $field): bool + { + foreach ($this->sorts as $sort) { + if (str_starts_with($sort, "{$field} ")) { + return true; + } + } + + return false; + } + + public function limit(int $limit): self + { + if ($limit < 0) { + throw new InvalidArgumentException('The limit must be greater than or equal to 0.'); + } + + $this->limit = $limit; + + return $this; + } + + public function getLimit(): ?int + { + return $this->limit; + } + + /** + * Compiles to the ES|QL request body: the query string and its named parameters. + * + * @return array{query: string, params: list>} + */ + public function compile(): array + { + $index = $this->index; + if (!preg_match('/^[a-zA-Z0-9_.*-]+$/', $index)) { + throw new InvalidArgumentException(\sprintf('Invalid index name "%s".', $index)); + } + + $commands = [\sprintf('FROM %s%s', $index, $this->metadataFields ? ' METADATA '.implode(', ', $this->metadataFields) : '')]; + + $where = match (true) { + null !== $this->fullTextWhere && null !== $this->where => "({$this->fullTextWhere}) AND ({$this->where})", + null !== $this->fullTextWhere => $this->fullTextWhere, + default => $this->where, + }; + + if (null !== $where) { + $commands[] = "WHERE {$where}"; + } + + if ($this->sorts) { + $commands[] = 'SORT '.implode(', ', $this->sorts); + } + + if (null !== $this->limit) { + $commands[] = "LIMIT {$this->limit}"; + } + + return [ + 'query' => implode(' | ', $commands), + 'params' => $this->params, + ]; + } +} diff --git a/src/Elasticsearch/Esql/Extension/CollectionExtensionInterface.php b/src/Elasticsearch/Esql/Extension/CollectionExtensionInterface.php new file mode 100644 index 0000000000..c1057f417f --- /dev/null +++ b/src/Elasticsearch/Esql/Extension/CollectionExtensionInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\Operation; + +/** + * Alters the ES|QL query while querying a resource collection. + * + * @experimental + * + * @author Julien Lary + */ +interface CollectionExtensionInterface +{ + /** + * @param array $context + */ + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void; +} diff --git a/src/Elasticsearch/Esql/Extension/PaginationExtension.php b/src/Elasticsearch/Esql/Extension/PaginationExtension.php new file mode 100644 index 0000000000..4ae9ce4902 --- /dev/null +++ b/src/Elasticsearch/Esql/Extension/PaginationExtension.php @@ -0,0 +1,98 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Paginator; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ApiResource\Error; +use ApiPlatform\State\Pagination\Pagination; +use Elastic\Elasticsearch\Client; +use Elastic\Elasticsearch\Exception\ClientResponseException; +use Elastic\Elasticsearch\Response\Elasticsearch; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Paginates the ES|QL query and executes it. + * + * ES|QL supports no offset: only partial (cursor-friendly) pagination is + * available. One extra row is fetched (LIMIT itemsPerPage + 1) to detect + * whether a next page exists; combine with a sorted unique field and a + * ComparisonFilter (keyset pagination) or the "paginationViaCursor" + * operation attribute to navigate pages. + * + * @experimental + * + * @author Julien Lary + */ +final class PaginationExtension implements ResultCollectionExtensionInterface +{ + public function __construct( + private readonly Client $client, + private readonly ?DenormalizerInterface $denormalizer = null, + private readonly ?Pagination $pagination = null, + ) { + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (null === $this->pagination || !$this->pagination->isEnabled($operation, $context)) { + return; + } + + $limit = $this->pagination->getLimit($operation, $context); + + // one extra row to detect whether a next page exists + $query->limit($limit > 0 ? $limit + 1 : 0); + } + + /** + * {@inheritdoc} + */ + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool + { + return null !== $this->pagination && $this->pagination->isEnabled($operation, $context); + } + + /** + * {@inheritdoc} + */ + public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable + { + try { + $response = $this->client->esql()->query(['body' => $query->compile()]); + } catch (ClientResponseException $e) { + $errorResponse = $e->getResponse(); + throw new Error(status: $errorResponse->getStatusCode(), detail: (string) $errorResponse->getBody(), title: $errorResponse->getReasonPhrase(), originalTrace: $e->getTrace()); + } + + if ($response instanceof Elasticsearch) { + $response = $response->asArray(); + } + + $limit = $this->pagination->getLimit($operation, $context); + + return new Paginator( + $this->denormalizer, + $response, + $resourceClass, + $limit, + $this->pagination->getPage($context), + $context, + ); + } +} diff --git a/src/Elasticsearch/Esql/Extension/ParameterExtension.php b/src/Elasticsearch/Esql/Extension/ParameterExtension.php new file mode 100644 index 0000000000..3adcad73f6 --- /dev/null +++ b/src/Elasticsearch/Esql/Extension/ParameterExtension.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Filter\FilterInterface; +use ApiPlatform\Elasticsearch\Util\FieldDatatypeTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\State\ParameterNotFound; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Applies parameter filters (QueryParameter) to the ES|QL query. + * + * The bridge between {@see \ApiPlatform\Metadata\Parameter} and + * {@see FilterInterface}: resolves the + * parameter's filter (instance or service id), converts the property name to + * its Elasticsearch field name and rejects fields mapped as `nested` + * (unsupported by ES|QL). + * + * @experimental + * + * @author Julien Lary + */ +final class ParameterExtension implements CollectionExtensionInterface +{ + use FieldDatatypeTrait; + + public function __construct( + private readonly ContainerInterface $filterLocator, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + ResourceClassResolverInterface $resourceClassResolver, + private readonly ?NameConverterInterface $nameConverter = null, + ) { + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->resourceClassResolver = $resourceClassResolver; + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + foreach ($operation?->getParameters() ?? [] as $parameter) { + if (null === ($value = $parameter->getValue()) || $value instanceof ParameterNotFound) { + continue; + } + + if (null === ($filterId = $parameter->getFilter())) { + continue; + } + + $filter = match (true) { + $filterId instanceof FilterInterface => $filterId, + \is_string($filterId) && $this->filterLocator->has($filterId) => $this->filterLocator->get($filterId), + default => null, + }; + + if (!$filter instanceof FilterInterface) { + continue; + } + + $property = $parameter->getProperty() ?? $parameter->getKey(); + if (null !== $property && $this->isNestedField($resourceClass, $property)) { + throw new InvalidArgumentException(\sprintf('The property "%s" of the resource "%s" is mapped as an Elasticsearch "nested" field, which is not supported by ES|QL. Use the Query DSL instead (see the "queryLanguage" state option).', $property, $resourceClass)); + } + + $esField = null === $property || null === $this->nameConverter ? $property : $this->nameConverter->normalize($property, $resourceClass, null, $context); + + $filter->apply($query, $resourceClass, $operation, [ + 'parameter' => $parameter, + 'es_field' => $esField, + ] + $context); + } + } +} diff --git a/src/Elasticsearch/Esql/Extension/ResultCollectionExtensionInterface.php b/src/Elasticsearch/Esql/Extension/ResultCollectionExtensionInterface.php new file mode 100644 index 0000000000..16f1c26c54 --- /dev/null +++ b/src/Elasticsearch/Esql/Extension/ResultCollectionExtensionInterface.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\Operation; + +/** + * Executes the ES|QL query and returns the collection result, short-circuiting + * the provider (e.g. to return a paginator). + * + * @experimental + * + * @author Julien Lary + */ +interface ResultCollectionExtensionInterface extends CollectionExtensionInterface +{ + /** + * @param array $context + */ + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool; + + /** + * @param array $context + * + * @return iterable + */ + public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable; +} diff --git a/src/Elasticsearch/Esql/Extension/SortExtension.php b/src/Elasticsearch/Esql/Extension/SortExtension.php new file mode 100644 index 0000000000..9642818610 --- /dev/null +++ b/src/Elasticsearch/Esql/Extension/SortExtension.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Applies the operation default order ("order" attribute) when no sort was + * requested, then always adds a sort on "_id" as a tiebreaker so results are + * deterministic across pages. + * + * @experimental + * + * @author Julien Lary + */ +final class SortExtension implements CollectionExtensionInterface +{ + public function __construct( + private readonly ?NameConverterInterface $nameConverter = null, + private readonly ?string $defaultDirection = null, + ) { + } + + /** + * {@inheritdoc} + */ + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + if (!$query->hasSort()) { + $order = $operation?->getOrder() ?? []; + if (!$order && null !== $this->defaultDirection) { + $order = ['_id' => $this->defaultDirection]; + } + + foreach ($order as $property => $direction) { + if (\is_int($property)) { + $property = $direction; + $direction = 'ASC'; + } + + $field = '_id' === $property || null === $this->nameConverter ? $property : $this->nameConverter->normalize($property, $resourceClass, null, $context); + $query->sort($field, $direction); + } + } + + // deterministic tiebreaker, required for stable pagination + if (!$query->hasSortOn('_id')) { + $query->sort('_id'); + } + } +} diff --git a/src/Elasticsearch/Esql/Filter/ComparisonFilter.php b/src/Elasticsearch/Esql/Filter/ComparisonFilter.php new file mode 100644 index 0000000000..5a13ae01ff --- /dev/null +++ b/src/Elasticsearch/Esql/Filter/ComparisonFilter.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Filter; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; + +/** + * Compares a field against one or more bounds. + * + * Usage: `new QueryParameter(filter: new ComparisonFilter(), property: 'rating')`, + * then `?rating[gt]=4`, `?rating[lte]=2`, `?rating[ne]=3` or `?rating[between]=2..4`. + * + * @experimental + * + * @author Julien Lary + */ +final class ComparisonFilter implements FilterInterface, JsonSchemaFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + + private const OPERATORS = [ + 'gt' => '>', + 'gte' => '>=', + 'lt' => '<', + 'lte' => '<=', + 'ne' => '!=', + ]; + + public const OPERATOR_BETWEEN = 'between'; + + public function apply(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter'] ?? null; + if (null === $parameter) { + return; + } + + $values = $parameter->getValue(null); + if (!\is_array($values)) { + return; + } + + $field = $context['es_field'] ?? $parameter->getProperty() ?? $parameter->getKey(); + if (null === $field) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property.', $parameter->getKey())); + } + + $whereClause = ($context['esql_where_clause'] ?? 'andWhere'); + + foreach ($values as $operator => $value) { + if ('' === $value || null === $value) { + continue; + } + + if (self::OPERATOR_BETWEEN === $operator) { + if (!\is_string($value) || 2 !== \count($bounds = explode('..', $value)) || '' === $bounds[0] || '' === $bounds[1]) { + throw new InvalidArgumentException(\sprintf('The "between" operator of the parameter "%s" expects a "min..max" value.', $parameter->getKey())); + } + + $fieldPlaceholder = $query->identifier($field); + $query->{$whereClause}(\sprintf('%1$s >= %2$s AND %1$s <= %3$s', $fieldPlaceholder, $query->param($bounds[0]), $query->param($bounds[1]))); + + continue; + } + + if (isset(self::OPERATORS[$operator])) { + $query->{$whereClause}(\sprintf('%s %s %s', $query->identifier($field), self::OPERATORS[$operator], $query->param($value))); + } + } + } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'gt' => ['type' => 'string'], + 'gte' => ['type' => 'string'], + 'lt' => ['type' => 'string'], + 'lte' => ['type' => 'string'], + 'ne' => ['type' => 'string'], + 'between' => ['type' => 'string', 'pattern' => '^.+\.\..+$'], + ], + ]; + } +} diff --git a/src/Elasticsearch/Esql/Filter/ExactFilter.php b/src/Elasticsearch/Esql/Filter/ExactFilter.php new file mode 100644 index 0000000000..680552bd14 --- /dev/null +++ b/src/Elasticsearch/Esql/Filter/ExactFilter.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Filter; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Operation; + +/** + * Matches documents whose field is exactly equal to one of the given values. + * + * Usage: `new QueryParameter(filter: new ExactFilter(), property: 'genre')`, + * then `?genre=fantasy` or `?genre[]=fantasy&genre[]=sci-fi`. + * + * @experimental + * + * @author Julien Lary + */ +final class ExactFilter implements FilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + + public function apply(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter'] ?? null; + if (null === $parameter) { + return; + } + + $value = $parameter->getValue(null); + if (null === $value || '' === $value || [] === $value) { + return; + } + + $field = $context['es_field'] ?? $parameter->getProperty() ?? $parameter->getKey(); + if (null === $field) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property.', $parameter->getKey())); + } + + $fieldPlaceholder = $query->identifier($field); + $whereClause = ($context['esql_where_clause'] ?? 'andWhere'); + + if (\is_array($value)) { + $placeholders = array_map(static fn (mixed $v): string => $query->param($v), array_values($value)); + $query->{$whereClause}(\sprintf('%s IN (%s)', $fieldPlaceholder, implode(', ', $placeholders))); + + return; + } + + $query->{$whereClause}(\sprintf('%s == %s', $fieldPlaceholder, $query->param($value))); + } +} diff --git a/src/Elasticsearch/Esql/Filter/FilterInterface.php b/src/Elasticsearch/Esql/Filter/FilterInterface.php new file mode 100644 index 0000000000..0d6030996e --- /dev/null +++ b/src/Elasticsearch/Esql/Filter/FilterInterface.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Filter; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\FilterInterface as BaseFilterInterface; +use ApiPlatform\Metadata\Operation; + +/** + * Alters the ES|QL query according to the current parameter. + * + * Filters implementing this interface are designed for use with Parameters (QueryParameter): + * the current parameter is available in `$context['parameter']` and the Elasticsearch field + * name (already processed by the name converter) in `$context['es_field']`. + * + * @experimental + * + * @author Julien Lary + */ +interface FilterInterface extends BaseFilterInterface +{ + /** + * @param array $context + */ + public function apply(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void; +} diff --git a/src/Elasticsearch/Esql/Filter/MatchFilter.php b/src/Elasticsearch/Esql/Filter/MatchFilter.php new file mode 100644 index 0000000000..9716a8db3c --- /dev/null +++ b/src/Elasticsearch/Esql/Filter/MatchFilter.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Filter; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Operation; + +/** + * Matches documents using the MATCH full-text function. + * + * Usage: `new QueryParameter(filter: new MatchFilter(), property: 'title')`, + * then `?title=hobbit`. Multiple values are combined with a logical OR. + * + * @see https://www.elastic.co/docs/reference/query-languages/esql/functions-operators/search-functions + * + * @experimental + * + * @author Julien Lary + */ +final class MatchFilter implements FilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + + public function apply(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter'] ?? null; + if (null === $parameter) { + return; + } + + $value = $parameter->getValue(null); + if (null === $value || '' === $value || [] === $value) { + return; + } + + $field = $context['es_field'] ?? $parameter->getProperty() ?? $parameter->getKey(); + if (null === $field) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property.', $parameter->getKey())); + } + + $fieldPlaceholder = $query->identifier($field); + $conditions = array_map( + static fn (mixed $v): string => \sprintf('MATCH(%s, %s)', $fieldPlaceholder, $query->param($v)), + \is_array($value) ? array_values($value) : [$value] + ); + + $query->fullTextWhere(implode(' OR ', $conditions)); + } +} diff --git a/src/Elasticsearch/Esql/Filter/SortFilter.php b/src/Elasticsearch/Esql/Filter/SortFilter.php new file mode 100644 index 0000000000..24d49abb77 --- /dev/null +++ b/src/Elasticsearch/Esql/Filter/SortFilter.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\Filter; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; + +/** + * Sorts the collection by a property. + * + * Usage: `new QueryParameter(filter: new SortFilter(), property: 'rating')` + * with a key like `sort[:property]`, then `?sort[rating]=desc`. + * + * @experimental + * + * @author Julien Lary + */ +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, SortFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + + public function apply(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter'] ?? null; + if (null === $parameter) { + return; + } + + $value = $parameter->getValue(null); + if (!\is_string($value) || !\in_array($direction = strtoupper($value), ['ASC', 'DESC'], true)) { + return; + } + + $field = $context['es_field'] ?? $parameter->getProperty() ?? $parameter->getKey(); + if (null === $field) { + return; + } + + $query->sort($field, $direction); + } + + /** + * @return array + */ + public function getSchema(Parameter $parameter): array + { + return ['type' => 'string', 'enum' => ['asc', 'desc', 'ASC', 'DESC']]; + } +} diff --git a/src/Elasticsearch/Esql/Paginator.php b/src/Elasticsearch/Esql/Paginator.php new file mode 100644 index 0000000000..e547777021 --- /dev/null +++ b/src/Elasticsearch/Esql/Paginator.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql; + +use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Paginator for ES|QL results. + * + * ES|QL has no offset command and no total hit count: this paginator implements + * partial pagination only. The next page is detected by fetching one extra row + * (LIMIT itemsPerPage + 1) and discarding it, see {@see Extension\PaginationExtension}. + * + * @experimental + * + * @author Julien Lary + */ +final class Paginator implements \IteratorAggregate, PartialPaginatorInterface, HasNextPagePaginatorInterface +{ + /** + * @var list> + */ + private readonly array $documents; + + private readonly bool $hasNextPage; + + /** + * @param array{columns?: list, values?: list>} $response the raw (row-oriented) ES|QL response + */ + public function __construct( + private readonly DenormalizerInterface $denormalizer, + array $response, + private readonly string $resourceClass, + private readonly int $limit, + private readonly int $currentPage = 1, + private readonly array $denormalizationContext = [], + ) { + $columns = array_column($response['columns'] ?? [], 'name'); + $documents = array_map(static fn (array $row): array => self::rowToDocument($columns, $row), $response['values'] ?? []); + + $this->hasNextPage = $limit > 0 && \count($documents) > $limit; + $this->documents = $this->hasNextPage ? \array_slice($documents, 0, $limit) : $documents; + } + + /** + * {@inheritdoc} + */ + public function count(): int + { + return \count($this->documents); + } + + /** + * {@inheritdoc} + */ + public function getCurrentPage(): float + { + return (float) $this->currentPage; + } + + /** + * {@inheritdoc} + */ + public function getItemsPerPage(): float + { + return (float) $this->limit; + } + + /** + * {@inheritdoc} + */ + public function hasNextPage(): bool + { + return $this->hasNextPage; + } + + /** + * {@inheritdoc} + */ + public function getIterator(): \Traversable + { + $denormalizationContext = array_merge([AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true], $this->denormalizationContext); + + foreach ($this->documents as $document) { + yield $this->denormalizer->denormalize( + $document, + $this->resourceClass, + DocumentNormalizer::FORMAT, + $denormalizationContext + ); + } + } + + /** + * Converts an ES|QL row to the document shape expected by the DocumentNormalizer + * (`_id` + `_source`), expanding dotted column names to nested arrays. + * + * @param list $columns + * @param list $row + * + * @return array + */ + private static function rowToDocument(array $columns, array $row): array + { + $document = ['_source' => []]; + + foreach ($columns as $i => $name) { + $value = $row[$i] ?? null; + + if (str_starts_with($name, '_')) { + $document[$name] = $value; + + continue; + } + + $target = &$document['_source']; + foreach (explode('.', $name) as $segment) { + $target = &$target[$segment]; + } + $target = $value; + unset($target); + } + + return $document; + } +} diff --git a/src/Elasticsearch/Esql/State/CollectionProvider.php b/src/Elasticsearch/Esql/State/CollectionProvider.php new file mode 100644 index 0000000000..d4d974dc09 --- /dev/null +++ b/src/Elasticsearch/Esql/State/CollectionProvider.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\State; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Extension\CollectionExtensionInterface; +use ApiPlatform\Elasticsearch\Esql\Extension\ResultCollectionExtensionInterface; +use ApiPlatform\Elasticsearch\Esql\Paginator; +use ApiPlatform\Elasticsearch\State\Options; +use ApiPlatform\Metadata\InflectorInterface; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Util\Inflector; +use ApiPlatform\State\ApiResource\Error; +use ApiPlatform\State\ProviderInterface; +use Elastic\Elasticsearch\Client; +use Elastic\Elasticsearch\Exception\ClientResponseException; +use Elastic\Elasticsearch\Response\Elasticsearch; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Collection provider using ES|QL (the Elasticsearch "_query" endpoint). + * + * Requires Elasticsearch >= 8.14. Note that ES|QL comes with limitations + * compared to the Query DSL: no offset (partial pagination only), no total + * item count and no support for "nested" fields. + * + * @experimental + * + * @author Julien Lary + */ +final class CollectionProvider implements ProviderInterface +{ + /** + * @param iterable $collectionExtensions + */ + public function __construct( + private readonly Client $client, + private readonly ?DenormalizerInterface $denormalizer = null, + private readonly iterable $collectionExtensions = [], + private readonly ?InflectorInterface $inflector = new Inflector(), + ) { + } + + /** + * {@inheritdoc} + */ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $resourceClass = $operation->getClass(); + $options = $operation->getStateOptions() instanceof Options ? $operation->getStateOptions() : new Options(); + + $query = new EsqlQuery($options->getIndex() ?? $this->inflector->tableize($operation->getShortName())); + + if (\is_callable($handleLinks = $options->getHandleLinks())) { + $handleLinks($query, $uriVariables, ['operation' => $operation, 'resourceClass' => $resourceClass] + $context); + } + + foreach ($this->collectionExtensions as $extension) { + $extension->applyToCollection($query, $resourceClass, $operation, $context); + + if ($extension instanceof ResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operation, $context)) { + return $extension->getResult($query, $resourceClass, $operation, $context); + } + } + + try { + $response = $this->client->esql()->query(['body' => $query->compile()]); + } catch (ClientResponseException $e) { + $errorResponse = $e->getResponse(); + throw new Error(status: $errorResponse->getStatusCode(), detail: (string) $errorResponse->getBody(), title: $errorResponse->getReasonPhrase(), originalTrace: $e->getTrace()); + } + + if ($response instanceof Elasticsearch) { + $response = $response->asArray(); + } + + return new Paginator( + $this->denormalizer, + $response, + $resourceClass, + $query->getLimit() ?? 0, + 1, + $context, + ); + } +} diff --git a/src/Elasticsearch/Esql/State/LinksHandlerInterface.php b/src/Elasticsearch/Esql/State/LinksHandlerInterface.php new file mode 100644 index 0000000000..5e43271cac --- /dev/null +++ b/src/Elasticsearch/Esql/State/LinksHandlerInterface.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Esql\State; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; + +/** + * Alters the ES|QL query according to the operation links (e.g. a subresource + * URI template such as "/users/{userId}/books" adding `WHERE user_id == ?id`). + * + * Set a callable (or a service implementing this interface) on the + * "handleLinks" Elasticsearch state option to enable it. + * + * @experimental + * + * @author Julien Lary + */ +interface LinksHandlerInterface +{ + /** + * @param array $uriVariables + * @param array $context the context contains the "operation" and the "resourceClass" keys + */ + public function handleLinks(EsqlQuery $query, array $uriVariables, array $context): void; +} diff --git a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php index f15819a103..679c940569 100644 --- a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php +++ b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php @@ -13,17 +13,25 @@ namespace ApiPlatform\Elasticsearch\Metadata\Resource\Factory; +use ApiPlatform\Elasticsearch\Esql\State\CollectionProvider as EsqlCollectionProvider; use ApiPlatform\Elasticsearch\State\CollectionProvider; use ApiPlatform\Elasticsearch\State\ItemProvider; use ApiPlatform\Elasticsearch\State\Options; +use ApiPlatform\Elasticsearch\State\QueryLanguage; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; final class ElasticsearchProviderResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface { - public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated) - { + private readonly QueryLanguage $defaultQueryLanguage; + + public function __construct( + private readonly ResourceMetadataCollectionFactoryInterface $decorated, + QueryLanguage|string $defaultQueryLanguage = QueryLanguage::Dsl, + ) { + $this->defaultQueryLanguage = \is_string($defaultQueryLanguage) ? QueryLanguage::from($defaultQueryLanguage) : $defaultQueryLanguage; } /** @@ -46,7 +54,7 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $operations->add($operationName, $operation->withProvider($operation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class)); + $operations->add($operationName, $operation->withProvider($this->getProvider($operation))); } $resourceMetadata = $resourceMetadata->withOperations($operations); @@ -64,7 +72,7 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $graphQlOperations[$operationName] = $graphQlOperation->withProvider($graphQlOperation instanceof CollectionOperationInterface ? CollectionProvider::class : ItemProvider::class); + $graphQlOperations[$operationName] = $graphQlOperation->withProvider($this->getProvider($graphQlOperation)); } $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); @@ -75,4 +83,17 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } + + private function getProvider(Operation $operation): string + { + if (!$operation instanceof CollectionOperationInterface) { + // items are always fetched through the document GET API, whatever the query language + return ItemProvider::class; + } + + /** @var Options $options */ + $options = $operation->getStateOptions(); + + return QueryLanguage::Esql === ($options->getQueryLanguage() ?? $this->defaultQueryLanguage) ? EsqlCollectionProvider::class : CollectionProvider::class; + } } diff --git a/src/Elasticsearch/State/Options.php b/src/Elasticsearch/State/Options.php index fc82af387b..6503e93106 100644 --- a/src/Elasticsearch/State/Options.php +++ b/src/Elasticsearch/State/Options.php @@ -17,8 +17,13 @@ class Options implements OptionsInterface { + /** + * @param mixed $handleLinks a callable that alters the ES|QL query according to the operation links (only used with QueryLanguage::Esql), see {@see \ApiPlatform\Elasticsearch\Esql\State\LinksHandlerInterface} + */ public function __construct( protected ?string $index = null, + protected ?QueryLanguage $queryLanguage = null, + protected mixed $handleLinks = null, ) { } @@ -34,4 +39,30 @@ public function withIndex(?string $index): self return $self; } + + public function getQueryLanguage(): ?QueryLanguage + { + return $this->queryLanguage; + } + + public function withQueryLanguage(?QueryLanguage $queryLanguage): self + { + $self = clone $this; + $self->queryLanguage = $queryLanguage; + + return $self; + } + + public function getHandleLinks(): mixed + { + return $this->handleLinks; + } + + public function withHandleLinks(mixed $handleLinks): self + { + $self = clone $this; + $self->handleLinks = $handleLinks; + + return $self; + } } diff --git a/src/Elasticsearch/State/QueryLanguage.php b/src/Elasticsearch/State/QueryLanguage.php new file mode 100644 index 0000000000..de54f9b63b --- /dev/null +++ b/src/Elasticsearch/State/QueryLanguage.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\State; + +/** + * The query language used to query Elasticsearch. + * + * @experimental + */ +enum QueryLanguage: string +{ + /** + * The classic Query DSL, sent to the "_search" endpoint. + */ + case Dsl = 'dsl'; + + /** + * ES|QL, sent to the "_query" endpoint (requires Elasticsearch >= 8.14). + */ + case Esql = 'esql'; +} diff --git a/src/Elasticsearch/Tests/Esql/EsqlQueryTest.php b/src/Elasticsearch/Tests/Esql/EsqlQueryTest.php new file mode 100644 index 0000000000..8b54bdff70 --- /dev/null +++ b/src/Elasticsearch/Tests/Esql/EsqlQueryTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Esql; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use PHPUnit\Framework\TestCase; + +class EsqlQueryTest extends TestCase +{ + public function testCompileMinimal(): void + { + $query = new EsqlQuery('book'); + + self::assertSame(['query' => 'FROM book METADATA _id', 'params' => []], $query->compile()); + } + + public function testCompileWithoutMetadata(): void + { + $query = new EsqlQuery('book', []); + + self::assertSame(['query' => 'FROM book', 'params' => []], $query->compile()); + } + + public function testCompileFullQuery(): void + { + $query = new EsqlQuery('book'); + + $field = $query->identifier('genre'); + $value = $query->param('fantasy'); + $query->andWhere("{$field} == {$value}"); + + $rating = $query->param(4.2); + $query->andWhere("rating >= {$rating}"); + + $title = $query->param('ring'); + $query->fullTextWhere("MATCH(title, {$title})"); + + $query->sort('rating', 'desc'); + $query->sort('_id'); + $query->limit(11); + + self::assertSame([ + 'query' => 'FROM book METADATA _id | WHERE (MATCH(title, ?p4)) AND ((??f1 == ?p2) AND (rating >= ?p3)) | SORT rating DESC, _id ASC | LIMIT 11', + 'params' => [ + ['f1' => 'genre'], + ['p2' => 'fantasy'], + ['p3' => 4.2], + ['p4' => 'ring'], + ], + ], $query->compile()); + } + + public function testOrWhere(): void + { + $query = new EsqlQuery('book', []); + $query->andWhere('a == 1')->orWhere('b == 2')->andWhere('c == 3'); + + self::assertSame('FROM book | WHERE ((a == 1) OR (b == 2)) AND (c == 3)', $query->compile()['query']); + } + + public function testFullTextWhereOrOperator(): void + { + $query = new EsqlQuery('book', []); + $query->fullTextWhere('MATCH(a, ?p1)')->fullTextWhere('MATCH(b, ?p2)', 'or'); + + self::assertSame('FROM book | WHERE (MATCH(a, ?p1)) OR (MATCH(b, ?p2))', $query->compile()['query']); + } + + public function testHasSortAndGetLimit(): void + { + $query = new EsqlQuery('book'); + + self::assertFalse($query->hasSort()); + self::assertNull($query->getLimit()); + + $query->sort('id'); + $query->limit(10); + + self::assertTrue($query->hasSort()); + self::assertSame(10, $query->getLimit()); + } + + public function testParamsAreOrderedAndUnique(): void + { + $query = new EsqlQuery('book'); + + self::assertSame('?p1', $query->param('a')); + self::assertSame('??f2', $query->identifier('b')); + self::assertSame('?p3', $query->param('a')); + } + + public function testInvalidIndexIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new EsqlQuery('book | DROP something'))->compile(); + } + + public function testInvalidSortFieldIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new EsqlQuery('book'))->sort('rating | LIMIT 1'); + } + + public function testInvalidSortDirectionIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new EsqlQuery('book'))->sort('rating', 'sideways'); + } + + public function testNegativeLimitIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + (new EsqlQuery('book'))->limit(-1); + } +} diff --git a/src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php b/src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php new file mode 100644 index 0000000000..d7dc4b2c17 --- /dev/null +++ b/src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Extension\PaginationExtension; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\State\Pagination\Pagination; +use Elastic\Elasticsearch\ClientBuilder; +use PHPUnit\Framework\TestCase; + +class PaginationExtensionTest extends TestCase +{ + public function testApplyToCollectionFetchesOneExtraRow(): void + { + $query = new EsqlQuery('foo'); + $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination(['items_per_page' => 20])); + + $extension->applyToCollection($query, Foo::class, new GetCollection()); + + self::assertSame(21, $query->getLimit()); + } + + public function testApplyToCollectionWithPaginationDisabled(): void + { + $query = new EsqlQuery('foo'); + $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination(['enabled' => false])); + + $extension->applyToCollection($query, Foo::class, new GetCollection()); + + self::assertNull($query->getLimit()); + } + + public function testSupportsResult(): void + { + $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination()); + + self::assertTrue($extension->supportsResult(Foo::class, new GetCollection())); + } + + public function testSupportsResultWithPaginationDisabled(): void + { + $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination(['enabled' => false])); + + self::assertFalse($extension->supportsResult(Foo::class, new GetCollection())); + } +} diff --git a/src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php b/src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php new file mode 100644 index 0000000000..2f50dd1325 --- /dev/null +++ b/src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Extension\ParameterExtension; +use ApiPlatform\Elasticsearch\Esql\Filter\ExactFilter; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Container\ContainerInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\TypeInfo\Type; + +class ParameterExtensionTest extends TestCase +{ + use ProphecyTrait; + + public function testAppliesParameterFilters(): void + { + $query = new EsqlQuery('foo', []); + $parameter = (new QueryParameter(key: 'genre', property: 'genre', filter: new ExactFilter()))->setValue('fantasy'); + $operation = (new GetCollection())->withParameters(['genre' => $parameter]); + + $nameConverter = $this->prophesize(NameConverterInterface::class); + $nameConverter->normalize('genre', Foo::class, null, [])->willReturn('genre_field')->shouldBeCalled(); + + $this->createExtension(nameConverter: $nameConverter->reveal())->applyToCollection($query, Foo::class, $operation); + + self::assertSame([ + 'query' => 'FROM foo | WHERE ??f1 == ?p2', + 'params' => [['f1' => 'genre_field'], ['p2' => 'fantasy']], + ], $query->compile()); + } + + public function testSkipsParameterWithoutValueOrFilter(): void + { + $query = new EsqlQuery('foo', []); + $operation = (new GetCollection())->withParameters([ + 'nofilter' => (new QueryParameter(key: 'nofilter', property: 'genre'))->setValue('x'), + 'novalue' => new QueryParameter(key: 'novalue', property: 'genre', filter: new ExactFilter()), + ]); + + $this->createExtension()->applyToCollection($query, Foo::class, $operation); + + self::assertSame('FROM foo', $query->compile()['query']); + } + + public function testRejectsNestedField(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/not supported by ES\|QL/'); + + $query = new EsqlQuery('foo', []); + $parameter = (new QueryParameter(key: 'bar.baz', property: 'bar.baz', filter: new ExactFilter()))->setValue('x'); + $operation = (new GetCollection())->withParameters(['bar.baz' => $parameter]); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(Foo::class, 'bar')->willReturn((new ApiProperty())->withNativeType(Type::list(Type::object(Foo::class)))); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Foo::class)->willReturn(true); + + $this->createExtension($propertyMetadataFactory->reveal(), $resourceClassResolver->reveal())->applyToCollection($query, Foo::class, $operation); + } + + private function createExtension( + ?PropertyMetadataFactoryInterface $propertyMetadataFactory = null, + ?ResourceClassResolverInterface $resourceClassResolver = null, + ?NameConverterInterface $nameConverter = null, + ): ParameterExtension { + return new ParameterExtension( + $this->prophesize(ContainerInterface::class)->reveal(), + $propertyMetadataFactory ?? $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), + $resourceClassResolver ?? $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + $nameConverter, + ); + } +} diff --git a/src/Elasticsearch/Tests/Esql/Extension/SortExtensionTest.php b/src/Elasticsearch/Tests/Esql/Extension/SortExtensionTest.php new file mode 100644 index 0000000000..96674ec700 --- /dev/null +++ b/src/Elasticsearch/Tests/Esql/Extension/SortExtensionTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Esql\Extension; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Extension\SortExtension; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Metadata\GetCollection; +use PHPUnit\Framework\TestCase; + +class SortExtensionTest extends TestCase +{ + public function testAppliesOperationOrderAndTiebreaker(): void + { + $query = new EsqlQuery('foo', []); + + (new SortExtension())->applyToCollection($query, Foo::class, (new GetCollection())->withOrder(['name' => 'desc', 'bar'])); + + self::assertSame('FROM foo | SORT name DESC, bar ASC, _id ASC', $query->compile()['query']); + } + + public function testAppliesDefaultDirectionWhenNoOrder(): void + { + $query = new EsqlQuery('foo', []); + + (new SortExtension(defaultDirection: 'desc'))->applyToCollection($query, Foo::class, new GetCollection()); + + self::assertSame('FROM foo | SORT _id DESC', $query->compile()['query']); + } + + public function testOnlyAddsTiebreakerWhenAlreadySorted(): void + { + $query = new EsqlQuery('foo', []); + $query->sort('rating', 'DESC'); + + (new SortExtension())->applyToCollection($query, Foo::class, (new GetCollection())->withOrder(['name'])); + + self::assertSame('FROM foo | SORT rating DESC, _id ASC', $query->compile()['query']); + } + + public function testNoOrderAtAllStillAddsTiebreaker(): void + { + $query = new EsqlQuery('foo', []); + + (new SortExtension())->applyToCollection($query, Foo::class, new GetCollection()); + + self::assertSame('FROM foo | SORT _id ASC', $query->compile()['query']); + } +} diff --git a/src/Elasticsearch/Tests/Esql/Filter/FilterTest.php b/src/Elasticsearch/Tests/Esql/Filter/FilterTest.php new file mode 100644 index 0000000000..19b5e98e78 --- /dev/null +++ b/src/Elasticsearch/Tests/Esql/Filter/FilterTest.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Esql\Filter; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Filter\ComparisonFilter; +use ApiPlatform\Elasticsearch\Esql\Filter\ExactFilter; +use ApiPlatform\Elasticsearch\Esql\Filter\MatchFilter; +use ApiPlatform\Elasticsearch\Esql\Filter\SortFilter; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\QueryParameter; +use PHPUnit\Framework\TestCase; + +class FilterTest extends TestCase +{ + public function testExactFilterWithSingleValue(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'genre', property: 'genre'))->setValue('fantasy'); + + (new ExactFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'genre']); + + self::assertSame([ + 'query' => 'FROM book | WHERE ??f1 == ?p2', + 'params' => [['f1' => 'genre'], ['p2' => 'fantasy']], + ], $query->compile()); + } + + public function testExactFilterWithMultipleValues(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'genre', property: 'genre'))->setValue(['a', 'b']); + + (new ExactFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'genre']); + + self::assertSame([ + 'query' => 'FROM book | WHERE ??f1 IN (?p2, ?p3)', + 'params' => [['f1' => 'genre'], ['p2' => 'a'], ['p3' => 'b']], + ], $query->compile()); + } + + public function testExactFilterIgnoresEmptyValues(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'genre', property: 'genre'))->setValue(null); + + (new ExactFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'genre']); + + self::assertSame('FROM book', $query->compile()['query']); + } + + public function testMatchFilter(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'title', property: 'title'))->setValue('hobbit'); + + (new MatchFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'title']); + + self::assertSame([ + 'query' => 'FROM book | WHERE MATCH(??f1, ?p2)', + 'params' => [['f1' => 'title'], ['p2' => 'hobbit']], + ], $query->compile()); + } + + public function testMatchFilterWithMultipleValuesIsCombinedWithOr(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'title', property: 'title'))->setValue(['a', 'b']); + + (new MatchFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'title']); + + self::assertSame('FROM book | WHERE MATCH(??f1, ?p2) OR MATCH(??f1, ?p3)', $query->compile()['query']); + } + + public function testMatchFilterIsCompiledBeforeOtherConditions(): void + { + $query = new EsqlQuery('book', []); + $query->andWhere('rating > 4'); + $parameter = (new QueryParameter(key: 'title', property: 'title'))->setValue('hobbit'); + + (new MatchFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'title']); + + self::assertSame('FROM book | WHERE (MATCH(??f1, ?p2)) AND (rating > 4)', $query->compile()['query']); + } + + public function testComparisonFilter(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'rating', property: 'rating'))->setValue(['gt' => '4', 'lte' => '5']); + + (new ComparisonFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'rating']); + + self::assertSame([ + 'query' => 'FROM book | WHERE (??f1 > ?p2) AND (??f3 <= ?p4)', + 'params' => [['f1' => 'rating'], ['p2' => '4'], ['f3' => 'rating'], ['p4' => '5']], + ], $query->compile()); + } + + public function testComparisonFilterBetween(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'rating', property: 'rating'))->setValue(['between' => '2..4']); + + (new ComparisonFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'rating']); + + self::assertSame('FROM book | WHERE ??f1 >= ?p2 AND ??f1 <= ?p3', $query->compile()['query']); + } + + public function testComparisonFilterInvalidBetween(): void + { + $this->expectException(InvalidArgumentException::class); + + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'rating', property: 'rating'))->setValue(['between' => '2']); + + (new ComparisonFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'rating']); + } + + public function testComparisonFilterIgnoresUnknownOperators(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'rating', property: 'rating'))->setValue(['like' => 'x']); + + (new ComparisonFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'rating']); + + self::assertSame('FROM book', $query->compile()['query']); + } + + public function testSortFilter(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'sort[:property]', property: 'rating'))->setValue('desc'); + + (new SortFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'rating']); + + self::assertSame('FROM book | SORT rating DESC', $query->compile()['query']); + } + + public function testSortFilterIgnoresInvalidDirection(): void + { + $query = new EsqlQuery('book', []); + $parameter = (new QueryParameter(key: 'sort[:property]', property: 'rating'))->setValue('sideways'); + + (new SortFilter())->apply($query, 'Book', null, ['parameter' => $parameter, 'es_field' => 'rating']); + + self::assertSame('FROM book', $query->compile()['query']); + } +} diff --git a/src/Elasticsearch/Tests/Esql/PaginatorTest.php b/src/Elasticsearch/Tests/Esql/PaginatorTest.php new file mode 100644 index 0000000000..b572a7f4f9 --- /dev/null +++ b/src/Elasticsearch/Tests/Esql/PaginatorTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Esql; + +use ApiPlatform\Elasticsearch\Esql\Paginator; +use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class PaginatorTest extends TestCase +{ + use ProphecyTrait; + + private const RESPONSE = [ + 'columns' => [ + ['name' => '_id', 'type' => 'keyword'], + ['name' => 'name', 'type' => 'keyword'], + ['name' => 'bar.baz', 'type' => 'keyword'], + ], + 'values' => [ + ['1', 'Kilian', 'a'], + ['2', 'Xavier', 'b'], + ['3', 'René', 'c'], + ], + ]; + + public function testPartialPaginationWithNextPage(): void + { + // limit 2, 3 rows fetched (limit + 1): a next page exists, the extra row is discarded + $paginator = new Paginator($this->denormalizer(), self::RESPONSE, Foo::class, 2); + + self::assertCount(2, $paginator); + self::assertTrue($paginator->hasNextPage()); + self::assertSame(1., $paginator->getCurrentPage()); + self::assertSame(2., $paginator->getItemsPerPage()); + self::assertCount(2, iterator_to_array($paginator)); + } + + public function testPartialPaginationWithoutNextPage(): void + { + $paginator = new Paginator($this->denormalizer(), self::RESPONSE, Foo::class, 3); + + self::assertCount(3, $paginator); + self::assertFalse($paginator->hasNextPage()); + self::assertCount(3, iterator_to_array($paginator)); + } + + public function testRowsAreConvertedToDocuments(): void + { + $denormalizer = $this->prophesize(DenormalizerInterface::class); + $denormalizer->denormalize( + ['_source' => ['name' => 'Kilian', 'bar' => ['baz' => 'a']], '_id' => '1'], + Foo::class, + DocumentNormalizer::FORMAT, + [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true] + )->willReturn(new Foo())->shouldBeCalled(); + + $paginator = new Paginator($denormalizer->reveal(), [ + 'columns' => self::RESPONSE['columns'], + 'values' => [self::RESPONSE['values'][0]], + ], Foo::class, 10); + + iterator_to_array($paginator); + } + + public function testEmptyResponse(): void + { + $paginator = new Paginator($this->prophesize(DenormalizerInterface::class)->reveal(), [], Foo::class, 10); + + self::assertCount(0, $paginator); + self::assertFalse($paginator->hasNextPage()); + self::assertSame([], iterator_to_array($paginator)); + } + + private function denormalizer(): DenormalizerInterface + { + $denormalizer = $this->prophesize(DenormalizerInterface::class); + $denormalizer->denormalize(Argument::cetera())->willReturn(new Foo()); + + return $denormalizer->reveal(); + } +} diff --git a/src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php b/src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php new file mode 100644 index 0000000000..0376e4c7b3 --- /dev/null +++ b/src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Elasticsearch\Tests\Esql\State; + +use ApiPlatform\Elasticsearch\Esql\EsqlQuery; +use ApiPlatform\Elasticsearch\Esql\Extension\CollectionExtensionInterface; +use ApiPlatform\Elasticsearch\Esql\Extension\ResultCollectionExtensionInterface; +use ApiPlatform\Elasticsearch\Esql\State\CollectionProvider; +use ApiPlatform\Elasticsearch\State\Options; +use ApiPlatform\Elasticsearch\State\QueryLanguage; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use Elastic\Elasticsearch\ClientBuilder; +use PHPUnit\Framework\TestCase; + +class CollectionProviderTest extends TestCase +{ + public function testExtensionsAreAppliedAndResultExtensionShortCircuits(): void + { + $applied = new \ArrayObject(); + + $regularExtension = new class($applied) implements CollectionExtensionInterface { + public function __construct(private readonly \ArrayObject $applied) + { + } + + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->applied->append('regular'); + $query->andWhere('a == 1'); + } + }; + + $resultExtension = new class($applied) implements ResultCollectionExtensionInterface { + public ?string $compiledQuery = null; + + public function __construct(private readonly \ArrayObject $applied) + { + } + + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->applied->append('result'); + } + + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool + { + return true; + } + + public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable + { + $this->compiledQuery = $query->compile()['query']; + + return [new Foo()]; + } + }; + + $provider = new CollectionProvider( + ClientBuilder::create()->build(), + null, + [$regularExtension, $resultExtension], + ); + + $operation = (new GetCollection(name: 'get_collection', shortName: 'Foo')) + ->withClass(Foo::class) + ->withStateOptions(new Options(index: 'custom_index', queryLanguage: QueryLanguage::Esql)); + + $result = $provider->provide($operation); + + self::assertSame(['regular', 'result'], $applied->getArrayCopy()); + self::assertCount(1, $result); + self::assertSame('FROM custom_index METADATA _id | WHERE a == 1', $resultExtension->compiledQuery); + } + + public function testIndexDefaultsToTableizedShortName(): void + { + $resultExtension = new class implements ResultCollectionExtensionInterface { + public ?string $compiledQuery = null; + + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + } + + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool + { + return true; + } + + public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable + { + $this->compiledQuery = $query->compile()['query']; + + return []; + } + }; + + $provider = new CollectionProvider(ClientBuilder::create()->build(), null, [$resultExtension]); + + $operation = (new GetCollection(name: 'get_collection', shortName: 'FooBar'))->withClass(Foo::class); + $provider->provide($operation); + + self::assertSame('FROM foo_bar METADATA _id', $resultExtension->compiledQuery); + } + + public function testHandleLinksCallableIsInvoked(): void + { + $resultExtension = new class implements ResultCollectionExtensionInterface { + public ?string $compiledQuery = null; + + public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + } + + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool + { + return true; + } + + public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable + { + $this->compiledQuery = $query->compile()['query']; + + return []; + } + }; + + $handleLinks = static function (EsqlQuery $query, array $uriVariables, array $context): void { + $query->andWhere(\sprintf('user_id == %s', $query->param($uriVariables['userId']))); + }; + + $provider = new CollectionProvider(ClientBuilder::create()->build(), null, [$resultExtension]); + + $operation = (new GetCollection(name: 'get_collection', shortName: 'Foo')) + ->withClass(Foo::class) + ->withStateOptions(new Options(index: 'foo', queryLanguage: QueryLanguage::Esql, handleLinks: $handleLinks)); + + $provider->provide($operation, ['userId' => 42]); + + self::assertSame('FROM foo METADATA _id | WHERE user_id == ?p1', $resultExtension->compiledQuery); + } +} diff --git a/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php b/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php index 6d4f59f169..cbf0fe4191 100644 --- a/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php +++ b/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php @@ -13,8 +13,19 @@ namespace ApiPlatform\Elasticsearch\Tests\Metadata\Resource\Factory; +use ApiPlatform\Elasticsearch\Esql\State\CollectionProvider as EsqlCollectionProvider; use ApiPlatform\Elasticsearch\Metadata\Resource\Factory\ElasticsearchProviderResourceMetadataCollectionFactory; +use ApiPlatform\Elasticsearch\State\CollectionProvider; +use ApiPlatform\Elasticsearch\State\ItemProvider; +use ApiPlatform\Elasticsearch\State\Options; +use ApiPlatform\Elasticsearch\State\QueryLanguage; +use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -31,4 +42,55 @@ public function testConstruct(): void ) ); } + + public function testDslProvidersAreAssignedByDefault(): void + { + $resourceMetadataCollection = $this->createFactory()->create(Foo::class); + $operations = iterator_to_array($resourceMetadataCollection[0]->getOperations()); + + self::assertSame(CollectionProvider::class, $operations['get_collection']->getProvider()); + self::assertSame(ItemProvider::class, $operations['get']->getProvider()); + } + + public function testEsqlCollectionProviderIsAssignedFromStateOptions(): void + { + $resourceMetadataCollection = $this->createFactory(new Options(queryLanguage: QueryLanguage::Esql))->create(Foo::class); + $operations = iterator_to_array($resourceMetadataCollection[0]->getOperations()); + + self::assertSame(EsqlCollectionProvider::class, $operations['get_collection']->getProvider()); + // items are always fetched through the document GET API + self::assertSame(ItemProvider::class, $operations['get']->getProvider()); + } + + public function testEsqlCollectionProviderIsAssignedFromGlobalDefault(): void + { + $resourceMetadataCollection = $this->createFactory(defaultQueryLanguage: 'esql')->create(Foo::class); + $operations = iterator_to_array($resourceMetadataCollection[0]->getOperations()); + + self::assertSame(EsqlCollectionProvider::class, $operations['get_collection']->getProvider()); + self::assertSame(ItemProvider::class, $operations['get']->getProvider()); + } + + public function testStateOptionsOverrideGlobalDefault(): void + { + $resourceMetadataCollection = $this->createFactory(new Options(queryLanguage: QueryLanguage::Dsl), 'esql')->create(Foo::class); + $operations = iterator_to_array($resourceMetadataCollection[0]->getOperations()); + + self::assertSame(CollectionProvider::class, $operations['get_collection']->getProvider()); + } + + private function createFactory(?Options $options = null, QueryLanguage|string $defaultQueryLanguage = QueryLanguage::Dsl): ElasticsearchProviderResourceMetadataCollectionFactory + { + $options ??= new Options(); + + $resource = (new ApiResource(shortName: 'Foo'))->withOperations(new Operations([ + 'get_collection' => (new GetCollection())->withStateOptions($options), + 'get' => (new Get())->withStateOptions($options), + ])); + + $decorated = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $decorated->create(Foo::class)->willReturn(new ResourceMetadataCollection(Foo::class, [$resource])); + + return new ElasticsearchProviderResourceMetadataCollectionFactory($decorated->reveal(), $defaultQueryLanguage); + } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 6719043c28..ec668c84ad 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -23,6 +23,7 @@ use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter as DoctrineOrmAbstractFilter; use ApiPlatform\Doctrine\Orm\State\LinksHandlerInterface as OrmLinksHandlerInterface; +use ApiPlatform\Elasticsearch\Esql\Extension\CollectionExtensionInterface as EsqlCollectionExtensionInterface; use ApiPlatform\Elasticsearch\Extension\RequestBodySearchCollectionExtensionInterface; use ApiPlatform\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\GraphQl\Executor; @@ -1039,7 +1040,10 @@ private function registerElasticsearchConfiguration(ContainerBuilder $container, $container->setDefinition('api_platform.elasticsearch.client', $clientDefinition); $container->registerForAutoconfiguration(RequestBodySearchCollectionExtensionInterface::class) ->addTag('api_platform.elasticsearch.request_body_search_extension.collection'); + $container->registerForAutoconfiguration(EsqlCollectionExtensionInterface::class) + ->addTag('api_platform.elasticsearch.esql_extension.collection'); $container->setParameter('api_platform.elasticsearch.client', $config['elasticsearch']['client']); + $container->setParameter('api_platform.elasticsearch.query_language', $config['elasticsearch']['query_language']); $container->setParameter('api_platform.elasticsearch.hosts', $config['elasticsearch']['hosts']); $container->setParameter('api_platform.elasticsearch.ssl_ca_bundle', $config['elasticsearch']['ssl_ca_bundle']); $container->setParameter('api_platform.elasticsearch.ssl_verification', $config['elasticsearch']['ssl_verification']); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index ef28f4bb4a..a67f07ec35 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -516,6 +516,15 @@ private function addElasticsearchSection(ArrayNodeDefinition $rootNode): void }) ->end() ->end() + ->enumNode('query_language') + ->values(['dsl', 'esql']) + ->defaultValue('dsl') + ->info('The default query language used by collection operations: "dsl" (the classic _search Query DSL) or "esql" (ES|QL, requires Elasticsearch >= 8.14; partial pagination only, no "nested" field support). Can be overridden per operation with the "queryLanguage" state option.') + ->end() + ->end() + ->validate() + ->ifTrue(static fn (array $v): bool => 'opensearch' === $v['client'] && 'esql' === $v['query_language']) + ->thenInvalid('ES|QL (api_platform.elasticsearch.query_language: "esql") is not supported by OpenSearch.') ->end() ->end() ->end(); diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 267e19bbb6..8ca22e11ad 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -13,6 +13,10 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\Elasticsearch\Esql\Extension\PaginationExtension as EsqlPaginationExtension; +use ApiPlatform\Elasticsearch\Esql\Extension\ParameterExtension as EsqlParameterExtension; +use ApiPlatform\Elasticsearch\Esql\Extension\SortExtension as EsqlSortExtension; +use ApiPlatform\Elasticsearch\Esql\State\CollectionProvider as EsqlCollectionProvider; use ApiPlatform\Elasticsearch\Extension\ConstantScoreFilterExtension; use ApiPlatform\Elasticsearch\Extension\SortExtension; use ApiPlatform\Elasticsearch\Extension\SortFilterExtension; @@ -136,5 +140,44 @@ $services->set('api_platform.elasticsearch.metadata.resource.metadata_collection_factory', ElasticsearchProviderResourceMetadataCollectionFactory::class) ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 40) - ->args([service('api_platform.elasticsearch.metadata.resource.metadata_collection_factory.inner')]); + ->args([ + service('api_platform.elasticsearch.metadata.resource.metadata_collection_factory.inner'), + '%api_platform.elasticsearch.query_language%', + ]); + + $services->set('api_platform.elasticsearch.esql_extension.parameter', EsqlParameterExtension::class) + ->args([ + service('api_platform.filter_locator'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.resource_class_resolver'), + service('api_platform.elasticsearch.name_converter.inner_fields'), + ]) + ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 30]); + + $services->set('api_platform.elasticsearch.esql_extension.sort', EsqlSortExtension::class) + ->args([ + service('api_platform.elasticsearch.name_converter.inner_fields'), + '%api_platform.collection.order%', + ]) + ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 20]); + + $services->set('api_platform.elasticsearch.esql_extension.pagination', EsqlPaginationExtension::class) + ->args([ + service('api_platform.elasticsearch.client'), + service('serializer'), + service('api_platform.pagination'), + ]) + ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 10]); + + $services->set('api_platform.elasticsearch.esql.state.collection_provider', EsqlCollectionProvider::class) + ->args([ + service('api_platform.elasticsearch.client'), + service('serializer'), + tagged_iterator('api_platform.elasticsearch.esql_extension.collection'), + service('api_platform.inflector')->nullOnInvalid(), + ]) + ->tag('api_platform.state_provider', ['priority' => -100, 'key' => 'ApiPlatform\Elasticsearch\Esql\State\CollectionProvider']) + ->tag('api_platform.state_provider', ['priority' => -100]); + + $services->alias(EsqlCollectionProvider::class, 'api_platform.elasticsearch.esql.state.collection_provider'); }; diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index fced5378b7..e63810150c 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -138,6 +138,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'ssl_ca_bundle' => null, 'ssl_verification' => true, 'client' => 'elasticsearch', + 'query_language' => 'dsl', ], 'oauth' => [ 'enabled' => false, From 6c00552950c04e397a5e5f5c8810c2ee23f88830 Mon Sep 17 00:00:00 2001 From: Julien LARY <47776596+laryjulien@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:02:27 +0200 Subject: [PATCH 5/7] fix(elasticsearch): address ES|QL review findings - Paginator: expanding dotted columns no longer crashes on text multi-fields (e.g. "title" + "title.keyword"); the scalar parent wins and sub-field columns are skipped. Denormalized documents are now memoized like in the Query DSL paginator. - PaginationExtension: requesting "page" > 1 is rejected with a 400 instead of silently returning the first page; the pagination service is asserted in getResult(). - CollectionProvider: the handleLinks state option now accepts LinksHandlerInterface instances and throws on unresolvable values instead of silently skipping them (the WHERE constraint could be dropped). - Both ES|QL executors verify that the client provides the esql() endpoint (elasticsearch/elasticsearch >= 8.11) before querying. - The ES|QL services are only registered when the Elasticsearch client is used: lint:container passed again for OpenSearch users, and a per-operation "queryLanguage: esql" with OpenSearch now fails at metadata build time with an explicit error. GraphQL operations always use the Query DSL providers (the ES|QL paginator is partial-pagination only). - The opensearch/esql configuration validation no longer runs when Elasticsearch support is disabled. - New Elasticsearch tests use PHPUnit stubs instead of prophesize (CONTRIBUTING.md). Co-Authored-By: Claude Fable 5 --- .../Esql/Extension/PaginationExtension.php | 41 +++++- src/Elasticsearch/Esql/Paginator.php | 44 ++++++- .../Esql/State/CollectionProvider.php | 30 ++++- .../Esql/State/LinksHandlerInterface.php | 5 +- ...viderResourceMetadataCollectionFactory.php | 20 ++- .../Extension/PaginationExtensionTest.php | 49 ++++++- .../Esql/Extension/ParameterExtensionTest.php | 25 ++-- .../Tests/Esql/PaginatorTest.php | 121 +++++++++++++++--- .../Esql/State/CollectionProviderTest.php | 121 ++++++++++++------ ...rResourceMetadataCollectionFactoryTest.php | 50 ++++++-- .../ApiPlatformExtension.php | 20 ++- .../DependencyInjection/Configuration.php | 2 +- .../Bundle/Resources/config/elasticsearch.php | 41 +----- .../Resources/config/elasticsearch_esql.php | 59 +++++++++ .../DependencyInjection/ConfigurationTest.php | 19 +++ 15 files changed, 502 insertions(+), 145 deletions(-) create mode 100644 src/Symfony/Bundle/Resources/config/elasticsearch_esql.php diff --git a/src/Elasticsearch/Esql/Extension/PaginationExtension.php b/src/Elasticsearch/Esql/Extension/PaginationExtension.php index 4ae9ce4902..6fe1a97772 100644 --- a/src/Elasticsearch/Esql/Extension/PaginationExtension.php +++ b/src/Elasticsearch/Esql/Extension/PaginationExtension.php @@ -15,6 +15,7 @@ use ApiPlatform\Elasticsearch\Esql\EsqlQuery; use ApiPlatform\Elasticsearch\Esql\Paginator; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ApiResource\Error; use ApiPlatform\State\Pagination\Pagination; @@ -30,7 +31,8 @@ * available. One extra row is fetched (LIMIT itemsPerPage + 1) to detect * whether a next page exists; combine with a sorted unique field and a * ComparisonFilter (keyset pagination) or the "paginationViaCursor" - * operation attribute to navigate pages. + * operation attribute to navigate pages. Requesting a "page" greater than 1 + * is rejected with a 400 response rather than silently ignored. * * @experimental * @@ -40,7 +42,7 @@ final class PaginationExtension implements ResultCollectionExtensionInterface { public function __construct( private readonly Client $client, - private readonly ?DenormalizerInterface $denormalizer = null, + private readonly DenormalizerInterface $denormalizer, private readonly ?Pagination $pagination = null, ) { } @@ -54,6 +56,8 @@ public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Oper return; } + $this->assertPageIsNotRequested($context); + $limit = $this->pagination->getLimit($operation, $context); // one extra row to detect whether a next page exists @@ -73,6 +77,14 @@ public function supportsResult(string $resourceClass, ?Operation $operation = nu */ public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable { + if (null === $this->pagination) { + throw new \LogicException(\sprintf('The pagination service is required to compute the result, did you forget to pass it to "%s"?', self::class)); + } + + $this->assertPageIsNotRequested($context); + + self::assertEsqlIsSupported($this->client); + try { $response = $this->client->esql()->query(['body' => $query->compile()]); } catch (ClientResponseException $e) { @@ -91,8 +103,31 @@ public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $o $response, $resourceClass, $limit, - $this->pagination->getPage($context), + 1, $context, ); } + + /** + * ES|QL has no offset command: the "page" parameter cannot be honored and must not be silently ignored. + * + * @param array $context + */ + private function assertPageIsNotRequested(array $context): void + { + if (null !== $this->pagination && 1 < $this->pagination->getPage($context)) { + throw new InvalidArgumentException('ES|QL supports partial pagination only: the "page" parameter is not supported. Use keyset pagination instead, by sorting on a unique field and filtering it with a ComparisonFilter.'); + } + } + + /** + * The "esql" endpoint has been added in elasticsearch/elasticsearch 8.11, while + * this component supports 8.4 and later for the Query DSL. + */ + private static function assertEsqlIsSupported(object $client): void + { + if (!method_exists($client, 'esql')) { + throw new \RuntimeException(\sprintf('ES|QL support requires elasticsearch/elasticsearch >= 8.11, but "%s" does not provide the "esql" endpoint.', $client::class)); + } + } } diff --git a/src/Elasticsearch/Esql/Paginator.php b/src/Elasticsearch/Esql/Paginator.php index e547777021..0d667f83b3 100644 --- a/src/Elasticsearch/Esql/Paginator.php +++ b/src/Elasticsearch/Esql/Paginator.php @@ -39,6 +39,11 @@ final class Paginator implements \IteratorAggregate, PartialPaginatorInterface, private readonly bool $hasNextPage; + /** + * @var array + */ + private array $cachedDenormalizedDocuments = []; + /** * @param array{columns?: list, values?: list>} $response the raw (row-oriented) ES|QL response */ @@ -96,8 +101,18 @@ public function getIterator(): \Traversable { $denormalizationContext = array_merge([AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true], $this->denormalizationContext); - foreach ($this->documents as $document) { - yield $this->denormalizer->denormalize( + foreach ($this->documents as $i => $document) { + // ES|QL rows carry no index name, unlike search hits: the document "_id" alone + // identifies a row, falling back to its position when the query does not select it. + $cacheKey = isset($document['_id']) ? hash('xxh3', (string) $document['_id']) : "#$i"; + + if (\array_key_exists($cacheKey, $this->cachedDenormalizedDocuments)) { + yield $this->cachedDenormalizedDocuments[$cacheKey]; + + continue; + } + + yield $this->cachedDenormalizedDocuments[$cacheKey] = $this->denormalizer->denormalize( $document, $this->resourceClass, DocumentNormalizer::FORMAT, @@ -128,11 +143,32 @@ private static function rowToDocument(array $columns, array $row): array continue; } + $segments = explode('.', $name); + $leaf = array_pop($segments); $target = &$document['_source']; - foreach (explode('.', $name) as $segment) { + $skip = false; + + foreach ($segments as $segment) { + if (!\array_key_exists($segment, $target)) { + $target[$segment] = []; + } elseif (!\is_array($target[$segment])) { + // A "text" field mapped with a sub-field is returned by ES|QL as several + // columns sharing the same prefix (e.g. "title" and "title.keyword"), + // sorted alphabetically so the scalar parent always comes first. Sub-field + // columns only duplicate the parent value, so the already written scalar + // wins and the sub-column is skipped rather than descended into. + $skip = true; + break; + } + $target = &$target[$segment]; } - $target = $value; + + // First write wins: keeps the scalar value of a duplicated or multi-field column. + if (!$skip && !\array_key_exists($leaf, $target)) { + $target[$leaf] = $value; + } + unset($target); } diff --git a/src/Elasticsearch/Esql/State/CollectionProvider.php b/src/Elasticsearch/Esql/State/CollectionProvider.php index d4d974dc09..5f37c06241 100644 --- a/src/Elasticsearch/Esql/State/CollectionProvider.php +++ b/src/Elasticsearch/Esql/State/CollectionProvider.php @@ -18,6 +18,7 @@ use ApiPlatform\Elasticsearch\Esql\Extension\ResultCollectionExtensionInterface; use ApiPlatform\Elasticsearch\Esql\Paginator; use ApiPlatform\Elasticsearch\State\Options; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\InflectorInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Util\Inflector; @@ -46,7 +47,7 @@ final class CollectionProvider implements ProviderInterface */ public function __construct( private readonly Client $client, - private readonly ?DenormalizerInterface $denormalizer = null, + private readonly DenormalizerInterface $denormalizer, private readonly iterable $collectionExtensions = [], private readonly ?InflectorInterface $inflector = new Inflector(), ) { @@ -60,10 +61,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $resourceClass = $operation->getClass(); $options = $operation->getStateOptions() instanceof Options ? $operation->getStateOptions() : new Options(); - $query = new EsqlQuery($options->getIndex() ?? $this->inflector->tableize($operation->getShortName())); + $query = new EsqlQuery($options->getIndex() ?? ($this->inflector ?? new Inflector())->tableize((string) $operation->getShortName())); - if (\is_callable($handleLinks = $options->getHandleLinks())) { - $handleLinks($query, $uriVariables, ['operation' => $operation, 'resourceClass' => $resourceClass] + $context); + if (null !== ($handleLinks = $options->getHandleLinks())) { + $linksContext = ['operation' => $operation, 'resourceClass' => $resourceClass] + $context; + + if ($handleLinks instanceof LinksHandlerInterface) { + $handleLinks->handleLinks($query, $uriVariables, $linksContext); + } elseif (\is_callable($handleLinks)) { + $handleLinks($query, $uriVariables, $linksContext); + } else { + throw new RuntimeException(\sprintf('Could not resolve the handleLinks option of the operation "%s": expected a callable or an instance of "%s", but got "%s".', $operation->getName() ?? $resourceClass, LinksHandlerInterface::class, get_debug_type($handleLinks))); + } } foreach ($this->collectionExtensions as $extension) { @@ -74,6 +83,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } } + self::assertEsqlIsSupported($this->client); + try { $response = $this->client->esql()->query(['body' => $query->compile()]); } catch (ClientResponseException $e) { @@ -94,4 +105,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $context, ); } + + /** + * The "esql" endpoint has been added in elasticsearch/elasticsearch 8.11, while + * this component supports 8.4 and later for the Query DSL. + */ + private static function assertEsqlIsSupported(object $client): void + { + if (!method_exists($client, 'esql')) { + throw new \RuntimeException(\sprintf('ES|QL support requires elasticsearch/elasticsearch >= 8.11, but "%s" does not provide the "esql" endpoint.', $client::class)); + } + } } diff --git a/src/Elasticsearch/Esql/State/LinksHandlerInterface.php b/src/Elasticsearch/Esql/State/LinksHandlerInterface.php index 5e43271cac..53acd74809 100644 --- a/src/Elasticsearch/Esql/State/LinksHandlerInterface.php +++ b/src/Elasticsearch/Esql/State/LinksHandlerInterface.php @@ -19,8 +19,9 @@ * Alters the ES|QL query according to the operation links (e.g. a subresource * URI template such as "/users/{userId}/books" adding `WHERE user_id == ?id`). * - * Set a callable (or a service implementing this interface) on the - * "handleLinks" Elasticsearch state option to enable it. + * Set an instance of this interface, or any callable, on the "handleLinks" + * Elasticsearch state option to enable it. Service identifiers are not + * supported: pass the instance itself. * * @experimental * diff --git a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php index 679c940569..2861005805 100644 --- a/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php +++ b/src/Elasticsearch/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactory.php @@ -19,6 +19,7 @@ use ApiPlatform\Elasticsearch\State\Options; use ApiPlatform\Elasticsearch\State\QueryLanguage; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -30,6 +31,7 @@ final class ElasticsearchProviderResourceMetadataCollectionFactory implements Re public function __construct( private readonly ResourceMetadataCollectionFactoryInterface $decorated, QueryLanguage|string $defaultQueryLanguage = QueryLanguage::Dsl, + private readonly bool $esqlAvailable = true, ) { $this->defaultQueryLanguage = \is_string($defaultQueryLanguage) ? QueryLanguage::from($defaultQueryLanguage) : $defaultQueryLanguage; } @@ -72,7 +74,8 @@ public function create(string $resourceClass): ResourceMetadataCollection continue; } - $graphQlOperations[$operationName] = $graphQlOperation->withProvider($this->getProvider($graphQlOperation)); + // ES|QL paginator implements partial pagination only, incompatible with GraphQL connections (totalCount/cursors) + $graphQlOperations[$operationName] = $graphQlOperation->withProvider($this->getProvider($graphQlOperation, esqlAllowed: false)); } $resourceMetadata = $resourceMetadata->withGraphQlOperations($graphQlOperations); @@ -84,7 +87,10 @@ public function create(string $resourceClass): ResourceMetadataCollection return $resourceMetadataCollection; } - private function getProvider(Operation $operation): string + /** + * @param bool $esqlAllowed whether the operation may be served by the ES|QL provider + */ + private function getProvider(Operation $operation, bool $esqlAllowed = true): string { if (!$operation instanceof CollectionOperationInterface) { // items are always fetched through the document GET API, whatever the query language @@ -94,6 +100,14 @@ private function getProvider(Operation $operation): string /** @var Options $options */ $options = $operation->getStateOptions(); - return QueryLanguage::Esql === ($options->getQueryLanguage() ?? $this->defaultQueryLanguage) ? EsqlCollectionProvider::class : CollectionProvider::class; + if (!$esqlAllowed || QueryLanguage::Esql !== ($options->getQueryLanguage() ?? $this->defaultQueryLanguage)) { + return CollectionProvider::class; + } + + if (!$this->esqlAvailable) { + throw new RuntimeException(\sprintf('ES|QL is not supported by the OpenSearch client, remove the "queryLanguage" state option of the operation "%s" or use the Elasticsearch client.', $operation->getName() ?? $operation->getShortName())); + } + + return EsqlCollectionProvider::class; } } diff --git a/src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php b/src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php index d7dc4b2c17..18985fe48a 100644 --- a/src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php +++ b/src/Elasticsearch/Tests/Esql/Extension/PaginationExtensionTest.php @@ -16,17 +16,19 @@ use ApiPlatform\Elasticsearch\Esql\EsqlQuery; use ApiPlatform\Elasticsearch\Esql\Extension\PaginationExtension; use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\State\Pagination\Pagination; use Elastic\Elasticsearch\ClientBuilder; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; class PaginationExtensionTest extends TestCase { public function testApplyToCollectionFetchesOneExtraRow(): void { $query = new EsqlQuery('foo'); - $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination(['items_per_page' => 20])); + $extension = $this->createExtension(new Pagination(['items_per_page' => 20])); $extension->applyToCollection($query, Foo::class, new GetCollection()); @@ -36,24 +38,63 @@ public function testApplyToCollectionFetchesOneExtraRow(): void public function testApplyToCollectionWithPaginationDisabled(): void { $query = new EsqlQuery('foo'); - $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination(['enabled' => false])); + $extension = $this->createExtension(new Pagination(['enabled' => false])); $extension->applyToCollection($query, Foo::class, new GetCollection()); self::assertNull($query->getLimit()); } + public function testApplyToCollectionWithPageGreaterThanOne(): void + { + $query = new EsqlQuery('foo'); + $extension = $this->createExtension(new Pagination(['items_per_page' => 20])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ES|QL supports partial pagination only: the "page" parameter is not supported.'); + + $extension->applyToCollection($query, Foo::class, new GetCollection(), ['filters' => ['page' => 3]]); + } + + public function testGetResultWithPageGreaterThanOne(): void + { + $extension = $this->createExtension(new Pagination(['items_per_page' => 20])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('ES|QL supports partial pagination only: the "page" parameter is not supported.'); + + $extension->getResult(new EsqlQuery('foo'), Foo::class, new GetCollection(), ['filters' => ['page' => 2]]); + } + + public function testGetResultWithoutPagination(): void + { + $extension = $this->createExtension(); + + $this->expectException(\LogicException::class); + + $extension->getResult(new EsqlQuery('foo'), Foo::class, new GetCollection()); + } + public function testSupportsResult(): void { - $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination()); + $extension = $this->createExtension(new Pagination()); self::assertTrue($extension->supportsResult(Foo::class, new GetCollection())); } public function testSupportsResultWithPaginationDisabled(): void { - $extension = new PaginationExtension(ClientBuilder::create()->build(), null, new Pagination(['enabled' => false])); + $extension = $this->createExtension(new Pagination(['enabled' => false])); self::assertFalse($extension->supportsResult(Foo::class, new GetCollection())); } + + private function createExtension(?Pagination $pagination = null): PaginationExtension + { + return new PaginationExtension( + ClientBuilder::create()->build(), + $this->createStub(DenormalizerInterface::class), + $pagination, + ); + } } diff --git a/src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php b/src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php index 2f50dd1325..99fb487243 100644 --- a/src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php +++ b/src/Elasticsearch/Tests/Esql/Extension/ParameterExtensionTest.php @@ -24,25 +24,22 @@ use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Metadata\ResourceClassResolverInterface; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Psr\Container\ContainerInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; class ParameterExtensionTest extends TestCase { - use ProphecyTrait; - public function testAppliesParameterFilters(): void { $query = new EsqlQuery('foo', []); $parameter = (new QueryParameter(key: 'genre', property: 'genre', filter: new ExactFilter()))->setValue('fantasy'); $operation = (new GetCollection())->withParameters(['genre' => $parameter]); - $nameConverter = $this->prophesize(NameConverterInterface::class); - $nameConverter->normalize('genre', Foo::class, null, [])->willReturn('genre_field')->shouldBeCalled(); + $nameConverter = $this->createMock(NameConverterInterface::class); + $nameConverter->expects($this->once())->method('normalize')->with('genre', Foo::class, null, [])->willReturn('genre_field'); - $this->createExtension(nameConverter: $nameConverter->reveal())->applyToCollection($query, Foo::class, $operation); + $this->createExtension(nameConverter: $nameConverter)->applyToCollection($query, Foo::class, $operation); self::assertSame([ 'query' => 'FROM foo | WHERE ??f1 == ?p2', @@ -72,13 +69,13 @@ public function testRejectsNestedField(): void $parameter = (new QueryParameter(key: 'bar.baz', property: 'bar.baz', filter: new ExactFilter()))->setValue('x'); $operation = (new GetCollection())->withParameters(['bar.baz' => $parameter]); - $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactory->create(Foo::class, 'bar')->willReturn((new ApiProperty())->withNativeType(Type::list(Type::object(Foo::class)))); + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn((new ApiProperty())->withNativeType(Type::list(Type::object(Foo::class)))); - $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolver->isResourceClass(Foo::class)->willReturn(true); + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('isResourceClass')->willReturn(true); - $this->createExtension($propertyMetadataFactory->reveal(), $resourceClassResolver->reveal())->applyToCollection($query, Foo::class, $operation); + $this->createExtension($propertyMetadataFactory, $resourceClassResolver)->applyToCollection($query, Foo::class, $operation); } private function createExtension( @@ -87,9 +84,9 @@ private function createExtension( ?NameConverterInterface $nameConverter = null, ): ParameterExtension { return new ParameterExtension( - $this->prophesize(ContainerInterface::class)->reveal(), - $propertyMetadataFactory ?? $this->prophesize(PropertyMetadataFactoryInterface::class)->reveal(), - $resourceClassResolver ?? $this->prophesize(ResourceClassResolverInterface::class)->reveal(), + $this->createStub(ContainerInterface::class), + $propertyMetadataFactory ?? $this->createStub(PropertyMetadataFactoryInterface::class), + $resourceClassResolver ?? $this->createStub(ResourceClassResolverInterface::class), $nameConverter, ); } diff --git a/src/Elasticsearch/Tests/Esql/PaginatorTest.php b/src/Elasticsearch/Tests/Esql/PaginatorTest.php index b572a7f4f9..3b38cf66c5 100644 --- a/src/Elasticsearch/Tests/Esql/PaginatorTest.php +++ b/src/Elasticsearch/Tests/Esql/PaginatorTest.php @@ -17,15 +17,11 @@ use ApiPlatform\Elasticsearch\Serializer\DocumentNormalizer; use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use PHPUnit\Framework\TestCase; -use Prophecy\Argument; -use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; class PaginatorTest extends TestCase { - use ProphecyTrait; - private const RESPONSE = [ 'columns' => [ ['name' => '_id', 'type' => 'keyword'], @@ -62,15 +58,18 @@ public function testPartialPaginationWithoutNextPage(): void public function testRowsAreConvertedToDocuments(): void { - $denormalizer = $this->prophesize(DenormalizerInterface::class); - $denormalizer->denormalize( - ['_source' => ['name' => 'Kilian', 'bar' => ['baz' => 'a']], '_id' => '1'], - Foo::class, - DocumentNormalizer::FORMAT, - [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true] - )->willReturn(new Foo())->shouldBeCalled(); - - $paginator = new Paginator($denormalizer->reveal(), [ + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->expects($this->once()) + ->method('denormalize') + ->with( + ['_source' => ['name' => 'Kilian', 'bar' => ['baz' => 'a']], '_id' => '1'], + Foo::class, + DocumentNormalizer::FORMAT, + [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true] + ) + ->willReturn(new Foo()); + + $paginator = new Paginator($denormalizer, [ 'columns' => self::RESPONSE['columns'], 'values' => [self::RESPONSE['values'][0]], ], Foo::class, 10); @@ -78,9 +77,97 @@ public function testRowsAreConvertedToDocuments(): void iterator_to_array($paginator); } + /** + * A "text" field mapped with a sub-field is returned as two columns ("title" and + * "title.keyword"): expanding the dotted name must not try to descend into the scalar. + */ + public function testMultiFieldColumnsDoNotOverrideTheScalarColumn(): void + { + $documents = []; + $denormalizer = $this->createStub(DenormalizerInterface::class); + $denormalizer->method('denormalize')->willReturnCallback( + static function (array $document) use (&$documents): Foo { + $documents[] = $document; + + return new Foo(); + } + ); + + $paginator = new Paginator($denormalizer, [ + 'columns' => [ + ['name' => '_id', 'type' => 'keyword'], + ['name' => 'title', 'type' => 'text'], + ['name' => 'title.keyword', 'type' => 'keyword'], + ], + 'values' => [['1', 'Kilian', 'Kilian']], + ], Foo::class, 10); + + self::assertCount(1, iterator_to_array($paginator)); + self::assertSame([['_source' => ['title' => 'Kilian'], '_id' => '1']], $documents); + } + + public function testNestedColumnsAreExpanded(): void + { + $documents = []; + $denormalizer = $this->createStub(DenormalizerInterface::class); + $denormalizer->method('denormalize')->willReturnCallback( + static function (array $document) use (&$documents): Foo { + $documents[] = $document; + + return new Foo(); + } + ); + + $paginator = new Paginator($denormalizer, [ + 'columns' => [ + ['name' => 'author.name', 'type' => 'keyword'], + ['name' => 'author.address.city', 'type' => 'keyword'], + ], + 'values' => [['Kilian', 'Lille']], + ], Foo::class, 10); + + iterator_to_array($paginator); + + self::assertSame([['_source' => ['author' => ['name' => 'Kilian', 'address' => ['city' => 'Lille']]]]], $documents); + } + + public function testDenormalizedDocumentsAreCached(): void + { + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->expects($this->exactly(3)) + ->method('denormalize') + ->willReturnCallback(static fn (): Foo => new Foo()); + + $paginator = new Paginator($denormalizer, self::RESPONSE, Foo::class, 10); + + $first = iterator_to_array($paginator); + $second = iterator_to_array($paginator); + + self::assertCount(3, $first); + self::assertSame($first, $second); + } + + public function testDenormalizedDocumentsAreCachedWithoutIdColumn(): void + { + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->expects($this->exactly(2)) + ->method('denormalize') + ->willReturnCallback(static fn (): Foo => new Foo()); + + $paginator = new Paginator($denormalizer, [ + 'columns' => [['name' => 'name', 'type' => 'keyword']], + 'values' => [['Kilian'], ['Xavier']], + ], Foo::class, 10); + + $first = iterator_to_array($paginator); + + self::assertCount(2, $first); + self::assertSame($first, iterator_to_array($paginator)); + } + public function testEmptyResponse(): void { - $paginator = new Paginator($this->prophesize(DenormalizerInterface::class)->reveal(), [], Foo::class, 10); + $paginator = new Paginator($this->createStub(DenormalizerInterface::class), [], Foo::class, 10); self::assertCount(0, $paginator); self::assertFalse($paginator->hasNextPage()); @@ -89,9 +176,9 @@ public function testEmptyResponse(): void private function denormalizer(): DenormalizerInterface { - $denormalizer = $this->prophesize(DenormalizerInterface::class); - $denormalizer->denormalize(Argument::cetera())->willReturn(new Foo()); + $denormalizer = $this->createStub(DenormalizerInterface::class); + $denormalizer->method('denormalize')->willReturn(new Foo()); - return $denormalizer->reveal(); + return $denormalizer; } } diff --git a/src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php b/src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php index 0376e4c7b3..bc98a0770e 100644 --- a/src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php +++ b/src/Elasticsearch/Tests/Esql/State/CollectionProviderTest.php @@ -17,13 +17,16 @@ use ApiPlatform\Elasticsearch\Esql\Extension\CollectionExtensionInterface; use ApiPlatform\Elasticsearch\Esql\Extension\ResultCollectionExtensionInterface; use ApiPlatform\Elasticsearch\Esql\State\CollectionProvider; +use ApiPlatform\Elasticsearch\Esql\State\LinksHandlerInterface; use ApiPlatform\Elasticsearch\State\Options; use ApiPlatform\Elasticsearch\State\QueryLanguage; use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operation; use Elastic\Elasticsearch\ClientBuilder; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; class CollectionProviderTest extends TestCase { @@ -68,11 +71,7 @@ public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $o } }; - $provider = new CollectionProvider( - ClientBuilder::create()->build(), - null, - [$regularExtension, $resultExtension], - ); + $provider = $this->createProvider([$regularExtension, $resultExtension]); $operation = (new GetCollection(name: 'get_collection', shortName: 'Foo')) ->withClass(Foo::class) @@ -87,38 +86,94 @@ public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $o public function testIndexDefaultsToTableizedShortName(): void { - $resultExtension = new class implements ResultCollectionExtensionInterface { - public ?string $compiledQuery = null; + $compiled = new \ArrayObject(); + $provider = $this->createProvider([$this->createResultExtension($compiled)]); - public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void - { - } + $operation = (new GetCollection(name: 'get_collection', shortName: 'FooBar'))->withClass(Foo::class); + $provider->provide($operation); - public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool - { - return true; - } + self::assertSame('FROM foo_bar METADATA _id', $compiled['query']); + } - public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable - { - $this->compiledQuery = $query->compile()['query']; + public function testHandleLinksCallableIsInvoked(): void + { + $compiled = new \ArrayObject(); - return []; + $handleLinks = static function (EsqlQuery $query, array $uriVariables, array $context): void { + $query->andWhere(\sprintf('user_id == %s', $query->param($uriVariables['userId']))); + }; + + $provider = $this->createProvider([$this->createResultExtension($compiled)]); + + $operation = (new GetCollection(name: 'get_collection', shortName: 'Foo')) + ->withClass(Foo::class) + ->withStateOptions(new Options(index: 'foo', queryLanguage: QueryLanguage::Esql, handleLinks: $handleLinks)); + + $provider->provide($operation, ['userId' => 42]); + + self::assertSame('FROM foo METADATA _id | WHERE user_id == ?p1', $compiled['query']); + } + + public function testHandleLinksInterfaceIsInvoked(): void + { + $compiled = new \ArrayObject(); + + $handleLinks = new class implements LinksHandlerInterface { + public function handleLinks(EsqlQuery $query, array $uriVariables, array $context): void + { + $query->andWhere(\sprintf('user_id == %s', $query->param($uriVariables['userId']))); } }; - $provider = new CollectionProvider(ClientBuilder::create()->build(), null, [$resultExtension]); + $provider = $this->createProvider([$this->createResultExtension($compiled)]); - $operation = (new GetCollection(name: 'get_collection', shortName: 'FooBar'))->withClass(Foo::class); - $provider->provide($operation); + $operation = (new GetCollection(name: 'get_collection', shortName: 'Foo')) + ->withClass(Foo::class) + ->withStateOptions(new Options(index: 'foo', queryLanguage: QueryLanguage::Esql, handleLinks: $handleLinks)); + + $provider->provide($operation, ['userId' => 42]); - self::assertSame('FROM foo_bar METADATA _id', $resultExtension->compiledQuery); + self::assertSame('FROM foo METADATA _id | WHERE user_id == ?p1', $compiled['query']); } - public function testHandleLinksCallableIsInvoked(): void + public function testHandleLinksWithAnUnsupportedValue(): void { - $resultExtension = new class implements ResultCollectionExtensionInterface { - public ?string $compiledQuery = null; + $provider = $this->createProvider([$this->createResultExtension(new \ArrayObject())]); + + $operation = (new GetCollection(name: 'get_collection', shortName: 'Foo')) + ->withClass(Foo::class) + ->withStateOptions(new Options(index: 'foo', queryLanguage: QueryLanguage::Esql, handleLinks: 'app.links_handler')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not resolve the handleLinks option of the operation "get_collection"'); + + $provider->provide($operation, ['userId' => 42]); + } + + /** + * @param iterable $collectionExtensions + */ + private function createProvider(iterable $collectionExtensions): CollectionProvider + { + return new CollectionProvider( + ClientBuilder::create()->build(), + $this->createStub(DenormalizerInterface::class), + $collectionExtensions, + ); + } + + /** + * @param \ArrayObject $compiled receives the compiled query under the "query" key + */ + private function createResultExtension(\ArrayObject $compiled): ResultCollectionExtensionInterface + { + return new class($compiled) implements ResultCollectionExtensionInterface { + /** + * @param \ArrayObject $compiled + */ + public function __construct(private readonly \ArrayObject $compiled) + { + } public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void { @@ -131,24 +186,10 @@ public function supportsResult(string $resourceClass, ?Operation $operation = nu public function getResult(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): iterable { - $this->compiledQuery = $query->compile()['query']; + $this->compiled['query'] = $query->compile()['query']; return []; } }; - - $handleLinks = static function (EsqlQuery $query, array $uriVariables, array $context): void { - $query->andWhere(\sprintf('user_id == %s', $query->param($uriVariables['userId']))); - }; - - $provider = new CollectionProvider(ClientBuilder::create()->build(), null, [$resultExtension]); - - $operation = (new GetCollection(name: 'get_collection', shortName: 'Foo')) - ->withClass(Foo::class) - ->withStateOptions(new Options(index: 'foo', queryLanguage: QueryLanguage::Esql, handleLinks: $handleLinks)); - - $provider->provide($operation, ['userId' => 42]); - - self::assertSame('FROM foo METADATA _id | WHERE user_id == ?p1', $resultExtension->compiledQuery); } } diff --git a/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php b/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php index cbf0fe4191..f8c4bc01b3 100644 --- a/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php +++ b/src/Elasticsearch/Tests/Metadata/Resource/Factory/ElasticsearchProviderResourceMetadataCollectionFactoryTest.php @@ -21,8 +21,11 @@ use ApiPlatform\Elasticsearch\State\QueryLanguage; use ApiPlatform\Elasticsearch\Tests\Fixtures\Foo; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -79,18 +82,49 @@ public function testStateOptionsOverrideGlobalDefault(): void self::assertSame(CollectionProvider::class, $operations['get_collection']->getProvider()); } - private function createFactory(?Options $options = null, QueryLanguage|string $defaultQueryLanguage = QueryLanguage::Dsl): ElasticsearchProviderResourceMetadataCollectionFactory + public function testEsqlStateOptionsThrowWhenEsqlIsNotAvailable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ES|QL is not supported by the OpenSearch client'); + + $this->createFactory(new Options(queryLanguage: QueryLanguage::Esql), esqlAvailable: false)->create(Foo::class); + } + + public function testEsqlGlobalDefaultThrowsWhenEsqlIsNotAvailable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ES|QL is not supported by the OpenSearch client'); + + $this->createFactory(defaultQueryLanguage: 'esql', esqlAvailable: false)->create(Foo::class); + } + + public function testGraphQlOperationsAlwaysUseTheDslProviders(): void + { + // the ES|QL paginator implements partial pagination only, which is incompatible with GraphQL connections + $resourceMetadataCollection = $this->createFactory(new Options(queryLanguage: QueryLanguage::Esql), 'esql')->create(Foo::class); + $graphQlOperations = $resourceMetadataCollection[0]->getGraphQlOperations(); + + self::assertSame(CollectionProvider::class, $graphQlOperations['collection_query']->getProvider()); + self::assertSame(ItemProvider::class, $graphQlOperations['item_query']->getProvider()); + } + + private function createFactory(?Options $options = null, QueryLanguage|string $defaultQueryLanguage = QueryLanguage::Dsl, bool $esqlAvailable = true): ElasticsearchProviderResourceMetadataCollectionFactory { $options ??= new Options(); - $resource = (new ApiResource(shortName: 'Foo'))->withOperations(new Operations([ - 'get_collection' => (new GetCollection())->withStateOptions($options), - 'get' => (new Get())->withStateOptions($options), - ])); + $resource = (new ApiResource(shortName: 'Foo')) + ->withOperations(new Operations([ + 'get_collection' => (new GetCollection())->withStateOptions($options), + 'get' => (new Get())->withStateOptions($options), + ])) + ->withGraphQlOperations([ + 'collection_query' => (new QueryCollection())->withStateOptions($options), + 'item_query' => (new Query())->withStateOptions($options), + ]); - $decorated = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $decorated->create(Foo::class)->willReturn(new ResourceMetadataCollection(Foo::class, [$resource])); + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn(new ResourceMetadataCollection(Foo::class, [$resource])); - return new ElasticsearchProviderResourceMetadataCollectionFactory($decorated->reveal(), $defaultQueryLanguage); + return new ElasticsearchProviderResourceMetadataCollectionFactory($decorated, $defaultQueryLanguage, $esqlAvailable); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index ec668c84ad..f64e071543 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -1030,24 +1030,34 @@ private function registerElasticsearchConfiguration(ContainerBuilder $container, throw new \LogicException('Elasticsearch support cannot be enabled as the Elasticsearch component is not installed. Try running "composer require api-platform/elasticsearch".'); } - if ('opensearch' === $config['elasticsearch']['client']) { - $clientClass = \OpenSearch\Client::class; // @phpstan-ignore class.notFound - } else { + // ES|QL is an Elasticsearch-only API, the OpenSearch client does not provide it + $esqlSupported = 'opensearch' !== $config['elasticsearch']['client']; + + if ($esqlSupported) { $clientClass = \Elastic\Elasticsearch\Client::class; + } else { + $clientClass = \OpenSearch\Client::class; // @phpstan-ignore class.notFound } $clientDefinition = new Definition($clientClass); $container->setDefinition('api_platform.elasticsearch.client', $clientDefinition); $container->registerForAutoconfiguration(RequestBodySearchCollectionExtensionInterface::class) ->addTag('api_platform.elasticsearch.request_body_search_extension.collection'); - $container->registerForAutoconfiguration(EsqlCollectionExtensionInterface::class) - ->addTag('api_platform.elasticsearch.esql_extension.collection'); $container->setParameter('api_platform.elasticsearch.client', $config['elasticsearch']['client']); + $container->setParameter('api_platform.elasticsearch.esql', $esqlSupported); $container->setParameter('api_platform.elasticsearch.query_language', $config['elasticsearch']['query_language']); $container->setParameter('api_platform.elasticsearch.hosts', $config['elasticsearch']['hosts']); $container->setParameter('api_platform.elasticsearch.ssl_ca_bundle', $config['elasticsearch']['ssl_ca_bundle']); $container->setParameter('api_platform.elasticsearch.ssl_verification', $config['elasticsearch']['ssl_verification']); $loader->load('elasticsearch.php'); + + if (!$esqlSupported) { + return; + } + + $container->registerForAutoconfiguration(EsqlCollectionExtensionInterface::class) + ->addTag('api_platform.elasticsearch.esql_extension.collection'); + $loader->load('elasticsearch_esql.php'); } private function registerSecurityConfiguration(ContainerBuilder $container, array $config, PhpFileLoader $loader): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index a67f07ec35..6b19774c24 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -523,7 +523,7 @@ private function addElasticsearchSection(ArrayNodeDefinition $rootNode): void ->end() ->end() ->validate() - ->ifTrue(static fn (array $v): bool => 'opensearch' === $v['client'] && 'esql' === $v['query_language']) + ->ifTrue(static fn (array $v): bool => ($v['enabled'] ?? false) && 'opensearch' === $v['client'] && 'esql' === $v['query_language']) ->thenInvalid('ES|QL (api_platform.elasticsearch.query_language: "esql") is not supported by OpenSearch.') ->end() ->end() diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 8ca22e11ad..3092e984df 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -13,10 +13,6 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; -use ApiPlatform\Elasticsearch\Esql\Extension\PaginationExtension as EsqlPaginationExtension; -use ApiPlatform\Elasticsearch\Esql\Extension\ParameterExtension as EsqlParameterExtension; -use ApiPlatform\Elasticsearch\Esql\Extension\SortExtension as EsqlSortExtension; -use ApiPlatform\Elasticsearch\Esql\State\CollectionProvider as EsqlCollectionProvider; use ApiPlatform\Elasticsearch\Extension\ConstantScoreFilterExtension; use ApiPlatform\Elasticsearch\Extension\SortExtension; use ApiPlatform\Elasticsearch\Extension\SortFilterExtension; @@ -143,41 +139,6 @@ ->args([ service('api_platform.elasticsearch.metadata.resource.metadata_collection_factory.inner'), '%api_platform.elasticsearch.query_language%', + '%api_platform.elasticsearch.esql%', ]); - - $services->set('api_platform.elasticsearch.esql_extension.parameter', EsqlParameterExtension::class) - ->args([ - service('api_platform.filter_locator'), - service('api_platform.metadata.property.metadata_factory'), - service('api_platform.resource_class_resolver'), - service('api_platform.elasticsearch.name_converter.inner_fields'), - ]) - ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 30]); - - $services->set('api_platform.elasticsearch.esql_extension.sort', EsqlSortExtension::class) - ->args([ - service('api_platform.elasticsearch.name_converter.inner_fields'), - '%api_platform.collection.order%', - ]) - ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 20]); - - $services->set('api_platform.elasticsearch.esql_extension.pagination', EsqlPaginationExtension::class) - ->args([ - service('api_platform.elasticsearch.client'), - service('serializer'), - service('api_platform.pagination'), - ]) - ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 10]); - - $services->set('api_platform.elasticsearch.esql.state.collection_provider', EsqlCollectionProvider::class) - ->args([ - service('api_platform.elasticsearch.client'), - service('serializer'), - tagged_iterator('api_platform.elasticsearch.esql_extension.collection'), - service('api_platform.inflector')->nullOnInvalid(), - ]) - ->tag('api_platform.state_provider', ['priority' => -100, 'key' => 'ApiPlatform\Elasticsearch\Esql\State\CollectionProvider']) - ->tag('api_platform.state_provider', ['priority' => -100]); - - $services->alias(EsqlCollectionProvider::class, 'api_platform.elasticsearch.esql.state.collection_provider'); }; diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch_esql.php b/src/Symfony/Bundle/Resources/config/elasticsearch_esql.php new file mode 100644 index 0000000000..5d32dea32a --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/elasticsearch_esql.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\Elasticsearch\Esql\Extension\PaginationExtension as EsqlPaginationExtension; +use ApiPlatform\Elasticsearch\Esql\Extension\ParameterExtension as EsqlParameterExtension; +use ApiPlatform\Elasticsearch\Esql\Extension\SortExtension as EsqlSortExtension; +use ApiPlatform\Elasticsearch\Esql\State\CollectionProvider as EsqlCollectionProvider; + +return static function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.elasticsearch.esql_extension.parameter', EsqlParameterExtension::class) + ->args([ + service('api_platform.filter_locator'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.resource_class_resolver'), + service('api_platform.elasticsearch.name_converter.inner_fields'), + ]) + ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 30]); + + $services->set('api_platform.elasticsearch.esql_extension.sort', EsqlSortExtension::class) + ->args([ + service('api_platform.elasticsearch.name_converter.inner_fields'), + '%api_platform.collection.order%', + ]) + ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 20]); + + $services->set('api_platform.elasticsearch.esql_extension.pagination', EsqlPaginationExtension::class) + ->args([ + service('api_platform.elasticsearch.client'), + service('serializer'), + service('api_platform.pagination'), + ]) + ->tag('api_platform.elasticsearch.esql_extension.collection', ['priority' => 10]); + + $services->set('api_platform.elasticsearch.esql.state.collection_provider', EsqlCollectionProvider::class) + ->args([ + service('api_platform.elasticsearch.client'), + service('serializer'), + tagged_iterator('api_platform.elasticsearch.esql_extension.collection'), + service('api_platform.inflector')->nullOnInvalid(), + ]) + ->tag('api_platform.state_provider', ['priority' => -100, 'key' => 'ApiPlatform\Elasticsearch\Esql\State\CollectionProvider']) + ->tag('api_platform.state_provider', ['priority' => -100]); + + $services->alias(EsqlCollectionProvider::class, 'api_platform.elasticsearch.esql.state.collection_provider'); +}; diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index e63810150c..22fa6f00fc 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -507,6 +507,25 @@ public function testElasticsearchOpenSearchClientRequiresPackage(): void ]); } + public function testElasticsearchEsqlWithOpenSearchIsNotValidatedWhenDisabled(): void + { + if (!class_exists(\OpenSearch\Client::class)) { + self::markTestSkipped('opensearch-project/opensearch-php is not installed.'); + } + + $config = $this->processor->processConfiguration($this->configuration, [ + 'api_platform' => [ + 'elasticsearch' => [ + 'enabled' => false, + 'client' => 'opensearch', + 'query_language' => 'esql', + ], + ], + ]); + + $this->assertFalse($config['elasticsearch']['enabled']); + } + public function testElasticsearchSslCaBundleAndVerificationDisabledMutuallyExclusive(): void { $this->expectException(InvalidConfigurationException::class); From 08604f90f5e9856d2027934fc0082e8cd90c8a03 Mon Sep 17 00:00:00 2001 From: Julien LARY <47776596+laryjulien@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:42:02 +0200 Subject: [PATCH 6/7] fix(elasticsearch): repair CI after the ES 7 drop and ES|QL introduction - require elasticsearch/elasticsearch >= 8.11 (root and component): the esql() endpoint does not exist before, and older elastic/transport releases pin psr/http-message ^1.0 which conflicts with opensearch-project/opensearch-php ^2.5 under --prefer-lowest - CI: replace the broken prefer-lowest Elasticsearch matrix entry (no lock file, so the partial update installed the v9 client against the 8.4 server) with an explicit v8 client pin - do not implement SortFilterInterface in the ES|QL SortFilter yet: the component jobs may resolve api-platform/metadata to a 4.x release that predates the marker, and GraphQL operations never use ES|QL filters - allow mcp/sdk 0.7 in the mcp component, same conflict as the root one - drop the stale PartialDenormalizationException PHPStan ignore pattern, unmatched since a dependency bump Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 13 ++++--------- composer.json | 2 +- phpstan.neon.dist | 1 - src/Elasticsearch/Esql/Filter/SortFilter.php | 9 +++++++-- src/Elasticsearch/composer.json | 2 +- src/Mcp/composer.json | 2 +- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bcac5cd1a..90ebd91bcc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -720,13 +720,9 @@ jobs: elasticsearch-version: '9.0.0' elasticsearch-package: '' extensions: 'intl, bcmath, curl, openssl, mbstring, mongodb' - - php: '8.5' - elasticsearch-version: '8.4.0' - elasticsearch-package: 'elasticsearch/elasticsearch:^8.4' - extensions: 'intl, bcmath, curl, openssl, mbstring' - php: '8.3' elasticsearch-version: '8.4.0' - elasticsearch-package: 'elasticsearch/elasticsearch:prefer-lowest' + elasticsearch-package: 'elasticsearch/elasticsearch:^8.11' extensions: 'intl, bcmath, curl, openssl, mbstring' fail-fast: false env: @@ -767,10 +763,9 @@ jobs: composer global require soyuka/pmu composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . - if [ "${{ matrix.elasticsearch-package }}" == "elasticsearch/elasticsearch:^8.4" ]; then - composer require elasticsearch/elasticsearch "^8.4" -W - elif [ "${{ matrix.elasticsearch-package }}" == "elasticsearch/elasticsearch:prefer-lowest" ]; then - composer update elasticsearch/elasticsearch --prefer-lowest + if [ "${{ matrix.elasticsearch-package }}" == "elasticsearch/elasticsearch:^8.11" ]; then + # pin the v8 client: the default resolution installs v9, which cannot talk to the v8 server of this job + composer require elasticsearch/elasticsearch "^8.11" -W fi - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi diff --git a/composer.json b/composer.json index 2c8e606a19..e29cec93a6 100644 --- a/composer.json +++ b/composer.json @@ -129,7 +129,7 @@ "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/orm": "^2.17 || ^3.0", - "elasticsearch/elasticsearch": "^8.4 || ^9.0", + "elasticsearch/elasticsearch": "^8.11 || ^9.0", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", "illuminate/config": "^11.0 || ^12.0 || ^13.0", diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8afc2a94ef..070dab5f64 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -91,7 +91,6 @@ parameters: - "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\Serializer\\\\\\\\Serializer' and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Normalizer\\\\NormalizerInterface and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#" - - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" diff --git a/src/Elasticsearch/Esql/Filter/SortFilter.php b/src/Elasticsearch/Esql/Filter/SortFilter.php index 24d49abb77..3eef0d9e7b 100644 --- a/src/Elasticsearch/Esql/Filter/SortFilter.php +++ b/src/Elasticsearch/Esql/Filter/SortFilter.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; -use ApiPlatform\Metadata\SortFilterInterface; /** * Sorts the collection by a property. @@ -26,11 +25,17 @@ * Usage: `new QueryParameter(filter: new SortFilter(), property: 'rating')` * with a key like `sort[:property]`, then `?sort[rating]=desc`. * + * Note: this filter intentionally does not implement the + * {@see \ApiPlatform\Metadata\SortFilterInterface} marker yet: it only drives + * the GraphQL sort arguments, and GraphQL operations never use the ES|QL + * providers. Implementing it would also require api-platform/metadata 5.0, + * which the component test suites cannot guarantee yet. + * * @experimental * * @author Julien Lary */ -final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, SortFilterInterface +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface { use BackwardCompatibleFilterDescriptionTrait; diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 51d078d1b8..bb582bf87a 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -27,7 +27,7 @@ "api-platform/metadata": "^5.0@alpha", "api-platform/serializer": "^5.0@alpha", "api-platform/state": "^5.0@alpha", - "elasticsearch/elasticsearch": "^8.4 || ^9.0", + "elasticsearch/elasticsearch": "^8.11 || ^9.0", "symfony/cache": "^7.4 || ^8.0", "symfony/console": "^7.4 || ^8.0", "symfony/property-access": "^7.4 || ^8.0", diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index a824d20b63..09ee15018a 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -30,7 +30,7 @@ "php": ">=8.2", "api-platform/metadata": "^5.0@alpha", "api-platform/json-schema": "^5.0@alpha", - "mcp/sdk": "^0.6", + "mcp/sdk": "^0.6 || ^0.7", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/polyfill-php85": "^1.32" }, From 22f867950c4df8db5f0d0fe475eed9ebe5b8f864 Mon Sep 17 00:00:00 2001 From: Julien LARY <47776596+laryjulien@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:11:30 +0200 Subject: [PATCH 7/7] fix: repair remaining CI failures (Laravel MCP wiring, lowest+opensearch) - Laravel: symfony/mcp-bundle dev-main added a required MiddlewareFactory argument to McpController; pass one with its defaults (SDK DNS-rebinding protection kept) - Elasticsearch component: conflict with psr/http-factory < 1.0.2 so a --prefer-lowest install can coexist with opensearch-project/opensearch-php (psr/http-factory 1.0.0 pins psr/http-message ^1.0 while opensearch-php requires ^2.0, and the factory is outside the -W allowlist of the opensearch require) Co-Authored-By: Claude Fable 5 --- src/Elasticsearch/composer.json | 5 ++++- src/Laravel/ApiPlatformProvider.php | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index bb582bf87a..6e5abf3d5e 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -81,5 +81,8 @@ "test": "./vendor/bin/phpunit" }, "minimum-stability": "beta", - "prefer-stable": true + "prefer-stable": true, + "conflict": { + "psr/http-factory": "<1.0.2" + } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 4cc6fd7aa1..557e6192a0 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -193,6 +193,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use Psr\Log\LoggerInterface; use Symfony\AI\McpBundle\Controller\McpController; +use Symfony\AI\McpBundle\Http\MiddlewareFactory as McpMiddlewareFactory; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\HttpFoundation\RequestStack; @@ -1342,7 +1343,8 @@ private function registerMcp(): void $psrHttpFactory, $httpFoundationFactory, $psr17Factory, - $psr17Factory + $psr17Factory, + new McpMiddlewareFactory() ); }); }