diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index ade4b0fb7..a4e8149f3 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -5,13 +5,17 @@ namespace MongoDB\Laravel\Schema; use Closure; +use MongoDB\Collection; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Model\CollectionInfo; use MongoDB\Model\IndexInfo; +use function array_column; use function array_fill_keys; use function array_filter; use function array_keys; use function array_map; +use function array_merge; use function assert; use function count; use function current; @@ -225,9 +229,11 @@ public function getColumns($table) public function getIndexes($table) { - $indexes = $this->connection->getMongoDB()->selectCollection($table)->listIndexes(); - + $collection = $this->connection->getMongoDB()->selectCollection($table); + assert($collection instanceof Collection); $indexList = []; + + $indexes = $collection->listIndexes(); foreach ($indexes as $index) { assert($index instanceof IndexInfo); $indexList[] = [ @@ -238,12 +244,35 @@ public function getIndexes($table) $index->isText() => 'text', $index->is2dSphere() => '2dsphere', $index->isTtl() => 'ttl', - default => 'default', + default => null, }, 'unique' => $index->isUnique(), ]; } + try { + $indexes = $collection->listSearchIndexes(['typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]); + foreach ($indexes as $index) { + $indexList[] = [ + 'name' => $index['name'], + 'columns' => match ($index['type']) { + 'search' => array_merge( + $index['latestDefinition']['mappings']['dynamic'] ? ['dynamic'] : [], + array_keys($index['latestDefinition']['mappings']['fields'] ?? []), + ), + 'vectorSearch' => array_column($index['latestDefinition']['fields'], 'path'), + }, + 'type' => $index['type'], + 'primary' => false, + 'unique' => false, + ]; + } + } catch (ServerException $exception) { + if (! self::isAtlasSearchNotSupportedException($exception)) { + throw $exception; + } + } + return $indexList; } @@ -290,4 +319,16 @@ protected function getAllCollections() return $collections; } + + /** @internal */ + public static function isAtlasSearchNotSupportedException(ServerException $e): bool + { + return in_array($e->getCode(), [ + 59, // MongoDB 4 to 6, 7-community: no such command: 'createSearchIndexes' + 40324, // MongoDB 4 to 6: Unrecognized pipeline stage name: '$listSearchIndexes' + 115, // MongoDB 7-ent: Search index commands are only supported with Atlas. + 6047401, // MongoDB 7: $listSearchIndexes stage is only allowed on MongoDB Atlas + 31082, // MongoDB 8: Using Atlas Search Database Commands and the $listSearchIndexes aggregation stage requires additional configuration. + ], true); + } } diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php new file mode 100644 index 000000000..cfab2347a --- /dev/null +++ b/tests/AtlasSearchTest.php @@ -0,0 +1,138 @@ + 'Introduction to Algorithms'], + ['title' => 'Clean Code: A Handbook of Agile Software Craftsmanship'], + ['title' => 'Design Patterns: Elements of Reusable Object-Oriented Software'], + ['title' => 'The Pragmatic Programmer: Your Journey to Mastery'], + ['title' => 'Artificial Intelligence: A Modern Approach'], + ['title' => 'Structure and Interpretation of Computer Programs'], + ['title' => 'Code Complete: A Practical Handbook of Software Construction'], + ['title' => 'The Art of Computer Programming'], + ['title' => 'Computer Networks'], + ['title' => 'Operating System Concepts'], + ['title' => 'Database System Concepts'], + ['title' => 'Compilers: Principles, Techniques, and Tools'], + ['title' => 'Introduction to the Theory of Computation'], + ['title' => 'Modern Operating Systems'], + ['title' => 'Computer Organization and Design'], + ['title' => 'The Mythical Man-Month: Essays on Software Engineering'], + ['title' => 'Algorithms'], + ['title' => 'Understanding Machine Learning: From Theory to Algorithms'], + ['title' => 'Deep Learning'], + ['title' => 'Pattern Recognition and Machine Learning'], + ]); + + $collection = $this->getConnection('mongodb')->getCollection('books'); + assert($collection instanceof MongoDBCollection); + try { + $collection->createSearchIndex([ + 'mappings' => [ + 'fields' => [ + 'title' => [ + ['type' => 'string', 'analyzer' => 'lucene.english'], + ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ], + ], + ], + ]); + + $collection->createSearchIndex([ + 'mappings' => ['dynamic' => true], + ], ['name' => 'dynamic_search']); + + $collection->createSearchIndex([ + 'fields' => [ + ['type' => 'vector', 'numDimensions' => 16, 'path' => 'vector16', 'similarity' => 'cosine'], + ['type' => 'vector', 'numDimensions' => 32, 'path' => 'vector32', 'similarity' => 'euclidean'], + ], + ], ['name' => 'vector', 'type' => 'vectorSearch']); + } catch (ServerException $e) { + if (Builder::isAtlasSearchNotSupportedException($e)) { + self::markTestSkipped('Atlas Search not supported. ' . $e->getMessage()); + } + + throw $e; + } + + // Wait for the index to be ready + do { + $ready = true; + usleep(10_000); + foreach ($collection->listSearchIndexes() as $index) { + if ($index['status'] !== 'READY') { + $ready = false; + } + } + } while (! $ready); + } + + public function tearDown(): void + { + $this->getConnection('mongodb')->getCollection('books')->drop(); + + parent::tearDown(); + } + + public function testGetIndexes() + { + $indexes = Schema::getIndexes('books'); + + self::assertIsArray($indexes); + self::assertCount(4, $indexes); + + // Order of indexes is not guaranteed + usort($indexes, fn ($a, $b) => $a['name'] <=> $b['name']); + + $expected = [ + [ + 'name' => '_id_', + 'columns' => ['_id'], + 'primary' => true, + 'type' => null, + 'unique' => false, + ], + [ + 'name' => 'default', + 'columns' => ['title'], + 'type' => 'search', + 'primary' => false, + 'unique' => false, + ], + [ + 'name' => 'dynamic_search', + 'columns' => ['dynamic'], + 'type' => 'search', + 'primary' => false, + 'unique' => false, + ], + [ + 'name' => 'vector', + 'columns' => ['vector16', 'vector32'], + 'type' => 'vectorSearch', + 'primary' => false, + 'unique' => false, + ], + ]; + + self::assertSame($expected, $indexes); + } +} diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index ff3dfe626..ec1ae47dd 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -482,20 +482,41 @@ public function testGetIndexes() $collection->string('mykey3')->index(); }); $indexes = Schema::getIndexes('newcollection'); - $this->assertIsArray($indexes); - $this->assertCount(4, $indexes); - - $indexes = collect($indexes)->keyBy('name'); - - $indexes->each(function ($index) { - $this->assertIsString($index['name']); - $this->assertIsString($index['type']); - $this->assertIsArray($index['columns']); - $this->assertIsBool($index['unique']); - $this->assertIsBool($index['primary']); - }); - $this->assertTrue($indexes->get('_id_')['primary']); - $this->assertTrue($indexes->get('unique_index_1')['unique']); + self::assertIsArray($indexes); + self::assertCount(4, $indexes); + + $expected = [ + [ + 'name' => '_id_', + 'columns' => ['_id'], + 'primary' => true, + 'type' => null, + 'unique' => false, + ], + [ + 'name' => 'mykey1_1', + 'columns' => ['mykey1'], + 'primary' => false, + 'type' => null, + 'unique' => false, + ], + [ + 'name' => 'unique_index_1', + 'columns' => ['unique_index'], + 'primary' => false, + 'type' => null, + 'unique' => true, + ], + [ + 'name' => 'mykey3_1', + 'columns' => ['mykey3'], + 'primary' => false, + 'type' => null, + 'unique' => false, + ], + ]; + + self::assertSame($expected, $indexes); // Non-existent collection $indexes = Schema::getIndexes('missing');