From c53088068b017746c4123c7df31028949a439750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 30 Sep 2025 09:48:10 +0200 Subject: [PATCH 1/3] Add an assertion on missing search index when a $search or $vectorSearch pipeline returns an empty result --- .../ODM/MongoDB/Aggregation/Aggregation.php | 46 ++++++++++++++++++- lib/Doctrine/ODM/MongoDB/Configuration.php | 17 +++++++ lib/Doctrine/ODM/MongoDB/SchemaException.php | 6 +++ .../Tests/Functional/AtlasSearchTest.php | 33 +++++++++++++ 4 files changed, 101 insertions(+), 1 deletion(-) diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php index 013e7e1bdd..8498277a9a 100644 --- a/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php +++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php @@ -11,9 +11,11 @@ use Doctrine\ODM\MongoDB\Iterator\Iterator; use Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\SchemaException; use MongoDB\Collection; use MongoDB\Driver\CursorInterface; +use function array_key_first; use function array_merge; /** @phpstan-import-type PipelineExpression from Builder */ @@ -64,6 +66,48 @@ private function prepareIterator(CursorInterface $cursor): Iterator $cursor = new HydratingIterator($cursor, $this->dm->getUnitOfWork(), $this->classMetadata); } - return $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor); + $iterator = $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor); + + $this->assertSearchIndexExistsWhenAggregationResultsIsEmpty($iterator); + + return $iterator; + } + + /** + * If the server implements a server-side error for missing search indexes, + * this assertion can be removed. + * + * @see https://jira.mongodb.org/browse/SERVER-110974 + * + * @param Iterator $iterator + */ + private function assertSearchIndexExistsWhenAggregationResultsIsEmpty(Iterator $iterator): void + { + if ($iterator->current() !== false) { + return; // Results not empty + } + + if (! $this->dm->getConfiguration()->assertSearchIndexExistsWhenAggregationResultsIsEmpty()) { + return; // Feature disabled + } + + // Search stages must be the first stage in the pipeline + $indexName = match (array_key_first($this->pipeline[0])) { + '$search' => $this->pipeline[0]['$search']->index ?? null, + '$searchMeta' => $this->pipeline[0]['$searchMeta']->index ?? null, + '$vectorSearch' => $this->pipeline[0]['$vectorSearch']->index ?? null, + default => null, + }; + + if ($indexName === null) { + return; // Not a search aggregation or index not specified + } + + $index = $this->collection->listSearchIndexes(['filter' => ['name' => $indexName]])->current(); + if ($index) { + return; // Index exists + } + + throw SchemaException::searchIndexNotFound($this->collection->getNamespace(), $indexName); } } diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 8ea5c844c6..5fe7c9a016 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -808,6 +808,23 @@ private function getAutoEncryptionOptions(): array ...$this->attributes['autoEncryption'] ?? [], ]; } + + /** + * Pipelines using a search index that does not exist or is not queryable + * will return zero documents. By enabling this feature, an additional query + * is made when the pipeline doesn't return any results to check if the + * search index exists and is queryable. If the index does not exist or is + * not queryable, an exception is thrown. This feature is enabled by default. + */ + public function setAssertSearchIndexExistsWhenAggregationResultsIsEmpty(bool $enabled): void + { + $this->attributes['assertSearchIndexExistsWhenAggregationResultsIsEmpty'] = $enabled; + } + + public function assertSearchIndexExistsWhenAggregationResultsIsEmpty(): bool + { + return $this->attributes['assertSearchIndexExistsWhenAggregationResultsIsEmpty'] ?? true; + } } interface_exists(MappingDriver::class); diff --git a/lib/Doctrine/ODM/MongoDB/SchemaException.php b/lib/Doctrine/ODM/MongoDB/SchemaException.php index 048090030f..50059989f0 100644 --- a/lib/Doctrine/ODM/MongoDB/SchemaException.php +++ b/lib/Doctrine/ODM/MongoDB/SchemaException.php @@ -23,4 +23,10 @@ public static function missingSearchIndex(string $documentClass, array $missingI { return new self(sprintf('The document class "%s" is missing the following search index(es): "%s"', $documentClass, implode('", "', $missingIndexes))); } + + /** @internal */ + public static function searchIndexNotFound(string $namespace, string $indexName): self + { + return new self(sprintf('The search index "%s" of the collection "%s" is not found.', $indexName, $namespace)); + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php index bcb28e5487..35fc6dd19e 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php @@ -4,6 +4,7 @@ namespace Doctrine\ODM\MongoDB\Tests\Functional; +use Doctrine\ODM\MongoDB\SchemaException; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; use Documents\CmsArticle; use Documents\CmsUser; @@ -54,6 +55,7 @@ public function testAtlasSearch(): void $schemaManager->waitForSearchIndexes([CmsArticle::class, CmsUser::class]); $results = $this->dm->createAggregationBuilder(CmsArticle::class) + ->rewindable(false) ->search() ->index('search_articles') ->autocomplete() @@ -116,4 +118,35 @@ public function testAtlasSearch(): void $this->assertNotEmpty($results, 'Count search should return results'); } + + public function testIndexNotCreated(): void + { + $aggregation = $this->dm->createAggregationBuilder(CmsArticle::class) + ->search() + ->index('search_articles') + ->text() + ->query('Atlas Search') + ->path('text') + ->getAggregation(); + + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches('#^The search index "search_articles" of the collection "[^."]+\.CmsArticle" is not found\.$#'); + + $aggregation->execute(); + } + + public function testIndexNotCreatedWithoutException(): void + { + $this->dm->getConfiguration()->setAssertSearchIndexExistsWhenAggregationResultsIsEmpty(false); + + $results = $this->dm->createAggregationBuilder(CmsArticle::class) + ->search() + ->index('search_articles') + ->text() + ->query('Atlas Search') + ->path('text') + ->getAggregation()->execute(); + + $this->assertCount(0, $results->toArray()); + } } From ff1a0afa4a877ec1299150106ac4cda930509453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 1 Oct 2025 10:11:02 +0200 Subject: [PATCH 2/3] Rename setting and refactor --- .../ODM/MongoDB/Aggregation/Aggregation.php | 33 ++++++++++--------- lib/Doctrine/ODM/MongoDB/Configuration.php | 15 +++++---- .../Tests/Functional/AtlasSearchTest.php | 20 +++++++++-- 3 files changed, 43 insertions(+), 25 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php index 8498277a9a..dccdf745ad 100644 --- a/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php +++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php @@ -4,6 +4,7 @@ namespace Doctrine\ODM\MongoDB\Aggregation; +use Doctrine\ODM\MongoDB\Configuration; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Iterator\CachingIterator; use Doctrine\ODM\MongoDB\Iterator\HydratingIterator; @@ -15,8 +16,10 @@ use MongoDB\Collection; use MongoDB\Driver\CursorInterface; -use function array_key_first; use function array_merge; +use function current; +use function in_array; +use function key; /** @phpstan-import-type PipelineExpression from Builder */ final class Aggregation implements IterableResult @@ -68,7 +71,7 @@ private function prepareIterator(CursorInterface $cursor): Iterator $iterator = $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor); - $this->assertSearchIndexExistsWhenAggregationResultsIsEmpty($iterator); + $this->assertSearchIndexExistsForEmptyResult($iterator); return $iterator; } @@ -78,32 +81,30 @@ private function prepareIterator(CursorInterface $cursor): Iterator * this assertion can be removed. * * @see https://jira.mongodb.org/browse/SERVER-110974 + * @see Configuration::setAssertSearchIndexExistsForEmptyResult() * - * @param Iterator $iterator + * @param CachingIterator|UnrewindableIterator $iterator */ - private function assertSearchIndexExistsWhenAggregationResultsIsEmpty(Iterator $iterator): void + private function assertSearchIndexExistsForEmptyResult(CachingIterator|UnrewindableIterator $iterator): void { - if ($iterator->current() !== false) { + // The iterator is always rewinded + if ($iterator->current()) { return; // Results not empty } - if (! $this->dm->getConfiguration()->assertSearchIndexExistsWhenAggregationResultsIsEmpty()) { + if (! $this->dm->getConfiguration()->assertSearchIndexExistsForEmptyResult()) { return; // Feature disabled } // Search stages must be the first stage in the pipeline - $indexName = match (array_key_first($this->pipeline[0])) { - '$search' => $this->pipeline[0]['$search']->index ?? null, - '$searchMeta' => $this->pipeline[0]['$searchMeta']->index ?? null, - '$vectorSearch' => $this->pipeline[0]['$vectorSearch']->index ?? null, - default => null, - }; - - if ($indexName === null) { - return; // Not a search aggregation or index not specified + $stage = $this->pipeline[0] ?? null; + if (! $stage || ! in_array(key($stage), ['$search', '$searchMeta', '$vectorSearch'], true)) { + return; // Not a search aggregation } - $index = $this->collection->listSearchIndexes(['filter' => ['name' => $indexName]])->current(); + // @phpcs:ignore SlevomatCodingStandard.PHP.UselessParentheses + $indexName = ((object) current($stage))->index ?? 'default'; + $index = $this->collection->listSearchIndexes(['filter' => ['name' => $indexName]])->current(); if ($index) { return; // Index exists } diff --git a/lib/Doctrine/ODM/MongoDB/Configuration.php b/lib/Doctrine/ODM/MongoDB/Configuration.php index 5fe7c9a016..6934bd60fe 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -812,18 +812,19 @@ private function getAutoEncryptionOptions(): array /** * Pipelines using a search index that does not exist or is not queryable * will return zero documents. By enabling this feature, an additional query - * is made when the pipeline doesn't return any results to check if the - * search index exists and is queryable. If the index does not exist or is - * not queryable, an exception is thrown. This feature is enabled by default. + * is performed when the pipeline doesn't return any results to check if the + * search index exists. If the index does not exist, an exception is thrown. + * This feature is enabled by default. + * This applies to $search, $searchMeta and $vectorSearch pipelines. */ - public function setAssertSearchIndexExistsWhenAggregationResultsIsEmpty(bool $enabled): void + public function setAssertSearchIndexExistsForEmptyResult(bool $enabled): void { - $this->attributes['assertSearchIndexExistsWhenAggregationResultsIsEmpty'] = $enabled; + $this->attributes['assertSearchIndexExistsForEmptyResult'] = $enabled; } - public function assertSearchIndexExistsWhenAggregationResultsIsEmpty(): bool + public function assertSearchIndexExistsForEmptyResult(): bool { - return $this->attributes['assertSearchIndexExistsWhenAggregationResultsIsEmpty'] ?? true; + return $this->attributes['assertSearchIndexExistsForEmptyResult'] ?? true; } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php index 35fc6dd19e..e1cafe14fe 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php @@ -4,6 +4,7 @@ namespace Doctrine\ODM\MongoDB\Tests\Functional; +use Doctrine\ODM\MongoDB\Aggregation\Stage; use Doctrine\ODM\MongoDB\SchemaException; use Doctrine\ODM\MongoDB\Tests\BaseTestCase; use Documents\CmsArticle; @@ -55,7 +56,6 @@ public function testAtlasSearch(): void $schemaManager->waitForSearchIndexes([CmsArticle::class, CmsUser::class]); $results = $this->dm->createAggregationBuilder(CmsArticle::class) - ->rewindable(false) ->search() ->index('search_articles') ->autocomplete() @@ -137,7 +137,7 @@ public function testIndexNotCreated(): void public function testIndexNotCreatedWithoutException(): void { - $this->dm->getConfiguration()->setAssertSearchIndexExistsWhenAggregationResultsIsEmpty(false); + $this->dm->getConfiguration()->setAssertSearchIndexExistsForEmptyResult(false); $results = $this->dm->createAggregationBuilder(CmsArticle::class) ->search() @@ -149,4 +149,20 @@ public function testIndexNotCreatedWithoutException(): void $this->assertCount(0, $results->toArray()); } + + public function testIndexNotCreatedWithCustomStage(): void + { + $aggregation = ($builder = $this->dm->createAggregationBuilder(CmsArticle::class)) + ->addStage(new class ($builder) extends Stage { + public function getExpression(): array + { + return ['$search' => ['text' => ['query' => 'Atlas Search', 'path' => 'text']]]; + } + })->getAggregation(); + + $this->expectException(SchemaException::class); + $this->expectExceptionMessageMatches('#^The search index "default" of the collection "[^."]+\.CmsArticle" is not found\.$#'); + + $aggregation->execute(); + } } From 3edbc2385fdbed35da57745102dfe81f9b74c9f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Oct 2025 17:33:37 +0200 Subject: [PATCH 3/3] Check Iterator::key() is more reliable than current() --- lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php index dccdf745ad..4169f237a2 100644 --- a/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php +++ b/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php @@ -88,7 +88,7 @@ private function prepareIterator(CursorInterface $cursor): Iterator private function assertSearchIndexExistsForEmptyResult(CachingIterator|UnrewindableIterator $iterator): void { // The iterator is always rewinded - if ($iterator->current()) { + if ($iterator->key() !== null) { return; // Results not empty } @@ -104,8 +104,7 @@ private function assertSearchIndexExistsForEmptyResult(CachingIterator|Unrewinda // @phpcs:ignore SlevomatCodingStandard.PHP.UselessParentheses $indexName = ((object) current($stage))->index ?? 'default'; - $index = $this->collection->listSearchIndexes(['filter' => ['name' => $indexName]])->current(); - if ($index) { + if ($this->collection->listSearchIndexes(['filter' => ['name' => $indexName]])->key() !== null) { return; // Index exists }