From 5700592f78c17edf081668d180eec4468b9d5223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 31 Dec 2024 14:25:56 +0100 Subject: [PATCH 1/3] PHPORM-274 List search indexes in Schema::getIndexes introspection method --- src/Schema/Builder.php | 35 ++++++++++- tests/AtlasSearchTest.php | 122 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 tests/AtlasSearchTest.php diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index ade4b0fb7..41fa8ef78 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -5,9 +5,12 @@ 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; @@ -225,9 +228,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[] = [ @@ -244,6 +249,26 @@ public function getIndexes($table) ]; } + 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_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 +315,10 @@ protected function getAllCollections() return $collections; } + + /** @internal */ + public static function isAtlasSearchNotSupportedException(ServerException $e): bool + { + return in_array($e->getCode(), [59, 40324, 115, 6047401, 31082], true); + } } diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php new file mode 100644 index 000000000..45e512f43 --- /dev/null +++ b/tests/AtlasSearchTest.php @@ -0,0 +1,122 @@ + '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([ + '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::assertCount(3, $indexes); + + $expected = [ + [ + 'name' => '_id_', + 'columns' => ['_id'], + 'primary' => true, + 'type' => 'default', + 'unique' => false, + ], + [ + 'name' => 'default', + 'columns' => ['title'], + 'type' => 'search', + 'primary' => false, + 'unique' => false, + ], + [ + 'name' => 'vector', + 'columns' => ['vector16', 'vector32'], + 'type' => 'vectorSearch', + 'primary' => false, + 'unique' => false, + ], + ]; + + self::assertSame($expected, $indexes); + } +} From d30ab349a8230935c30bddc59f11867799d840d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 31 Dec 2024 15:43:00 +0100 Subject: [PATCH 2/3] Add details on error codes --- src/Schema/Builder.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 41fa8ef78..97e301a0e 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -319,6 +319,12 @@ protected function getAllCollections() /** @internal */ public static function isAtlasSearchNotSupportedException(ServerException $e): bool { - return in_array($e->getCode(), [59, 40324, 115, 6047401, 31082], true); + 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); } } From 45603fd48478fc9a094e8e6bc0ed4330c02eecd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 2 Jan 2025 14:01:05 +0100 Subject: [PATCH 3/3] Show dynamic search index and set type null for standard indexes --- src/Schema/Builder.php | 8 +++++-- tests/AtlasSearchTest.php | 20 ++++++++++++++-- tests/SchemaTest.php | 49 ++++++++++++++++++++++++++++----------- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 97e301a0e..a4e8149f3 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -15,6 +15,7 @@ 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; @@ -243,7 +244,7 @@ public function getIndexes($table) $index->isText() => 'text', $index->is2dSphere() => '2dsphere', $index->isTtl() => 'ttl', - default => 'default', + default => null, }, 'unique' => $index->isUnique(), ]; @@ -255,7 +256,10 @@ public function getIndexes($table) $indexList[] = [ 'name' => $index['name'], 'columns' => match ($index['type']) { - 'search' => array_keys($index['latestDefinition']['mappings']['fields'] ?? []), + 'search' => array_merge( + $index['latestDefinition']['mappings']['dynamic'] ? ['dynamic'] : [], + array_keys($index['latestDefinition']['mappings']['fields'] ?? []), + ), 'vectorSearch' => array_column($index['latestDefinition']['fields'], 'path'), }, 'type' => $index['type'], diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index 45e512f43..cfab2347a 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -10,6 +10,7 @@ use function assert; use function usleep; +use function usort; class AtlasSearchTest extends TestCase { @@ -54,6 +55,10 @@ public function setUp(): void ], ]); + $collection->createSearchIndex([ + 'mappings' => ['dynamic' => true], + ], ['name' => 'dynamic_search']); + $collection->createSearchIndex([ 'fields' => [ ['type' => 'vector', 'numDimensions' => 16, 'path' => 'vector16', 'similarity' => 'cosine'], @@ -91,14 +96,18 @@ public function testGetIndexes() { $indexes = Schema::getIndexes('books'); - self::assertCount(3, $indexes); + 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' => 'default', + 'type' => null, 'unique' => false, ], [ @@ -108,6 +117,13 @@ public function testGetIndexes() 'primary' => false, 'unique' => false, ], + [ + 'name' => 'dynamic_search', + 'columns' => ['dynamic'], + 'type' => 'search', + 'primary' => false, + 'unique' => false, + ], [ 'name' => 'vector', 'columns' => ['vector16', 'vector32'], 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');