Skip to content

Commit 3acd6da

Browse files
authored
Add an assertion on missing search index when a $search or $vectorSearch pipeline returns an empty result (#2841)
1 parent 0529a48 commit 3acd6da

File tree

4 files changed

+118
-1
lines changed

4 files changed

+118
-1
lines changed

lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,22 @@
44

55
namespace Doctrine\ODM\MongoDB\Aggregation;
66

7+
use Doctrine\ODM\MongoDB\Configuration;
78
use Doctrine\ODM\MongoDB\DocumentManager;
89
use Doctrine\ODM\MongoDB\Iterator\CachingIterator;
910
use Doctrine\ODM\MongoDB\Iterator\HydratingIterator;
1011
use Doctrine\ODM\MongoDB\Iterator\IterableResult;
1112
use Doctrine\ODM\MongoDB\Iterator\Iterator;
1213
use Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator;
1314
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
15+
use Doctrine\ODM\MongoDB\SchemaException;
1416
use MongoDB\Collection;
1517
use MongoDB\Driver\CursorInterface;
1618

1719
use function array_merge;
20+
use function current;
21+
use function in_array;
22+
use function key;
1823

1924
/** @phpstan-import-type PipelineExpression from Builder */
2025
final class Aggregation implements IterableResult
@@ -64,6 +69,45 @@ private function prepareIterator(CursorInterface $cursor): Iterator
6469
$cursor = new HydratingIterator($cursor, $this->dm->getUnitOfWork(), $this->classMetadata);
6570
}
6671

67-
return $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor);
72+
$iterator = $this->rewindable ? new CachingIterator($cursor) : new UnrewindableIterator($cursor);
73+
74+
$this->assertSearchIndexExistsForEmptyResult($iterator);
75+
76+
return $iterator;
77+
}
78+
79+
/**
80+
* If the server implements a server-side error for missing search indexes,
81+
* this assertion can be removed.
82+
*
83+
* @see https://jira.mongodb.org/browse/SERVER-110974
84+
* @see Configuration::setAssertSearchIndexExistsForEmptyResult()
85+
*
86+
* @param CachingIterator<mixed>|UnrewindableIterator<mixed> $iterator
87+
*/
88+
private function assertSearchIndexExistsForEmptyResult(CachingIterator|UnrewindableIterator $iterator): void
89+
{
90+
// The iterator is always rewinded
91+
if ($iterator->key() !== null) {
92+
return; // Results not empty
93+
}
94+
95+
if (! $this->dm->getConfiguration()->assertSearchIndexExistsForEmptyResult()) {
96+
return; // Feature disabled
97+
}
98+
99+
// Search stages must be the first stage in the pipeline
100+
$stage = $this->pipeline[0] ?? null;
101+
if (! $stage || ! in_array(key($stage), ['$search', '$searchMeta', '$vectorSearch'], true)) {
102+
return; // Not a search aggregation
103+
}
104+
105+
// @phpcs:ignore SlevomatCodingStandard.PHP.UselessParentheses
106+
$indexName = ((object) current($stage))->index ?? 'default';
107+
if ($this->collection->listSearchIndexes(['filter' => ['name' => $indexName]])->key() !== null) {
108+
return; // Index exists
109+
}
110+
111+
throw SchemaException::searchIndexNotFound($this->collection->getNamespace(), $indexName);
68112
}
69113
}

lib/Doctrine/ODM/MongoDB/Configuration.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,24 @@ private function getAutoEncryptionOptions(): array
808808
...$this->attributes['autoEncryption'] ?? [],
809809
];
810810
}
811+
812+
/**
813+
* Pipelines using a search index that does not exist or is not queryable
814+
* will return zero documents. By enabling this feature, an additional query
815+
* is performed when the pipeline doesn't return any results to check if the
816+
* search index exists. If the index does not exist, an exception is thrown.
817+
* This feature is enabled by default.
818+
* This applies to $search, $searchMeta and $vectorSearch pipelines.
819+
*/
820+
public function setAssertSearchIndexExistsForEmptyResult(bool $enabled): void
821+
{
822+
$this->attributes['assertSearchIndexExistsForEmptyResult'] = $enabled;
823+
}
824+
825+
public function assertSearchIndexExistsForEmptyResult(): bool
826+
{
827+
return $this->attributes['assertSearchIndexExistsForEmptyResult'] ?? true;
828+
}
811829
}
812830

813831
interface_exists(MappingDriver::class);

lib/Doctrine/ODM/MongoDB/SchemaException.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,10 @@ public static function missingSearchIndex(string $documentClass, array $missingI
2323
{
2424
return new self(sprintf('The document class "%s" is missing the following search index(es): "%s"', $documentClass, implode('", "', $missingIndexes)));
2525
}
26+
27+
/** @internal */
28+
public static function searchIndexNotFound(string $namespace, string $indexName): self
29+
{
30+
return new self(sprintf('The search index "%s" of the collection "%s" is not found.', $indexName, $namespace));
31+
}
2632
}

tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
namespace Doctrine\ODM\MongoDB\Tests\Functional;
66

7+
use Doctrine\ODM\MongoDB\Aggregation\Stage;
8+
use Doctrine\ODM\MongoDB\SchemaException;
79
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
810
use Documents\CmsArticle;
911
use Documents\CmsUser;
@@ -116,4 +118,51 @@ public function testAtlasSearch(): void
116118

117119
$this->assertNotEmpty($results, 'Count search should return results');
118120
}
121+
122+
public function testIndexNotCreated(): void
123+
{
124+
$aggregation = $this->dm->createAggregationBuilder(CmsArticle::class)
125+
->search()
126+
->index('search_articles')
127+
->text()
128+
->query('Atlas Search')
129+
->path('text')
130+
->getAggregation();
131+
132+
$this->expectException(SchemaException::class);
133+
$this->expectExceptionMessageMatches('#^The search index "search_articles" of the collection "[^."]+\.CmsArticle" is not found\.$#');
134+
135+
$aggregation->execute();
136+
}
137+
138+
public function testIndexNotCreatedWithoutException(): void
139+
{
140+
$this->dm->getConfiguration()->setAssertSearchIndexExistsForEmptyResult(false);
141+
142+
$results = $this->dm->createAggregationBuilder(CmsArticle::class)
143+
->search()
144+
->index('search_articles')
145+
->text()
146+
->query('Atlas Search')
147+
->path('text')
148+
->getAggregation()->execute();
149+
150+
$this->assertCount(0, $results->toArray());
151+
}
152+
153+
public function testIndexNotCreatedWithCustomStage(): void
154+
{
155+
$aggregation = ($builder = $this->dm->createAggregationBuilder(CmsArticle::class))
156+
->addStage(new class ($builder) extends Stage {
157+
public function getExpression(): array
158+
{
159+
return ['$search' => ['text' => ['query' => 'Atlas Search', 'path' => 'text']]];
160+
}
161+
})->getAggregation();
162+
163+
$this->expectException(SchemaException::class);
164+
$this->expectExceptionMessageMatches('#^The search index "default" of the collection "[^."]+\.CmsArticle" is not found\.$#');
165+
166+
$aggregation->execute();
167+
}
119168
}

0 commit comments

Comments
 (0)