diff --git a/src/Exception/SearchNotSupportedException.php b/src/Exception/SearchNotSupportedException.php new file mode 100644 index 000000000..0f5bed829 --- /dev/null +++ b/src/Exception/SearchNotSupportedException.php @@ -0,0 +1,44 @@ +getCode() === 31082 + ? $e->getMessage() + : 'Using Atlas Search Database Commands and the $listSearchIndexes aggregation stage requires additional configuration. ' + . 'Please connect to Atlas or an AtlasCLI local deployment to enable. ' + . 'For more information on how to connect, see https://dochub.mongodb.org/core/atlas-cli-deploy-local-reqs'; + + return new self($message, $e->getCode(), $e); + } + + /** @internal */ + public static function isSearchNotSupportedError(Throwable $e): bool + { + if (! $e instanceof ServerException) { + return false; + } + + return match ($e->getCode()) { + // MongoDB 8: Using Atlas Search Database Commands and the $listSearchIndexes aggregation stage requires additional configuration. + 31082 => true, + // MongoDB 7: $listSearchIndexes stage is only allowed on MongoDB Atlas + 6047401 => true, + // MongoDB 7-ent: Search index commands are only supported with Atlas. + 115 => true, + // MongoDB 4 to 6, 7-community + 59 => 'no such command: \'createSearchIndexes\'' === $e->getMessage(), + // MongoDB 4 to 6 + 40324 => 'Unrecognized pipeline stage name: \'$listSearchIndexes\'' === $e->getMessage(), + // Not an Atlas Search error + default => false, + }; + } +} diff --git a/src/Operation/Aggregate.php b/src/Operation/Aggregate.php index b5da6470c..4d120f9c4 100644 --- a/src/Operation/Aggregate.php +++ b/src/Operation/Aggregate.php @@ -21,12 +21,14 @@ use MongoDB\Driver\Command; use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; use MongoDB\Driver\Session; use MongoDB\Driver\WriteConcern; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\SearchNotSupportedException; use MongoDB\Exception\UnexpectedValueException; use MongoDB\Exception\UnsupportedException; use MongoDB\Model\CodecCursor; @@ -233,7 +235,15 @@ public function execute(Server $server): CursorInterface $this->createCommandOptions(), ); - $cursor = $this->executeCommand($server, $command); + try { + $cursor = $this->executeCommand($server, $command); + } catch (ServerException $exception) { + if (SearchNotSupportedException::isSearchNotSupportedError($exception)) { + throw SearchNotSupportedException::create($exception); + } + + throw $exception; + } if (isset($this->options['codec'])) { return CodecCursor::fromCursor($cursor, $this->options['codec']); diff --git a/src/Operation/CreateSearchIndexes.php b/src/Operation/CreateSearchIndexes.php index d21ed9428..0b5b59573 100644 --- a/src/Operation/CreateSearchIndexes.php +++ b/src/Operation/CreateSearchIndexes.php @@ -19,8 +19,10 @@ use MongoDB\Driver\Command; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Driver\Server; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\SearchNotSupportedException; use MongoDB\Exception\UnsupportedException; use MongoDB\Model\SearchIndexInput; @@ -83,7 +85,15 @@ public function execute(Server $server): array $cmd['comment'] = $this->options['comment']; } - $cursor = $server->executeCommand($this->databaseName, new Command($cmd)); + try { + $cursor = $server->executeCommand($this->databaseName, new Command($cmd)); + } catch (ServerException $exception) { + if (SearchNotSupportedException::isSearchNotSupportedError($exception)) { + throw SearchNotSupportedException::create($exception); + } + + throw $exception; + } /** @var object{indexesCreated: list} $result */ $result = current($cursor->toArray()); diff --git a/src/Operation/DropSearchIndex.php b/src/Operation/DropSearchIndex.php index c79025fbb..1e75216cd 100644 --- a/src/Operation/DropSearchIndex.php +++ b/src/Operation/DropSearchIndex.php @@ -22,6 +22,7 @@ use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; use MongoDB\Driver\Server; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\SearchNotSupportedException; use MongoDB\Exception\UnsupportedException; /** @@ -72,6 +73,10 @@ public function execute(Server $server): void } 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) { + if (SearchNotSupportedException::isSearchNotSupportedError($e)) { + throw SearchNotSupportedException::create($e); + } + throw $e; } } diff --git a/src/Operation/UpdateSearchIndex.php b/src/Operation/UpdateSearchIndex.php index 4543914bb..56cff3562 100644 --- a/src/Operation/UpdateSearchIndex.php +++ b/src/Operation/UpdateSearchIndex.php @@ -19,8 +19,10 @@ use MongoDB\Driver\Command; use MongoDB\Driver\Exception\RuntimeException as DriverRuntimeException; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Driver\Server; use MongoDB\Exception\InvalidArgumentException; +use MongoDB\Exception\SearchNotSupportedException; use MongoDB\Exception\UnsupportedException; use function MongoDB\is_document; @@ -76,6 +78,14 @@ public function execute(Server $server): void $cmd['comment'] = $this->options['comment']; } - $server->executeCommand($this->databaseName, new Command($cmd)); + try { + $server->executeCommand($this->databaseName, new Command($cmd)); + } catch (ServerException $e) { + if (SearchNotSupportedException::isSearchNotSupportedError($e)) { + throw SearchNotSupportedException::create($e); + } + + throw $e; + } } } diff --git a/tests/Collection/CollectionFunctionalTest.php b/tests/Collection/CollectionFunctionalTest.php index 95659381c..e5eaa775b 100644 --- a/tests/Collection/CollectionFunctionalTest.php +++ b/tests/Collection/CollectionFunctionalTest.php @@ -783,7 +783,7 @@ public function testMethodInTransactionWithReadConcernOption($method): void public function testListSearchIndexesInheritTypeMap(): void { - $this->skipIfAtlasSearchIndexIsNotSupported(); + $this->skipIfSearchIndexIsNotSupported(); $collection = new Collection($this->manager, $this->getDatabaseName(), $this->getCollectionName(), ['typeMap' => ['root' => 'array']]); diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index 78ade169c..679e85a25 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -8,7 +8,6 @@ use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use function bin2hex; -use function getenv; use function putenv; use function random_bytes; use function sprintf; @@ -230,10 +229,7 @@ public static function provideExamples(): Generator #[Group('atlas')] public function testAtlasSearch(): void { - $uri = getenv('MONGODB_URI') ?? ''; - if (! self::isAtlas($uri)) { - $this->markTestSkipped('Atlas Search examples are only supported on MongoDB Atlas'); - } + $this->skipIfSearchIndexIsNotSupported(); $this->skipIfServerVersion('<', '7.0', 'Atlas Search examples require MongoDB 7.0 or later'); diff --git a/tests/Exception/SearchNotSupportedExceptionTest.php b/tests/Exception/SearchNotSupportedExceptionTest.php new file mode 100644 index 000000000..eae5cc00b --- /dev/null +++ b/tests/Exception/SearchNotSupportedExceptionTest.php @@ -0,0 +1,76 @@ +createCollection($this->getDatabaseName(), 'SearchNotSupportedException'); + + try { + $collection->listSearchIndexes(); + } catch (SearchNotSupportedException) { + // If an exception is thrown because Atlas Search is not supported, + // then the test is successful because it has the correct exception class. + } + } + + #[DoesNotPerformAssertions] + public function testCreateSearchIndexNotSupportedException(): void + { + $collection = $this->createCollection($this->getDatabaseName(), 'SearchNotSupportedException'); + + try { + $collection->createSearchIndex(['mappings' => ['dynamic' => false]], ['name' => 'test-search-index']); + } catch (SearchNotSupportedException) { + // If an exception is thrown because Atlas Search is not supported, + // then the test is successful because it has the correct exception class. + } + + try { + $collection->updateSearchIndex('test-search-index', ['mappings' => ['dynamic' => true]]); + } catch (SearchNotSupportedException) { + // If an exception is thrown because Atlas Search is not supported, + // then the test is successful because it has the correct exception class. + } + + try { + $collection->dropSearchIndex('test-search-index'); + } catch (SearchNotSupportedException) { + // If an exception is thrown because Atlas Search is not supported, + // then the test is successful because it has the correct exception class. + } + } + + public function testOtherStageNotFound(): void + { + $collection = $this->createCollection($this->getDatabaseName(), 'SearchNotSupportedException'); + + try { + $collection->aggregate([ + ['$searchStageNotExisting' => ['text' => ['query' => 'test', 'path' => 'field']]], + ]); + self::fail('Expected ServerException was not thrown'); + } catch (ServerException $exception) { + self::assertNotInstanceOf(SearchNotSupportedException::class, $exception, $exception); + } + } + + public function testOtherCommandNotFound(): void + { + try { + $this->manager->executeCommand($this->getDatabaseName(), new Command(['nonExistingCommand' => 1])); + self::fail('Expected ServerException was not thrown'); + } catch (ServerException $exception) { + self::assertFalse(SearchNotSupportedException::isSearchNotSupportedError($exception)); + } + } +} diff --git a/tests/FunctionalTestCase.php b/tests/FunctionalTestCase.php index 156d80149..83f548fe6 100644 --- a/tests/FunctionalTestCase.php +++ b/tests/FunctionalTestCase.php @@ -8,6 +8,7 @@ use MongoDB\Collection; use MongoDB\Driver\Command; use MongoDB\Driver\Exception\CommandException; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Driver\Manager; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\Server; @@ -50,8 +51,6 @@ abstract class FunctionalTestCase extends TestCase { - private const ATLAS_TLD = '/\.(mongodb\.net|mongodb-dev\.net)/'; - protected Manager $manager; private array $configuredFailPoints = []; @@ -434,13 +433,22 @@ protected function skipIfServerVersion(string $operator, string $version, ?strin } } - protected function skipIfAtlasSearchIndexIsNotSupported(): void + protected function skipIfSearchIndexIsNotSupported(): void { - if (! self::isAtlas()) { - self::markTestSkipped('Search Indexes are only supported on MongoDB Atlas 7.0+'); - } + try { + $this->createCollection($this->getDatabaseName(), __METHOD__); + $this->manager->executeWriteCommand($this->getDatabaseName(), new Command([ + 'dropSearchIndex' => __METHOD__, + 'name' => 'nonexistent-index', + ])); + } catch (ServerException $exception) { + // Code 27 = Search index does not exist, which indicates that the feature is supported + if ($exception->getCode() === 27) { + return; + } - $this->skipIfServerVersion('<', '7.0', 'Search Indexes are only supported on MongoDB Atlas 7.0+'); + self::markTestSkipped($exception->getMessage()); + } } protected function skipIfChangeStreamIsNotSupported(): void @@ -518,11 +526,6 @@ protected function isEnterprise(): bool 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/SpecTests/SearchIndexSpecTest.php b/tests/SpecTests/SearchIndexSpecTest.php index 6f8d3b144..6d825a129 100644 --- a/tests/SpecTests/SearchIndexSpecTest.php +++ b/tests/SpecTests/SearchIndexSpecTest.php @@ -31,7 +31,7 @@ public function setUp(): void { parent::setUp(); - $this->skipIfAtlasSearchIndexIsNotSupported(); + $this->skipIfSearchIndexIsNotSupported(); } /** diff --git a/tests/UnifiedSpecTests/UnifiedSpecTest.php b/tests/UnifiedSpecTests/UnifiedSpecTest.php index 70d90530e..3d40d4e87 100644 --- a/tests/UnifiedSpecTests/UnifiedSpecTest.php +++ b/tests/UnifiedSpecTests/UnifiedSpecTest.php @@ -366,9 +366,7 @@ public static function provideFailingTests(): Generator #[DataProvider('provideIndexManagementTests')] public function testIndexManagement(UnifiedTestCase $test): void { - if (self::isAtlas()) { - self::markTestSkipped('Search Indexes tests must run on a non-Atlas cluster'); - } + $this->skipIfSearchIndexIsNotSupported(); if (! self::isEnterprise()) { self::markTestSkipped('Specific Atlas error messages are only available on Enterprise server'); diff --git a/tests/UnifiedSpecTests/UnifiedTestRunner.php b/tests/UnifiedSpecTests/UnifiedTestRunner.php index 3069636b7..0267edffc 100644 --- a/tests/UnifiedSpecTests/UnifiedTestRunner.php +++ b/tests/UnifiedSpecTests/UnifiedTestRunner.php @@ -32,6 +32,7 @@ use function PHPUnit\Framework\assertIsString; use function PHPUnit\Framework\assertNotEmpty; use function PHPUnit\Framework\assertNotFalse; +use function preg_match; use function preg_replace; use function sprintf; use function str_starts_with; @@ -89,7 +90,7 @@ public function __construct(private string $internalClientUri) * * Atlas Data Lake also does not support killAllSessions. */ - if (FunctionalTestCase::isAtlas($internalClientUri) || $this->isAtlasDataLake()) { + if ($this->isAtlas($internalClientUri) || $this->isAtlasDataLake()) { $this->allowKillAllSessions = false; } @@ -307,6 +308,11 @@ private function getTopology(): string }; } + private function isAtlas(string $internalClientUri): bool + { + return preg_match('/\.(mongodb\.net|mongodb-dev\.net)/', $internalClientUri); + } + private function isAtlasDataLake(): bool { $database = $this->internalClient->selectDatabase('admin');