Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion lib/Doctrine/ODM/MongoDB/Aggregation/Aggregation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@

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;
use Doctrine\ODM\MongoDB\Iterator\IterableResult;
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
Expand Down Expand Up @@ -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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noted that both CachingIterator and UnrewindableIterator rewind the inner iterator upon construction, so it's kosher to immediately call current() in the assertion function; however, you should probably make a note of this.

I think the best way to capture this would be to use union types on the assertion method's signature and add a docblock there that notes the point above.

Otherwise, we could not be certain that an arbitrary Iterator instance would be rewound already and capable of accessing current().


$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<mixed>|UnrewindableIterator<mixed> $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);
}
}
18 changes: 18 additions & 0 deletions lib/Doctrine/ODM/MongoDB/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
6 changes: 6 additions & 0 deletions lib/Doctrine/ODM/MongoDB/SchemaException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
49 changes: 49 additions & 0 deletions tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
}