From fbee86b4e67830efc837f7f1138a6cf5b765a9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 15 Sep 2025 14:54:54 +0200 Subject: [PATCH 1/5] Introduce AtlasSearchFilter --- .../Odm/Extension/ParameterExtension.php | 5 +- src/Doctrine/Odm/Filter/AtlasSearchFilter.php | 100 ++++++++++++++++++ 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/Doctrine/Odm/Filter/AtlasSearchFilter.php diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index 585ea9ec05..a5584914ec 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -89,7 +89,7 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass $filter->setProperties($properties ?? []); } - $filterContext = ['filters' => $values, 'parameter' => $parameter, 'match' => $context['match'] ?? null]; + $filterContext = ['filters' => $values, 'parameter' => $parameter, 'match' => $context['match'] ?? null, 'search' => $context['search'] ?? null]; $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); // update by reference if (isset($filterContext['mongodb_odm_sort_fields'])) { @@ -98,6 +98,9 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass if (isset($filterContext['match'])) { $context['match'] = $filterContext['match']; } + if (isset($filterContext['search'])) { + $context['search'] = $filterContext['search']; + } } if (isset($context['match'])) { diff --git a/src/Doctrine/Odm/Filter/AtlasSearchFilter.php b/src/Doctrine/Odm/Filter/AtlasSearchFilter.php new file mode 100644 index 0000000000..dec1121f0e --- /dev/null +++ b/src/Doctrine/Odm/Filter/AtlasSearchFilter.php @@ -0,0 +1,100 @@ + + */ +final class AtlasSearchFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use ManagerRegistryAwareTrait; + use OpenApiFilterTrait; + + /** + * @see https://www.mongodb.com/docs/atlas/atlas-search/compound/ + * + * @param string $term The term to use in the Atlas Search query (must, mustNot, should, filter). Default to "must". + */ + public function __construct( + private readonly string $index = 'default', + private readonly string $operator = 'text', + private readonly string $term = 'must', + private readonly ?array $facet = null, + ) { + } + + /** + * @throws MappingException + * @throws LockException + */ + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + $property = $parameter->getProperty(); + $value = $parameter->getValue(); + + if (!isset($context['search'])) { + $searchStage = $context['search']['stage'] = $aggregationBuilder->search(); + $searchStage->index($this->index); + + if ($this->facet) { + $searchStage->facet() + ->operator($compound = $searchStage->compound()) + ->add(...$this->facet); + } else { + $compound = $context['search']['compound'] = $searchStage->compound(); + } + $context['search']['compound'] = $compound; + } else { + $compound = $context['search']['compound']; + } + + $compound->{$this->term}(); + + switch ($this->operator) { + case 'queryString': + $operator = $compound->queryString() + ->defaultPath($property) + ->query($value); + break; + case 'wildcard': + $operator = $compound->wildcard() + ->path($property) + ->query($value); + break; + case 'text': + $operator = $compound->text() + ->path($property) + ->query($value) + ->fuzzy(maxEdits: 1); + break; + case 'phrase': + $operator = $compound->phrase() + ->path($property) + ->query($value) + ->slop(2); + break; + case 'autocomplete': + $operator = $compound->autocomplete() + ->path($property) + ->query($value) + ->fuzzy(maxEdits: 1); + break; + default: + throw new \InvalidArgumentException(sprintf('Unsupported operator "%s" for AtlasSearchFilter', $this->operator)); + } + } +} From 6478b01e6f8b52974fdbc98d66207ec64c6f9492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 16 Sep 2025 09:24:07 +0200 Subject: [PATCH 2/5] Remove facet --- src/Doctrine/Odm/Filter/AtlasSearchFilter.php | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Doctrine/Odm/Filter/AtlasSearchFilter.php b/src/Doctrine/Odm/Filter/AtlasSearchFilter.php index dec1121f0e..20e8e26e7f 100644 --- a/src/Doctrine/Odm/Filter/AtlasSearchFilter.php +++ b/src/Doctrine/Odm/Filter/AtlasSearchFilter.php @@ -32,7 +32,6 @@ public function __construct( private readonly string $index = 'default', private readonly string $operator = 'text', private readonly string $term = 'must', - private readonly ?array $facet = null, ) { } @@ -49,19 +48,10 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera if (!isset($context['search'])) { $searchStage = $context['search']['stage'] = $aggregationBuilder->search(); $searchStage->index($this->index); - - if ($this->facet) { - $searchStage->facet() - ->operator($compound = $searchStage->compound()) - ->add(...$this->facet); - } else { - $compound = $context['search']['compound'] = $searchStage->compound(); - } - $context['search']['compound'] = $compound; - } else { - $compound = $context['search']['compound']; + $context['search']['compound'] = $searchStage->compound(); } + $compound = $context['search']['compound']; $compound->{$this->term}(); switch ($this->operator) { From ead7099eaeb1ab7bd12c27337d8309b6b854ff33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 16 Sep 2025 09:54:30 +0200 Subject: [PATCH 3/5] Sort in the stage instead of using stage --- src/Doctrine/Odm/Extension/OrderExtension.php | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/src/Doctrine/Odm/Extension/OrderExtension.php b/src/Doctrine/Odm/Extension/OrderExtension.php index 0afb8d70fc..931ef2c30f 100644 --- a/src/Doctrine/Odm/Extension/OrderExtension.php +++ b/src/Doctrine/Odm/Extension/OrderExtension.php @@ -17,6 +17,7 @@ use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\Aggregation\Stage\Search; use Doctrine\ODM\MongoDB\Aggregation\Stage\Sort; use Doctrine\Persistence\ManagerRegistry; @@ -66,7 +67,8 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC if ($this->isPropertyNested($field, $resourceClass)) { [$field] = $this->addLookupsForNestedProperty($field, $aggregationBuilder, $resourceClass, true); } - $aggregationBuilder->sort( + $this->addSort( + $aggregationBuilder, $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$field => $order] ); } @@ -76,7 +78,8 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC if (null !== $this->order) { foreach ($identifiers as $identifier) { - $aggregationBuilder->sort( + $this->addSort( + $aggregationBuilder, $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$identifier => $this->order] ); } @@ -88,26 +91,30 @@ protected function getManagerRegistry(): ManagerRegistry return $this->managerRegistry; } - private function hasSortStage(Builder $aggregationBuilder): bool + private function addSort(Builder $aggregationBuilder, array $sortFields): void { - $shouldStop = false; - $index = 0; + $firstStage = $aggregationBuilder->getPipeline(0); + if ($firstStage instanceof Search) { + // The $search stage supports "sort" for performance, it's always first if present + $firstStage->sort($sortFields); + } else { + // Append a $sort stage at the end of the pipeline + $aggregationBuilder->sort($sortFields); + } + } - do { - try { + private function hasSortStage(Builder $aggregationBuilder): bool + { + try { + for ($index = 0; true; $index++) { if ($aggregationBuilder->getStage($index) instanceof Sort) { // If at least one stage is sort, then it has sorting return true; } - } catch (\OutOfRangeException $outOfRangeException) { - // There is no more stages on the aggregation builder - $shouldStop = true; } - - ++$index; - } while (!$shouldStop); - - // No stage was sort, and we iterated through all stages - return false; + } catch (\OutOfRangeException) { + // There is no more stages on the aggregation builder + return false; + } } } From 6b89e4feb51007a4755b53f0f086a0808659c855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 16 Sep 2025 15:23:31 +0200 Subject: [PATCH 4/5] Don't add default sort order on $search pipelines Results are already ordered by score --- src/Doctrine/Odm/Extension/OrderExtension.php | 39 ++++++++++--------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/src/Doctrine/Odm/Extension/OrderExtension.php b/src/Doctrine/Odm/Extension/OrderExtension.php index 931ef2c30f..aceb700c3c 100644 --- a/src/Doctrine/Odm/Extension/OrderExtension.php +++ b/src/Doctrine/Odm/Extension/OrderExtension.php @@ -67,19 +67,22 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC if ($this->isPropertyNested($field, $resourceClass)) { [$field] = $this->addLookupsForNestedProperty($field, $aggregationBuilder, $resourceClass, true); } - $this->addSort( - $aggregationBuilder, - $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$field => $order] - ); + + $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$field => $order]; + if ($this->isSearchPipeline($aggregationBuilder)) { + $aggregationBuilder->getStage(0) + ->sort($context['mongodb_odm_sort_fields']); + } else { + $aggregationBuilder->sort($context['mongodb_odm_sort_fields']); + } } return; } - if (null !== $this->order) { + if (null !== $this->order && !$this->isSearchPipeline($aggregationBuilder)) { foreach ($identifiers as $identifier) { - $this->addSort( - $aggregationBuilder, + $aggregationBuilder->sort( $context['mongodb_odm_sort_fields'] = ($context['mongodb_odm_sort_fields'] ?? []) + [$identifier => $this->order] ); } @@ -91,18 +94,6 @@ protected function getManagerRegistry(): ManagerRegistry return $this->managerRegistry; } - private function addSort(Builder $aggregationBuilder, array $sortFields): void - { - $firstStage = $aggregationBuilder->getPipeline(0); - if ($firstStage instanceof Search) { - // The $search stage supports "sort" for performance, it's always first if present - $firstStage->sort($sortFields); - } else { - // Append a $sort stage at the end of the pipeline - $aggregationBuilder->sort($sortFields); - } - } - private function hasSortStage(Builder $aggregationBuilder): bool { try { @@ -117,4 +108,14 @@ private function hasSortStage(Builder $aggregationBuilder): bool return false; } } + + private function isSearchPipeline(Builder $aggregationBuilder): bool + { + try { + return $aggregationBuilder->getStage(0) instanceof Search; + } catch (\OutOfRangeException) { + // Empty pipeline + return false; + } + } } From e00db09d9a8314e38250cee00945e04754fcc65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 16 Sep 2025 15:24:05 +0200 Subject: [PATCH 5/5] Make ParameterExtension context more generic --- src/Doctrine/Odm/Extension/OrderExtension.php | 2 +- src/Doctrine/Odm/Extension/ParameterExtension.php | 14 +++----------- src/Doctrine/Odm/Filter/AtlasSearchFilter.php | 11 ++++++++++- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Doctrine/Odm/Extension/OrderExtension.php b/src/Doctrine/Odm/Extension/OrderExtension.php index aceb700c3c..7e64a6dc42 100644 --- a/src/Doctrine/Odm/Extension/OrderExtension.php +++ b/src/Doctrine/Odm/Extension/OrderExtension.php @@ -97,7 +97,7 @@ protected function getManagerRegistry(): ManagerRegistry private function hasSortStage(Builder $aggregationBuilder): bool { try { - for ($index = 0; true; $index++) { + for ($index = 0; true; ++$index) { if ($aggregationBuilder->getStage($index) instanceof Sort) { // If at least one stage is sort, then it has sorting return true; diff --git a/src/Doctrine/Odm/Extension/ParameterExtension.php b/src/Doctrine/Odm/Extension/ParameterExtension.php index a5584914ec..9dff1dd01d 100644 --- a/src/Doctrine/Odm/Extension/ParameterExtension.php +++ b/src/Doctrine/Odm/Extension/ParameterExtension.php @@ -89,18 +89,10 @@ private function applyFilter(Builder $aggregationBuilder, ?string $resourceClass $filter->setProperties($properties ?? []); } - $filterContext = ['filters' => $values, 'parameter' => $parameter, 'match' => $context['match'] ?? null, 'search' => $context['search'] ?? null]; + $specificContext = ['filters' => $values, 'parameter' => $parameter]; + $filterContext = array_replace($context, $specificContext); $filter->apply($aggregationBuilder, $resourceClass, $operation, $filterContext); - // update by reference - if (isset($filterContext['mongodb_odm_sort_fields'])) { - $context['mongodb_odm_sort_fields'] = $filterContext['mongodb_odm_sort_fields']; - } - if (isset($filterContext['match'])) { - $context['match'] = $filterContext['match']; - } - if (isset($filterContext['search'])) { - $context['search'] = $filterContext['search']; - } + $context = array_replace($context, array_diff_key($filterContext, $specificContext)); } if (isset($context['match'])) { diff --git a/src/Doctrine/Odm/Filter/AtlasSearchFilter.php b/src/Doctrine/Odm/Filter/AtlasSearchFilter.php index 20e8e26e7f..f71527c300 100644 --- a/src/Doctrine/Odm/Filter/AtlasSearchFilter.php +++ b/src/Doctrine/Odm/Filter/AtlasSearchFilter.php @@ -1,5 +1,14 @@ + * + * 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\Doctrine\Odm\Filter; @@ -84,7 +93,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ->fuzzy(maxEdits: 1); break; default: - throw new \InvalidArgumentException(sprintf('Unsupported operator "%s" for AtlasSearchFilter', $this->operator)); + throw new \InvalidArgumentException(\sprintf('Unsupported operator "%s" for AtlasSearchFilter', $this->operator)); } } }