diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 17571a178..f1db3e9fa 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -28,6 +28,11 @@ ($value is NativeType ? BSONType : $value) + + + $options + + $cmd[$option] @@ -317,6 +322,14 @@ isInTransaction + + + $index + + + + + options['typeMap']]]> @@ -397,6 +410,11 @@ isInTransaction + + + + + options['typeMap']]]> @@ -576,6 +594,11 @@ isInTransaction + + + + + cursor->firstBatch]]> diff --git a/src/Collection.php b/src/Collection.php index b5a672308..0420be097 100644 --- a/src/Collection.php +++ b/src/Collection.php @@ -17,6 +17,7 @@ namespace MongoDB; +use Countable; use Iterator; use MongoDB\BSON\JavascriptInterface; use MongoDB\Codec\DocumentCodec; @@ -38,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; @@ -55,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; @@ -360,6 +365,64 @@ public function createIndexes(array $indexes, array $options = []) return $operation->execute(select_server($this->manager, $options)); } + /** + * 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. * @@ -501,6 +564,24 @@ public function dropIndexes(array $options = []) return $operation->execute(select_server($this->manager, $options)); } + /** + * 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); + + $operation->execute($server); + } + /** * Gets an estimated number of documents in the collection using the collection metadata. * @@ -812,6 +893,24 @@ public function listIndexes(array $options = []) return $operation->execute(select_server($this->manager, $options)); } + /** + * 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. * @@ -946,6 +1045,25 @@ public function updateOne($filter, $update, array $options = []) return $operation->execute(select_server($this->manager, $options)); } + /** + * 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..96e529b08 --- /dev/null +++ b/src/Operation/CreateSearchIndexes.php @@ -0,0 +1,101 @@ + $index) { + if (! is_array($index)) { + throw InvalidArgumentException::invalidType(sprintf('$indexes[%d]', $i), $index, 'array'); + } + + $this->indexes[] = new SearchIndexInput($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, + ]; + + if (isset($this->options['comment'])) { + $cmd['comment'] = $this->options['comment']; + } + + $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..a8a6ff1b7 --- /dev/null +++ b/src/Operation/DropSearchIndex.php @@ -0,0 +1,90 @@ +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, + ]; + + if (isset($this->options['comment'])) { + $cmd['comment'] = $this->options['comment']; + } + + try { + $server->executeCommand($this->databaseName, new Command($cmd)); + } catch (CommandException $e) { + // Drop operations are idempotent. The server may return an error if the collection does not exist. + if ($e->getCode() !== self::ERROR_CODE_NAMESPACE_NOT_FOUND) { + throw $e; + } + } + } +} diff --git a/src/Operation/ListSearchIndexes.php b/src/Operation/ListSearchIndexes.php new file mode 100644 index 000000000..875eab3af --- /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..a44bc4103 --- /dev/null +++ b/src/Operation/UpdateSearchIndex.php @@ -0,0 +1,90 @@ +databaseName = $databaseName; + $this->collectionName = $collectionName; + $this->name = $name; + $this->definition = (object) $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, + ]; + + if (isset($this->options['comment'])) { + $cmd['comment'] = $this->options['comment']; + } + + $server->executeCommand($this->databaseName, new Command($cmd)); + } +} diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 2a79e6b19..c45a94367 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -27,6 +27,7 @@ use function filter_var; use function getenv; use function implode; +use function in_array; use function is_array; use function is_callable; use function is_executable; @@ -51,12 +52,14 @@ abstract class FunctionalTestCase extends TestCase { + private const ATLAS_TLD = '/\.(mongodb\.net|mongodb-dev\.net)/'; + protected Manager $manager; private array $configuredFailPoints = []; /** @var array{int,{Collection,array}} */ - private $collectionsToCleanup = []; + private array $collectionsToCleanup = []; public function setUp(): void { @@ -519,6 +522,25 @@ protected function skipIfTransactionsAreNotSupported(): void } } + protected function isEnterprise(): bool + { + $buildInfo = $this->getPrimaryServer()->executeCommand( + $this->getDatabaseName(), + new Command(['buildInfo' => 1]), + )->toArray()[0]; + + if (isset($buildInfo->modules) && is_array($buildInfo->modules)) { + return in_array('enterprise', $buildInfo->modules); + } + + throw new UnexpectedValueException('Could not determine server modules'); + } + + public static function isAtlas(?string $uri = null): bool + { + return preg_match(self::ATLAS_TLD, $uri ?? static::getUri()); + } + /** @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..3f5e814e5 100644 --- a/tests/Model/IndexInputTest.php +++ b/tests/Model/IndexInputTest.php @@ -15,19 +15,23 @@ 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 + /** @dataProvider provideInvalidDocumentValues */ + public function testConstructorShouldRequireKeyToBeArrayOrObject($key): void { $this->expectException(InvalidArgumentException::class); - new IndexInput(['key' => 'foo']); + $this->expectExceptionMessage('Expected "key" option to have type "document"'); + new IndexInput(['key' => $key]); } /** @dataProvider provideInvalidFieldOrderValues */ 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]]); } @@ -36,10 +40,12 @@ public function provideInvalidFieldOrderValues() return $this->wrapValuesForDataProvider([true, [], new stdClass()]); } - public function testConstructorShouldRequireNameToBeString(): void + /** @dataProvider provideInvalidStringValues */ + public function testConstructorShouldRequireNameToBeString($name): void { $this->expectException(InvalidArgumentException::class); - new IndexInput(['key' => ['x' => 1], 'name' => 1]); + $this->expectExceptionMessage('Expected "name" option to have type "string"'); + new IndexInput(['key' => ['x' => 1], 'name' => $name]); } /** @@ -67,7 +73,7 @@ public function provideExpectedNameAndKey(): array public function testBsonSerialization(): void { - $expected = [ + $expected = (object) [ 'key' => ['x' => 1], 'unique' => true, 'name' => 'x_1', @@ -79,6 +85,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..0126aaec6 --- /dev/null +++ b/tests/Model/SearchIndexInputTest.php @@ -0,0 +1,50 @@ +expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required "definition" document is missing from search index specification'); + new SearchIndexInput([]); + } + + /** @dataProvider provideInvalidDocumentValues */ + public function testConstructorIndexDefinitionMustBeADocument($definition): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "definition" option to have type "document"'); + new SearchIndexInput(['definition' => $definition]); + } + + /** @dataProvider provideInvalidStringValues */ + public function testConstructorShouldRequireNameToBeString($name): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected "name" option to have type "string"'); + new SearchIndexInput(['definition' => ['mapping' => ['dynamic' => true]], 'name' => $name]); + } + + 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 a307f5b01..2c255bec5 100644 --- a/tests/Operation/BulkWriteTest.php +++ b/tests/Operation/BulkWriteTest.php @@ -106,11 +106,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..e2dcb1e65 --- /dev/null +++ b/tests/Operation/CreateSearchIndexesTest.php @@ -0,0 +1,47 @@ +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 provideInvalidArrayValues */ + public function testConstructorIndexDefinitionMustBeADocument($index): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Expected $indexes[0] to have type "array"'); + 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]], []); + } +} 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..d5ed619ef --- /dev/null +++ b/tests/SpecTests/SearchIndexSpecTest.php @@ -0,0 +1,204 @@ +skipIfServerVersion('<', '7.0', 'Search Indexes are only supported on MongoDB Atlas 7.0+'); + } + + /** + * Case 1: Driver can successfully create and list search indexes + * + * @see https://github.com/mongodb/specifications/blob/master/source/index-management/tests/README.rst#case-1-driver-can-successfully-create-and-list-search-indexes + */ + public function testCreateAndListSearchIndexes(): void + { + $collection = $this->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); + + $indexes = $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + + $this->assertCount(1, $indexes); + $this->assertSame($name, $indexes[0]->name); + $this->assertSameDocument($mapping, $indexes[0]->latestDefinition); + } + + /** + * Case 2: Driver can successfully create multiple indexes in batch + * + * @see https://github.com/mongodb/specifications/blob/master/source/index-management/tests/README.rst#case-2-driver-can-successfully-create-multiple-indexes-in-batch + */ + 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)); + + $this->assertCount(2, $indexes); + foreach ($names as $key => $name) { + $index = $indexes[$key]; + $this->assertSame($name, $index->name); + $this->assertSameDocument($mapping, $index->latestDefinition); + } + } + + /** + * Case 3: Driver can successfully drop search indexes + * + * @see https://github.com/mongodb/specifications/blob/master/source/index-management/tests/README.rst#case-3-driver-can-successfully-drop-search-indexes + */ + 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); + + $indexes = $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + $this->assertCount(1, $indexes); + + $collection->dropSearchIndex($name); + + $indexes = $this->waitForIndexes($collection, fn (array $indexes): bool => count($indexes) === 0); + $this->assertCount(0, $indexes); + } + + /** + * Case 4: Driver can update a search index + * + * @see https://github.com/mongodb/specifications/blob/master/source/index-management/tests/README.rst#case-4-driver-can-update-a-search-index + */ + 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); + + $indexes = $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + $this->assertCount(1, $indexes); + + $mapping = ['mappings' => ['dynamic' => true]]; + $collection->updateSearchIndex($name, $mapping); + + $indexes = $this->waitForIndexes($collection, fn ($indexes) => $this->allIndexesAreQueryable($indexes)); + + $this->assertCount(1, $indexes); + $this->assertSame($name, $indexes[0]->name); + $this->assertSameDocument($mapping, $indexes[0]->latestDefinition); + } + + /** + * Case 5: dropSearchIndex suppresses namespace not found errors + * + * @see https://github.com/mongodb/specifications/blob/master/source/index-management/tests/README.rst#case-5-dropsearchindex-suppresses-namespace-not-found-errors + */ + 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_SEC; + 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 === self::STATUS_READY) { + return false; + } + } + + return true; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 9b2f69982..4ecfe8cda 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -165,21 +165,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..224fd903c 100644 --- a/tests/UnifiedSpecTests/Operation.php +++ b/tests/UnifiedSpecTests/Operation.php @@ -541,6 +541,47 @@ 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; + } + + assertInstanceOf(stdClass::class, $args['model']->definition); + + return $collection->createSearchIndex($args['model']->definition, $options); + + case 'createSearchIndexes': + $indexes = array_map(function ($index) { + $index = (array) $index; + assertInstanceOf(stdClass::class, $index['definition']); + + return $index; + }, $args['models']); + + return $collection->createSearchIndexes($indexes); + + 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': + $args += (array) ($args['aggregationOptions'] ?? []); + unset($args['aggregationOptions']); + + return $collection->listSearchIndexes($args); + default: Assert::fail('Unsupported collection operation: ' . $this->name); } diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 1559ee439..9da22cf76 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -273,6 +273,25 @@ 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'); + } + + if (! self::isEnterprise()) { + self::markTestSkipped('Specific Atlas error messages are only available on Enterprise server'); + } + + 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/UnifiedTestRunner.php b/tests/UnifiedSpecTests/UnifiedTestRunner.php index 35a0ec456..dc34f3752 100644 --- a/tests/UnifiedSpecTests/UnifiedTestRunner.php +++ b/tests/UnifiedSpecTests/UnifiedTestRunner.php @@ -49,8 +49,6 @@ */ final class UnifiedTestRunner { - public const ATLAS_TLD = 'mongodb.net'; - public const SERVER_ERROR_INTERRUPTED = 11601; public const SERVER_ERROR_UNAUTHORIZED = 13; @@ -83,7 +81,7 @@ public function __construct(string $internalClientUri) /* Atlas prohibits killAllSessions. Inspect the connection string to * determine if we should avoid calling killAllSessions(). This does * mean that lingering transactions could block test execution. */ - if ($this->isServerless() || strpos($internalClientUri, self::ATLAS_TLD) !== false) { + if ($this->isServerless() || FunctionalTestCase::isAtlas($internalClientUri)) { $this->allowKillAllSessions = false; } 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" + } + } + } + ] + } + ] + } + ] +}