diff --git a/psalm-baseline.xml b/psalm-baseline.xml index e6b0a3436..84b6373ad 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -28,6 +28,11 @@ ($value is NativeType ? BSONType : $value) + + + $options + + $cmd[$option] @@ -309,6 +314,14 @@ isInTransaction + + + (array) $index + + + $cmd[$option] + + options['typeMap']]]> @@ -389,6 +402,11 @@ isInTransaction + + + $cmd[$option] + + options['typeMap']]]> @@ -537,6 +555,11 @@ isInTransaction + + + $cmd[$option] + + cursor->firstBatch]]> diff --git a/src/Collection.php b/src/Collection.php index 801511951..4b552e385 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -17,8 +17,11 @@ namespace MongoDB; +use Countable; +use Iterator; use MongoDB\BSON\JavascriptInterface; use MongoDB\Driver\Cursor; +use MongoDB\Driver\Exception\CommandException; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadConcern; @@ -36,12 +39,14 @@ use MongoDB\Operation\Count; use MongoDB\Operation\CountDocuments; use MongoDB\Operation\CreateIndexes; +use MongoDB\Operation\CreateSearchIndexes; use MongoDB\Operation\DeleteMany; use MongoDB\Operation\DeleteOne; use MongoDB\Operation\Distinct; use MongoDB\Operation\DropCollection; use MongoDB\Operation\DropEncryptedCollection; use MongoDB\Operation\DropIndexes; +use MongoDB\Operation\DropSearchIndex; use MongoDB\Operation\EstimatedDocumentCount; use MongoDB\Operation\Explain; use MongoDB\Operation\Explainable; @@ -53,11 +58,13 @@ use MongoDB\Operation\InsertMany; use MongoDB\Operation\InsertOne; use MongoDB\Operation\ListIndexes; +use MongoDB\Operation\ListSearchIndexes; use MongoDB\Operation\MapReduce; use MongoDB\Operation\RenameCollection; use MongoDB\Operation\ReplaceOne; use MongoDB\Operation\UpdateMany; use MongoDB\Operation\UpdateOne; +use MongoDB\Operation\UpdateSearchIndex; use MongoDB\Operation\Watch; use function array_diff_key; @@ -377,6 +384,64 @@ public function createIndexes(array $indexes, array $options = []) return $operation->execute($server); } + /** + * Create an Atlas Search index for the collection. + * Only available when used against a 7.0+ Atlas cluster. + * + * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/ + * @see https://mongodb.com/docs/manual/reference/method/db.collection.createSearchIndex/ + * @param array|object $definition Atlas Search index mapping definition + * @param array{name?: string, comment?: mixed} $options Command options + * @return string The name of the created search index + * @throws UnsupportedException if options are not supported by the selected server + * @throws InvalidArgumentException for parameter/option parsing errors + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function createSearchIndex($definition, array $options = []): string + { + $index = ['definition' => $definition]; + if (isset($options['name'])) { + $index['name'] = $options['name']; + unset($options['name']); + } + + $names = $this->createSearchIndexes([$index], $options); + + return current($names); + } + + /** + * Create one or more Atlas Search indexes for the collection. + * Only available when used against a 7.0+ Atlas cluster. + * + * Each element in the $indexes array must have "definition" document and they may have a "name" string. + * The name can be omitted for a single index, in which case a name will be the default. + * For example: + * + * $indexes = [ + * // Create a search index with the default name, on + * ['definition' => ['mappings' => ['dynamic' => false, 'fields' => ['title' => ['type' => 'string']]]]], + * // Create a named search index on all fields + * ['name' => 'search_all', 'definition' => ['mappings' => ['dynamic' => true]]], + * ]; + * + * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/ + * @see https://mongodb.com/docs/manual/reference/method/db.collection.createSearchIndex/ + * @param list $indexes List of search index specifications + * @param array{comment?: string} $options Command options + * @return string[] The names of the created search indexes + * @throws UnsupportedException if options are not supported by the selected server + * @throws InvalidArgumentException for parameter/option parsing errors + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function createSearchIndexes(array $indexes, array $options = []): array + { + $operation = new CreateSearchIndexes($this->databaseName, $this->collectionName, $indexes, $options); + $server = select_server($this->manager, $options); + + return $operation->execute($server); + } + /** * Deletes all documents matching the filter. * @@ -554,6 +619,31 @@ public function dropIndexes(array $options = []) return $operation->execute($server); } + /** + * Drop a single Atlas Search index in the collection. + * Only available when used against a 7.0+ Atlas cluster. + * + * @param string $name Search index name + * @param array{comment?: mixed} $options Additional options + * @throws UnsupportedException if options are not supported by the selected server + * @throws InvalidArgumentException for parameter/option parsing errors + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function dropSearchIndex(string $name, array $options = []): void + { + $operation = new DropSearchIndex($this->databaseName, $this->collectionName, $name); + $server = select_server($this->manager, $options); + + try { + $operation->execute($server); + } catch (CommandException $e) { + // Suppress namespace not found errors for idempotency + if ($e->getCode() !== 26) { + throw $e; + } + } + } + /** * Gets an estimated number of documents in the collection using the collection metadata. * @@ -928,6 +1018,24 @@ public function listIndexes(array $options = []) return $operation->execute($server); } + /** + * Returns information for all Atlas Search indexes for the collection. + * Only available when used against a 7.0+ Atlas cluster. + * + * @param array{name?: string} $options Command options + * @return Countable&Iterator + * @throws InvalidArgumentException for parameter/option parsing errors + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + * @see ListSearchIndexes::__construct() for supported options + */ + public function listSearchIndexes(array $options = []): Iterator + { + $operation = new ListSearchIndexes($this->databaseName, $this->collectionName, $options); + $server = select_server($this->manager, $options); + + return $operation->execute($server); + } + /** * Executes a map-reduce aggregation on the collection. * @@ -1088,6 +1196,25 @@ public function updateOne($filter, $update, array $options = []) return $operation->execute($server); } + /** + * Update a single Atlas Search index in the collection. + * Only available when used against a 7.0+ Atlas cluster. + * + * @param string $name Search index name + * @param array|object $definition Atlas Search index definition + * @param array{comment?: mixed} $options Command options + * @throws UnsupportedException if options are not supported by the selected server + * @throws InvalidArgumentException for parameter parsing errors + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function updateSearchIndex(string $name, $definition, array $options = []): void + { + $operation = new UpdateSearchIndex($this->databaseName, $this->collectionName, $name, $definition, $options); + $server = select_server($this->manager, $options); + + $operation->execute($server); + } + /** * Create a change stream for watching changes to the collection. * diff --git a/src/Model/IndexInput.php b/src/Model/IndexInput.php index 1c089db9e..4a21bc6c5 100644 --- a/src/Model/IndexInput.php +++ b/src/Model/IndexInput.php @@ -86,9 +86,9 @@ public function __toString(): string * @see \MongoDB\Collection::createIndexes() * @see https://php.net/mongodb-bson-serializable.bsonserialize */ - public function bsonSerialize(): array + public function bsonSerialize(): object { - return $this->index; + return (object) $this->index; } /** diff --git a/src/Model/SearchIndexInput.php b/src/Model/SearchIndexInput.php new file mode 100644 index 000000000..991159f6a --- /dev/null +++ b/src/Model/SearchIndexInput.php @@ -0,0 +1,73 @@ +index = $index; + } + + /** + * Serialize the search index information to BSON for search index creation. + * + * @see \MongoDB\Collection::createSearchIndexes() + * @see https://php.net/mongodb-bson-serializable.bsonserialize + */ + public function bsonSerialize(): object + { + return (object) $this->index; + } +} diff --git a/src/Operation/CreateSearchIndexes.php b/src/Operation/CreateSearchIndexes.php new file mode 100644 index 000000000..00f1ea6cb --- /dev/null +++ b/src/Operation/CreateSearchIndexes.php @@ -0,0 +1,103 @@ + $indexes List of search index specifications + * @param array{comment?: mixed} $options Command options + * @throws InvalidArgumentException for parameter parsing errors + */ + public function __construct(string $databaseName, string $collectionName, array $indexes, array $options) + { + if (! array_is_list($indexes)) { + throw new InvalidArgumentException('$indexes is not a list'); + } + + foreach ($indexes as $i => $index) { + if (! is_document($index)) { + throw InvalidArgumentException::expectedDocumentType(sprintf('$indexes[%d]', $i), $index); + } + + $this->indexes[] = new SearchIndexInput((array) $index); + } + + $this->databaseName = $databaseName; + $this->collectionName = $collectionName; + $this->options = $options; + } + + /** + * Execute the operation. + * + * @see Executable::execute() + * @return string[] The names of the created indexes + * @throws UnsupportedException if write concern is used and unsupported + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function execute(Server $server): array + { + $cmd = [ + 'createSearchIndexes' => $this->collectionName, + 'indexes' => $this->indexes, + ]; + + foreach (['comment'] as $option) { + if (isset($this->options[$option])) { + $cmd[$option] = $this->options[$option]; + } + } + + $cursor = $server->executeCommand($this->databaseName, new Command($cmd)); + + /** @var object{indexesCreated: list} $result */ + $result = current($cursor->toArray()); + + return array_column($result->indexesCreated, 'name'); + } +} diff --git a/src/Operation/DropIndexes.php b/src/Operation/DropIndexes.php index 66ded5712..0bbd08247 100644 --- a/src/Operation/DropIndexes.php +++ b/src/Operation/DropIndexes.php @@ -72,8 +72,6 @@ class DropIndexes implements Executable */ public function __construct(string $databaseName, string $collectionName, string $indexName, array $options = []) { - $indexName = $indexName; - if ($indexName === '') { throw new InvalidArgumentException('$indexName cannot be empty'); } diff --git a/src/Operation/DropSearchIndex.php b/src/Operation/DropSearchIndex.php new file mode 100644 index 000000000..3fad9b967 --- /dev/null +++ b/src/Operation/DropSearchIndex.php @@ -0,0 +1,82 @@ +databaseName = $databaseName; + $this->collectionName = $collectionName; + $this->name = $name; + $this->options = $options; + } + + /** + * Execute the operation. + * + * @see Executable::execute() + * @throws UnsupportedException if write concern is used and unsupported + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function execute(Server $server): void + { + $cmd = [ + 'dropSearchIndex' => $this->collectionName, + 'name' => $this->name, + ]; + + foreach (['comment'] as $option) { + if (isset($this->options[$option])) { + $cmd[$option] = $this->options[$option]; + } + } + + $server->executeCommand($this->databaseName, new Command($cmd)); + } +} diff --git a/src/Operation/ListSearchIndexes.php b/src/Operation/ListSearchIndexes.php new file mode 100644 index 000000000..792428159 --- /dev/null +++ b/src/Operation/ListSearchIndexes.php @@ -0,0 +1,95 @@ +databaseName = $databaseName; + $this->collectionName = $collectionName; + $this->listSearchIndexesOptions = array_intersect_key($options, ['name' => 1]); + $this->aggregateOptions = array_intersect_key($options, ['batchSize' => 1, 'collation' => 1, 'comment' => 1, 'maxTimeMS' => 1, 'readConcern' => 1, 'readPreference' => 1, 'session' => 1, 'typeMap' => 1]); + + $this->aggregate = $this->createAggregate(); + } + + /** + * Execute the operation. + * + * @return Iterator&Countable + * @see Executable::execute() + * @throws UnexpectedValueException if the command response was malformed + * @throws UnsupportedException if collation or read concern is used and unsupported + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function execute(Server $server): Iterator + { + $cursor = $this->aggregate->execute($server); + + return new CachingIterator($cursor); + } + + private function createAggregate(): Aggregate + { + $pipeline = [ + ['$listSearchIndexes' => (object) $this->listSearchIndexesOptions], + ]; + + return new Aggregate($this->databaseName, $this->collectionName, $pipeline, $this->aggregateOptions); + } +} diff --git a/src/Operation/UpdateSearchIndex.php b/src/Operation/UpdateSearchIndex.php new file mode 100644 index 000000000..fab1917e4 --- /dev/null +++ b/src/Operation/UpdateSearchIndex.php @@ -0,0 +1,93 @@ +databaseName = $databaseName; + $this->collectionName = $collectionName; + $this->name = $name; + $this->definition = document_to_array($definition); + $this->options = $options; + } + + /** + * Execute the operation. + * + * @see Executable::execute() + * @throws UnsupportedException if write concern is used and unsupported + * @throws DriverRuntimeException for other driver errors (e.g. connection errors) + */ + public function execute(Server $server): void + { + $cmd = [ + 'updateSearchIndex' => $this->collectionName, + 'name' => $this->name, + 'definition' => $this->definition, + ]; + + foreach (['comment'] as $option) { + if (isset($this->options[$option])) { + $cmd[$option] = $this->options[$option]; + } + } + + $server->executeCommand($this->databaseName, new Command($cmd)); + } +} diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 2a79e6b19..87e97ed3f 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -51,6 +51,8 @@ abstract class FunctionalTestCase extends TestCase { + private const ATLAS_TLD = '/\.(mongodb\.net|mongodb-dev\.net)/'; + protected Manager $manager; private array $configuredFailPoints = []; @@ -519,6 +521,11 @@ protected function skipIfTransactionsAreNotSupported(): void } } + public static function isAtlas(): bool + { + return preg_match(self::ATLAS_TLD, getenv('MONGODB_URI') ?: ''); + } + /** @see https://www.mongodb.com/docs/manual/core/queryable-encryption/reference/shared-library/ */ public static function isCryptSharedLibAvailable(): bool { diff --git a/tests/Model/IndexInputTest.php b/tests/Model/IndexInputTest.php index b9653afda..e9dad4f89 100644 --- a/tests/Model/IndexInputTest.php +++ b/tests/Model/IndexInputTest.php @@ -15,12 +15,14 @@ class IndexInputTest extends TestCase public function testConstructorShouldRequireKey(): void { $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required "key" document is missing from index specification'); new IndexInput([]); } public function testConstructorShouldRequireKeyToBeArrayOrObject(): void { $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "key" option to have type "document"'); new IndexInput(['key' => 'foo']); } @@ -28,6 +30,7 @@ public function testConstructorShouldRequireKeyToBeArrayOrObject(): void public function testConstructorShouldRequireKeyFieldOrderToBeNumericOrString($order): void { $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected order value for "x" field within "key" option to have type "numeric or string"'); new IndexInput(['key' => ['x' => $order]]); } @@ -39,6 +42,7 @@ public function provideInvalidFieldOrderValues() public function testConstructorShouldRequireNameToBeString(): void { $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "name" option to have type "string"'); new IndexInput(['key' => ['x' => 1], 'name' => 1]); } @@ -67,7 +71,7 @@ public function provideExpectedNameAndKey(): array public function testBsonSerialization(): void { - $expected = [ + $expected = (object) [ 'key' => ['x' => 1], 'unique' => true, 'name' => 'x_1', @@ -79,6 +83,6 @@ public function testBsonSerialization(): void ]); $this->assertInstanceOf(Serializable::class, $indexInput); - $this->assertSame($expected, $indexInput->bsonSerialize()); + $this->assertEquals($expected, $indexInput->bsonSerialize()); } } diff --git a/tests/Model/SearchIndexInputTest.php b/tests/Model/SearchIndexInputTest.php new file mode 100644 index 000000000..3c6fbe142 --- /dev/null +++ b/tests/Model/SearchIndexInputTest.php @@ -0,0 +1,48 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required "definition" document is missing from search index specification'); + new SearchIndexInput([]); + } + + public function testConstructorIndexDefinitionMustBeADocument(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "definition" option to have type "document"'); + new SearchIndexInput(['definition' => 'foo']); + } + + public function testConstructorShouldRequireNameToBeString(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "name" option to have type "string"'); + new SearchIndexInput(['definition' => ['mapping' => ['dynamid' => true]], 'name' => 1]); + } + + public function testBsonSerialization(): void + { + $expected = (object) [ + 'name' => 'my_search', + 'definition' => ['mapping' => ['dynamic' => true]], + ]; + + $indexInput = new SearchIndexInput([ + 'name' => 'my_search', + 'definition' => ['mapping' => ['dynamic' => true]], + ]); + + $this->assertInstanceOf(Serializable::class, $indexInput); + $this->assertEquals($expected, $indexInput->bsonSerialize()); + } +} diff --git a/tests/Operation/BulkWriteTest.php b/tests/Operation/BulkWriteTest.php index 5da144343..505ae9698 100644 --- a/tests/Operation/BulkWriteTest.php +++ b/tests/Operation/BulkWriteTest.php @@ -92,11 +92,6 @@ public function testDeleteManyCollationOptionTypeCheck($collation): void ]); } - public function provideInvalidDocumentValues() - { - return $this->wrapValuesForDataProvider($this->getInvalidDocumentValues()); - } - public function testDeleteOneFilterArgumentMissing(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Operation/CreateIndexesTest.php b/tests/Operation/CreateIndexesTest.php index 0ece84f43..04e0e86ba 100644 --- a/tests/Operation/CreateIndexesTest.php +++ b/tests/Operation/CreateIndexesTest.php @@ -98,9 +98,4 @@ public function testConstructorRequiresIndexSpecificationNameToBeString($name): $this->expectExceptionMessage('Expected "name" option to have type "string"'); new CreateIndexes($this->getDatabaseName(), $this->getCollectionName(), [['key' => ['x' => 1], 'name' => $name]]); } - - public function provideInvalidStringValues() - { - return $this->wrapValuesForDataProvider($this->getInvalidStringValues()); - } } diff --git a/tests/Operation/CreateSearchIndexesTest.php b/tests/Operation/CreateSearchIndexesTest.php new file mode 100644 index 000000000..678678f59 --- /dev/null +++ b/tests/Operation/CreateSearchIndexesTest.php @@ -0,0 +1,52 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$indexes is not a list'); + new CreateSearchIndexes($this->getDatabaseName(), $this->getCollectionName(), [1 => ['name' => 'index name', 'definition' => ['mappings' => ['dynamic' => true]]]], []); + } + + /** @dataProvider provideInvalidIndexSpecificationTypes */ + public function testConstructorIndexDefinitionMustBeADocument($index): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected $indexes[0] to have type "document"'); + new CreateSearchIndexes($this->getDatabaseName(), $this->getCollectionName(), [$index], []); + } + + /** @dataProvider provideInvalidStringValues */ + public function testConstructorIndexNameMustBeAString($name): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "name" option to have type "string"'); + new CreateSearchIndexes($this->getDatabaseName(), $this->getCollectionName(), [['name' => $name, 'definition' => ['mappings' => ['dynamic' => true]]]], []); + } + + public function testConstructorIndexDefinitionMustBeDefined(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required "definition" document is missing from search index specification'); + new CreateSearchIndexes($this->getDatabaseName(), $this->getCollectionName(), [['name' => 'index name']], []); + } + + /** @dataProvider provideInvalidDocumentValues */ + public function testConstructorIndexDefinitionMustBeAnArray($definition): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "definition" option to have type "document"'); + new CreateSearchIndexes($this->getDatabaseName(), $this->getCollectionName(), [['definition' => $definition]], []); + } + + public function provideInvalidIndexSpecificationTypes(): array + { + return $this->wrapValuesForDataProvider($this->getInvalidDocumentValues()); + } +} diff --git a/tests/Operation/DropSearchIndexTest.php b/tests/Operation/DropSearchIndexTest.php new file mode 100644 index 000000000..06be34e59 --- /dev/null +++ b/tests/Operation/DropSearchIndexTest.php @@ -0,0 +1,15 @@ +expectException(InvalidArgumentException::class); + new DropSearchIndex($this->getDatabaseName(), $this->getCollectionName(), ''); + } +} diff --git a/tests/Operation/ListSearchIndexesTest.php b/tests/Operation/ListSearchIndexesTest.php new file mode 100644 index 000000000..65d020e68 --- /dev/null +++ b/tests/Operation/ListSearchIndexesTest.php @@ -0,0 +1,33 @@ +expectException(InvalidArgumentException::class); + new ListSearchIndexes($this->getDatabaseName(), $this->getCollectionName(), ['name' => '']); + } + + /** @dataProvider provideInvalidConstructorOptions */ + public function testConstructorOptionTypeChecks(array $options): void + { + $this->expectException(InvalidArgumentException::class); + new ListSearchIndexes($this->getDatabaseName(), $this->getCollectionName(), $options); + } + + public function provideInvalidConstructorOptions(): array + { + $options = []; + + foreach ($this->getInvalidIntegerValues() as $value) { + $options[][] = ['batchSize' => $value]; + } + + return $options; + } +} diff --git a/tests/Operation/UpdateSearchIndexTest.php b/tests/Operation/UpdateSearchIndexTest.php new file mode 100644 index 000000000..90c623fc3 --- /dev/null +++ b/tests/Operation/UpdateSearchIndexTest.php @@ -0,0 +1,23 @@ +expectException(InvalidArgumentException::class); + new UpdateSearchIndex($this->getDatabaseName(), $this->getCollectionName(), '', []); + } + + /** @dataProvider provideInvalidDocumentValues */ + public function testConstructorIndexDefinitionMustBeADocument($definition): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected $definition to have type "document"'); + new UpdateSearchIndex($this->getDatabaseName(), $this->getCollectionName(), 'index name', $definition); + } +} diff --git a/tests/SpecTests/SearchIndexSpecTest.php b/tests/SpecTests/SearchIndexSpecTest.php new file mode 100644 index 000000000..58032b4fa --- /dev/null +++ b/tests/SpecTests/SearchIndexSpecTest.php @@ -0,0 +1,188 @@ +createCollection($this->getDatabaseName(), $this->getCollectionName()); + $name = 'test-search-index'; + $mapping = ['mappings' => ['dynamic' => false]]; + + $createdName = $collection->createSearchIndex( + $mapping, + ['name' => $name, 'comment' => 'Index creation test'], + ); + $this->assertSame($name, $createdName); + + [$index] = $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + + $this->assertSame($name, $index->name); + + // Convert to JSON to compare nested associative arrays and nested objects + $this->assertJsonStringEqualsJsonString( + json_encode($mapping, JSON_THROW_ON_ERROR), + json_encode($index->latestDefinition, JSON_THROW_ON_ERROR), + ); + } + + public function testCreateMultipleIndexesInBatch(): void + { + $collection = $this->createCollection($this->getDatabaseName(), $this->getCollectionName()); + $names = ['test-search-index-1', 'test-search-index-2']; + $mapping = ['mappings' => ['dynamic' => false]]; + + $createdNames = $collection->createSearchIndexes([ + ['name' => $names[0], 'definition' => $mapping], + ['name' => $names[1], 'definition' => $mapping], + ]); + $this->assertSame($names, $createdNames); + + $indexes = $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + + foreach ($names as $key => $name) { + $index = $indexes[$key]; + $this->assertSame($name, $index->name); + + // Convert to JSON to compare nested associative arrays and nested objects + $this->assertJsonStringEqualsJsonString( + json_encode($mapping, JSON_THROW_ON_ERROR), + json_encode($index->latestDefinition, JSON_THROW_ON_ERROR), + ); + } + } + + public function testDropSearchIndexes(): void + { + $collection = $this->createCollection($this->getDatabaseName(), $this->getCollectionName()); + $name = 'test-search-index'; + $mapping = ['mappings' => ['dynamic' => false]]; + + $createdName = $collection->createSearchIndex( + $mapping, + ['name' => $name], + ); + $this->assertSame($name, $createdName); + + $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + + $collection->dropSearchIndex($name); + + $indexes = $this->waitForIndexes($collection, fn (array $indexes): bool => count($indexes) === 0); + + $this->assertCount(0, $indexes); + } + + public function testUpdateSearchIndex(): void + { + $collection = $this->createCollection($this->getDatabaseName(), $this->getCollectionName()); + $name = 'test-search-index'; + $mapping = ['mappings' => ['dynamic' => false]]; + + $createdName = $collection->createSearchIndex( + $mapping, + ['name' => $name], + ); + $this->assertSame($name, $createdName); + + $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + + $mapping = ['mappings' => ['dynamic' => true]]; + $collection->updateSearchIndex($name, $mapping); + + [$index] = $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + + $this->assertSame($name, $index->name); + + // Convert to JSON to compare nested associative arrays and nested objects + $this->assertJsonStringEqualsJsonString( + json_encode($mapping, JSON_THROW_ON_ERROR), + json_encode($index->latestDefinition, JSON_THROW_ON_ERROR), + ); + } + + public function testDropSearchIndexSuppressNamespaceNotFoundError(): void + { + $collection = $this->dropCollection($this->getDatabaseName(), $this->getCollectionName()); + + $collection->dropSearchIndex('test-seach-index'); + + $this->expectNotToPerformAssertions(); + } + + /** + * Randomize the collection name to avoid duplicate index names when running tests concurrently. + * Search index operations are asynchronous and can take up to a few minutes. + */ + protected function getCollectionName(): string + { + return sprintf('%s.%s', parent::getCollectionName(), bin2hex(random_bytes(5))); + } + + private function waitForIndexes(Collection $collection, Closure $callback): array + { + $timeout = hrtime()[0] + self::WAIT_TIMEOUT; + while (hrtime()[0] < $timeout) { + sleep(5); + $result = $collection->listSearchIndexes(); + $this->assertInstanceOf(CachingIterator::class, $result); + $result = iterator_to_array($result); + if ($callback($result)) { + return $result; + } + } + + $this->fail('Operation did not complete in time'); + } + + private function allIndexesAreQueryable(array $indexes): bool + { + if (count($indexes) === 0) { + return false; + } + + foreach ($indexes as $index) { + if (! $index->queryable) { + return false; + } + + if (! $index->status === 'READY') { + return false; + } + } + + return true; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index a5d20b837..19c386f2b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -164,21 +164,26 @@ public function dataDescription(): string return is_string($dataName) ? $dataName : ''; } - public function provideInvalidArrayValues() + public function provideInvalidArrayValues(): array { return $this->wrapValuesForDataProvider($this->getInvalidArrayValues()); } - public function provideInvalidDocumentValues() + public function provideInvalidDocumentValues(): array { return $this->wrapValuesForDataProvider($this->getInvalidDocumentValues()); } - public function provideInvalidIntegerValues() + public function provideInvalidIntegerValues(): array { return $this->wrapValuesForDataProvider($this->getInvalidIntegerValues()); } + public function provideInvalidStringValues(): array + { + return $this->wrapValuesForDataProvider($this->getInvalidStringValues()); + } + protected function assertDeprecated(callable $execution): void { $errors = []; diff --git a/tests/UnifiedSpecTests/Operation.php b/tests/UnifiedSpecTests/Operation.php index 2eadc7eb0..6bef86c32 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -541,6 +541,35 @@ private function executeForCollection(Collection $collection) array_diff_key($args, ['to' => 1]), ); + case 'createSearchIndex': + $options = []; + if (isset($args['model']->name)) { + assertIsString($args['model']->name); + $options['name'] = $args['model']->name; + } + + return $collection->createSearchIndex($args['model']->definition, $options); + + case 'createSearchIndexes': + return $collection->createSearchIndexes($args['models']); + + case 'dropSearchIndex': + assertArrayHasKey('name', $args); + assertIsString($args['name']); + + return $collection->dropSearchIndex($args['name']); + + case 'updateSearchIndex': + assertArrayHasKey('name', $args); + assertArrayHasKey('definition', $args); + assertIsString($args['name']); + assertInstanceOf(stdClass::class, $args['definition']); + + return $collection->updateSearchIndex($args['name'], $args['definition']); + + case 'listSearchIndexes': + return $collection->listSearchIndexes($args + (array) ($args['aggregationOptions'] ?? [])); + default: Assert::fail('Unsupported collection operation: ' . $this->name); } diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 1559ee439..f83c336a8 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -273,6 +273,23 @@ public function provideFailingTests() yield from $this->provideTests(__DIR__ . '/valid-fail/*.json'); } + /** @dataProvider provideIndexManagementTests */ + public function testIndexManagement(UnifiedTestCase $test): void + { + if (self::isAtlas()) { + self::markTestSkipped('Search Indexes tests must run on a non-atlas cluster'); + } + + self::skipIfServerVersion('<', '7.0.0', 'A dedicated error message was introduced in 7.0.0'); + + self::$runner->run($test); + } + + public function provideIndexManagementTests() + { + yield from $this->provideTests(__DIR__ . '/index-management/*.json'); + } + private function provideTests(string $pattern): Generator { foreach (glob($pattern) as $filename) { diff --git a/tests/UnifiedSpecTests/Util.php b/tests/UnifiedSpecTests/Util.php index 3ddb8fcee..b7b44f11a 100644 --- a/tests/UnifiedSpecTests/Util.php +++ b/tests/UnifiedSpecTests/Util.php @@ -88,6 +88,8 @@ final class Util 'createChangeStream' => ['pipeline', 'session', 'fullDocument', 'fullDocumentBeforeChange', 'resumeAfter', 'startAfter', 'startAtOperationTime', 'batchSize', 'collation', 'maxAwaitTimeMS', 'comment', 'showExpandedEvents'], 'createFindCursor' => ['filter', 'session', 'allowDiskUse', 'allowPartialResults', 'batchSize', 'collation', 'comment', 'cursorType', 'hint', 'limit', 'max', 'maxAwaitTimeMS', 'maxScan', 'maxTimeMS', 'min', 'modifiers', 'noCursorTimeout', 'oplogReplay', 'projection', 'returnKey', 'showRecordId', 'skip', 'snapshot', 'sort'], 'createIndex' => ['keys', 'comment', 'commitQuorum', 'maxTimeMS', 'name', 'session', 'unique'], + 'createSearchIndex' => ['model'], + 'createSearchIndexes' => ['models'], 'dropIndex' => ['name', 'session', 'maxTimeMS', 'comment'], 'count' => ['filter', 'session', 'collation', 'hint', 'limit', 'maxTimeMS', 'skip', 'comment'], 'countDocuments' => ['filter', 'session', 'limit', 'skip', 'collation', 'hint', 'maxTimeMS', 'comment'], @@ -97,6 +99,7 @@ final class Util 'findOneAndDelete' => ['let', 'filter', 'session', 'projection', 'arrayFilters', 'bypassDocumentValidation', 'collation', 'hint', 'maxTimeMS', 'new', 'sort', 'update', 'upsert', 'comment'], 'distinct' => ['fieldName', 'filter', 'session', 'collation', 'maxTimeMS', 'comment'], 'drop' => ['session', 'comment'], + 'dropSearchIndex' => ['name'], 'find' => ['let', 'filter', 'session', 'allowDiskUse', 'allowPartialResults', 'batchSize', 'collation', 'comment', 'cursorType', 'hint', 'limit', 'max', 'maxAwaitTimeMS', 'maxScan', 'maxTimeMS', 'min', 'modifiers', 'noCursorTimeout', 'oplogReplay', 'projection', 'returnKey', 'showRecordId', 'skip', 'snapshot', 'sort'], 'findOne' => ['let', 'filter', 'session', 'allowDiskUse', 'allowPartialResults', 'batchSize', 'collation', 'comment', 'cursorType', 'hint', 'max', 'maxAwaitTimeMS', 'maxScan', 'maxTimeMS', 'min', 'modifiers', 'noCursorTimeout', 'oplogReplay', 'projection', 'returnKey', 'showRecordId', 'skip', 'snapshot', 'sort'], 'findOneAndReplace' => ['let', 'returnDocument', 'filter', 'replacement', 'session', 'projection', 'returnDocument', 'upsert', 'arrayFilters', 'bypassDocumentValidation', 'collation', 'hint', 'maxTimeMS', 'new', 'remove', 'sort', 'comment'], @@ -105,9 +108,11 @@ final class Util 'findOneAndUpdate' => ['let', 'returnDocument', 'filter', 'update', 'session', 'upsert', 'projection', 'remove', 'arrayFilters', 'bypassDocumentValidation', 'collation', 'hint', 'maxTimeMS', 'sort', 'comment'], 'updateMany' => ['let', 'filter', 'update', 'session', 'upsert', 'arrayFilters', 'bypassDocumentValidation', 'collation', 'hint', 'comment'], 'updateOne' => ['let', 'filter', 'update', 'session', 'upsert', 'arrayFilters', 'bypassDocumentValidation', 'collation', 'hint', 'comment'], + 'updateSearchIndex' => ['name', 'definition'], 'insertMany' => ['documents', 'session', 'ordered', 'bypassDocumentValidation', 'comment'], 'insertOne' => ['document', 'session', 'bypassDocumentValidation', 'comment'], 'listIndexes' => ['session', 'maxTimeMS', 'comment'], + 'listSearchIndexes' => ['name', 'aggregationOptions'], 'mapReduce' => ['map', 'reduce', 'out', 'session', 'bypassDocumentValidation', 'collation', 'finalize', 'jsMode', 'limit', 'maxTimeMS', 'query', 'scope', 'sort', 'verbose', 'comment'], ], ChangeStream::class => [ diff --git a/tests/UnifiedSpecTests/index-management/createSearchIndex.json b/tests/UnifiedSpecTests/index-management/createSearchIndex.json new file mode 100644 index 000000000..04cffbe9c --- /dev/null +++ b/tests/UnifiedSpecTests/index-management/createSearchIndex.json @@ -0,0 +1,136 @@ +{ + "description": "createSearchIndex", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "no name provided for an index definition", + "operations": [ + { + "name": "createSearchIndex", + "object": "collection0", + "arguments": { + "model": { + "definition": { + "mappings": { + "dynamic": true + } + } + } + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "name provided for an index definition", + "operations": [ + { + "name": "createSearchIndex", + "object": "collection0", + "arguments": { + "model": { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + ], + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/index-management/createSearchIndexes.json b/tests/UnifiedSpecTests/index-management/createSearchIndexes.json new file mode 100644 index 000000000..95dbedde7 --- /dev/null +++ b/tests/UnifiedSpecTests/index-management/createSearchIndexes.json @@ -0,0 +1,172 @@ +{ + "description": "createSearchIndexes", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "empty index definition array", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [] + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "no name provided for an index definition", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ] + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "name provided for an index definition", + "operations": [ + { + "name": "createSearchIndexes", + "object": "collection0", + "arguments": { + "models": [ + { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + ] + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "createSearchIndexes": "collection0", + "indexes": [ + { + "definition": { + "mappings": { + "dynamic": true + } + }, + "name": "test index" + } + ], + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/index-management/dropSearchIndex.json b/tests/UnifiedSpecTests/index-management/dropSearchIndex.json new file mode 100644 index 000000000..0f21a5b68 --- /dev/null +++ b/tests/UnifiedSpecTests/index-management/dropSearchIndex.json @@ -0,0 +1,74 @@ +{ + "description": "dropSearchIndex", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "sends the correct command", + "operations": [ + { + "name": "dropSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index" + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "dropSearchIndex": "collection0", + "name": "test index", + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/index-management/listSearchIndexes.json b/tests/UnifiedSpecTests/index-management/listSearchIndexes.json new file mode 100644 index 000000000..24c51ad88 --- /dev/null +++ b/tests/UnifiedSpecTests/index-management/listSearchIndexes.json @@ -0,0 +1,156 @@ +{ + "description": "listSearchIndexes", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "when no name is provided, it does not populate the filter", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "pipeline": [ + { + "$listSearchIndexes": {} + } + ] + } + } + } + ] + } + ] + }, + { + "description": "when a name is provided, it is present in the filter", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "arguments": { + "name": "test index" + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "pipeline": [ + { + "$listSearchIndexes": { + "name": "test index" + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + }, + { + "description": "aggregation cursor options are supported", + "operations": [ + { + "name": "listSearchIndexes", + "object": "collection0", + "arguments": { + "name": "test index", + "aggregationOptions": { + "batchSize": 10 + } + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "aggregate": "collection0", + "cursor": { + "batchSize": 10 + }, + "pipeline": [ + { + "$listSearchIndexes": { + "name": "test index" + } + } + ], + "$db": "database0" + } + } + } + ] + } + ] + } + ] +} diff --git a/tests/UnifiedSpecTests/index-management/updateSearchIndex.json b/tests/UnifiedSpecTests/index-management/updateSearchIndex.json new file mode 100644 index 000000000..88a46a306 --- /dev/null +++ b/tests/UnifiedSpecTests/index-management/updateSearchIndex.json @@ -0,0 +1,76 @@ +{ + "description": "updateSearchIndex", + "schemaVersion": "1.4", + "createEntities": [ + { + "client": { + "id": "client0", + "useMultipleMongoses": false, + "observeEvents": [ + "commandStartedEvent" + ] + } + }, + { + "database": { + "id": "database0", + "client": "client0", + "databaseName": "database0" + } + }, + { + "collection": { + "id": "collection0", + "database": "database0", + "collectionName": "collection0" + } + } + ], + "runOnRequirements": [ + { + "minServerVersion": "7.0.0", + "topologies": [ + "replicaset", + "load-balanced", + "sharded" + ], + "serverless": "forbid" + } + ], + "tests": [ + { + "description": "sends the correct command", + "operations": [ + { + "name": "updateSearchIndex", + "object": "collection0", + "arguments": { + "name": "test index", + "definition": {} + }, + "expectError": { + "isError": true, + "errorContains": "Search index commands are only supported with Atlas" + } + } + ], + "expectEvents": [ + { + "client": "client0", + "events": [ + { + "commandStartedEvent": { + "command": { + "updateSearchIndex": "collection0", + "name": "test index", + "definition": {}, + "$db": "database0" + } + } + } + ] + } + ] + } + ] +}