Skip to content
Merged
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
26 changes: 26 additions & 0 deletions lib/Doctrine/ODM/MongoDB/SchemaException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB;

use RuntimeException;

use function implode;
use function sprintf;

/**
* Exception for schema-related issues.
*/
final class SchemaException extends RuntimeException
{
/**
* @internal
*
* @param string[] $missingIndexes
*/
public static function missingSearchIndex(string $documentClass, array $missingIndexes): self
{
return new self(sprintf('The document class "%s" is missing the following search index(es): "%s"', $documentClass, implode('", "', $missingIndexes)));
}
}
64 changes: 63 additions & 1 deletion lib/Doctrine/ODM/MongoDB/SchemaManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@
use function array_diff_key;
use function array_filter;
use function array_keys;
use function array_map;
use function array_merge;
use function array_search;
use function array_unique;
use function array_values;
use function assert;
use function count;
use function hrtime;
use function implode;
use function in_array;
use function is_array;
Expand All @@ -35,6 +37,7 @@
use function ksort;
use function sprintf;
use function str_contains;
use function usleep;

/**
* @phpstan-import-type IndexMapping from ClassMetadata
Expand Down Expand Up @@ -337,6 +340,65 @@ public function createSearchIndexes(): void
}
}

/**
* Wait until all search indexes are queryable for the given document classes.
*
* @param list<class-string>|null $classNames List of class names to check, or null to check all mapped classes
* @param positive-int $maxTimeMs Maximum time to wait in milliseconds (default: 10,000 ms)
* @param positive-int $waitTimeMs Time to wait between checks in milliseconds (default: 100 ms)
*/
public function waitForSearchIndexes(?array $classNames = null, int $maxTimeMs = 10_000, int $waitTimeMs = 100): void
{
if ($maxTimeMs < 1) {
throw new InvalidArgumentException('$maxTimeMs must be a positive number of milliseconds.');
}

if ($waitTimeMs < 1) {
throw new InvalidArgumentException('$waitTimeMs must be a positive number of milliseconds.');
}

$classes = $classNames === null ? $this->metadataFactory->getAllMetadata() : array_map($this->metadataFactory->getMetadataFor(...), $classNames);

/** @var array<class-string, string[]> $indexesToCheck Search indexes for each class */
$indexesToCheck = [];
foreach ($classes as $class) {
if (! $class->hasSearchIndexes()) {
continue;
}

$indexesToCheck[$class->getName()] = array_column($class->getSearchIndexes(), 'name');
}

$start = hrtime(true);
while ($indexesToCheck) {
if (hrtime(true) > $start + $maxTimeMs * 1_000_000) {
throw new MongoDBException(sprintf('Timed out waiting for search indexes to become queryable after %d ms. Search indexes are not ready for the following class(es): %s', $maxTimeMs, implode(', ', array_keys($indexesToCheck))));
}

foreach ($indexesToCheck as $className => $indexNames) {
$collection = $this->dm->getDocumentCollection($className);

/** @var array<string, bool> $indexStatus Queryable status for each index name */
$indexStatus = array_column(iterator_to_array($collection->listSearchIndexes([
Copy link
Member

Choose a reason for hiding this comment

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

What a marvelous function array_column() is. Thank you, @ramsey.

'filter' => ['name' => ['$in' => array_keys($indexNames)]],
'typeMap' => ['root' => 'array'],
])), 'queryable', 'name');

// Check that all indexes exist
$missingIndexes = array_diff_key($indexNames, array_keys($indexStatus));
if ($missingIndexes) {
throw SchemaException::missingSearchIndex($className, $missingIndexes);
}

// Remove the indexes that are ready from the list of indexes to check
$indexesToCheck[$className] = array_keys(array_filter($indexStatus, static fn ($queryable) => ! $queryable));
}

// Remove empty arrays and wait before checking again
($indexesToCheck = array_filter($indexesToCheck)) && usleep($waitTimeMs * 1_000);
}
}

/**
* Create search indexes for the given document class.
*
Expand Down Expand Up @@ -368,7 +430,7 @@ public function createDocumentSearchIndexes(string $documentName): void
$unprocessedNames = array_diff($definedNames, $createdNames);

if (! empty($unprocessedNames)) {
throw new InvalidArgumentException(sprintf('The following search indexes for %s were not created: %s', $class->name, implode(', ', $unprocessedNames)));
throw SchemaException::missingSearchIndex($class->name, $unprocessedNames);
}
}

Expand Down
30 changes: 0 additions & 30 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -384,18 +384,6 @@ parameters:
count: 1
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata has type alias SearchIndexDefinition with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 2
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Class Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata has type alias SearchIndexMapping with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 2
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Instanceof between Doctrine\\Persistence\\Reflection\\RuntimeReflectionProperty and ReflectionProperty will always evaluate to true\.$#'
identifier: instanceof.alwaysTrue
Expand All @@ -408,24 +396,12 @@ parameters:
count: 1
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:addSearchIndex\(\) has parameter \$definition with no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 2
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:getBucketName\(\) never returns null so it can be removed from the return type\.$#'
identifier: return.unusedType
count: 1
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:getSearchIndexes\(\) return type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 2
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Method Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:mapField\(\) should return array\{type\: string, fieldName\: string, name\: string, isCascadeRemove\: bool, isCascadePersist\: bool, isCascadeRefresh\: bool, isCascadeMerge\: bool, isCascadeDetach\: bool, \.\.\.\} but returns array\{type\?\: string, fieldName\?\: string, name\?\: string, strategy\?\: string, association\?\: int, id\?\: bool, isOwningSide\?\: bool, collectionClass\?\: class\-string, \.\.\.\}\.$#'
identifier: return.type
Expand Down Expand Up @@ -456,12 +432,6 @@ parameters:
count: 1
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Property Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:\$searchIndexes type has no value type specified in iterable type array\.$#'
identifier: missingType.iterableValue
count: 2
path: lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php

-
message: '#^Template type T is declared as covariant, but occurs in invariant position in property Doctrine\\ODM\\MongoDB\\Mapping\\ClassMetadata\:\:\$name\.$#'
identifier: generics.variance
Expand Down
13 changes: 8 additions & 5 deletions tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,16 @@
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
use Documents\CmsArticle;
use Documents\CmsUser;
use MongoDB\Driver\WriteConcern;
use PHPUnit\Framework\Attributes\Group;

use function sleep;

#[Group('atlas')]
class AtlasSearchTest extends BaseTestCase
{
public function testAtlasSearch(): void
{
$schemaManager = $this->dm->getSchemaManager();
$schemaManager->createDocumentCollection(CmsArticle::class);
$schemaManager->createDocumentSearchIndexes(CmsArticle::class);

$user = new CmsUser();
$user->status = 'active';
Expand All @@ -45,10 +43,15 @@ public function testAtlasSearch(): void
$this->dm->persist($article1);
$this->dm->persist($article2);
$this->dm->persist($article3);
$this->dm->flush();

// Write with majority concern to ensure data is visible for search
$this->dm->flush(['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)]);
Copy link
Member

Choose a reason for hiding this comment

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

FWIW, I asked about this in #atlas-search as I couldn't find this discussed in the Atlas Search docs. In any event, no action needed on your part here.


// Index must be created after data insertion, so the index status is not immediately "READY"
$schemaManager->createDocumentSearchIndexes(CmsArticle::class);

// Wait for the search index to be ready (Atlas Local needs time to build the index)
sleep(2);
$schemaManager->waitForSearchIndexes([CmsArticle::class, CmsUser::class]);

$results = $this->dm->createAggregationBuilder(CmsArticle::class)
->search()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Doctrine\ODM\MongoDB\Tests\Functional;

use Doctrine\ODM\MongoDB\MongoDBException;
use Doctrine\ODM\MongoDB\SchemaException;
use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
use Documents\CmsArticle;
use MongoDB\Driver\BulkWrite;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\TestWith;

use function hrtime;

#[Group('atlas')]
class SchemaManagerWaitForSearchIndexesTest extends BaseTestCase
{
#[TestWith([0])]
#[TestWith([50_000])]
public function testWait(int $nbDocuments): void
{
$schemaManager = $this->dm->getSchemaManager();
$collection = $this->dm->getDocumentCollection(CmsArticle::class);

$schemaManager->createDocumentCollection(CmsArticle::class);

if ($nbDocuments) {
$bulk = new BulkWrite();
for ($i = 0; $i < $nbDocuments; $i++) {
$bulk->insert(['topic' => 'topic ' . $i, 'title' => 'title ' . $i, 'text' => 'text ' . $i]);
}

$collection->getManager()->executeBulkWrite($collection->getNamespace(), $bulk);
}

// The index must be created after data insertion, so the index status is not immediately "READY"
$schemaManager->createDocumentSearchIndexes(CmsArticle::class);

$this->assertNotSame('READY', $collection->listSearchIndexes(['name' => 'search_articles'])->current()['status']);

$start = hrtime(true);
$schemaManager->waitForSearchIndexes([CmsArticle::class]);
$timeMs = (hrtime(true) - $start) / 1_000_000;

$this->assertSame($nbDocuments, $collection->aggregate([
[
'$searchMeta' => [
'index' => 'search_articles',
'exists' => ['path' => '_id'],
'count' => ['type' => 'total'],
],
],
])->toArray()[0]['count']['total'], 'All documents are indexed');

$this->assertSame('READY', $collection->listSearchIndexes(['name' => 'search_articles'])->current()['status'], 'Ready after ' . $timeMs . ' ms');
}

public function testErrors(): void
{
$schemaManager = $this->dm->getSchemaManager();

// Search index missing
try {
$schemaManager->waitForSearchIndexes([CmsArticle::class]);
$this->fail('Expected SchemaException not thrown');
} catch (SchemaException $exception) {
$this->assertSame('The document class "Documents\CmsArticle" is missing the following search index(es): "search_articles"', $exception->getMessage());
}

$schemaManager->createDocumentCollection(CmsArticle::class);
$schemaManager->createDocumentSearchIndexes(CmsArticle::class);

// Timeout too short
try {
$schemaManager->waitForSearchIndexes([CmsArticle::class], 1);
$this->fail('Expected SchemaException not thrown');
} catch (MongoDBException $exception) {
$this->assertSame('Timed out waiting for search indexes to become queryable after 1 ms. Search indexes are not ready for the following class(es): Documents\CmsArticle', $exception->getMessage());
}

// Not specifying classes waits for all
try {
$schemaManager->waitForSearchIndexes();
$this->fail('Expected SchemaException not thrown');
} catch (SchemaException $exception) {
// The missing class varies depending on the test execution order,
// classes are added to the ClassMetadataFactory in the order they are used
$this->assertMatchesRegularExpression('#The document class "Documents\\\\(CmsAddress|VectorEmbedding)" is missing the following search index\(es\): "default"#', $exception->getMessage());
}

// Remove the collection
$schemaManager->dropDocumentCollection(CmsArticle::class);

try {
$schemaManager->waitForSearchIndexes([CmsArticle::class]);
$this->fail('Expected SchemaException not thrown');
} catch (SchemaException $exception) {
$this->assertSame('The document class "Documents\CmsArticle" is missing the following search index(es): "search_articles"', $exception->getMessage());
}
}
}
12 changes: 7 additions & 5 deletions tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@

use Doctrine\ODM\MongoDB\Tests\BaseTestCase;
use Documents\VectorEmbedding;
use MongoDB\Driver\WriteConcern;
use PHPUnit\Framework\Attributes\Group;

use function sleep;

#[Group('atlas')]
class VectorSearchTest extends BaseTestCase
{
Expand All @@ -20,7 +19,6 @@ public function testAtlasVectorSearch(): void

// Create the collection and vector search indexes
$schemaManager->createDocumentCollection(VectorEmbedding::class);
$schemaManager->createDocumentSearchIndexes(VectorEmbedding::class);

// Insert some test documents with vector embeddings
$doc1 = new VectorEmbedding();
Expand All @@ -41,10 +39,14 @@ public function testAtlasVectorSearch(): void
$this->dm->persist($doc1);
$this->dm->persist($doc2);
$this->dm->persist($doc3);
$this->dm->flush();
// Write with majority concern to ensure data is visible for search
$this->dm->flush(['writeConcern' => new WriteConcern(WriteConcern::MAJORITY)]);

// Index must be created after data insertion, so the index status is not immediately "READY"
$schemaManager->createDocumentSearchIndexes(VectorEmbedding::class);

// Wait for search index to be ready (Atlas Local needs time to build the index)
sleep(2);
$schemaManager->waitForSearchIndexes([VectorEmbedding::class]);

$results = $this->dm->createAggregationBuilder(VectorEmbedding::class)
->vectorSearch()
Expand Down
7 changes: 4 additions & 3 deletions tests/Doctrine/ODM/MongoDB/Tests/SchemaManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Doctrine\ODM\MongoDB\DocumentManager;
use Doctrine\ODM\MongoDB\Mapping\ClassMetadata;
use Doctrine\ODM\MongoDB\Mapping\TimeSeries\Granularity;
use Doctrine\ODM\MongoDB\SchemaException;
use Doctrine\ODM\MongoDB\SchemaManager;
use Documents\BaseDocument;
use Documents\CmsAddress;
Expand Down Expand Up @@ -418,8 +419,8 @@ public function testCreateDocumentSearchIndexesNotCreatedError(): void
->with($this->anything())
->willReturn(['foo']);

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The following search indexes for Documents\CmsArticle were not created: search_articles');
$this->expectException(SchemaException::class);
$this->expectExceptionMessage('The document class "Documents\CmsArticle" is missing the following search index(es): "search_articles"');

$this->schemaManager->createDocumentSearchIndexes(CmsArticle::class);
}
Expand Down Expand Up @@ -486,9 +487,9 @@ public function testCreateVectorSearchIndex(): void
'name' => 'vector_int',
'definition' => [
'fields' => [
['type' => 'vector', 'path' => 'vectorInt', 'numDimensions' => 3, 'similarity' => 'cosine'],
['type' => 'filter', 'path' => 'filterField'],
['type' => 'filter', 'path' => 'not_mapped_filter'],
['type' => 'vector', 'path' => 'vectorInt', 'numDimensions' => 3, 'similarity' => 'cosine'],
],
],
],
Expand Down
Loading