diff --git a/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php b/lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php index 013e7e1bd..4169f237a 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; @@ -11,10 +12,14 @@ 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_merge; +use function current; +use function in_array; +use function key; /** @phpstan-import-type PipelineExpression from Builder */ final class Aggregation implements IterableResult @@ -64,6 +69,45 @@ 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->assertSearchIndexExistsForEmptyResult($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 + * @see Configuration::setAssertSearchIndexExistsForEmptyResult() + * + * @param CachingIterator|UnrewindableIterator $iterator + */ + private function assertSearchIndexExistsForEmptyResult(CachingIterator|UnrewindableIterator $iterator): void + { + // The iterator is always rewinded + if ($iterator->key() !== null) { + return; // Results not empty + } + + if (! $this->dm->getConfiguration()->assertSearchIndexExistsForEmptyResult()) { + return; // Feature disabled + } + + // Search stages must be the first stage in the pipeline + $stage = $this->pipeline[0] ?? null; + if (! $stage || ! in_array(key($stage), ['$search', '$searchMeta', '$vectorSearch'], true)) { + return; // Not a search aggregation + } + + // @phpcs:ignore SlevomatCodingStandard.PHP.UselessParentheses + $indexName = ((object) current($stage))->index ?? 'default'; + if ($this->collection->listSearchIndexes(['filter' => ['name' => $indexName]])->key() !== null) { + 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 8ea5c844c..6934bd60f 100644 --- a/lib/Doctrine/ODM/MongoDB/Configuration.php +++ b/lib/Doctrine/ODM/MongoDB/Configuration.php @@ -808,6 +808,24 @@ 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 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 setAssertSearchIndexExistsForEmptyResult(bool $enabled): void + { + $this->attributes['assertSearchIndexExistsForEmptyResult'] = $enabled; + } + + public function assertSearchIndexExistsForEmptyResult(): bool + { + return $this->attributes['assertSearchIndexExistsForEmptyResult'] ?? true; + } } interface_exists(MappingDriver::class); diff --git a/lib/Doctrine/ODM/MongoDB/SchemaException.php b/lib/Doctrine/ODM/MongoDB/SchemaException.php index 048090030..50059989f 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 bcb28e548..e1cafe14f 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php @@ -4,6 +4,8 @@ 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; use Documents\CmsUser; @@ -116,4 +118,51 @@ 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()->setAssertSearchIndexExistsForEmptyResult(false); + + $results = $this->dm->createAggregationBuilder(CmsArticle::class) + ->search() + ->index('search_articles') + ->text() + ->query('Atlas Search') + ->path('text') + ->getAggregation()->execute(); + + $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(); + } }