Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: '7.17.0'
elasticsearch-package: 'elasticsearch/elasticsearch:prefer-lowest'
elasticsearch-version: '8.4.0'
elasticsearch-package: 'elasticsearch/elasticsearch:^8.11'
extensions: 'intl, bcmath, curl, openssl, mbstring'
fail-fast: false
env:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.11 || ^9.0",
"friendsofphp/php-cs-fixer": "^3.93",
"guzzlehttp/guzzle": "^6.0 || ^7.0",
"illuminate/config": "^11.0 || ^12.0 || ^13.0",
Expand All @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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\\.#"



Expand Down
206 changes: 206 additions & 0 deletions src/Elasticsearch/Esql/EsqlQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<string>
*/
private array $sorts = [];

private ?int $limit = null;

/**
* @var list<array<string, mixed>>
*/
private array $params = [];

private int $paramCount = 0;

/**
* @param list<string> $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<array<string, mixed>>}
*/
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,
];
}
}
32 changes: 32 additions & 0 deletions src/Elasticsearch/Esql/Extension/CollectionExtensionInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 <julien.lary@les-tilleuls.coop>
*/
interface CollectionExtensionInterface
{
/**
* @param array<string, mixed> $context
*/
public function applyToCollection(EsqlQuery $query, string $resourceClass, ?Operation $operation = null, array $context = []): void;
}
Loading
Loading