Skip to content

Commit c530880

Browse files
committed
Add an assertion on missing search index when a $search or $vectorSearch pipeline returns an empty result
1 parent f22d8ec commit c530880

File tree

4 files changed

+101
-1
lines changed

4 files changed

+101
-1
lines changed

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
use Doctrine\ODM\MongoDB\Iterator\Iterator;
1212
use Doctrine\ODM\MongoDB\Iterator\UnrewindableIterator;
1313
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
14+
use Doctrine\ODM\MongoDB\SchemaException;
1415
use MongoDB\Collection;
1516
use MongoDB\Driver\CursorInterface;
1617

18+
use function array_key_first;
1719
use function array_merge;
1820

1921
/** @phpstan-import-type PipelineExpression from Builder */
@@ -64,6 +66,48 @@ private function prepareIterator(CursorInterface $cursor): Iterator
6466
$cursor = new HydratingIterator($cursor, $this->dm->getUnitOfWork(), $this->classMetadata);
6567
}
6668

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

lib/Doctrine/ODM/MongoDB/Configuration.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,23 @@ 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 made when the pipeline doesn't return any results to check if the
816+
* search index exists and is queryable. If the index does not exist or is
817+
* not queryable, an exception is thrown. This feature is enabled by default.
818+
*/
819+
public function setAssertSearchIndexExistsWhenAggregationResultsIsEmpty(bool $enabled): void
820+
{
821+
$this->attributes['assertSearchIndexExistsWhenAggregationResultsIsEmpty'] = $enabled;
822+
}
823+
824+
public function assertSearchIndexExistsWhenAggregationResultsIsEmpty(): bool
825+
{
826+
return $this->attributes['assertSearchIndexExistsWhenAggregationResultsIsEmpty'] ?? true;
827+
}
811828
}
812829

813830
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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

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

7+
use Doctrine\ODM\MongoDB\SchemaException;
78
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
89
use Documents\CmsArticle;
910
use Documents\CmsUser;
@@ -54,6 +55,7 @@ public function testAtlasSearch(): void
5455
$schemaManager->waitForSearchIndexes([CmsArticle::class, CmsUser::class]);
5556

5657
$results = $this->dm->createAggregationBuilder(CmsArticle::class)
58+
->rewindable(false)
5759
->search()
5860
->index('search_articles')
5961
->autocomplete()
@@ -116,4 +118,35 @@ 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()->setAssertSearchIndexExistsWhenAggregationResultsIsEmpty(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+
}
119152
}

0 commit comments

Comments
 (0)