diff --git a/.github/workflows/atlas-ci.yml b/.github/workflows/atlas-ci.yml new file mode 100644 index 0000000000..e113452fd0 --- /dev/null +++ b/.github/workflows/atlas-ci.yml @@ -0,0 +1,99 @@ +name: "Atlas CI" + +on: + pull_request: + branches: + - "*.x" + - "feature/*" + push: + +jobs: + atlas-local: + runs-on: "ubuntu-latest" + strategy: + fail-fast: false + matrix: + php-version: + - "8.4" + symfony: + - "stable" + proxy: + - "lazy-ghost" + include: + # Test with ProxyManager + - php-version: "8.1" + symfony: "6.4" + proxy: "proxy-manager" + os: "ubuntu-latest" + + steps: + - name: "Checkout" + uses: "actions/checkout@v5" + with: + fetch-depth: 2 + + - name: "Create MongoDB Atlas Local" + run: | + docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:8.2 + until docker exec --tty mongodb mongosh --eval "db.runCommand({ ping: 1 })"; do + sleep 1 + done + until docker exec --tty mongodb mongosh --eval "db.createCollection('connection_test') && db.getCollection('connection_test').createSearchIndex({mappings:{dynamic: true}})"; do + sleep 1 + done + + - name: "Show MongoDB server status" + run: | + docker exec --tty mongodb mongosh --eval "db.runCommand({ serverStatus: 1 })" + + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php-version }} + extensions: "mongodb, bcmath" + key: "extcache-v1" + + - name: Cache extensions + uses: actions/cache@v4 + with: + path: ${{ steps.extcache.outputs.dir }} + key: ${{ steps.extcache.outputs.key }} + restore-keys: ${{ steps.extcache.outputs.key }} + + - name: "Install PHP" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + tools: "pecl" + extensions: "mongodb, bcmath" + coverage: "none" + ini-values: "zend.assertions=1" + + - name: "Show driver information" + run: "php --ri mongodb" + + # Not used, skip transient dependencies + - name: "Remove phpbench/phpbench" + run: composer remove --no-update --dev phpbench/phpbench + + - name: "Configure Symfony ${{ matrix.symfony }}" + if: "${{ matrix.symfony != 'stable' }}" + run: | + composer config minimum-stability dev + # update symfony deps + composer require --no-update symfony/console:^${{ matrix.symfony }} + composer require --no-update symfony/var-dumper:^${{ matrix.symfony }} + composer require --no-update --dev symfony/cache:^${{ matrix.symfony }} + + - name: "Install dependencies with Composer" + uses: "ramsey/composer-install@v3" + with: + dependency-versions: "highest" + composer-options: "--prefer-dist" + + - name: "Run PHPUnit with Atlas Local" + run: "vendor/bin/phpunit --group atlas" + env: + DOCTRINE_MONGODB_SERVER: "mongodb://127.0.0.1:27017/?directConnection=true" + USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }} diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index c766bd2238..16407bfd99 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -159,7 +159,7 @@ jobs: topology: ${{ matrix.topology }} - name: "Run PHPUnit" - run: "vendor/bin/phpunit" + run: "vendor/bin/phpunit --exclude-group=atlas" env: DOCTRINE_MONGODB_SERVER: ${{ steps.setup-mongodb.outputs.cluster-uri }} USE_LAZY_GHOST_OBJECTS: ${{ matrix.proxy == 'lazy-ghost' && '1' || '0' }}" diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000000..3226d135fa --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,7 @@ +version: '3.8' + +services: + mongodb-atlas-local: + image: mongodb/mongodb-atlas-local + ports: + - "27018:27017" diff --git a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php index 741b3ea1b0..36a2642722 100644 --- a/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php +++ b/lib/Doctrine/ODM/MongoDB/Mapping/ClassMetadata.php @@ -473,7 +473,7 @@ public const VECTOR_SIMILARITY_EUCLIDEAN = 'euclidean'; public const VECTOR_SIMILARITY_COSINE = 'cosine'; - public const VECTOR_SIMILARITY_DOT_PRODUCT = 'dot_product'; + public const VECTOR_SIMILARITY_DOT_PRODUCT = 'dotProduct'; public const VECTOR_QUANTIZATION_NONE = 'none'; public const VECTOR_QUANTIZATION_SCALAR = 'scalar'; public const VECTOR_QUANTIZATION_BINARY = 'binary'; diff --git a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php index e1c347eff9..665f4ef964 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/BaseTestCase.php @@ -14,6 +14,7 @@ use Doctrine\Persistence\Mapping\Driver\MappingDriver; use MongoDB\Client; use MongoDB\Driver\Command; +use MongoDB\Driver\Exception\CommandException; use MongoDB\Driver\Manager; use MongoDB\Driver\Server; use MongoDB\Model\DatabaseInfo; @@ -45,23 +46,34 @@ abstract class BaseTestCase extends TestCase protected static bool $allowsTransactions = true; protected ?DocumentManager $dm; protected UnitOfWork $uow; + private bool $disableFailPoints = false; - public function setUp(): void + protected function setUp(): void { $this->dm = static::createTestDocumentManager(); $this->uow = $this->dm->getUnitOfWork(); } - public function tearDown(): void + protected function tearDown(): void { if (! $this->dm) { return; } + $client = $this->dm->getClient(); + + // Remove any fail points that may have been set + if ($this->disableFailPoints) { + $client->getDatabase('admin')->command([ + 'configureFailPoint' => 'failCommand', + 'mode' => 'off', + ]); + $this->disableFailPoints = false; + } + // Check if the database exists. Calling listCollections on a non-existing // database in a sharded setup will cause an invalid command cursor to be // returned - $client = $this->dm->getClient(); $databases = iterator_to_array($client->listDatabases()); $databaseNames = array_map(static fn (DatabaseInfo $database) => $database->getName(), $databases); if (! in_array(DOCTRINE_MONGODB_DATABASE, $databaseNames)) { @@ -294,4 +306,28 @@ private static function detectTransactionSupport(): bool return $manager->selectServer()->getType() !== Server::TYPE_STANDALONE; } + + protected function createFailPoint(string $failCommand, bool $transient = false, int $times = 1): void + { + try { + $this->dm->getClient()->getManager()->executeCommand( + 'admin', + new Command([ + 'configureFailPoint' => 'failCommand', + 'mode' => ['times' => $times], + 'data' => [ + 'errorCode' => 192, // FailPointEnabled + 'errorLabels' => $transient ? ['TransientTransactionError'] : [], + 'failCommands' => [$failCommand], + ], + ]), + ); + $this->disableFailPoints = true; + } catch (CommandException $exception) { + // no such command: 'configureFailPoint' + if ($exception->getCode() === 59) { + self::markTestSkipped('Test skipped because the server does not support fail points'); + } + } + } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php index 7d2cd2bc02..d0567a57ea 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/Events/TransactionalLifecycleEventsTest.php @@ -23,16 +23,6 @@ public function setUp(): void $this->skipTestIfTransactionalFlushDisabled(); } - public function tearDown(): void - { - $this->dm->getClient()->getDatabase('admin')->command([ - 'configureFailPoint' => 'failCommand', - 'mode' => 'off', - ]); - - parent::tearDown(); - } - public function testPersistEvents(): void { $root = new RootEventDocument(); @@ -41,7 +31,7 @@ public function testPersistEvents(): void $root->embedded = new EmbeddedEventDocument(); $root->embedded->name = 'embedded'; - $this->createFailPoint('insert'); + $this->createFailPoint('insert', transient: true); $this->dm->persist($root); $this->dm->flush(); @@ -61,7 +51,7 @@ public function testUpdateEvents(): void $this->dm->persist($root); $this->dm->flush(); - $this->createFailPoint('update'); + $this->createFailPoint('update', transient: true); $root->name = 'updated'; $root->embedded->name = 'updated'; @@ -85,7 +75,7 @@ public function testUpdateEventsRootOnly(): void $this->dm->persist($root); $this->dm->flush(); - $this->createFailPoint('update'); + $this->createFailPoint('update', transient: true); $root->name = 'updated'; @@ -108,7 +98,7 @@ public function testUpdateEventsEmbeddedOnly(): void $this->dm->persist($root); $this->dm->flush(); - $this->createFailPoint('update'); + $this->createFailPoint('update', transient: true); $root->embedded->name = 'updated'; @@ -136,7 +126,7 @@ public function testUpdateEventsWithNewEmbeddedDocument(): void $this->dm->persist($root); $this->dm->flush(); - $this->createFailPoint('update'); + $this->createFailPoint('update', transient: true); $root->name = 'updated'; $root->embedded = $secondEmbedded; @@ -168,7 +158,7 @@ public function testRemoveEvents(): void $this->dm->persist($root); $this->dm->flush(); - $this->createFailPoint('delete'); + $this->createFailPoint('delete', transient: true); $this->dm->remove($root); $this->dm->flush(); @@ -185,19 +175,6 @@ protected static function createTestDocumentManager(): DocumentManager return DocumentManager::create($client, $config); } - - private function createFailPoint(string $failCommand): void - { - $this->dm->getClient()->getDatabase('admin')->command([ - 'configureFailPoint' => 'failCommand', - 'mode' => ['times' => 1], - 'data' => [ - 'errorCode' => 192, // FailPointEnabled - 'errorLabels' => ['TransientTransactionError'], - 'failCommands' => [$failCommand], - ], - ]); - } } #[ODM\MappedSuperclass] diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php new file mode 100644 index 0000000000..7dc805bbcc --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/AtlasSearchTest.php @@ -0,0 +1,116 @@ +dm->getSchemaManager(); + $schemaManager->createDocumentCollection(CmsArticle::class); + $schemaManager->createDocumentSearchIndexes(CmsArticle::class); + + $user = new CmsUser(); + $user->status = 'active'; + $this->dm->persist($user); + + $article1 = new CmsArticle(); + $article1->topic = 'Technology'; + $article1->title = 'Introduction to MongoDB Atlas Search'; + $article1->text = 'MongoDB Atlas Search provides full-text search capabilities with advanced features like autocomplete and fuzzy matching.'; + $article1->setAuthor($user); + + $article2 = new CmsArticle(); + $article2->topic = 'Database'; + $article2->title = 'Working with Document Databases'; + $article2->text = 'Document databases like MongoDB offer flexible schema design and powerful query capabilities for modern applications.'; + $article2->setAuthor($user); + + $article3 = new CmsArticle(); + $article3->topic = 'Programming'; + $article3->title = 'PHP and MongoDB Integration'; + $article3->text = 'The MongoDB ODM for PHP provides an easy way to work with MongoDB documents using object-oriented programming.'; + $article3->setAuthor($user); + + $this->dm->persist($article1); + $this->dm->persist($article2); + $this->dm->persist($article3); + $this->dm->flush(); + + // Wait for the search index to be ready (Atlas Local needs time to build the index) + sleep(2); + + $results = $this->dm->createAggregationBuilder(CmsArticle::class) + ->search() + ->index('search_articles') + ->text() + ->query('Mongo') + ->path('title') + ->fuzzy(2, 2) + ->limit(5) + ->getAggregation()->execute()->toArray(); + + $this->assertNotEmpty($results, 'Autocomplete search should return results'); + + $results = $this->dm->createAggregationBuilder(CmsArticle::class) + ->search() + ->index('search_articles') + ->compound() + ->must() + ->text() + ->query('database') + ->path('text') + ->should() + ->text() + ->query('MongoDB') + ->path('title') + ->addFields() + ->field('score') + ->expression(['$meta' => 'searchScore']) + ->sort(['score' => 'searchScore']) + ->getAggregation()->execute()->toArray(); + + foreach ($results as $result) { + $this->assertIsArray($result); + $this->assertStringContainsStringIgnoringCase('database', $result['text']); + } + + $results = $this->dm->createAggregationBuilder(CmsArticle::class) + ->search() + ->index('search_articles') + ->text() + ->query('Atlas Search') + ->path('text') + ->highlight('text', 100, 1) + ->addFields() + ->field('highlights') + ->expression(['$meta' => 'searchHighlights']) + ->getAggregation()->execute()->toArray(); + + foreach ($results as $result) { + $this->assertIsArray($result); + $this->assertIsArray($result['highlights']); + } + + $results = $this->dm->createAggregationBuilder(CmsArticle::class) + ->search() + ->index('search_articles') + ->text() + ->query('MongoDB') + ->path('title', 'text') + ->countDocuments('total') + ->getAggregation()->execute()->toArray(); + + $this->assertNotEmpty($results, 'Count search should return results'); + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php b/tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php new file mode 100644 index 0000000000..081dcbccbb --- /dev/null +++ b/tests/Doctrine/ODM/MongoDB/Tests/Functional/VectorSearchTest.php @@ -0,0 +1,84 @@ +dm->getSchemaManager(); + + // Create the collection and vector search indexes + $schemaManager->createDocumentCollection(VectorEmbedding::class); + $schemaManager->createDocumentSearchIndexes(VectorEmbedding::class); + + // Insert some test documents with vector embeddings + $doc1 = new VectorEmbedding(); + $doc1->vectorFloat = [1.0, 2.0, 3.0]; + $doc1->vectorInt = [1, 2, 3]; + $doc1->filterField = 'active'; + + $doc2 = new VectorEmbedding(); + $doc2->vectorFloat = [4.0, 5.0, 6.0]; + $doc2->vectorInt = [4, 5, 6]; + $doc2->filterField = 'inactive'; + + $doc3 = new VectorEmbedding(); + $doc3->vectorFloat = [1.5, 2.5, 3.5]; + $doc3->vectorInt = [2, 3, 4]; + $doc3->filterField = 'active'; + + $this->dm->persist($doc1); + $this->dm->persist($doc2); + $this->dm->persist($doc3); + $this->dm->flush(); + + // Wait for search index to be ready (Atlas Local needs time to build the index) + sleep(2); + + $results = $this->dm->createAggregationBuilder(VectorEmbedding::class) + ->vectorSearch() + ->index('default') + ->queryVector([1.1, 2.1, 3.1]) + ->path('vectorFloat') + ->numCandidates(10) + ->limit(5) + ->set() + ->field('score') + ->expression(['$meta' => 'vectorSearchScore']) + ->getAggregation()->execute()->toArray(); + + $this->assertCount(3, $results); + foreach ($results as $result) { + $this->assertIsArray($result); + $this->assertIsFloat($result['score'], 'Result should have a score'); + } + + // Test with filter + $results = ($builder = $this->dm->createAggregationBuilder(VectorEmbedding::class)) + ->vectorSearch() + ->index('vector_int') + ->queryVector([1, 1, 3]) + ->path('vectorInt') + ->numCandidates(10) + ->limit(5) + ->filter($builder->matchExpr()->field('filterField')->equals('active')) + ->getAggregation()->execute()->toArray(); + + $this->assertCount(2, $results); + foreach ($results as $result) { + $this->assertIsArray($result); + $this->assertEquals('active', $result['filterField'], 'Filtered results should only contain active documents'); + } + } +} diff --git a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkCommitConsistencyTest.php b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkCommitConsistencyTest.php index 07376b591a..d75f8c17f3 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkCommitConsistencyTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkCommitConsistencyTest.php @@ -18,16 +18,6 @@ class UnitOfWorkCommitConsistencyTest extends BaseTestCase // This test requires transactions to be disabled protected static bool $allowsTransactions = false; - public function tearDown(): void - { - $this->dm->getClient()->getDatabase('admin')->command([ - 'configureFailPoint' => 'failCommand', - 'mode' => 'off', - ]); - - parent::tearDown(); - } - public function testInsertErrorKeepsFailingInsertions(): void { $firstUser = new ForumUser(); @@ -446,16 +436,4 @@ protected static function createTestDocumentManager(): DocumentManager return DocumentManager::create($client, $config); } - - private function createFailpoint(string $commandName): void - { - $this->dm->getClient()->getDatabase('admin')->command([ - 'configureFailPoint' => 'failCommand', - 'mode' => ['times' => 1], - 'data' => [ - 'errorCode' => 192, // FailPointEnabled - 'failCommands' => [$commandName], - ], - ]); - } } diff --git a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTransactionalCommitConsistencyTest.php b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTransactionalCommitConsistencyTest.php index da80267c1c..9c69b0b44e 100644 --- a/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTransactionalCommitConsistencyTest.php +++ b/tests/Doctrine/ODM/MongoDB/Tests/UnitOfWorkTransactionalCommitConsistencyTest.php @@ -25,16 +25,6 @@ public function setUp(): void $this->skipTestIfNoTransactionSupport(); } - public function tearDown(): void - { - $this->dm->getClient()->getDatabase('admin')->command([ - 'configureFailPoint' => 'failCommand', - 'mode' => 'off', - ]); - - parent::tearDown(); - } - public function testFatalInsertError(): void { $firstUser = new ForumUser(); @@ -48,7 +38,7 @@ public function testFatalInsertError(): void $friendUser = new FriendUser('GromNaN'); $this->uow->persist($friendUser); - $this->createFatalFailPoint('insert'); + $this->createFailPoint('insert'); try { $this->uow->commit(); @@ -92,7 +82,7 @@ public function testTransientInsertError(): void $this->uow->persist($friendUser); // Add a failpoint that triggers a transient error. The transaction will be retried and succeeds - $this->createTransientFailPoint('insert'); + $this->createFailPoint('insert', transient: true); $this->uow->commit(); @@ -130,7 +120,7 @@ public function testMultipleTransientErrors(): void $this->uow->persist($friendUser); // Add a failpoint that triggers multiple transient errors. The transaction is expected to fail - $this->createTransientFailPoint('insert', 2); + $this->createFailPoint('insert', transient: true, times: 2); try { $this->uow->commit(); @@ -251,7 +241,7 @@ public function testFatalUpsertError(): void $user->username = 'alcaeus'; $this->uow->persist($user); - $this->createFatalFailPoint('update'); + $this->createFailPoint('update'); try { $this->uow->commit(); @@ -278,7 +268,7 @@ public function testTransientUpsertError(): void $user->username = 'alcaeus'; $this->uow->persist($user); - $this->createTransientFailPoint('update'); + $this->createFailPoint('update', transient: true); $this->uow->commit(); @@ -300,7 +290,7 @@ public function testFatalUpdateError(): void $user->username = 'jmikola'; - $this->createFatalFailPoint('update'); + $this->createFailPoint('update'); try { $this->uow->commit(); @@ -328,7 +318,7 @@ public function testTransientUpdateError(): void $user->username = 'jmikola'; - $this->createTransientFailPoint('update'); + $this->createFailPoint('update', transient: true); $this->uow->commit(); @@ -353,7 +343,7 @@ public function testFatalUpdateErrorWithNewEmbeddedDocument(): void $address->setCity('Olching'); $user->setAddress($address); - $this->createFatalFailPoint('update'); + $this->createFailPoint('update'); try { $this->uow->commit(); @@ -381,7 +371,7 @@ public function testTransientUpdateErrorWithNewEmbeddedDocument(): void $address->setCity('Olching'); $user->setAddress($address); - $this->createTransientFailPoint('update'); + $this->createFailPoint('update', transient: true); $this->uow->commit(); @@ -405,7 +395,7 @@ public function testFatalUpdateErrorOfEmbeddedDocument(): void $address->setCity('Munich'); - $this->createFatalFailPoint('update'); + $this->createFailPoint('update'); try { $this->uow->commit(); @@ -435,7 +425,7 @@ public function testTransientUpdateErrorOfEmbeddedDocument(): void $address->setCity('Munich'); - $this->createTransientFailPoint('update'); + $this->createFailPoint('update', transient: true); $this->uow->commit(); @@ -459,7 +449,7 @@ public function testFatalUpdateErrorWithRemovedEmbeddedDocument(): void $user->removeAddress(); - $this->createFatalFailPoint('update'); + $this->createFailPoint('update'); try { $this->uow->commit(); @@ -491,7 +481,7 @@ public function testTransientUpdateErrorWithRemovedEmbeddedDocument(): void $user->removeAddress(); - $this->createTransientFailPoint('update'); + $this->createFailPoint('update', transient: true); $this->uow->commit(); @@ -515,7 +505,7 @@ public function testFatalDeleteErrorWithEmbeddedDocument(): void $this->uow->remove($user); - $this->createFatalFailPoint('delete'); + $this->createFailPoint('delete'); try { $this->uow->commit(); @@ -549,7 +539,7 @@ public function testTransientDeleteErrorWithEmbeddedDocument(): void $this->uow->remove($user); - $this->createTransientFailPoint('delete'); + $this->createFailPoint('delete', transient: true); $this->uow->commit(); @@ -582,7 +572,7 @@ public function testTransientErrorPreservesCollectionChangesets(): void // Remove fooUser and create a transient failpoint to force the deletion // to fail. This exposes the issue with collections $this->uow->remove($fooUser); - $this->createTransientFailPoint('delete'); + $this->createFailPoint('delete', transient: true); $this->uow->commit(); @@ -608,29 +598,4 @@ protected static function getConfiguration(): Configuration return $configuration; } - - private function createTransientFailPoint(string $failCommand, int $times = 1): void - { - $this->dm->getClient()->getDatabase('admin')->command([ - 'configureFailPoint' => 'failCommand', - 'mode' => ['times' => $times], - 'data' => [ - 'errorCode' => 192, // FailPointEnabled - 'errorLabels' => ['TransientTransactionError'], - 'failCommands' => [$failCommand], - ], - ]); - } - - private function createFatalFailPoint(string $failCommand): void - { - $this->dm->getClient()->getDatabase('admin')->command([ - 'configureFailPoint' => 'failCommand', - 'mode' => ['times' => 1], - 'data' => [ - 'errorCode' => 192, // FailPointEnabled - 'failCommands' => [$failCommand], - ], - ]); - } }