From 05f5b74709c48716cab5e493ee3cca9a414de853 Mon Sep 17 00:00:00 2001 From: Nora Reidy Date: Fri, 11 Oct 2024 13:22:01 -0400 Subject: [PATCH 01/92] DOCSP-43615: raw() field conversions (#3172) * DOCSP-43615: raw() ID conversion * utcdatetime * wording --- docs/upgrade.txt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/upgrade.txt b/docs/upgrade.txt index 3032b8e1e..d730435fa 100644 --- a/docs/upgrade.txt +++ b/docs/upgrade.txt @@ -114,12 +114,19 @@ This library version introduces the following breaking changes: - In query results, the library converts BSON ``UTCDateTime`` objects to ``Carbon`` date classes, applying the default timezone. + In v5.1, the library also performs this conversion to the ``Model::raw()`` + method results before hydrating a Model instance. + - ``id`` is an alias for the ``_id`` field in MongoDB documents, and the library automatically converts between ``id`` and ``_id`` when querying data. The query result object includes an ``id`` field to represent the document's ``_id`` field. Because of this behavior, you cannot have two separate ``id`` and ``_id`` fields in your documents. + In v5.1, the library also performs this conversion to the ``Model::raw()`` + method results before hydrating a Model instance. When passing a complex query + filter, use the ``DB::where()`` method instead of ``Model::raw()``. + - Removes support for the ``$collection`` property. The following code shows how to assign a MongoDB collection to a variable in your ``User`` class in older versions compared to v5.0: From 99af0359da6dcf32f9e16e650693f9476b469084 Mon Sep 17 00:00:00 2001 From: Nora Reidy Date: Fri, 11 Oct 2024 13:33:13 -0400 Subject: [PATCH 02/92] DOCSP-44172: Laravel Herd (#3171) Adds information about Laravel Herd to the quick start --- docs/quick-start/download-and-install.txt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/quick-start/download-and-install.txt b/docs/quick-start/download-and-install.txt index 5e9139ec8..f4e480ce5 100644 --- a/docs/quick-start/download-and-install.txt +++ b/docs/quick-start/download-and-install.txt @@ -28,6 +28,19 @@ Download and Install the Dependencies Complete the following steps to install and add the {+odm-short+} dependencies to a Laravel web application. +.. tip:: + + As an alternative to the following installation steps, you can use Laravel Herd + to install MongoDB and configure a Laravel MongoDB development environment. For + more information about using Laravel Herd with MongoDB, see the following resources: + + - `Installing MongoDB via Herd Pro + `__ in the Herd + documentation + - `Laravel Herd Adds Native MongoDB Support + `__ + in the MongoDB Developer Center + .. procedure:: :style: connected From 8cf9f66fee93f0b7e1f461948e37b2f18405e1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 6 Nov 2024 14:27:25 +0100 Subject: [PATCH 03/92] PHPORM-259 Register MongoDB Session Handler with `SESSION_DRIVER=mongodb` (#3192) * PHPORM-259 Register MongoDB Session Handler with SESSION_DRIVER=mongodb * Explicit dependency to symfony/http-foundation --- composer.json | 3 ++- phpstan-baseline.neon | 5 ++++ src/MongoDBServiceProvider.php | 21 +++++++++++++++++ tests/SessionTest.php | 43 +++++++++++++++++++++++++++++++++- 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 9c958f1c4..68ec8bc4f 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ "illuminate/database": "^10.30|^11", "illuminate/events": "^10.0|^11", "illuminate/support": "^10.0|^11", - "mongodb/mongodb": "^1.18" + "mongodb/mongodb": "^1.18", + "symfony/http-foundation": "^6.4|^7" }, "require-dev": { "mongodb/builder": "^0.2", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e85adb7d2..7b34210ad 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,6 +5,11 @@ parameters: count: 3 path: src/MongoDBBusServiceProvider.php + - + message: "#^Access to an undefined property Illuminate\\\\Foundation\\\\Application\\:\\:\\$config\\.$#" + count: 4 + path: src/MongoDBServiceProvider.php + - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" count: 3 diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index 0932048c9..9db2122dc 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -10,6 +10,7 @@ use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; +use Illuminate\Session\SessionManager; use Illuminate\Support\ServiceProvider; use InvalidArgumentException; use League\Flysystem\Filesystem; @@ -20,6 +21,7 @@ use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Queue\MongoConnector; use RuntimeException; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler; use function assert; use function class_exists; @@ -53,6 +55,25 @@ public function register() }); }); + // Session handler for MongoDB + $this->app->resolving(SessionManager::class, function (SessionManager $sessionManager) { + $sessionManager->extend('mongodb', function (Application $app) { + $connectionName = $app->config->get('session.connection') ?: 'mongodb'; + $connection = $app->make('db')->connection($connectionName); + + assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The database connection "%s" used for the session does not use the "mongodb" driver.', $connectionName))); + + return new MongoDbSessionHandler( + $connection->getMongoClient(), + $app->config->get('session.options', []) + [ + 'database' => $connection->getDatabaseName(), + 'collection' => $app->config->get('session.table') ?: 'sessions', + 'ttl' => $app->config->get('session.lifetime'), + ], + ); + }); + }); + // Add cache and lock drivers. $this->app->resolving('cache', function (CacheManager $cache) { $cache->extend('mongodb', function (Application $app, array $config): Repository { diff --git a/tests/SessionTest.php b/tests/SessionTest.php index 7ffbb51f0..ee086f5b8 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -3,7 +3,9 @@ namespace MongoDB\Laravel\Tests; use Illuminate\Session\DatabaseSessionHandler; +use Illuminate\Session\SessionManager; use Illuminate\Support\Facades\DB; +use Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler; class SessionTest extends TestCase { @@ -14,7 +16,7 @@ protected function tearDown(): void parent::tearDown(); } - public function testDatabaseSessionHandler() + public function testDatabaseSessionHandlerCompatibility() { $sessionId = '123'; @@ -30,4 +32,43 @@ public function testDatabaseSessionHandler() $handler->write($sessionId, 'bar'); $this->assertEquals('bar', $handler->read($sessionId)); } + + public function testDatabaseSessionHandlerRegistration() + { + $this->app['config']->set('session.driver', 'database'); + $this->app['config']->set('session.connection', 'mongodb'); + + $session = $this->app['session']; + $this->assertInstanceOf(SessionManager::class, $session); + $this->assertInstanceOf(DatabaseSessionHandler::class, $session->getHandler()); + + $this->assertSessionCanStoreInMongoDB($session); + } + + public function testMongoDBSessionHandlerRegistration() + { + $this->app['config']->set('session.driver', 'mongodb'); + $this->app['config']->set('session.connection', 'mongodb'); + + $session = $this->app['session']; + $this->assertInstanceOf(SessionManager::class, $session); + $this->assertInstanceOf(MongoDbSessionHandler::class, $session->getHandler()); + + $this->assertSessionCanStoreInMongoDB($session); + } + + private function assertSessionCanStoreInMongoDB(SessionManager $session): void + { + $session->put('foo', 'bar'); + $session->save(); + + $this->assertNotNull($session->getId()); + + $data = DB::connection('mongodb') + ->getCollection('sessions') + ->findOne(['_id' => $session->getId()]); + + self::assertIsObject($data); + self::assertSame($session->getId(), $data->_id); + } } From 78905184d965daaaeb92436bba82c0f88d4831f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 2 Jan 2025 14:41:48 +0100 Subject: [PATCH 04/92] PHPORM-274 List search indexes in `Schema::getIndexes()` introspection method (#3233) --- src/Schema/Builder.php | 47 ++++++++++++- tests/AtlasSearchTest.php | 138 ++++++++++++++++++++++++++++++++++++++ tests/SchemaTest.php | 49 ++++++++++---- 3 files changed, 217 insertions(+), 17 deletions(-) create mode 100644 tests/AtlasSearchTest.php 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'); From 6cb38385c4d2b5a70d327f193a4e5c56e829b7d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 2 Jan 2025 18:13:18 +0100 Subject: [PATCH 05/92] PHPORM-273 Add schema helpers to create search and vector indexes (#3230) --- phpcs.xml.dist | 4 +++ src/Schema/Blueprint.php | 36 +++++++++++++++++++++ src/Schema/Builder.php | 5 +++ tests/SchemaTest.php | 67 ++++++++++++++++++++++++++++++++++++++-- tests/TestCase.php | 15 +++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 3b7cc671c..f83429905 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -53,4 +53,8 @@ tests/Ticket/*.php + + + src/Schema/Blueprint.php + diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index f107bd7e5..b77a7799e 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -303,6 +303,42 @@ public function sparse_and_unique($columns = null, $options = []) return $this; } + /** + * Create an Atlas Search Index. + * + * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-search-index-definition-create + * + * @phpstan-param array{ + * analyzer?: string, + * analyzers?: list, + * searchAnalyzer?: string, + * mappings: array{dynamic: true} | array{dynamic?: bool, fields: array}, + * storedSource?: bool|array, + * synonyms?: list, + * ... + * } $definition + */ + public function searchIndex(array $definition, string $name = 'default'): static + { + $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'search']); + + return $this; + } + + /** + * Create an Atlas Vector Search Index. + * + * @see https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#std-label-vector-search-index-definition-create + * + * @phpstan-param array{fields: array} $definition + */ + public function vectorSearchIndex(array $definition, string $name = 'default'): static + { + $this->collection->createSearchIndex($definition, ['name' => $name, 'type' => 'vectorSearch']); + + return $this; + } + /** * Allow fluent columns. * diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index a4e8149f3..fe806f0e5 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -253,6 +253,11 @@ public function getIndexes($table) try { $indexes = $collection->listSearchIndexes(['typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]); foreach ($indexes as $index) { + // Status 'DOES_NOT_EXIST' means the index has been dropped but is still in the process of being removed + if ($index['status'] === 'DOES_NOT_EXIST') { + continue; + } + $indexList[] = [ 'name' => $index['name'], 'columns' => match ($index['type']) { diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index ec1ae47dd..e23fa3d25 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -8,8 +8,11 @@ use Illuminate\Support\Facades\Schema; use MongoDB\BSON\Binary; use MongoDB\BSON\UTCDateTime; +use MongoDB\Collection; +use MongoDB\Database; use MongoDB\Laravel\Schema\Blueprint; +use function assert; use function collect; use function count; @@ -17,8 +20,10 @@ class SchemaTest extends TestCase { public function tearDown(): void { - Schema::drop('newcollection'); - Schema::drop('newcollection_two'); + $database = $this->getConnection('mongodb')->getMongoDB(); + assert($database instanceof Database); + $database->dropCollection('newcollection'); + $database->dropCollection('newcollection_two'); } public function testCreate(): void @@ -474,6 +479,7 @@ public function testGetColumns() $this->assertSame([], $columns); } + /** @see AtlasSearchTest::testGetIndexes() */ public function testGetIndexes() { Schema::create('newcollection', function (Blueprint $collection) { @@ -523,9 +529,54 @@ public function testGetIndexes() $this->assertSame([], $indexes); } + public function testSearchIndex(): void + { + $this->skipIfSearchIndexManagementIsNotSupported(); + + Schema::create('newcollection', function (Blueprint $collection) { + $collection->searchIndex([ + 'mappings' => [ + 'dynamic' => false, + 'fields' => [ + 'foo' => ['type' => 'string', 'analyzer' => 'lucene.whitespace'], + ], + ], + ]); + }); + + $index = $this->getSearchIndex('newcollection', 'default'); + self::assertNotNull($index); + + self::assertSame('default', $index['name']); + self::assertSame('search', $index['type']); + self::assertFalse($index['latestDefinition']['mappings']['dynamic']); + self::assertSame('lucene.whitespace', $index['latestDefinition']['mappings']['fields']['foo']['analyzer']); + } + + public function testVectorSearchIndex() + { + $this->skipIfSearchIndexManagementIsNotSupported(); + + Schema::create('newcollection', function (Blueprint $collection) { + $collection->vectorSearchIndex([ + 'fields' => [ + ['type' => 'vector', 'path' => 'foo', 'numDimensions' => 128, 'similarity' => 'euclidean', 'quantization' => 'none'], + ], + ], 'vector'); + }); + + $index = $this->getSearchIndex('newcollection', 'vector'); + self::assertNotNull($index); + + self::assertSame('vector', $index['name']); + self::assertSame('vectorSearch', $index['type']); + self::assertSame('vector', $index['latestDefinition']['fields'][0]['type']); + } + protected function getIndex(string $collection, string $name) { $collection = DB::getCollection($collection); + assert($collection instanceof Collection); foreach ($collection->listIndexes() as $index) { if (isset($index['key'][$name])) { @@ -535,4 +586,16 @@ protected function getIndex(string $collection, string $name) return false; } + + protected function getSearchIndex(string $collection, string $name): ?array + { + $collection = DB::getCollection($collection); + assert($collection instanceof Collection); + + foreach ($collection->listSearchIndexes(['name' => $name, 'typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]) as $index) { + return $index; + } + + return null; + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 5f5bbecdc..d924777ce 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,7 +5,9 @@ namespace MongoDB\Laravel\Tests; use Illuminate\Foundation\Application; +use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\MongoDBServiceProvider; +use MongoDB\Laravel\Schema\Builder; use MongoDB\Laravel\Tests\Models\User; use MongoDB\Laravel\Validation\ValidationServiceProvider; use Orchestra\Testbench\TestCase as OrchestraTestCase; @@ -64,4 +66,17 @@ protected function getEnvironmentSetUp($app): void $app['config']->set('queue.failed.database', 'mongodb2'); $app['config']->set('queue.failed.driver', 'mongodb'); } + + public function skipIfSearchIndexManagementIsNotSupported(): void + { + try { + $this->getConnection('mongodb')->getCollection('test')->listSearchIndexes(['name' => 'just_for_testing']); + } catch (ServerException $e) { + if (Builder::isAtlasSearchNotSupportedException($e)) { + self::markTestSkipped('Search index management is not supported on this server'); + } + + throw $e; + } + } } From aae91708bf20e63221f11fd84171d189de449930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Jan 2025 18:07:36 +0100 Subject: [PATCH 06/92] Fix tests on Schema index helpers (#3236) Add helpers for index exists/not-exists --- tests/SchemaTest.php | 128 ++++++++++++++++++++++--------------------- 1 file changed, 66 insertions(+), 62 deletions(-) diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index e23fa3d25..61280a726 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -11,10 +11,12 @@ use MongoDB\Collection; use MongoDB\Database; use MongoDB\Laravel\Schema\Blueprint; +use MongoDB\Model\IndexInfo; use function assert; use function collect; use function count; +use function sprintf; class SchemaTest extends TestCase { @@ -81,21 +83,21 @@ public function testIndex(): void $collection->index('mykey1'); }); - $index = $this->getIndex('newcollection', 'mykey1'); + $index = $this->assertIndexExists('newcollection', 'mykey1_1'); $this->assertEquals(1, $index['key']['mykey1']); Schema::table('newcollection', function ($collection) { $collection->index(['mykey2']); }); - $index = $this->getIndex('newcollection', 'mykey2'); + $index = $this->assertIndexExists('newcollection', 'mykey2_1'); $this->assertEquals(1, $index['key']['mykey2']); Schema::table('newcollection', function ($collection) { $collection->string('mykey3')->index(); }); - $index = $this->getIndex('newcollection', 'mykey3'); + $index = $this->assertIndexExists('newcollection', 'mykey3_1'); $this->assertEquals(1, $index['key']['mykey3']); } @@ -105,7 +107,7 @@ public function testPrimary(): void $collection->string('mykey', 100)->primary(); }); - $index = $this->getIndex('newcollection', 'mykey'); + $index = $this->assertIndexExists('newcollection', 'mykey_1'); $this->assertEquals(1, $index['unique']); } @@ -115,7 +117,7 @@ public function testUnique(): void $collection->unique('uniquekey'); }); - $index = $this->getIndex('newcollection', 'uniquekey'); + $index = $this->assertIndexExists('newcollection', 'uniquekey_1'); $this->assertEquals(1, $index['unique']); } @@ -126,58 +128,52 @@ public function testDropIndex(): void $collection->dropIndex('uniquekey_1'); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists('newcollection', 'uniquekey_1'); Schema::table('newcollection', function ($collection) { $collection->unique('uniquekey'); $collection->dropIndex(['uniquekey']); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists('newcollection', 'uniquekey_1'); Schema::table('newcollection', function ($collection) { $collection->index(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertNotNull($index); + $this->assertIndexExists('newcollection', 'field_a_1_field_b_1'); Schema::table('newcollection', function ($collection) { $collection->dropIndex(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertFalse($index); + $this->assertIndexNotExists('newcollection', 'field_a_1_field_b_1'); + $indexName = 'field_a_-1_field_b_1'; Schema::table('newcollection', function ($collection) { $collection->index(['field_a' => -1, 'field_b' => 1]); }); - $index = $this->getIndex('newcollection', 'field_a_-1_field_b_1'); - $this->assertNotNull($index); + $this->assertIndexExists('newcollection', $indexName); Schema::table('newcollection', function ($collection) { $collection->dropIndex(['field_a' => -1, 'field_b' => 1]); }); - $index = $this->getIndex('newcollection', 'field_a_-1_field_b_1'); - $this->assertFalse($index); + $this->assertIndexNotExists('newcollection', $indexName); - Schema::table('newcollection', function ($collection) { - $collection->index(['field_a', 'field_b'], 'custom_index_name'); + $indexName = 'custom_index_name'; + Schema::table('newcollection', function ($collection) use ($indexName) { + $collection->index(['field_a', 'field_b'], $indexName); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertNotNull($index); + $this->assertIndexExists('newcollection', $indexName); - Schema::table('newcollection', function ($collection) { - $collection->dropIndex('custom_index_name'); + Schema::table('newcollection', function ($collection) use ($indexName) { + $collection->dropIndex($indexName); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertFalse($index); + $this->assertIndexNotExists('newcollection', $indexName); } public function testDropIndexIfExists(): void @@ -187,66 +183,58 @@ public function testDropIndexIfExists(): void $collection->dropIndexIfExists('uniquekey_1'); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists('newcollection', 'uniquekey'); Schema::table('newcollection', function (Blueprint $collection) { $collection->unique('uniquekey'); $collection->dropIndexIfExists(['uniquekey']); }); - $index = $this->getIndex('newcollection', 'uniquekey'); - $this->assertEquals(null, $index); + $this->assertIndexNotExists('newcollection', 'uniquekey'); Schema::table('newcollection', function (Blueprint $collection) { $collection->index(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertNotNull($index); + $this->assertIndexExists('newcollection', 'field_a_1_field_b_1'); Schema::table('newcollection', function (Blueprint $collection) { $collection->dropIndexIfExists(['field_a', 'field_b']); }); - $index = $this->getIndex('newcollection', 'field_a_1_field_b_1'); - $this->assertFalse($index); + $this->assertIndexNotExists('newcollection', 'field_a_1_field_b_1'); Schema::table('newcollection', function (Blueprint $collection) { $collection->index(['field_a', 'field_b'], 'custom_index_name'); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertNotNull($index); + $this->assertIndexExists('newcollection', 'custom_index_name'); Schema::table('newcollection', function (Blueprint $collection) { $collection->dropIndexIfExists('custom_index_name'); }); - $index = $this->getIndex('newcollection', 'custom_index_name'); - $this->assertFalse($index); + $this->assertIndexNotExists('newcollection', 'custom_index_name'); } public function testHasIndex(): void { - $instance = $this; - - Schema::table('newcollection', function (Blueprint $collection) use ($instance) { + Schema::table('newcollection', function (Blueprint $collection) { $collection->index('myhaskey1'); - $instance->assertTrue($collection->hasIndex('myhaskey1_1')); - $instance->assertFalse($collection->hasIndex('myhaskey1')); + $this->assertTrue($collection->hasIndex('myhaskey1_1')); + $this->assertFalse($collection->hasIndex('myhaskey1')); }); - Schema::table('newcollection', function (Blueprint $collection) use ($instance) { + Schema::table('newcollection', function (Blueprint $collection) { $collection->index('myhaskey2'); - $instance->assertTrue($collection->hasIndex(['myhaskey2'])); - $instance->assertFalse($collection->hasIndex(['myhaskey2_1'])); + $this->assertTrue($collection->hasIndex(['myhaskey2'])); + $this->assertFalse($collection->hasIndex(['myhaskey2_1'])); }); - Schema::table('newcollection', function (Blueprint $collection) use ($instance) { + Schema::table('newcollection', function (Blueprint $collection) { $collection->index(['field_a', 'field_b']); - $instance->assertTrue($collection->hasIndex(['field_a_1_field_b'])); - $instance->assertFalse($collection->hasIndex(['field_a_1_field_b_1'])); + $this->assertTrue($collection->hasIndex(['field_a_1_field_b'])); + $this->assertFalse($collection->hasIndex(['field_a_1_field_b_1'])); }); } @@ -256,7 +244,7 @@ public function testSparse(): void $collection->sparse('sparsekey'); }); - $index = $this->getIndex('newcollection', 'sparsekey'); + $index = $this->assertIndexExists('newcollection', 'sparsekey_1'); $this->assertEquals(1, $index['sparse']); } @@ -266,7 +254,7 @@ public function testExpire(): void $collection->expire('expirekey', 60); }); - $index = $this->getIndex('newcollection', 'expirekey'); + $index = $this->assertIndexExists('newcollection', 'expirekey_1'); $this->assertEquals(60, $index['expireAfterSeconds']); } @@ -280,7 +268,7 @@ public function testSoftDeletes(): void $collection->string('email')->nullable()->index(); }); - $index = $this->getIndex('newcollection', 'email'); + $index = $this->assertIndexExists('newcollection', 'email_1'); $this->assertEquals(1, $index['key']['email']); } @@ -292,10 +280,10 @@ public function testFluent(): void $collection->timestamp('created_at'); }); - $index = $this->getIndex('newcollection', 'email'); + $index = $this->assertIndexExists('newcollection', 'email_1'); $this->assertEquals(1, $index['key']['email']); - $index = $this->getIndex('newcollection', 'token'); + $index = $this->assertIndexExists('newcollection', 'token_1'); $this->assertEquals(1, $index['key']['token']); } @@ -307,13 +295,13 @@ public function testGeospatial(): void $collection->geospatial('continent', '2dsphere'); }); - $index = $this->getIndex('newcollection', 'point'); + $index = $this->assertIndexExists('newcollection', 'point_2d'); $this->assertEquals('2d', $index['key']['point']); - $index = $this->getIndex('newcollection', 'area'); + $index = $this->assertIndexExists('newcollection', 'area_2d'); $this->assertEquals('2d', $index['key']['area']); - $index = $this->getIndex('newcollection', 'continent'); + $index = $this->assertIndexExists('newcollection', 'continent_2dsphere'); $this->assertEquals('2dsphere', $index['key']['continent']); } @@ -332,7 +320,7 @@ public function testSparseUnique(): void $collection->sparse_and_unique('sparseuniquekey'); }); - $index = $this->getIndex('newcollection', 'sparseuniquekey'); + $index = $this->assertIndexExists('newcollection', 'sparseuniquekey_1'); $this->assertEquals(1, $index['sparse']); $this->assertEquals(1, $index['unique']); } @@ -573,23 +561,39 @@ public function testVectorSearchIndex() self::assertSame('vector', $index['latestDefinition']['fields'][0]['type']); } - protected function getIndex(string $collection, string $name) + protected function assertIndexExists(string $collection, string $name): IndexInfo + { + $index = $this->getIndex($collection, $name); + + self::assertNotNull($index, sprintf('Index "%s.%s" does not exist.', $collection, $name)); + + return $index; + } + + protected function assertIndexNotExists(string $collection, string $name): void { - $collection = DB::getCollection($collection); + $index = $this->getIndex($collection, $name); + + self::assertNull($index, sprintf('Index "%s.%s" exists.', $collection, $name)); + } + + protected function getIndex(string $collection, string $name): ?IndexInfo + { + $collection = $this->getConnection('mongodb')->getCollection($collection); assert($collection instanceof Collection); foreach ($collection->listIndexes() as $index) { - if (isset($index['key'][$name])) { + if ($index->getName() === $name) { return $index; } } - return false; + return null; } protected function getSearchIndex(string $collection, string $name): ?array { - $collection = DB::getCollection($collection); + $collection = $this->getConnection('mongodb')->getCollection($collection); assert($collection instanceof Collection); foreach ($collection->listSearchIndexes(['name' => $name, 'typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]) as $index) { From 3960aeba3f9d065a3be6263ac47964066a039e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Jan 2025 19:16:24 +0100 Subject: [PATCH 07/92] PHPORM-266 Run tests on Atlas Local (#3216) --- .github/workflows/build-ci.yml | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 45833d579..7a987d251 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -19,6 +19,7 @@ jobs: - "5.0" - "6.0" - "7.0" + - "Atlas" php: - "8.1" - "8.2" @@ -45,15 +46,24 @@ jobs: - uses: "actions/checkout@v4" - name: "Create MongoDB Replica Set" + if: ${{ matrix.mongodb != 'Atlas' }} run: | docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi until docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do - sleep 1 + sleep 1 done sudo docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" + - name: "Create MongoDB Atlas Local" + if: ${{ matrix.mongodb == 'Atlas' }} + run: | + docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:latest + until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do + sleep 1 + done + - name: "Show MongoDB server status" run: | if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi @@ -91,6 +101,10 @@ jobs: $([[ "${{ matrix.mode }}" == low-deps ]] && echo ' --prefer-lowest') \ $([[ "${{ matrix.mode }}" == ignore-php-req ]] && echo ' --ignore-platform-req=php+') - name: "Run tests" - run: "./vendor/bin/phpunit --coverage-clover coverage.xml" - env: - MONGODB_URI: 'mongodb://127.0.0.1/?replicaSet=rs' + run: | + if [ "${{ matrix.mongodb }}" = "Atlas" ]; then + export MONGODB_URI="mongodb://127.0.0.1:27017/" + else + export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" + fi + ./vendor/bin/phpunit --coverage-clover coverage.xml From 223a9f76d5120660bfb509763309c127946b7805 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Jan 2025 19:31:00 +0100 Subject: [PATCH 08/92] PHPORM-283 Add `Schema::dropSearchIndex()` (#3235) --- src/Schema/Blueprint.php | 10 ++++++++++ tests/SchemaTest.php | 15 +++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index b77a7799e..e3d7a230b 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -339,6 +339,16 @@ public function vectorSearchIndex(array $definition, string $name = 'default'): return $this; } + /** + * Drop an Atlas Search or Vector Search index + */ + public function dropSearchIndex(string $name): static + { + $this->collection->dropSearchIndex($name); + + return $this; + } + /** * Allow fluent columns. * diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 61280a726..34029aa32 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -539,6 +539,13 @@ public function testSearchIndex(): void self::assertSame('search', $index['type']); self::assertFalse($index['latestDefinition']['mappings']['dynamic']); self::assertSame('lucene.whitespace', $index['latestDefinition']['mappings']['fields']['foo']['analyzer']); + + Schema::table('newcollection', function (Blueprint $collection) { + $collection->dropSearchIndex('default'); + }); + + $index = $this->getSearchIndex('newcollection', 'default'); + self::assertNull($index); } public function testVectorSearchIndex() @@ -559,6 +566,14 @@ public function testVectorSearchIndex() self::assertSame('vector', $index['name']); self::assertSame('vectorSearch', $index['type']); self::assertSame('vector', $index['latestDefinition']['fields'][0]['type']); + + // Drop the index + Schema::table('newcollection', function (Blueprint $collection) { + $collection->dropSearchIndex('vector'); + }); + + $index = $this->getSearchIndex('newcollection', 'vector'); + self::assertNull($index); } protected function assertIndexExists(string $collection, string $name): IndexInfo From d6d8004b675369d2bc0f9c1b3091cbbd9c6057d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 6 Jan 2025 18:33:33 +0100 Subject: [PATCH 09/92] PHPORM-275 PHPORM-276 Add `Query\Builder::search()` and `autocomplete()` (#3232) --- src/Eloquent/Builder.php | 34 +++++++++++++++++++- src/Query/Builder.php | 65 +++++++++++++++++++++++++++++++++++++++ tests/AtlasSearchTest.php | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 4fd4880df..fe0fec95d 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -5,7 +5,10 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Model; use MongoDB\BSON\Document; +use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; use MongoDB\Laravel\Connection; @@ -21,7 +24,10 @@ use function iterator_to_array; use function property_exists; -/** @method \MongoDB\Laravel\Query\Builder toBase() */ +/** + * @method \MongoDB\Laravel\Query\Builder toBase() + * @template TModel of Model + */ class Builder extends EloquentBuilder { private const DUPLICATE_KEY_ERROR = 11000; @@ -49,6 +55,7 @@ class Builder extends EloquentBuilder 'insertusing', 'max', 'min', + 'autocomplete', 'pluck', 'pull', 'push', @@ -69,6 +76,31 @@ public function aggregate($function = null, $columns = ['*']) return $result ?: $this; } + /** + * Performs a full-text search of the field or fields in an Atlas collection. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ + * + * @return Collection + */ + public function search( + SearchOperatorInterface|array $operator, + ?string $index = null, + ?array $highlight = null, + ?bool $concurrent = null, + ?string $count = null, + ?string $searchAfter = null, + ?string $searchBefore = null, + ?bool $scoreDetails = null, + ?array $sort = null, + ?bool $returnStoredSource = null, + ?array $tracking = null, + ): Collection { + $results = $this->toBase()->search($operator, $index, $highlight, $concurrent, $count, $searchAfter, $searchBefore, $scoreDetails, $sort, $returnStoredSource, $tracking); + + return $this->model->hydrate($results->all()); + } + /** @inheritdoc */ public function update(array $values, array $options = []) { diff --git a/src/Query/Builder.php b/src/Query/Builder.php index c62709ce5..0e9e028bb 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -23,13 +23,16 @@ use MongoDB\BSON\ObjectID; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Builder\Search; use MongoDB\Builder\Stage\FluentFactoryTrait; +use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\Cursor; use Override; use RuntimeException; use stdClass; use function array_fill_keys; +use function array_filter; use function array_is_list; use function array_key_exists; use function array_map; @@ -1490,6 +1493,68 @@ public function options(array $options) return $this; } + /** + * Performs a full-text search of the field or fields in an Atlas collection. + * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/aggregation-stages/search/ + * + * @return Collection + */ + public function search( + SearchOperatorInterface|array $operator, + ?string $index = null, + ?array $highlight = null, + ?bool $concurrent = null, + ?string $count = null, + ?string $searchAfter = null, + ?string $searchBefore = null, + ?bool $scoreDetails = null, + ?array $sort = null, + ?bool $returnStoredSource = null, + ?array $tracking = null, + ): Collection { + // Forward named arguments to the search stage, skip null values + $args = array_filter([ + 'operator' => $operator, + 'index' => $index, + 'highlight' => $highlight, + 'concurrent' => $concurrent, + 'count' => $count, + 'searchAfter' => $searchAfter, + 'searchBefore' => $searchBefore, + 'scoreDetails' => $scoreDetails, + 'sort' => $sort, + 'returnStoredSource' => $returnStoredSource, + 'tracking' => $tracking, + ], fn ($arg) => $arg !== null); + + return $this->aggregate()->search(...$args)->get(); + } + + /** + * Performs an autocomplete search of the field using an Atlas Search index. + * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * You must create an Atlas Search index with an autocomplete configuration before you can use this stage. + * + * @see https://www.mongodb.com/docs/atlas/atlas-search/autocomplete/ + * + * @return Collection + */ + public function autocomplete(string $path, string $query, bool|array $fuzzy = false, string $tokenOrder = 'any'): Collection + { + $args = ['path' => $path, 'query' => $query, 'tokenOrder' => $tokenOrder]; + if ($fuzzy === true) { + $args['fuzzy'] = ['maxEdits' => 2]; + } elseif ($fuzzy !== false) { + $args['fuzzy'] = $fuzzy; + } + + return $this->aggregate()->search( + Search::autocomplete(...$args), + )->get()->pluck($path); + } + /** * Apply the connection's session to options if it's not already specified. */ diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index cfab2347a..4dc58e902 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -2,7 +2,10 @@ namespace MongoDB\Laravel\Tests; +use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Support\Collection as LaravelCollection; use Illuminate\Support\Facades\Schema; +use MongoDB\Builder\Search; use MongoDB\Collection as MongoDBCollection; use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Schema\Builder; @@ -43,6 +46,7 @@ public function setUp(): void $collection = $this->getConnection('mongodb')->getCollection('books'); assert($collection instanceof MongoDBCollection); + try { $collection->createSearchIndex([ 'mappings' => [ @@ -50,6 +54,7 @@ public function setUp(): void 'title' => [ ['type' => 'string', 'analyzer' => 'lucene.english'], ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], ], ], ], @@ -135,4 +140,63 @@ public function testGetIndexes() self::assertSame($expected, $indexes); } + + public function testEloquentBuilderSearch() + { + $results = Book::search( + sort: ['title' => 1], + operator: Search::text('title', 'systems'), + ); + + self::assertInstanceOf(EloquentCollection::class, $results); + self::assertCount(3, $results); + self::assertInstanceOf(Book::class, $results->first()); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->pluck('title')->all()); + } + + public function testDatabaseBuilderSearch() + { + $results = $this->getConnection('mongodb')->table('books') + ->search(Search::text('title', 'systems'), sort: ['title' => 1]); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertIsArray($results->first()); + self::assertSame([ + 'Database System Concepts', + 'Modern Operating Systems', + 'Operating System Concepts', + ], $results->pluck('title')->all()); + } + + public function testEloquentBuilderAutocomplete() + { + $results = Book::autocomplete('title', 'system'); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertSame([ + 'Operating System Concepts', + 'Database System Concepts', + 'Modern Operating Systems', + ], $results->all()); + } + + public function testDatabaseBuilderAutocomplete() + { + $results = $this->getConnection('mongodb')->table('books') + ->autocomplete('title', 'system'); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(3, $results); + self::assertSame([ + 'Operating System Concepts', + 'Database System Concepts', + 'Modern Operating Systems', + ], $results->all()); + } } From 35f469918ca513378d6536aa38299a3a27f33462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 13 Jan 2025 17:40:52 +0100 Subject: [PATCH 10/92] PHPORM-277 Add `Builder::vectorSearch()` (#3242) --- src/Eloquent/Builder.php | 23 ++++++++++++ src/Query/Builder.php | 35 ++++++++++++++++++ tests/AtlasSearchTest.php | 78 +++++++++++++++++++++++++++++++++++---- 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index fe0fec95d..afe968e4b 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use MongoDB\BSON\Document; +use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Exception\WriteException; @@ -101,6 +102,28 @@ public function search( return $this->model->hydrate($results->all()); } + /** + * Performs a semantic search on data in your Atlas Vector Search index. + * NOTE: $vectorSearch is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * + * @see https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ + * + * @return Collection + */ + public function vectorSearch( + string $index, + array|string $path, + array $queryVector, + int $limit, + bool $exact = false, + QueryInterface|array $filter = [], + int|null $numCandidates = null, + ): Collection { + $results = $this->toBase()->vectorSearch($index, $path, $queryVector, $limit, $exact, $filter, $numCandidates); + + return $this->model->hydrate($results->all()); + } + /** @inheritdoc */ public function update(array $values, array $options = []) { diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 0e9e028bb..06eb5ac47 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -25,6 +25,7 @@ use MongoDB\BSON\UTCDateTime; use MongoDB\Builder\Search; use MongoDB\Builder\Stage\FluentFactoryTrait; +use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\Cursor; use Override; @@ -1532,6 +1533,40 @@ public function search( return $this->aggregate()->search(...$args)->get(); } + /** + * Performs a semantic search on data in your Atlas Vector Search index. + * NOTE: $vectorSearch is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. + * + * @see https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-stage/ + * + * @return Collection + */ + public function vectorSearch( + string $index, + array|string $path, + array $queryVector, + int $limit, + bool $exact = false, + QueryInterface|array|null $filter = null, + int|null $numCandidates = null, + ): Collection { + // Forward named arguments to the vectorSearch stage, skip null values + $args = array_filter([ + 'index' => $index, + 'limit' => $limit, + 'path' => $path, + 'queryVector' => $queryVector, + 'exact' => $exact, + 'filter' => $filter, + 'numCandidates' => $numCandidates, + ], fn ($arg) => $arg !== null); + + return $this->aggregate() + ->vectorSearch(...$args) + ->addFields(vectorSearchScore: ['$meta' => 'vectorSearchScore']) + ->get(); + } + /** * Performs an autocomplete search of the field using an Atlas Search index. * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index 4dc58e902..c9cd2d5e3 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -5,23 +5,31 @@ use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Collection as LaravelCollection; use Illuminate\Support\Facades\Schema; +use MongoDB\Builder\Query; use MongoDB\Builder\Search; use MongoDB\Collection as MongoDBCollection; use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Schema\Builder; use MongoDB\Laravel\Tests\Models\Book; +use function array_map; use function assert; +use function mt_getrandmax; +use function rand; +use function range; +use function srand; use function usleep; use function usort; class AtlasSearchTest extends TestCase { + private array $vectors; + public function setUp(): void { parent::setUp(); - Book::insert([ + Book::insert($this->addVector([ ['title' => 'Introduction to Algorithms'], ['title' => 'Clean Code: A Handbook of Agile Software Craftsmanship'], ['title' => 'Design Patterns: Elements of Reusable Object-Oriented Software'], @@ -42,7 +50,7 @@ public function setUp(): void ['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); @@ -66,8 +74,9 @@ public function setUp(): void $collection->createSearchIndex([ 'fields' => [ - ['type' => 'vector', 'numDimensions' => 16, 'path' => 'vector16', 'similarity' => 'cosine'], + ['type' => 'vector', 'numDimensions' => 4, 'path' => 'vector4', 'similarity' => 'cosine'], ['type' => 'vector', 'numDimensions' => 32, 'path' => 'vector32', 'similarity' => 'euclidean'], + ['type' => 'filter', 'path' => 'title'], ], ], ['name' => 'vector', 'type' => 'vectorSearch']); } catch (ServerException $e) { @@ -131,7 +140,7 @@ public function testGetIndexes() ], [ 'name' => 'vector', - 'columns' => ['vector16', 'vector32'], + 'columns' => ['vector4', 'vector32', 'title'], 'type' => 'vectorSearch', 'primary' => false, 'unique' => false, @@ -180,10 +189,10 @@ public function testEloquentBuilderAutocomplete() self::assertInstanceOf(LaravelCollection::class, $results); self::assertCount(3, $results); self::assertSame([ - 'Operating System Concepts', 'Database System Concepts', 'Modern Operating Systems', - ], $results->all()); + 'Operating System Concepts', + ], $results->sort()->values()->all()); } public function testDatabaseBuilderAutocomplete() @@ -194,9 +203,62 @@ public function testDatabaseBuilderAutocomplete() self::assertInstanceOf(LaravelCollection::class, $results); self::assertCount(3, $results); self::assertSame([ - 'Operating System Concepts', 'Database System Concepts', 'Modern Operating Systems', - ], $results->all()); + 'Operating System Concepts', + ], $results->sort()->values()->all()); + } + + public function testDatabaseBuilderVectorSearch() + { + $results = $this->getConnection('mongodb')->table('books') + ->vectorSearch( + index: 'vector', + path: 'vector4', + queryVector: $this->vectors[7], // This is an exact match of the vector + limit: 4, + exact: true, + ); + + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(4, $results); + self::assertSame('The Art of Computer Programming', $results->first()['title']); + self::assertSame(1.0, $results->first()['vectorSearchScore']); + } + + public function testEloquentBuilderVectorSearch() + { + $results = Book::vectorSearch( + index: 'vector', + path: 'vector4', + queryVector: $this->vectors[7], + limit: 5, + numCandidates: 15, + // excludes the exact match + filter: Query::query( + title: Query::ne('The Art of Computer Programming'), + ), + ); + + self::assertInstanceOf(EloquentCollection::class, $results); + self::assertCount(5, $results); + self::assertInstanceOf(Book::class, $results->first()); + self::assertNotSame('The Art of Computer Programming', $results->first()->title); + self::assertSame('The Mythical Man-Month: Essays on Software Engineering', $results->first()->title); + self::assertThat( + $results->first()->vectorSearchScore, + self::logicalAnd(self::isType('float'), self::greaterThan(0.9), self::lessThan(1.0)), + ); + } + + /** Generate random vectors using fixed seed to make tests deterministic */ + private function addVector(array $items): array + { + srand(1); + foreach ($items as &$item) { + $this->vectors[] = $item['vector4'] = array_map(fn () => rand() / mt_getrandmax(), range(0, 3)); + } + + return $items; } } From 8829052cf11613c8038664a4d6ee19cc3dffa469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 13 Jan 2025 18:08:48 +0100 Subject: [PATCH 11/92] PHPORM-286 Add `Query::countByGroup()` and other `aggregateByGroup()` functions (#3243) * PHPORM-286 Add Query::countByGroup and other aggregateByGroup functions * Support counting distinct values with aggregate by group * Disable fail-fast due to Atlas issues --- .github/workflows/build-ci.yml | 2 ++ src/Query/Builder.php | 48 ++++++++++++++++++++++++++--- tests/QueryBuilderTest.php | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 7a987d251..4fea1b84d 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -11,6 +11,8 @@ jobs: name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} MongoDB ${{ matrix.mongodb }} ${{ matrix.mode }}" strategy: + # Tests with Atlas fail randomly + fail-fast: false matrix: os: - "ubuntu-latest" diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 06eb5ac47..910844cdd 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -31,6 +31,7 @@ use Override; use RuntimeException; use stdClass; +use TypeError; use function array_fill_keys; use function array_filter; @@ -315,6 +316,7 @@ public function toMql(): array if ($this->groups || $this->aggregate) { $group = []; $unwinds = []; + $set = []; // Add grouping columns to the $group part of the aggregation pipeline. if ($this->groups) { @@ -325,8 +327,10 @@ public function toMql(): array // this mimics SQL's behaviour a bit. $group[$column] = ['$last' => '$' . $column]; } + } - // Do the same for other columns that are selected. + // Add the last value of each column when there is no aggregate function. + if ($this->groups && ! $this->aggregate) { foreach ($columns as $column) { $key = str_replace('.', '_', $column); @@ -350,15 +354,22 @@ public function toMql(): array $aggregations = blank($this->aggregate['columns']) ? [] : $this->aggregate['columns']; - if (in_array('*', $aggregations) && $function === 'count') { + if ($column === '*' && $function === 'count' && ! $this->groups) { $options = $this->inheritConnectionOptions($this->options); return ['countDocuments' => [$wheres, $options]]; } + // "aggregate" is the name of the field that will hold the aggregated value. if ($function === 'count') { - // Translate count into sum. - $group['aggregate'] = ['$sum' => 1]; + if ($column === '*' || $aggregations === []) { + // Translate count into sum. + $group['aggregate'] = ['$sum' => 1]; + } else { + // Count the number of distinct values. + $group['aggregate'] = ['$addToSet' => '$' . $column]; + $set['aggregate'] = ['$size' => '$aggregate']; + } } else { $group['aggregate'] = ['$' . $function => '$' . $column]; } @@ -385,6 +396,10 @@ public function toMql(): array $pipeline[] = ['$group' => $group]; } + if ($set) { + $pipeline[] = ['$set' => $set]; + } + // Apply order and limit if ($this->orders) { $pipeline[] = ['$sort' => $this->aliasIdForQuery($this->orders)]; @@ -560,6 +575,8 @@ public function generateCacheKey() /** @return ($function is null ? AggregationBuilder : mixed) */ public function aggregate($function = null, $columns = ['*']) { + assert(is_array($columns), new TypeError(sprintf('Argument #2 ($columns) must be of type array, %s given', get_debug_type($columns)))); + if ($function === null) { if (! trait_exists(FluentFactoryTrait::class)) { // This error will be unreachable when the mongodb/builder package will be merged into mongodb/mongodb @@ -600,6 +617,15 @@ public function aggregate($function = null, $columns = ['*']) $this->columns = $previousColumns; $this->bindings['select'] = $previousSelectBindings; + // When the aggregation is per group, we return the results as is. + if ($this->groups) { + return $results->map(function (object $result) { + unset($result->id); + + return $result; + }); + } + if (isset($results[0])) { $result = (array) $results[0]; @@ -607,6 +633,20 @@ public function aggregate($function = null, $columns = ['*']) } } + /** + * {@inheritDoc} + * + * @see \Illuminate\Database\Query\Builder::aggregateByGroup() + */ + public function aggregateByGroup(string $function, array $columns = ['*']) + { + if (count($columns) > 1) { + throw new InvalidArgumentException('Aggregating by group requires zero or one columns.'); + } + + return $this->aggregate($function, $columns); + } + /** @inheritdoc */ public function exists() { diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 136b1cf72..01f937915 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use DateTime; use DateTimeImmutable; +use Illuminate\Support\Collection as LaravelCollection; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\LazyCollection; @@ -32,6 +33,7 @@ use function count; use function key; use function md5; +use function method_exists; use function sort; use function strlen; @@ -617,6 +619,59 @@ public function testSubdocumentArrayAggregate() $this->assertEquals(12, DB::table('items')->avg('amount.*.hidden')); } + public function testAggregateGroupBy() + { + DB::table('users')->insert([ + ['name' => 'John Doe', 'role' => 'admin', 'score' => 1, 'active' => true], + ['name' => 'Jane Doe', 'role' => 'admin', 'score' => 2, 'active' => true], + ['name' => 'Robert Roe', 'role' => 'user', 'score' => 4], + ]); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('count', ['active']); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 0]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->aggregateByGroup('max', ['score']); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + if (! method_exists(Builder::class, 'countByGroup')) { + $this->markTestSkipped('*byGroup functions require Laravel v11.38+'); + } + + $results = DB::table('users')->groupBy('role')->orderBy('role')->countByGroup(); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 1]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->maxByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 2], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->minByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->sumByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 3], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + + $results = DB::table('users')->groupBy('role')->orderBy('role')->avgByGroup('score'); + $this->assertInstanceOf(LaravelCollection::class, $results); + $this->assertEquals([(object) ['role' => 'admin', 'aggregate' => 1.5], (object) ['role' => 'user', 'aggregate' => 4]], $results->toArray()); + } + + public function testAggregateByGroupException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Aggregating by group requires zero or one columns.'); + + DB::table('users')->aggregateByGroup('max', ['foo', 'bar']); + } + public function testUpdateWithUpsert() { DB::table('items')->where('name', 'knife') From 697c36f322ecffc44f6b1116d8b3e9ec0e8da012 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 14 Jan 2025 08:49:35 +0100 Subject: [PATCH 12/92] PHPORM-209 Add query builder helper to set read preference (#3244) * PHPORM-209 Add query builder helper to set read preference * Support query timeout as decimal number of seconds --- src/Query/Builder.php | 31 ++++++++++++++++++++++++++++--- tests/Query/BuilderTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 910844cdd..4c7c8513f 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -28,6 +28,7 @@ use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\Cursor; +use MongoDB\Driver\ReadPreference; use Override; use RuntimeException; use stdClass; @@ -102,7 +103,7 @@ class Builder extends BaseBuilder /** * The maximum amount of seconds to allow the query to run. * - * @var int + * @var int|float */ public $timeout; @@ -113,6 +114,8 @@ class Builder extends BaseBuilder */ public $hint; + private ReadPreference $readPreference; + /** * Custom options to add to the query. * @@ -211,7 +214,7 @@ public function project($columns) /** * The maximum amount of seconds to allow the query to run. * - * @param int $seconds + * @param int|float $seconds * * @return $this */ @@ -454,7 +457,7 @@ public function toMql(): array // Apply order, offset, limit and projection if ($this->timeout) { - $options['maxTimeMS'] = $this->timeout * 1000; + $options['maxTimeMS'] = (int) ($this->timeout * 1000); } if ($this->orders) { @@ -1534,6 +1537,24 @@ public function options(array $options) return $this; } + /** + * Set the read preference for the query + * + * @see https://www.php.net/manual/en/class.mongodb-driver-readpreference.php + * + * @param string $mode + * @param array $tagSets + * @param array $options + * + * @return $this + */ + public function readPreference(string $mode, ?array $tagSets = null, ?array $options = null): static + { + $this->readPreference = new ReadPreference($mode, $tagSets, $options); + + return $this; + } + /** * Performs a full-text search of the field or fields in an Atlas collection. * NOTE: $search is only available for MongoDB Atlas clusters, and is not available for self-managed deployments. @@ -1642,6 +1663,10 @@ private function inheritConnectionOptions(array $options = []): array } } + if (! isset($options['readPreference']) && isset($this->readPreference)) { + $options['readPreference'] = $this->readPreference; + } + return $options; } diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 20f4a4db2..2cc0c5764 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -15,6 +15,7 @@ use Mockery as m; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; +use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Connection; use MongoDB\Laravel\Query\Builder; use MongoDB\Laravel\Query\Grammar; @@ -1416,6 +1417,31 @@ function (Builder $elemMatchQuery): void { ['find' => [['embedded._id' => 1], []]], fn (Builder $builder) => $builder->where('embedded->id', 1), ]; + + yield 'options' => [ + ['find' => [[], ['comment' => 'hello']]], + fn (Builder $builder) => $builder->options(['comment' => 'hello']), + ]; + + yield 'readPreference' => [ + ['find' => [[], ['readPreference' => new ReadPreference(ReadPreference::SECONDARY_PREFERRED)]]], + fn (Builder $builder) => $builder->readPreference(ReadPreference::SECONDARY_PREFERRED), + ]; + + yield 'readPreference advanced' => [ + ['find' => [[], ['readPreference' => new ReadPreference(ReadPreference::NEAREST, [['dc' => 'ny']], ['maxStalenessSeconds' => 120])]]], + fn (Builder $builder) => $builder->readPreference(ReadPreference::NEAREST, [['dc' => 'ny']], ['maxStalenessSeconds' => 120]), + ]; + + yield 'hint' => [ + ['find' => [[], ['hint' => ['foo' => 1]]]], + fn (Builder $builder) => $builder->hint(['foo' => 1]), + ]; + + yield 'timeout' => [ + ['find' => [[], ['maxTimeMS' => 2345]]], + fn (Builder $builder) => $builder->timeout(2.3456), + ]; } #[DataProvider('provideExceptions')] From 19ed55e75767405d8ed9a901c9b19435fa4bff9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 14 Jan 2025 22:13:55 +0100 Subject: [PATCH 13/92] PHPORM-28 Add Scout engine to index into MongoDB Search (#3205) --- .github/workflows/build-ci.yml | 3 + composer.json | 1 + docker-compose.yml | 6 +- phpstan-baseline.neon | 5 + phpunit.xml.dist | 2 +- src/MongoDBServiceProvider.php | 21 + src/Scout/ScoutEngine.php | 551 +++++++++++++++++ tests/ModelTest.php | 3 +- tests/Models/SchemaVersion.php | 4 +- tests/Scout/Models/ScoutUser.php | 43 ++ .../Models/SearchableInSameNamespace.php | 30 + tests/Scout/Models/SearchableModel.php | 50 ++ tests/Scout/ScoutEngineTest.php | 582 ++++++++++++++++++ tests/Scout/ScoutIntegrationTest.php | 262 ++++++++ 14 files changed, 1555 insertions(+), 8 deletions(-) create mode 100644 src/Scout/ScoutEngine.php create mode 100644 tests/Scout/Models/ScoutUser.php create mode 100644 tests/Scout/Models/SearchableInSameNamespace.php create mode 100644 tests/Scout/Models/SearchableModel.php create mode 100644 tests/Scout/ScoutEngineTest.php create mode 100644 tests/Scout/ScoutIntegrationTest.php diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 4fea1b84d..16bd213ec 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -65,6 +65,9 @@ jobs: until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do sleep 1 done + until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.createCollection('connection_test') && db.getCollection('connection_test').createSearchIndex({mappings:{dynamic: true}})"; do + sleep 1 + done - name: "Show MongoDB server status" run: | diff --git a/composer.json b/composer.json index 68ec8bc4f..dce593ed5 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ }, "require-dev": { "mongodb/builder": "^0.2", + "laravel/scout": "^11", "league/flysystem-gridfs": "^3.28", "league/flysystem-read-only": "^3.0", "phpunit/phpunit": "^10.3", diff --git a/docker-compose.yml b/docker-compose.yml index f757ec3cd..fc0f0e49a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.5' - services: app: tty: true @@ -16,11 +14,11 @@ services: mongodb: container_name: mongodb - image: mongo:latest + image: mongodb/mongodb-atlas-local:latest ports: - "27017:27017" healthcheck: - test: echo 'db.runCommand("ping").ok' | mongosh mongodb:27017 --quiet + test: mongosh --quiet --eval 'db.runCommand("ping").ok' interval: 10s timeout: 10s retries: 5 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 7b34210ad..737e31f17 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -24,3 +24,8 @@ parameters: message: "#^Method Illuminate\\\\Database\\\\Schema\\\\Blueprint\\:\\:create\\(\\) invoked with 1 parameter, 0 required\\.$#" count: 1 path: src/Schema/Builder.php + + - + message: "#^Call to an undefined method Illuminate\\\\Support\\\\HigherOrderCollectionProxy\\<\\(int\\|string\\), Illuminate\\\\Database\\\\Eloquent\\\\Model\\>\\:\\:pushSoftDeleteMetadata\\(\\)\\.$#" + count: 1 + path: src/Scout/ScoutEngine.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5431164d8..7044f9069 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,7 +17,7 @@ - + diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index 9db2122dc..b0c085b8e 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -7,12 +7,14 @@ use Closure; use Illuminate\Cache\CacheManager; use Illuminate\Cache\Repository; +use Illuminate\Container\Container; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; use Illuminate\Session\SessionManager; use Illuminate\Support\ServiceProvider; use InvalidArgumentException; +use Laravel\Scout\EngineManager; use League\Flysystem\Filesystem; use League\Flysystem\GridFS\GridFSAdapter; use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter; @@ -20,6 +22,7 @@ use MongoDB\Laravel\Cache\MongoStore; use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Queue\MongoConnector; +use MongoDB\Laravel\Scout\ScoutEngine; use RuntimeException; use Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler; @@ -102,6 +105,7 @@ public function register() }); $this->registerFlysystemAdapter(); + $this->registerScoutEngine(); } private function registerFlysystemAdapter(): void @@ -155,4 +159,21 @@ private function registerFlysystemAdapter(): void }); }); } + + private function registerScoutEngine(): void + { + $this->app->resolving(EngineManager::class, function (EngineManager $engineManager) { + $engineManager->extend('mongodb', function (Container $app) { + $connectionName = $app->get('config')->get('scout.mongodb.connection', 'mongodb'); + $connection = $app->get('db')->connection($connectionName); + $softDelete = (bool) $app->get('config')->get('scout.soft_delete', false); + + assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The connection "%s" is not a MongoDB connection.', $connectionName))); + + return new ScoutEngine($connection->getMongoDB(), $softDelete); + }); + + return $engineManager; + }); + } } diff --git a/src/Scout/ScoutEngine.php b/src/Scout/ScoutEngine.php new file mode 100644 index 000000000..e3c9c68c3 --- /dev/null +++ b/src/Scout/ScoutEngine.php @@ -0,0 +1,551 @@ + [ + 'dynamic' => true, + ], + ]; + + private const TYPEMAP = ['root' => 'object', 'document' => 'bson', 'array' => 'bson']; + + public function __construct( + private Database $database, + private bool $softDelete, + ) { + } + + /** + * Update the given model in the index. + * + * @see Engine::update() + * + * @param EloquentCollection $models + * + * @throws MongoDBRuntimeException + */ + #[Override] + public function update($models) + { + assert($models instanceof EloquentCollection, new TypeError(sprintf('Argument #1 ($models) must be of type %s, %s given', EloquentCollection::class, get_debug_type($models)))); + + if ($models->isEmpty()) { + return; + } + + if ($this->softDelete && $this->usesSoftDelete($models)) { + $models->each->pushSoftDeleteMetadata(); + } + + $bulk = []; + foreach ($models as $model) { + assert($model instanceof Model && method_exists($model, 'toSearchableArray'), new LogicException(sprintf('Model "%s" must use "%s" trait', $model::class, Searchable::class))); + + $searchableData = $model->toSearchableArray(); + $searchableData = self::serialize($searchableData); + + // Skip/remove the model if it doesn't provide any searchable data + if (! $searchableData) { + $bulk[] = [ + 'deleteOne' => [ + ['_id' => $model->getScoutKey()], + ], + ]; + + continue; + } + + unset($searchableData['_id']); + + $searchableData = array_merge($searchableData, $model->scoutMetadata()); + + /** Convert the __soft_deleted set by {@see Searchable::pushSoftDeleteMetadata()} + * into a boolean for efficient storage and indexing. */ + if (isset($searchableData['__soft_deleted'])) { + $searchableData['__soft_deleted'] = (bool) $searchableData['__soft_deleted']; + } + + $bulk[] = [ + 'updateOne' => [ + ['_id' => $model->getScoutKey()], + [ + // The _id field is added automatically when the document is inserted + // Update all other fields + '$set' => $searchableData, + ], + ['upsert' => true], + ], + ]; + } + + $this->getIndexableCollection($models)->bulkWrite($bulk); + } + + /** + * Remove the given model from the index. + * + * @see Engine::delete() + * + * @param EloquentCollection $models + */ + #[Override] + public function delete($models): void + { + assert($models instanceof EloquentCollection, new TypeError(sprintf('Argument #1 ($models) must be of type %s, %s given', Collection::class, get_debug_type($models)))); + + if ($models->isEmpty()) { + return; + } + + $collection = $this->getIndexableCollection($models); + $ids = $models->map(fn (Model $model) => $model->getScoutKey())->all(); + $collection->deleteMany(['_id' => ['$in' => $ids]]); + } + + /** + * Perform the given search on the engine. + * + * @see Engine::search() + * + * @return array + */ + #[Override] + public function search(Builder $builder) + { + return $this->performSearch($builder); + } + + /** + * Perform the given search on the engine with pagination. + * + * @see Engine::paginate() + * + * @param int $perPage + * @param int $page + * + * @return array + */ + #[Override] + public function paginate(Builder $builder, $perPage, $page) + { + assert(is_int($perPage), new TypeError(sprintf('Argument #2 ($perPage) must be of type int, %s given', get_debug_type($perPage)))); + assert(is_int($page), new TypeError(sprintf('Argument #3 ($page) must be of type int, %s given', get_debug_type($page)))); + + $builder = clone $builder; + $builder->take($perPage); + + return $this->performSearch($builder, $perPage * ($page - 1)); + } + + /** + * Perform the given search on the engine. + */ + private function performSearch(Builder $builder, ?int $offset = null): array + { + $collection = $this->getSearchableCollection($builder->model); + + if ($builder->callback) { + $cursor = call_user_func( + $builder->callback, + $collection, + $builder->query, + $offset, + ); + assert($cursor instanceof CursorInterface, new LogicException(sprintf('The search builder closure must return a MongoDB cursor, %s returned', get_debug_type($cursor)))); + $cursor->setTypeMap(self::TYPEMAP); + + return $cursor->toArray(); + } + + // Using compound to combine search operators + // https://www.mongodb.com/docs/atlas/atlas-search/compound/#options + // "should" specifies conditions that contribute to the relevance score + // at least one of them must match, + // - "text" search for the text including fuzzy matching + // - "wildcard" allows special characters like * and ?, similar to LIKE in SQL + // These are the only search operators to accept wildcard path. + $compound = [ + 'should' => [ + [ + 'text' => [ + 'query' => $builder->query, + 'path' => ['wildcard' => '*'], + 'fuzzy' => ['maxEdits' => 2], + 'score' => ['boost' => ['value' => 5]], + ], + ], + [ + 'wildcard' => [ + 'query' => $builder->query . '*', + 'path' => ['wildcard' => '*'], + 'allowAnalyzedField' => true, + ], + ], + ], + 'minimumShouldMatch' => 1, + ]; + + // "filter" specifies conditions on exact values to match + // "mustNot" specifies conditions on exact values that must not match + // They don't contribute to the relevance score + foreach ($builder->wheres as $field => $value) { + if ($field === '__soft_deleted') { + $value = (bool) $value; + } + + $compound['filter'][] = ['equals' => ['path' => $field, 'value' => $value]]; + } + + foreach ($builder->whereIns as $field => $value) { + $compound['filter'][] = ['in' => ['path' => $field, 'value' => $value]]; + } + + foreach ($builder->whereNotIns as $field => $value) { + $compound['mustNot'][] = ['in' => ['path' => $field, 'value' => $value]]; + } + + // Sort by field value only if specified + $sort = []; + foreach ($builder->orders as $order) { + $sort[$order['column']] = $order['direction'] === 'asc' ? 1 : -1; + } + + $pipeline = [ + [ + '$search' => [ + 'index' => self::INDEX_NAME, + 'compound' => $compound, + 'count' => ['type' => 'lowerBound'], + ...($sort ? ['sort' => $sort] : []), + ], + ], + [ + '$addFields' => [ + // Metadata field with the total count of documents + '__count' => '$$SEARCH_META.count.lowerBound', + ], + ], + ]; + + if ($offset) { + $pipeline[] = ['$skip' => $offset]; + } + + if ($builder->limit) { + $pipeline[] = ['$limit' => $builder->limit]; + } + + $cursor = $collection->aggregate($pipeline); + $cursor->setTypeMap(self::TYPEMAP); + + return $cursor->toArray(); + } + + /** + * Pluck and return the primary keys of the given results. + * + * @see Engine::mapIds() + * + * @param list $results + */ + #[Override] + public function mapIds($results): Collection + { + assert(is_array($results), new TypeError(sprintf('Argument #1 ($results) must be of type array, %s given', get_debug_type($results)))); + + return new Collection(array_column($results, '_id')); + } + + /** + * Map the given results to instances of the given model. + * + * @see Engine::map() + * + * @param Builder $builder + * @param array $results + * @param Model $model + * + * @return Collection + */ + #[Override] + public function map(Builder $builder, $results, $model): Collection + { + return $this->performMap($builder, $results, $model, false); + } + + /** + * Map the given results to instances of the given model via a lazy collection. + * + * @see Engine::lazyMap() + * + * @param Builder $builder + * @param array $results + * @param Model $model + * + * @return LazyCollection + */ + #[Override] + public function lazyMap(Builder $builder, $results, $model): LazyCollection + { + return $this->performMap($builder, $results, $model, true); + } + + /** @return ($lazy is true ? LazyCollection : Collection) */ + private function performMap(Builder $builder, array $results, Model $model, bool $lazy): Collection|LazyCollection + { + if (! $results) { + $collection = $model->newCollection(); + + return $lazy ? LazyCollection::make($collection) : $collection; + } + + $objectIds = array_column($results, '_id'); + $objectIdPositions = array_flip($objectIds); + + return $model->queryScoutModelsByIds($builder, $objectIds) + ->{$lazy ? 'cursor' : 'get'}() + ->filter(function ($model) use ($objectIds) { + return in_array($model->getScoutKey(), $objectIds); + }) + ->map(function ($model) use ($results, $objectIdPositions) { + $result = $results[$objectIdPositions[$model->getScoutKey()]] ?? []; + + foreach ($result as $key => $value) { + if ($key[0] === '_' && $key !== '_id') { + $model->withScoutMetadata($key, $value); + } + } + + return $model; + }) + ->sortBy(function ($model) use ($objectIdPositions) { + return $objectIdPositions[$model->getScoutKey()]; + }) + ->values(); + } + + /** + * Get the total count from a raw result returned by the engine. + * This is an estimate if the count is larger than 1000. + * + * @see Engine::getTotalCount() + * @see https://www.mongodb.com/docs/atlas/atlas-search/counting/ + * + * @param stdClass[] $results + */ + #[Override] + public function getTotalCount($results): int + { + if (! $results) { + return 0; + } + + // __count field is added by the aggregation pipeline in performSearch() + // using the count.lowerBound in the $search stage + return $results[0]->__count; + } + + /** + * Flush all records from the engine. + * + * @see Engine::flush() + * + * @param Model $model + */ + #[Override] + public function flush($model): void + { + assert($model instanceof Model, new TypeError(sprintf('Argument #1 ($model) must be of type %s, %s given', Model::class, get_debug_type($model)))); + + $collection = $this->getIndexableCollection($model); + + $collection->deleteMany([]); + } + + /** + * Create the MongoDB Atlas Search index. + * + * Accepted options: + * - wait: bool, default true. Wait for the index to be created. + * + * @see Engine::createIndex() + * + * @param string $name Collection name + * @param array{wait?:bool} $options + */ + #[Override] + public function createIndex($name, array $options = []): void + { + assert(is_string($name), new TypeError(sprintf('Argument #1 ($name) must be of type string, %s given', get_debug_type($name)))); + + // Ensure the collection exists before creating the search index + $this->database->createCollection($name); + + $collection = $this->database->selectCollection($name); + $collection->createSearchIndex( + self::DEFAULT_DEFINITION, + ['name' => self::INDEX_NAME], + ); + + if ($options['wait'] ?? true) { + $this->wait(function () use ($collection) { + $indexes = $collection->listSearchIndexes([ + 'name' => self::INDEX_NAME, + 'typeMap' => ['root' => 'bson'], + ]); + + return $indexes->current() && $indexes->current()->status === 'READY'; + }); + } + } + + /** + * Delete a "search index", i.e. a MongoDB collection. + * + * @see Engine::deleteIndex() + */ + #[Override] + public function deleteIndex($name): void + { + assert(is_string($name), new TypeError(sprintf('Argument #1 ($name) must be of type string, %s given', get_debug_type($name)))); + + $this->database->dropCollection($name); + } + + /** Get the MongoDB collection used to search for the provided model */ + private function getSearchableCollection(Model|EloquentCollection $model): MongoDBCollection + { + if ($model instanceof EloquentCollection) { + $model = $model->first(); + } + + assert(method_exists($model, 'searchableAs'), sprintf('Model "%s" must use "%s" trait', $model::class, Searchable::class)); + + return $this->database->selectCollection($model->searchableAs()); + } + + /** Get the MongoDB collection used to index the provided model */ + private function getIndexableCollection(Model|EloquentCollection $model): MongoDBCollection + { + if ($model instanceof EloquentCollection) { + $model = $model->first(); + } + + assert($model instanceof Model); + assert(method_exists($model, 'indexableAs'), sprintf('Model "%s" must use "%s" trait', $model::class, Searchable::class)); + + if ( + $model->getConnection() instanceof Connection + && $model->getConnection()->getDatabaseName() === $this->database->getDatabaseName() + && $model->getTable() === $model->indexableAs() + ) { + throw new LogicException(sprintf('The MongoDB Scout collection "%s.%s" must use a different collection from the collection name of the model "%s". Set the "scout.prefix" configuration or use a distinct MongoDB database', $this->database->getDatabaseName(), $model->indexableAs(), $model::class)); + } + + return $this->database->selectCollection($model->indexableAs()); + } + + private static function serialize(mixed $value): mixed + { + if ($value instanceof DateTimeInterface) { + return new UTCDateTime($value); + } + + if ($value instanceof Serializable || ! is_iterable($value)) { + return $value; + } + + // Convert Laravel Collections and other Iterators to arrays + if ($value instanceof Traversable) { + $value = iterator_to_array($value); + } + + // Recursively serialize arrays + return array_map(self::serialize(...), $value); + } + + private function usesSoftDelete(Model|EloquentCollection $model): bool + { + if ($model instanceof EloquentCollection) { + $model = $model->first(); + } + + return in_array(SoftDeletes::class, class_uses_recursive($model)); + } + + /** + * Wait for the callback to return true, use it for asynchronous + * Atlas Search index management operations. + */ + private function wait(Closure $callback): void + { + // Fallback to time() if hrtime() is not supported + $timeout = (hrtime()[0] ?? time()) + self::WAIT_TIMEOUT_SEC; + while ((hrtime()[0] ?? time()) < $timeout) { + if ($callback()) { + return; + } + + sleep(1); + } + + throw new MongoDBRuntimeException(sprintf('Atlas search index operation time out after %s seconds', self::WAIT_TIMEOUT_SEC)); + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php index c532eea55..ef71a5fe0 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -406,8 +406,9 @@ public function testSoftDelete(): void $this->assertEquals(2, Soft::count()); } + /** @param class-string $model */ #[DataProvider('provideId')] - public function testPrimaryKey(string $model, $id, $expected, bool $expectedFound): void + public function testPrimaryKey(string $model, mixed $id, mixed $expected, bool $expectedFound): void { $model::truncate(); $expectedType = get_debug_type($expected); diff --git a/tests/Models/SchemaVersion.php b/tests/Models/SchemaVersion.php index 8acd73545..b142d8bda 100644 --- a/tests/Models/SchemaVersion.php +++ b/tests/Models/SchemaVersion.php @@ -5,9 +5,9 @@ namespace MongoDB\Laravel\Tests\Models; use MongoDB\Laravel\Eloquent\HasSchemaVersion; -use MongoDB\Laravel\Eloquent\Model as Eloquent; +use MongoDB\Laravel\Eloquent\Model; -class SchemaVersion extends Eloquent +class SchemaVersion extends Model { use HasSchemaVersion; diff --git a/tests/Scout/Models/ScoutUser.php b/tests/Scout/Models/ScoutUser.php new file mode 100644 index 000000000..50fa39a94 --- /dev/null +++ b/tests/Scout/Models/ScoutUser.php @@ -0,0 +1,43 @@ +dropIfExists('scout_users'); + $schema->create('scout_users', function (Blueprint $table) { + $table->increments('id'); + $table->string('name'); + $table->string('email')->nullable(); + $table->date('email_verified_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } +} diff --git a/tests/Scout/Models/SearchableInSameNamespace.php b/tests/Scout/Models/SearchableInSameNamespace.php new file mode 100644 index 000000000..91b909067 --- /dev/null +++ b/tests/Scout/Models/SearchableInSameNamespace.php @@ -0,0 +1,30 @@ +getTable(); + } +} diff --git a/tests/Scout/Models/SearchableModel.php b/tests/Scout/Models/SearchableModel.php new file mode 100644 index 000000000..e53200f1a --- /dev/null +++ b/tests/Scout/Models/SearchableModel.php @@ -0,0 +1,50 @@ +getAttribute($this->getScoutKeyName()) ?: 'key_' . $this->getKey(); + } + + /** + * This method must be overridden when the `getScoutKey` method is also overridden, + * to support model serialization for async indexing jobs. + * + * @see Searchable::getScoutKeyName() + */ + public function getScoutKeyName(): string + { + return 'scout_key'; + } +} diff --git a/tests/Scout/ScoutEngineTest.php b/tests/Scout/ScoutEngineTest.php new file mode 100644 index 000000000..a079ae530 --- /dev/null +++ b/tests/Scout/ScoutEngineTest.php @@ -0,0 +1,582 @@ + 'object', 'document' => 'bson', 'array' => 'bson']; + + /** @param callable(): Builder $builder */ + #[DataProvider('provideSearchPipelines')] + public function testSearch(Closure $builder, array $expectedPipeline): void + { + $data = [['_id' => 'key_1', '__count' => 15], ['_id' => 'key_2', '__count' => 15]]; + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('selectCollection') + ->with('collection_searchable') + ->andReturn($collection); + $cursor = m::mock(CursorInterface::class); + $cursor->shouldReceive('setTypeMap')->once()->with(self::EXPECTED_TYPEMAP); + $cursor->shouldReceive('toArray')->once()->with()->andReturn($data); + + $collection->shouldReceive('getCollectionName') + ->zeroOrMoreTimes() + ->andReturn('collection_searchable'); + $collection->shouldReceive('aggregate') + ->once() + ->withArgs(function ($pipeline) use ($expectedPipeline) { + self::assertEquals($expectedPipeline, $pipeline); + + return true; + }) + ->andReturn($cursor); + + $engine = new ScoutEngine($database, softDelete: false); + $result = $engine->search($builder()); + $this->assertEquals($data, $result); + } + + public function provideSearchPipelines(): iterable + { + $defaultPipeline = [ + [ + '$search' => [ + 'index' => 'scout', + 'compound' => [ + 'should' => [ + [ + 'text' => [ + 'path' => ['wildcard' => '*'], + 'query' => 'lar', + 'fuzzy' => ['maxEdits' => 2], + 'score' => ['boost' => ['value' => 5]], + ], + ], + [ + 'wildcard' => [ + 'query' => 'lar*', + 'path' => ['wildcard' => '*'], + 'allowAnalyzedField' => true, + ], + ], + ], + 'minimumShouldMatch' => 1, + ], + 'count' => [ + 'type' => 'lowerBound', + ], + ], + ], + [ + '$addFields' => [ + '__count' => '$$SEARCH_META.count.lowerBound', + ], + ], + ]; + + yield 'simple string' => [ + function () { + return new Builder(new SearchableModel(), 'lar'); + }, + $defaultPipeline, + ]; + + yield 'where conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->where('foo', 'bar'); + $builder->where('key', 'value'); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => 'foo', 'value' => 'bar']], + ['equals' => ['path' => 'key', 'value' => 'value']], + ], + ], + ], + ], + ]), + ]; + + yield 'where in conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->where('foo', 'bar'); + $builder->where('bar', 'baz'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => 'foo', 'value' => 'bar']], + ['equals' => ['path' => 'bar', 'value' => 'baz']], + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + ], + ], + ], + ]), + ]; + + yield 'where not in conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->where('foo', 'bar'); + $builder->where('bar', 'baz'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + $builder->whereNotIn('eaea', [3]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => 'foo', 'value' => 'bar']], + ['equals' => ['path' => 'bar', 'value' => 'baz']], + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + 'mustNot' => [ + ['in' => ['path' => 'eaea', 'value' => [3]]], + ], + ], + ], + ], + ]), + ]; + + yield 'where in conditions without other conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + ], + ], + ], + ]), + ]; + + yield 'where not in conditions without other conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + $builder->whereNotIn('eaea', [3]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + 'mustNot' => [ + ['in' => ['path' => 'eaea', 'value' => [3]]], + ], + ], + ], + ], + ]), + ]; + + yield 'empty where in conditions' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->whereIn('qux', [1, 2]); + $builder->whereIn('quux', [1, 2]); + $builder->whereNotIn('eaea', [3]); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['in' => ['path' => 'qux', 'value' => [1, 2]]], + ['in' => ['path' => 'quux', 'value' => [1, 2]]], + ], + 'mustNot' => [ + ['in' => ['path' => 'eaea', 'value' => [3]]], + ], + ], + ], + ], + ]), + ]; + + yield 'exclude soft-deleted' => [ + function () { + return new Builder(new SearchableModel(), 'lar', softDelete: true); + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => '__soft_deleted', 'value' => false]], + ], + ], + ], + ], + ]), + ]; + + yield 'only trashed' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar', softDelete: true); + $builder->onlyTrashed(); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'compound' => [ + 'filter' => [ + ['equals' => ['path' => '__soft_deleted', 'value' => true]], + ], + ], + ], + ], + ]), + ]; + + yield 'with callback' => [ + fn () => new Builder(new SearchableModel(), 'query', callback: function (...$args) { + $this->assertCount(3, $args); + $this->assertInstanceOf(Collection::class, $args[0]); + $this->assertSame('collection_searchable', $args[0]->getCollectionName()); + $this->assertSame('query', $args[1]); + $this->assertNull($args[2]); + + return $args[0]->aggregate(['pipeline']); + }), + ['pipeline'], + ]; + + yield 'ordered' => [ + function () { + $builder = new Builder(new SearchableModel(), 'lar'); + $builder->orderBy('name', 'desc'); + $builder->orderBy('age', 'asc'); + + return $builder; + }, + array_replace_recursive($defaultPipeline, [ + [ + '$search' => [ + 'sort' => [ + 'name' => -1, + 'age' => 1, + ], + ], + ], + ]), + ]; + } + + public function testPaginate() + { + $perPage = 5; + $page = 3; + + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $cursor = m::mock(CursorInterface::class); + $database->shouldReceive('selectCollection') + ->with('collection_searchable') + ->andReturn($collection); + $collection->shouldReceive('aggregate') + ->once() + ->withArgs(function (...$args) { + self::assertSame([ + [ + '$search' => [ + 'index' => 'scout', + 'compound' => [ + 'should' => [ + [ + 'text' => [ + 'query' => 'mustang', + 'path' => ['wildcard' => '*'], + 'fuzzy' => ['maxEdits' => 2], + 'score' => ['boost' => ['value' => 5]], + ], + ], + [ + 'wildcard' => [ + 'query' => 'mustang*', + 'path' => ['wildcard' => '*'], + 'allowAnalyzedField' => true, + ], + ], + ], + 'minimumShouldMatch' => 1, + ], + 'count' => [ + 'type' => 'lowerBound', + ], + 'sort' => [ + 'name' => -1, + ], + ], + ], + [ + '$addFields' => [ + '__count' => '$$SEARCH_META.count.lowerBound', + ], + ], + [ + '$skip' => 10, + ], + [ + '$limit' => 5, + ], + ], $args[0]); + + return true; + }) + ->andReturn($cursor); + $cursor->shouldReceive('setTypeMap')->once()->with(self::EXPECTED_TYPEMAP); + $cursor->shouldReceive('toArray') + ->once() + ->with() + ->andReturn([['_id' => 'key_1', '__count' => 17], ['_id' => 'key_2', '__count' => 17]]); + + $engine = new ScoutEngine($database, softDelete: false); + $builder = new Builder(new SearchableModel(), 'mustang'); + $builder->orderBy('name', 'desc'); + $engine->paginate($builder, $perPage, $page); + } + + public function testMapMethodRespectsOrder() + { + $database = m::mock(Database::class); + $engine = new ScoutEngine($database, false); + + $model = m::mock(Model::class); + $model->shouldReceive(['getScoutKeyName' => 'id']); + $model->shouldReceive('queryScoutModelsByIds->get') + ->andReturn(LaravelCollection::make([ + new ScoutUser(['id' => 1]), + new ScoutUser(['id' => 2]), + new ScoutUser(['id' => 3]), + new ScoutUser(['id' => 4]), + ])); + + $builder = m::mock(Builder::class); + + $results = $engine->map($builder, [ + ['_id' => 1, '__count' => 4], + ['_id' => 2, '__count' => 4], + ['_id' => 4, '__count' => 4], + ['_id' => 3, '__count' => 4], + ], $model); + + $this->assertEquals(4, count($results)); + $this->assertEquals([ + 0 => ['id' => 1], + 1 => ['id' => 2], + 2 => ['id' => 4], + 3 => ['id' => 3], + ], $results->toArray()); + } + + public function testLazyMapMethodRespectsOrder() + { + $lazy = false; + $database = m::mock(Database::class); + $engine = new ScoutEngine($database, false); + + $model = m::mock(Model::class); + $model->shouldReceive(['getScoutKeyName' => 'id']); + $model->shouldReceive('queryScoutModelsByIds->cursor') + ->andReturn(LazyCollection::make([ + new ScoutUser(['id' => 1]), + new ScoutUser(['id' => 2]), + new ScoutUser(['id' => 3]), + new ScoutUser(['id' => 4]), + ])); + + $builder = m::mock(Builder::class); + + $results = $engine->lazyMap($builder, [ + ['_id' => 1, '__count' => 4], + ['_id' => 2, '__count' => 4], + ['_id' => 4, '__count' => 4], + ['_id' => 3, '__count' => 4], + ], $model); + + $this->assertEquals(4, count($results)); + $this->assertEquals([ + 0 => ['id' => 1], + 1 => ['id' => 2], + 2 => ['id' => 4], + 3 => ['id' => 3], + ], $results->toArray()); + } + + public function testUpdate(): void + { + $date = new DateTimeImmutable('2000-01-02 03:04:05'); + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('selectCollection') + ->with('collection_indexable') + ->andReturn($collection); + $collection->shouldReceive('bulkWrite') + ->once() + ->with([ + [ + 'updateOne' => [ + ['_id' => 'key_1'], + ['$set' => ['id' => 1, 'date' => new UTCDateTime($date)]], + ['upsert' => true], + ], + ], + [ + 'updateOne' => [ + ['_id' => 'key_2'], + ['$set' => ['id' => 2]], + ['upsert' => true], + ], + ], + ]); + + $engine = new ScoutEngine($database, softDelete: false); + $engine->update(EloquentCollection::make([ + new SearchableModel([ + 'id' => 1, + 'date' => $date, + ]), + new SearchableModel([ + 'id' => 2, + ]), + ])); + } + + public function testUpdateWithSoftDelete(): void + { + $date = new DateTimeImmutable('2000-01-02 03:04:05'); + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('selectCollection') + ->with('collection_indexable') + ->andReturn($collection); + $collection->shouldReceive('bulkWrite') + ->once() + ->withArgs(function ($pipeline) { + $this->assertSame([ + [ + 'updateOne' => [ + ['_id' => 'key_1'], + ['$set' => ['id' => 1, '__soft_deleted' => false]], + ['upsert' => true], + ], + ], + ], $pipeline); + + return true; + }); + + $model = new SearchableModel(['id' => 1]); + $model->delete(); + + $engine = new ScoutEngine($database, softDelete: true); + $engine->update(EloquentCollection::make([$model])); + } + + public function testDelete(): void + { + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('selectCollection') + ->with('collection_indexable') + ->andReturn($collection); + $collection->shouldReceive('deleteMany') + ->once() + ->with(['_id' => ['$in' => ['key_1', 'key_2']]]); + + $engine = new ScoutEngine($database, softDelete: false); + $engine->delete(EloquentCollection::make([ + new SearchableModel(['id' => 1]), + new SearchableModel(['id' => 2]), + ])); + } + + public function testDeleteWithRemoveableScoutCollection(): void + { + $job = new RemoveFromSearch(EloquentCollection::make([ + new SearchableModel(['id' => 5, 'scout_key' => 'key_5']), + ])); + + $job = unserialize(serialize($job)); + + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('selectCollection') + ->with('collection_indexable') + ->andReturn($collection); + $collection->shouldReceive('deleteMany') + ->once() + ->with(['_id' => ['$in' => ['key_5']]]); + + $engine = new ScoutEngine($database, softDelete: false); + $engine->delete($job->models); + } +} diff --git a/tests/Scout/ScoutIntegrationTest.php b/tests/Scout/ScoutIntegrationTest.php new file mode 100644 index 000000000..7b9d704f6 --- /dev/null +++ b/tests/Scout/ScoutIntegrationTest.php @@ -0,0 +1,262 @@ +set('scout.driver', 'mongodb'); + $app['config']->set('scout.prefix', 'prefix_'); + } + + public function setUp(): void + { + parent::setUp(); + + $this->skipIfSearchIndexManagementIsNotSupported(); + + // Init the SQL database with some objects that will be indexed + // Test data copied from Laravel Scout tests + // https://github.com/laravel/scout/blob/10.x/tests/Integration/SearchableTests.php + ScoutUser::executeSchema(); + + $collect = LazyCollection::make(function () { + yield ['name' => 'Laravel Framework']; + + foreach (range(2, 10) as $key) { + yield ['name' => 'Example ' . $key]; + } + + yield ['name' => 'Larry Casper', 'email_verified_at' => null]; + yield ['name' => 'Reta Larkin']; + + foreach (range(13, 19) as $key) { + yield ['name' => 'Example ' . $key]; + } + + yield ['name' => 'Prof. Larry Prosacco DVM', 'email_verified_at' => null]; + + foreach (range(21, 38) as $key) { + yield ['name' => 'Example ' . $key, 'email_verified_at' => null]; + } + + yield ['name' => 'Linkwood Larkin', 'email_verified_at' => null]; + yield ['name' => 'Otis Larson MD']; + yield ['name' => 'Gudrun Larkin']; + yield ['name' => 'Dax Larkin']; + yield ['name' => 'Dana Larson Sr.']; + yield ['name' => 'Amos Larson Sr.']; + }); + + $id = 0; + $date = new DateTimeImmutable('2021-01-01 00:00:00'); + foreach ($collect as $data) { + $data = array_merge(['id' => ++$id, 'email_verified_at' => $date], $data); + ScoutUser::create($data)->save(); + } + + self::assertSame(44, ScoutUser::count()); + } + + /** This test create the search index for tests performing search */ + public function testItCanCreateTheCollection() + { + $collection = DB::connection('mongodb')->getCollection('prefix_scout_users'); + $collection->drop(); + + // Recreate the indexes using the artisan commands + // Ensure they return a success exit code (0) + self::assertSame(0, artisan($this, 'scout:delete-index', ['name' => ScoutUser::class])); + self::assertSame(0, artisan($this, 'scout:index', ['name' => ScoutUser::class])); + self::assertSame(0, artisan($this, 'scout:import', ['model' => ScoutUser::class])); + + self::assertSame(44, $collection->countDocuments()); + + $searchIndexes = $collection->listSearchIndexes(['name' => 'scout']); + self::assertCount(1, $searchIndexes); + + // Wait for all documents to be indexed asynchronously + $i = 100; + while (true) { + $indexedDocuments = $collection->aggregate([ + ['$search' => ['index' => 'scout', 'exists' => ['path' => 'name']]], + ])->toArray(); + + if (count($indexedDocuments) >= 44) { + break; + } + + if ($i-- === 0) { + self::fail('Documents not indexed'); + } + + usleep(100_000); + } + + self::assertCount(44, $indexedDocuments); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearch() + { + // All the search queries use "sort" option to ensure the results are deterministic + $results = ScoutUser::search('lar')->take(10)->orderBy('id')->get(); + + self::assertSame([ + 1 => 'Laravel Framework', + 11 => 'Larry Casper', + 12 => 'Reta Larkin', + 20 => 'Prof. Larry Prosacco DVM', + 39 => 'Linkwood Larkin', + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $results->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchCursor() + { + // All the search queries use "sort" option to ensure the results are deterministic + $results = ScoutUser::search('lar')->take(10)->orderBy('id')->cursor(); + + self::assertSame([ + 1 => 'Laravel Framework', + 11 => 'Larry Casper', + 12 => 'Reta Larkin', + 20 => 'Prof. Larry Prosacco DVM', + 39 => 'Linkwood Larkin', + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $results->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchWithQueryCallback() + { + $results = ScoutUser::search('lar')->take(10)->orderBy('id')->query(function ($query) { + return $query->whereNotNull('email_verified_at'); + })->get(); + + self::assertSame([ + 1 => 'Laravel Framework', + 12 => 'Reta Larkin', + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $results->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchToFetchKeys() + { + $results = ScoutUser::search('lar')->orderBy('id')->take(10)->keys(); + + self::assertSame([1, 11, 12, 20, 39, 40, 41, 42, 43, 44], $results->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUseBasicSearchWithQueryCallbackToFetchKeys() + { + $results = ScoutUser::search('lar')->take(10)->orderBy('id', 'desc')->query(function ($query) { + return $query->whereNotNull('email_verified_at'); + })->keys(); + + self::assertSame([44, 43, 42, 41, 40, 39, 20, 12, 11, 1], $results->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUsePaginatedSearch() + { + $page1 = ScoutUser::search('lar')->take(10)->orderBy('id')->paginate(5, 'page', 1); + $page2 = ScoutUser::search('lar')->take(10)->orderBy('id')->paginate(5, 'page', 2); + + self::assertSame([ + 1 => 'Laravel Framework', + 11 => 'Larry Casper', + 12 => 'Reta Larkin', + 20 => 'Prof. Larry Prosacco DVM', + 39 => 'Linkwood Larkin', + ], $page1->pluck('name', 'id')->all()); + + self::assertSame([ + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $page2->pluck('name', 'id')->all()); + } + + #[Depends('testItCanCreateTheCollection')] + public function testItCanUsePaginatedSearchWithQueryCallback() + { + $queryCallback = function ($query) { + return $query->whereNotNull('email_verified_at'); + }; + + $page1 = ScoutUser::search('lar')->take(10)->orderBy('id')->query($queryCallback)->paginate(5, 'page', 1); + $page2 = ScoutUser::search('lar')->take(10)->orderBy('id')->query($queryCallback)->paginate(5, 'page', 2); + + self::assertSame([ + 1 => 'Laravel Framework', + 12 => 'Reta Larkin', + ], $page1->pluck('name', 'id')->all()); + + self::assertSame([ + 40 => 'Otis Larson MD', + 41 => 'Gudrun Larkin', + 42 => 'Dax Larkin', + 43 => 'Dana Larson Sr.', + 44 => 'Amos Larson Sr.', + ], $page2->pluck('name', 'id')->all()); + } + + public function testItCannotIndexInTheSameNamespace() + { + self::expectException(LogicException::class); + self::expectExceptionMessage(sprintf( + 'The MongoDB Scout collection "%s.searchable_in_same_namespaces" must use a different collection from the collection name of the model "%s". Set the "scout.prefix" configuration or use a distinct MongoDB database', + env('MONGODB_DATABASE', 'unittest'), + SearchableInSameNamespace::class, + ),); + + SearchableInSameNamespace::create(['name' => 'test']); + } +} From 2b2c70a66279f9206085c857cb30f44ed52d8785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 16 Jan 2025 09:08:54 +0100 Subject: [PATCH 14/92] Split Atlas tests into a distinct workflow matrix (#3245) --- .github/workflows/build-ci-atlas.yml | 74 ++++++++++++++++++++++++++++ .github/workflows/build-ci.yml | 32 +++--------- tests/AtlasSearchTest.php | 2 + tests/Scout/ScoutIntegrationTest.php | 2 + 4 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 .github/workflows/build-ci-atlas.yml diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml new file mode 100644 index 000000000..7a4ebd03f --- /dev/null +++ b/.github/workflows/build-ci-atlas.yml @@ -0,0 +1,74 @@ +name: "Atlas CI" + +on: + push: + pull_request: + +jobs: + build: + runs-on: "${{ matrix.os }}" + + name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} Atlas" + + strategy: + matrix: + os: + - "ubuntu-latest" + php: + - "8.2" + - "8.3" + - "8.4" + laravel: + - "11.*" + + steps: + - uses: "actions/checkout@v4" + + - name: "Create MongoDB Atlas Local" + run: | + docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:latest + 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: "Installing php" + uses: "shivammathur/setup-php@v2" + with: + php-version: ${{ matrix.php }} + extensions: "curl,mbstring,xdebug" + coverage: "xdebug" + tools: "composer" + + - name: "Show Docker version" + if: ${{ runner.debug }} + run: "docker version && env" + + - name: "Restrict Laravel version" + run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" + + - name: "Download Composer cache dependencies from cache" + id: "composer-cache" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: "Cache Composer dependencies" + uses: "actions/cache@v4" + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ matrix.os }}-composer-" + + - name: "Install dependencies" + run: | + composer update --no-interaction + + - name: "Run tests" + run: | + export MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" + ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 16bd213ec..d16a5885f 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -11,8 +11,6 @@ jobs: name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} MongoDB ${{ matrix.mongodb }} ${{ matrix.mode }}" strategy: - # Tests with Atlas fail randomly - fail-fast: false matrix: os: - "ubuntu-latest" @@ -21,11 +19,12 @@ jobs: - "5.0" - "6.0" - "7.0" - - "Atlas" + - "8.0" php: - "8.1" - "8.2" - "8.3" + - "8.4" laravel: - "10.*" - "11.*" @@ -38,7 +37,6 @@ jobs: - php: "8.4" laravel: "11.*" mongodb: "7.0" - mode: "ignore-php-req" os: "ubuntu-latest" exclude: - php: "8.1" @@ -48,31 +46,19 @@ jobs: - uses: "actions/checkout@v4" - name: "Create MongoDB Replica Set" - if: ${{ matrix.mongodb != 'Atlas' }} run: | docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi - until docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do - sleep 1 - done - sudo docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" - - - name: "Create MongoDB Atlas Local" - if: ${{ matrix.mongodb == 'Atlas' }} - run: | - docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:latest - until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.runCommand({ ping: 1 })"; do - sleep 1 - done - until docker exec --tty mongodb mongosh 127.0.0.1:27017 --eval "db.createCollection('connection_test') && db.getCollection('connection_test').createSearchIndex({mappings:{dynamic: true}})"; do + until docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ ping: 1 })"; do sleep 1 done + sudo docker exec --tty mongodb $MONGOSH_BIN --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" - name: "Show MongoDB server status" run: | if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi - docker exec --tty mongodb $MONGOSH_BIN 127.0.0.1:27017 --eval "db.runCommand({ serverStatus: 1 })" + docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ serverStatus: 1 })" - name: "Installing php" uses: "shivammathur/setup-php@v2" @@ -107,9 +93,5 @@ jobs: $([[ "${{ matrix.mode }}" == ignore-php-req ]] && echo ' --ignore-platform-req=php+') - name: "Run tests" run: | - if [ "${{ matrix.mongodb }}" = "Atlas" ]; then - export MONGODB_URI="mongodb://127.0.0.1:27017/" - else - export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" - fi - ./vendor/bin/phpunit --coverage-clover coverage.xml + export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" + ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search diff --git a/tests/AtlasSearchTest.php b/tests/AtlasSearchTest.php index c9cd2d5e3..43848c09a 100644 --- a/tests/AtlasSearchTest.php +++ b/tests/AtlasSearchTest.php @@ -11,6 +11,7 @@ use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Schema\Builder; use MongoDB\Laravel\Tests\Models\Book; +use PHPUnit\Framework\Attributes\Group; use function array_map; use function assert; @@ -21,6 +22,7 @@ use function usleep; use function usort; +#[Group('atlas-search')] class AtlasSearchTest extends TestCase { private array $vectors; diff --git a/tests/Scout/ScoutIntegrationTest.php b/tests/Scout/ScoutIntegrationTest.php index 7b9d704f6..ff4617352 100644 --- a/tests/Scout/ScoutIntegrationTest.php +++ b/tests/Scout/ScoutIntegrationTest.php @@ -12,6 +12,7 @@ use MongoDB\Laravel\Tests\TestCase; use Override; use PHPUnit\Framework\Attributes\Depends; +use PHPUnit\Framework\Attributes\Group; use function array_merge; use function count; @@ -21,6 +22,7 @@ use function sprintf; use function usleep; +#[Group('atlas-search')] class ScoutIntegrationTest extends TestCase { #[Override] From b89a52eef5910b1a56ec3d4c322cf320582fcaae Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Fri, 24 Jan 2025 09:54:57 -0500 Subject: [PATCH 15/92] DOCSP-45877: txn parallel ops not supported (#3247) * DOCSP-45877: txn parallel ops not supported * small fix --- docs/transactions.txt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/transactions.txt b/docs/transactions.txt index 377423d67..b4a7827ba 100644 --- a/docs/transactions.txt +++ b/docs/transactions.txt @@ -24,8 +24,8 @@ In this guide, you can learn how to perform a **transaction** in MongoDB by using {+odm-long+}. Transactions let you run a sequence of write operations that update the data only after the transaction is committed. -If the transaction fails, the {+php-library+} that manages MongoDB operations -for the {+odm-short+} ensures that MongoDB discards all the changes made within +If the transaction fails, the {+php-library+}, which manages MongoDB operations +for the {+odm-short+}, ensures that MongoDB discards all the changes made within the transaction before they become visible. This property of transactions that ensures that all changes within a transaction are either applied or discarded is called **atomicity**. @@ -74,15 +74,20 @@ MongoDB Server and the {+odm-short+} have the following limitations: you perform write operations in a transaction. To learn more about this limitation, see :manual:`Create Collections and Indexes in a Transaction ` in the {+server-docs-name+}. + - MongoDB does not support nested transactions. If you attempt to start a transaction within another one, the extension raises a ``RuntimeException``. To learn more about this limitation, see :manual:`Transactions and Sessions ` in the {+server-docs-name+}. + - {+odm-long+} does not support the database testing traits ``Illuminate\Foundation\Testing\DatabaseTransactions`` and ``Illuminate\Foundation\Testing\RefreshDatabase``. As a workaround, you can create migrations with the ``Illuminate\Foundation\Testing\DatabaseMigrations`` trait to reset the database after each test. +- {+odm-long+} does not support running parallel operations within a + single transaction. + .. _laravel-transaction-callback: Run a Transaction in a Callback From 867731c3df701c444a1e0c965d4aebcf4fe055e3 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Wed, 29 Jan 2025 11:21:00 -0500 Subject: [PATCH 16/92] DOCSP-45065: sessions documentation (#3254) * DOCSP-45065: sessions documentation * MW PR fixes 1 * JT tech review 1 * small fix error in build --- docs/eloquent-models/schema-builder.txt | 2 + docs/index.txt | 2 + docs/query-builder.txt | 2 +- docs/sessions.txt | 102 ++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 docs/sessions.txt diff --git a/docs/eloquent-models/schema-builder.txt b/docs/eloquent-models/schema-builder.txt index 510365d06..dad3c8eed 100644 --- a/docs/eloquent-models/schema-builder.txt +++ b/docs/eloquent-models/schema-builder.txt @@ -248,6 +248,8 @@ field: To learn more about index options, see :manual:`Options for All Index Types ` in the {+server-docs-name+}. +.. _laravel-schema-builder-special-idx: + Create Sparse, TTL, and Unique Indexes ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/index.txt b/docs/index.txt index 104a6aa77..892be3c3e 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -21,6 +21,7 @@ Query Builder User Authentication Cache & Locks + HTTP Sessions Queues Transactions GridFS Filesystems @@ -84,6 +85,7 @@ see the following content: - :ref:`laravel-aggregation-builder` - :ref:`laravel-user-authentication` - :ref:`laravel-cache` +- :ref:`laravel-sessions` - :ref:`laravel-queues` - :ref:`laravel-transactions` - :ref:`laravel-filesystems` diff --git a/docs/query-builder.txt b/docs/query-builder.txt index 9bf4ea857..b3c89b0ae 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -678,7 +678,7 @@ a query: :end-before: end options The query builder accepts the same options that you can set for -the :phpmethod:`find() ` method in the +the :phpmethod:`MongoDB\Collection::find()` method in the {+php-library+}. Some of the options to modify query results, such as ``skip``, ``sort``, and ``limit``, are settable directly as query builder methods and are described in the diff --git a/docs/sessions.txt b/docs/sessions.txt new file mode 100644 index 000000000..ea33b0d66 --- /dev/null +++ b/docs/sessions.txt @@ -0,0 +1,102 @@ +.. _laravel-sessions: + +============= +HTTP Sessions +============= + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: php framework, odm, cookies, multiple requests + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to set up HTTP sessions by +using {+odm-long+}. Sessions allow your application to store information +about a user across multiple server requests. Your application stores this +information in a specified location that it can access in future +requests that the user makes. The session driver in {+odm-long+} uses +the ``MongoDbSessionHandler`` class from the Symfony framework to store +session information. + +To learn more about support for sessions, see `HTTP Session +`__ in the +Laravel documentation. + +Register a Session +------------------ + +Before you can register a session, you must configure your connection to +MongoDB in your application's ``config/database.php`` file. To learn how +to set up this connection, see the +:ref:`laravel-quick-start-connect-to-mongodb` step of the Quick Start +guide. + +Next, you can select the session driver and connection in one of the +following ways: + +1. In an ``.env`` file, by setting the following environment variables: + + .. code-block:: ini + :caption: .env + + SESSION_DRIVER=mongodb + # Optional, this is the default value + SESSION_CONNECTION=mongodb + +#. In the ``config/session.php`` file, as shown in the following code: + + .. code-block:: php + :caption: config/session.php + + 'mongodb', // Required + 'connection' => 'mongodb', // Database connection name, default is "mongodb" + 'table' => 'sessions', // Collection name, default is "sessions" + 'lifetime' => null, // TTL of session in minutes, default is 120 + 'options' => [] // Other driver options + ]; + +The following list describes other driver options that you can set in +the ``options`` array: + +- ``id_field``: Field name for storing the session ID (default: ``_id``) +- ``data_field``: Field name for storing the session data (default: ``data``) +- ``time_field``: Field name for storing the timestamp (default: ``time``) +- ``expiry_field``: Field name for storing the expiry-timestamp (default: ``expires_at``) +- ``ttl``: Time to live in seconds + +We recommend that you create an index on the ``expiry_field`` field for +garbage collection. You can also automatically expire sessions in the +database by creating a TTL index on the collection that stores session +information. + +You can use the ``Schema`` builder to create a TTL index, as shown in +the following code: + +.. code-block:: php + + Schema::create('sessions', function (Blueprint $collection) { + $collection->expire('expiry_field', 0); + }); + +Setting the time value to ``0`` in the index +definition instructs MongoDB to expire documents at the clock time +specified in the ``expiry_field`` field. + +To learn more about using the ``Schema`` builder to create indexes, see +the :ref:`laravel-schema-builder-special-idx` section of the Schema +Builder guide. + +To learn more about TTL indexes, see :manual:`Expire Data from +Collections by Setting TTL ` in the +{+server-docs-name+}. From af50a44548298db565c44dd6d38623c3505429bc Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Wed, 29 Jan 2025 15:36:28 -0500 Subject: [PATCH 17/92] DOCSP-45065: sessions page quick fix (#3256) * DOCSP-45065: sessions documentation * MW PR fixes 1 * JT tech review 1 * small fix error in build * DOCSP-45065: quick fix to full PR --- docs/sessions.txt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/sessions.txt b/docs/sessions.txt index ea33b0d66..e8ed10e7a 100644 --- a/docs/sessions.txt +++ b/docs/sessions.txt @@ -69,10 +69,10 @@ following ways: The following list describes other driver options that you can set in the ``options`` array: -- ``id_field``: Field name for storing the session ID (default: ``_id``) -- ``data_field``: Field name for storing the session data (default: ``data``) -- ``time_field``: Field name for storing the timestamp (default: ``time``) -- ``expiry_field``: Field name for storing the expiry-timestamp (default: ``expires_at``) +- ``id_field``: Custom field name for storing the session ID (default: ``_id``) +- ``data_field``: Custom field name for storing the session data (default: ``data``) +- ``time_field``: Custom field name for storing the timestamp (default: ``time``) +- ``expiry_field``: Custom field name for storing the expiry timestamp (default: ``expires_at``) - ``ttl``: Time to live in seconds We recommend that you create an index on the ``expiry_field`` field for @@ -86,12 +86,12 @@ the following code: .. code-block:: php Schema::create('sessions', function (Blueprint $collection) { - $collection->expire('expiry_field', 0); + $collection->expire('expires_at', 0); }); -Setting the time value to ``0`` in the index -definition instructs MongoDB to expire documents at the clock time -specified in the ``expiry_field`` field. +Setting the time value to ``0`` in the index definition instructs +MongoDB to expire documents at the clock time specified in the +``expires_at`` field. To learn more about using the ``Schema`` builder to create indexes, see the :ref:`laravel-schema-builder-special-idx` section of the Schema From ce2ba2f4891492dd8b548ba529a2900057f25fe1 Mon Sep 17 00:00:00 2001 From: Brad Miller <28307684+mad-briller@users.noreply.github.com> Date: Thu, 6 Feb 2025 15:45:56 +0000 Subject: [PATCH 18/92] Add template types to relation classes (#3262) --- phpstan-baseline.neon | 30 ++++++++++++++++++++++++++++++ src/Relations/BelongsTo.php | 8 +++++++- src/Relations/BelongsToMany.php | 5 +++++ src/Relations/EmbedsMany.php | 6 ++++++ src/Relations/EmbedsOne.php | 6 ++++++ src/Relations/EmbedsOneOrMany.php | 6 ++++++ src/Relations/HasMany.php | 5 +++++ src/Relations/HasOne.php | 5 +++++ src/Relations/MorphMany.php | 5 +++++ src/Relations/MorphTo.php | 5 +++++ src/Relations/MorphToMany.php | 5 +++++ 11 files changed, 85 insertions(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 737e31f17..67fdd4154 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -10,11 +10,41 @@ parameters: count: 4 path: src/MongoDBServiceProvider.php + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:pull\\(\\)\\.$#" + count: 1 + path: src/Relations/BelongsToMany.php + - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" count: 3 path: src/Relations/BelongsToMany.php + - + message: "#^Call to an undefined method MongoDB\\\\Laravel\\\\Relations\\\\EmbedsMany\\\\:\\:contains\\(\\)\\.$#" + count: 1 + path: src/Relations/EmbedsMany.php + + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:getParentRelation\\(\\)\\.$#" + count: 1 + path: src/Relations/EmbedsOneOrMany.php + + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:setParentRelation\\(\\)\\.$#" + count: 1 + path: src/Relations/EmbedsOneOrMany.php + + - + message: "#^Call to an undefined method TRelatedModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:setParentRelation\\(\\)\\.$#" + count: 2 + path: src/Relations/EmbedsOneOrMany.php + + - + message: "#^Call to an undefined method TDeclaringModel of Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:pull\\(\\)\\.$#" + count: 2 + path: src/Relations/MorphToMany.php + - message: "#^Method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:push\\(\\) invoked with 3 parameters, 0 required\\.$#" count: 6 diff --git a/src/Relations/BelongsTo.php b/src/Relations/BelongsTo.php index 175a53e49..93eb11f8e 100644 --- a/src/Relations/BelongsTo.php +++ b/src/Relations/BelongsTo.php @@ -6,8 +6,14 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo as EloquentBelongsTo; -class BelongsTo extends \Illuminate\Database\Eloquent\Relations\BelongsTo +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentBelongsTo + */ +class BelongsTo extends EloquentBelongsTo { /** * Get the key for comparing against the parent key in "has" query. diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php index b68c79d4c..a150fccf7 100644 --- a/src/Relations/BelongsToMany.php +++ b/src/Relations/BelongsToMany.php @@ -21,6 +21,11 @@ use function in_array; use function is_numeric; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentBelongsToMany + */ class BelongsToMany extends EloquentBelongsToMany { /** diff --git a/src/Relations/EmbedsMany.php b/src/Relations/EmbedsMany.php index e4bbf535f..49e1afa2d 100644 --- a/src/Relations/EmbedsMany.php +++ b/src/Relations/EmbedsMany.php @@ -21,6 +21,12 @@ use function throw_if; use function value; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @template TResult + * @extends EmbedsOneOrMany + */ class EmbedsMany extends EmbedsOneOrMany { /** @inheritdoc */ diff --git a/src/Relations/EmbedsOne.php b/src/Relations/EmbedsOne.php index 95d5cc15d..be7fb192f 100644 --- a/src/Relations/EmbedsOne.php +++ b/src/Relations/EmbedsOne.php @@ -11,6 +11,12 @@ use function throw_if; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @template TResult + * @extends EmbedsOneOrMany + */ class EmbedsOne extends EmbedsOneOrMany { public function initRelation(array $models, $relation) diff --git a/src/Relations/EmbedsOneOrMany.php b/src/Relations/EmbedsOneOrMany.php index f18d3d526..a46593cf4 100644 --- a/src/Relations/EmbedsOneOrMany.php +++ b/src/Relations/EmbedsOneOrMany.php @@ -21,6 +21,12 @@ use function str_starts_with; use function throw_if; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @template TResult + * @extends Relation + */ abstract class EmbedsOneOrMany extends Relation { /** diff --git a/src/Relations/HasMany.php b/src/Relations/HasMany.php index a38fba15a..c8e7e0590 100644 --- a/src/Relations/HasMany.php +++ b/src/Relations/HasMany.php @@ -8,6 +8,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany as EloquentHasMany; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentHasMany + */ class HasMany extends EloquentHasMany { /** diff --git a/src/Relations/HasOne.php b/src/Relations/HasOne.php index 740a489d8..ea26761d3 100644 --- a/src/Relations/HasOne.php +++ b/src/Relations/HasOne.php @@ -8,6 +8,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne as EloquentHasOne; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentHasOne + */ class HasOne extends EloquentHasOne { /** diff --git a/src/Relations/MorphMany.php b/src/Relations/MorphMany.php index 88f825dc0..5f395950f 100644 --- a/src/Relations/MorphMany.php +++ b/src/Relations/MorphMany.php @@ -7,6 +7,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany as EloquentMorphMany; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentMorphMany + */ class MorphMany extends EloquentMorphMany { /** diff --git a/src/Relations/MorphTo.php b/src/Relations/MorphTo.php index 4874b23bb..4888b2d97 100644 --- a/src/Relations/MorphTo.php +++ b/src/Relations/MorphTo.php @@ -7,6 +7,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo as EloquentMorphTo; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentMorphTo + */ class MorphTo extends EloquentMorphTo { /** @inheritdoc */ diff --git a/src/Relations/MorphToMany.php b/src/Relations/MorphToMany.php index f11d25473..929738360 100644 --- a/src/Relations/MorphToMany.php +++ b/src/Relations/MorphToMany.php @@ -24,6 +24,11 @@ use function is_array; use function is_numeric; +/** + * @template TRelatedModel of Model + * @template TDeclaringModel of Model + * @extends EloquentMorphToMany + */ class MorphToMany extends EloquentMorphToMany { /** @inheritdoc */ From 1c27b2a461cfcf22407a6531cae09025a801008c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 6 Feb 2025 19:36:29 +0100 Subject: [PATCH 19/92] Add tests on doesntExist (#3257) --- tests/QueryTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/QueryTest.php b/tests/QueryTest.php index 78a7b1bee..4fd362ae9 100644 --- a/tests/QueryTest.php +++ b/tests/QueryTest.php @@ -411,6 +411,8 @@ public function testExists(): void { $this->assertFalse(User::where('age', '>', 37)->exists()); $this->assertTrue(User::where('age', '<', 37)->exists()); + $this->assertTrue(User::where('age', '>', 37)->doesntExist()); + $this->assertFalse(User::where('age', '<', 37)->doesntExist()); } public function testSubQuery(): void From 08d54d8164a0499a067a4a68a3199a28fea874be Mon Sep 17 00:00:00 2001 From: Nora Reidy Date: Fri, 7 Feb 2025 12:58:54 -0500 Subject: [PATCH 20/92] DOCSP-46438: Read preference (#3260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DOCSP-46438: Read preference * edits * tip * fix test * fix * code * JS feedback * Switch example to SECONDARY_PREFERRED * JT feedback * apply phpcbf formatting * tests --------- Co-authored-by: Jérôme Tamarelle --- docs/fundamentals/read-operations.txt | 109 ++++++++++++++++++ .../read-operations/ReadOperationsTest.php | 19 +++ .../query-builder/QueryBuilderTest.php | 13 +++ docs/query-builder.txt | 34 +++++- 4 files changed, 171 insertions(+), 4 deletions(-) diff --git a/docs/fundamentals/read-operations.txt b/docs/fundamentals/read-operations.txt index d5605033b..f3b02c5ec 100644 --- a/docs/fundamentals/read-operations.txt +++ b/docs/fundamentals/read-operations.txt @@ -359,6 +359,8 @@ method: results in a specified order based on field values - :ref:`laravel-retrieve-one` uses the ``first()`` method to return the first document that matches the query filter +- :ref:`laravel-read-pref` uses the ``readPreference()`` method to direct the query + to specific replica set members .. _laravel-skip-limit: @@ -606,3 +608,110 @@ field. To learn more about the ``orderBy()`` method, see the :ref:`laravel-sort` section of this guide. + +.. _laravel-read-pref: + +Set a Read Preference +~~~~~~~~~~~~~~~~~~~~~ + +To specify which replica set members receive your read operations, +set a read preference by using the ``readPreference()`` method. + +The ``readPreference()`` method accepts the following parameters: + +- ``mode``: *(Required)* A string value specifying the read preference + mode. + +- ``tagSets``: *(Optional)* An array value specifying key-value tags that correspond to + certain replica set members. + +- ``options``: *(Optional)* An array value specifying additional read preference options. + +.. tip:: + + To view a full list of available read preference modes and options, see + :php:`MongoDB\Driver\ReadPreference::__construct ` + in the MongoDB PHP extension documentation. + +The following example queries for documents in which the value of the ``title`` +field is ``"Carrie"`` and sets the read preference to ``ReadPreference::SECONDARY_PREFERRED``. +As a result, the query retrieves the results from secondary replica set +members or the primary member if no secondaries are available: + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-read-pref + :end-before: end-read-pref + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movies = Movie::where('title', 'Carrie') + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + + return view('browse_movies', [ + 'movies' => $movies + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Carrie + Year: 1952 + Runtime: 118 + IMDB Rating: 7.5 + IMDB Votes: 1458 + Plot: Carrie boards the train to Chicago with big ambitions. She gets a + job stitching shoes and her sister's husband takes almost all of her pay + for room and board. Then she injures a finger and ... + + Title: Carrie + Year: 1976 + Runtime: 98 + IMDB Rating: 7.4 + IMDB Votes: 115528 + Plot: A shy, outcast 17-year old girl is humiliated by her classmates for the + last time. + + Title: Carrie + Year: 2002 + Runtime: 132 + IMDB Rating: 5.5 + IMDB Votes: 7412 + Plot: Carrie White is a lonely and painfully shy teenage girl with telekinetic + powers who is slowly pushed to the edge of insanity by frequent bullying from + both her classmates and her domineering, religious mother. + + Title: Carrie + Year: 2013 + Runtime: 100 + IMDB Rating: 6 + IMDB Votes: 98171 + Plot: A reimagining of the classic horror tale about Carrie White, a shy girl + outcast by her peers and sheltered by her deeply religious mother, who unleashes + telekinetic terror on her small town after being pushed too far at her senior prom. diff --git a/docs/includes/fundamentals/read-operations/ReadOperationsTest.php b/docs/includes/fundamentals/read-operations/ReadOperationsTest.php index c27680fb5..207fd442e 100644 --- a/docs/includes/fundamentals/read-operations/ReadOperationsTest.php +++ b/docs/includes/fundamentals/read-operations/ReadOperationsTest.php @@ -6,6 +6,7 @@ use App\Models\Movie; use Illuminate\Support\Facades\DB; +use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Tests\TestCase; class ReadOperationsTest extends TestCase @@ -33,6 +34,8 @@ protected function setUp(): void ['title' => 'movie_a', 'plot' => 'this is a love story'], ['title' => 'movie_b', 'plot' => 'love is a long story'], ['title' => 'movie_c', 'plot' => 'went on a trip'], + ['title' => 'Carrie', 'year' => 1976], + ['title' => 'Carrie', 'year' => 2002], ]); } @@ -164,4 +167,20 @@ public function arrayElemMatch(): void $this->assertNotNull($movies); $this->assertCount(2, $movies); } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testReadPreference(): void + { + // start-read-pref + $movies = Movie::where('title', 'Carrie') + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + // end-read-pref + + $this->assertNotNull($movies); + $this->assertCount(2, $movies); + } } diff --git a/docs/includes/query-builder/QueryBuilderTest.php b/docs/includes/query-builder/QueryBuilderTest.php index d99796fb2..f7525bf6e 100644 --- a/docs/includes/query-builder/QueryBuilderTest.php +++ b/docs/includes/query-builder/QueryBuilderTest.php @@ -11,6 +11,7 @@ use MongoDB\BSON\ObjectId; use MongoDB\BSON\Regex; use MongoDB\Collection; +use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Tests\TestCase; use function file_get_contents; @@ -452,6 +453,18 @@ public function testCursorTimeout(): void $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); } + public function testReadPreference(): void + { + // begin query read pref + $result = DB::table('movies') + ->where('runtime', '>', 240) + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + // end query read pref + + $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); + } + public function testNear(): void { $this->importTheaters(); diff --git a/docs/query-builder.txt b/docs/query-builder.txt index b3c89b0ae..89caf8846 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -840,6 +840,7 @@ to use the following MongoDB-specific query operations: - :ref:`Run MongoDB Query API operations ` - :ref:`Match documents that contain array elements ` - :ref:`Specify a cursor timeout ` +- :ref:`Specify a read preference ` - :ref:`Match locations by using geospatial searches ` .. _laravel-query-builder-exists: @@ -1033,6 +1034,31 @@ to specify a maximum duration to wait for cursor operations to complete. `MongoDB\Collection::find() `__ in the PHP Library documentation. +.. _laravel-query-builder-read-pref: + +Specify a Read Preference Example +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can control how the {+odm-short+} directs read operations to replica +set members by setting a read preference. + +The following example queries the ``movies`` collection for documents +in which the ``runtime`` value is greater than ``240``. The example passes a +value of ``ReadPreference::SECONDARY_PREFERRED`` to the ``readPreference()`` +method, which sends the query to secondary replica set members +or the primary member if no secondaries are available: + +.. literalinclude:: /includes/query-builder/QueryBuilderTest.php + :language: php + :dedent: + :start-after: begin query read pref + :end-before: end query read pref + +.. tip:: + + To learn more about read preferences, see :manual:`Read Preference + ` in the MongoDB {+server-docs-name+}. + .. _laravel-query-builder-geospatial: Match Locations by Using Geospatial Operations @@ -1061,7 +1087,7 @@ in the {+server-docs-name+}. .. _laravel-query-builder-geospatial-near: Near a Position Example -~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``near`` query operator with the ``where()`` query builder method to match documents that @@ -1081,7 +1107,7 @@ in the {+server-docs-name+}. .. _laravel-query-builder-geospatial-geoWithin: Within an Area Example -~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``geoWithin`` query operator with the ``where()`` @@ -1098,7 +1124,7 @@ GeoJSON object: .. _laravel-query-builder-geospatial-geoIntersects: Intersecting a Geometry Example -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``geoInstersects`` query operator with the ``where()`` query builder method to @@ -1114,7 +1140,7 @@ the specified ``LineString`` GeoJSON object: .. _laravel-query-builder-geospatial-geoNear: Proximity Data for Nearby Matches Example -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The following example shows how to use the ``geoNear`` aggregation operator with the ``raw()`` query builder method to perform an aggregation that returns From f68e0c20533635eb4dff13b75fa66cf49a7c59a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 7 Feb 2025 20:38:44 +0100 Subject: [PATCH 21/92] PHPORM-295 VectorSearch path cannot be an array (#3263) --- src/Eloquent/Builder.php | 2 +- src/Query/Builder.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index afe968e4b..eedbe8712 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -112,7 +112,7 @@ public function search( */ public function vectorSearch( string $index, - array|string $path, + string $path, array $queryVector, int $limit, bool $exact = false, diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 4c7c8513f..f613b6467 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1604,7 +1604,7 @@ public function search( */ public function vectorSearch( string $index, - array|string $path, + string $path, array $queryVector, int $limit, bool $exact = false, From 9fdfbe59d78bc2fdcab9145a6b510d325c246aa8 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Mon, 10 Feb 2025 09:16:37 -0500 Subject: [PATCH 22/92] DOCSP-46269: atlas search & atlas vector search pages (#3255) * DOCSP-46269: as & avs * wip * wip * wip * JT small fix * wip * wip * link fix * merge upstream and make some changes from last PR * revert changes to sessions page - will separate into another PR * LM PR fixes 1 * small note * filename change * LM PR fixes 2 * wip * wip * fix term links * fixes * JT small fixes * indentation fix --- docs/fundamentals.txt | 4 + docs/fundamentals/atlas-search.txt | 241 ++++++++++++++++++ docs/fundamentals/vector-search.txt | 162 ++++++++++++ .../fundamentals/as-avs/AtlasSearchTest.php | 157 ++++++++++++ docs/includes/fundamentals/as-avs/Movie.php | 12 + 5 files changed, 576 insertions(+) create mode 100644 docs/fundamentals/atlas-search.txt create mode 100644 docs/fundamentals/vector-search.txt create mode 100644 docs/includes/fundamentals/as-avs/AtlasSearchTest.php create mode 100644 docs/includes/fundamentals/as-avs/Movie.php diff --git a/docs/fundamentals.txt b/docs/fundamentals.txt index db482b2b8..dafc427c3 100644 --- a/docs/fundamentals.txt +++ b/docs/fundamentals.txt @@ -20,6 +20,8 @@ Fundamentals Read Operations Write Operations Aggregation Builder + Atlas Search + Atlas Vector Search Learn more about the following concepts related to {+odm-long+}: @@ -28,3 +30,5 @@ Learn more about the following concepts related to {+odm-long+}: - :ref:`laravel-fundamentals-read-ops` - :ref:`laravel-fundamentals-write-ops` - :ref:`laravel-aggregation-builder` +- :ref:`laravel-atlas-search` +- :ref:`laravel-vector-search` diff --git a/docs/fundamentals/atlas-search.txt b/docs/fundamentals/atlas-search.txt new file mode 100644 index 000000000..9aaa9156b --- /dev/null +++ b/docs/fundamentals/atlas-search.txt @@ -0,0 +1,241 @@ +.. _laravel-atlas-search: + +============ +Atlas Search +============ + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: code example, semantic, text + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to perform searches on your documents +by using the Atlas Search feature. {+odm-long+} provides an API to +perform Atlas Search queries directly with your models. This guide +describes how to create Atlas Search indexes and provides examples of +how to use the {+odm-short+} to perform searches. + +.. note:: Deployment Compatibility + + You can use the Atlas Search feature only when + you connect to MongoDB Atlas clusters. This feature is not + available for self-managed deployments. + +To learn more about Atlas Search, see the :atlas:`Overview +` in the +Atlas documentation. The Atlas Search API internally uses the +``$search`` aggregation operator to perform queries. To learn more about +this operator, see the :atlas:`$search +` reference in the Atlas +documentation. + +.. note:: + + You might not be able to use the methods described in + this guide for every type of Atlas Search query. + For more complex use cases, create an aggregation pipeline by using + the :ref:`laravel-aggregation-builder`. + + To perform searches on vector embeddings in MongoDB, you can use the + {+odm-long+} Atlas Vector Search API. To learn about this feature, see + the :ref:`laravel-vector-search` guide. + +.. _laravel-as-index: + +Create an Atlas Search Index +---------------------------- + +.. TODO in DOCSP-46230 + +Perform Queries +--------------- + +In this section, you can learn how to use the Atlas Search API in the +{+odm-short+}. + +General Queries +~~~~~~~~~~~~~~~ + +The {+odm-short+} provides the ``search()`` method as a query +builder method and as an Eloquent model method. You can use the +``search()`` method to run Atlas Search queries on documents in your +collections. + +You must pass an ``operator`` parameter to the ``search()`` method that +is an instance of ``SearchOperatorInterface`` or an array that contains +the operator type, field name, and query value. You can +create an instance of ``SearchOperatorInterface`` by calling the +``Search::text()`` method and passing the field you are +querying and your search term or phrase. + +You must include the following import statement in your application to +create a ``SearchOperatorInterface`` instance: + +.. code-block:: php + + use MongoDB\Builder\Search; + +The following code performs an Atlas Search query on the ``Movie`` +model's ``title`` field for the term ``'dream'``: + +.. io-code-block:: + :copyable: true + + .. input:: /includes/fundamentals/as-avs/AtlasSearchTest.php + :language: php + :dedent: + :start-after: start-search-query + :end-before: end-search-query + + .. output:: + :language: json + :visible: false + + [ + { "title": "Dreaming of Jakarta", + "year": 1990 + }, + { "title": "See You in My Dreams", + "year": 1996 + } + ] + +You can use the ``search()`` method to perform many types of Atlas +Search queries. Depending on your desired query, you can pass the +following optional parameters to ``search()``: + +.. list-table:: + :header-rows: 1 + + * - Optional Parameter + - Type + - Description + + * - ``index`` + - ``string`` + - Provides the name of the Atlas Search index to use + + * - ``highlight`` + - ``array`` + - Specifies highlighting options for displaying search terms in their + original context + + * - ``concurrent`` + - ``bool`` + - Parallelizes search query across segments on dedicated search nodes + + * - ``count`` + - ``string`` + - Specifies the count options for retrieving a count of the results + + * - ``searchAfter`` + - ``string`` + - Specifies a reference point for returning documents starting + immediately following that point + + * - ``searchBefore`` + - ``string`` + - Specifies a reference point for returning documents starting + immediately preceding that point + + * - ``scoreDetails`` + - ``bool`` + - Specifies whether to retrieve a detailed breakdown of the score + for results + + * - ``sort`` + - ``array`` + - Specifies the fields on which to sort the results + + * - ``returnStoredSource`` + - ``bool`` + - Specifies whether to perform a full document lookup on the + backend database or return only stored source fields directly + from Atlas Search + + * - ``tracking`` + - ``array`` + - Specifies the tracking option to retrieve analytics information + on the search terms + +To learn more about these parameters, see the :atlas:`Fields +` section of the +``$search`` operator reference in the Atlas documentation. + +Autocomplete Queries +~~~~~~~~~~~~~~~~~~~~ + +The {+odm-short+} provides the ``autocomplete()`` method as a query +builder method and as an Eloquent model method. You can use the +``autocomplete()`` method to run autocomplete searches on documents in your +collections. This method returns only the values of the field you +specify as the query path. + +To learn more about this type of Atlas Search query, see the +:atlas:`autocomplete ` reference in the +Atlas documentation. + +.. note:: + + You must create an Atlas Search index with an autocomplete configuration + on your collection before you can perform autocomplete searches. See the + :ref:`laravel-as-index` section of this guide to learn more about + creating Search indexes. + +The following code performs an Atlas Search autocomplete query for the +string ``"jak"`` on the ``title`` field: + +.. io-code-block:: + :copyable: true + + .. input:: /includes/fundamentals/as-avs/AtlasSearchTest.php + :language: php + :dedent: + :start-after: start-auto-query + :end-before: end-auto-query + + .. output:: + :language: json + :visible: false + + [ + "Dreaming of Jakarta", + "Jakob the Liar", + "Emily Calling Jake" + ] + +You can also pass the following optional parameters to the ``autocomplete()`` +method to customize the query: + +.. list-table:: + :header-rows: 1 + + * - Optional Parameter + - Type + - Description + - Default Value + + * - ``fuzzy`` + - ``bool`` or ``array`` + - Enables fuzzy search and fuzzy search options + - ``false`` + + * - ``tokenOrder`` + - ``string`` + - Specifies order in which to search for tokens + - ``'any'`` + +To learn more about these parameters, see the :atlas:`Options +` section of the +``autocomplete`` operator reference in the Atlas documentation. diff --git a/docs/fundamentals/vector-search.txt b/docs/fundamentals/vector-search.txt new file mode 100644 index 000000000..116cb75a0 --- /dev/null +++ b/docs/fundamentals/vector-search.txt @@ -0,0 +1,162 @@ +.. _laravel-vector-search: + +=================== +Atlas Vector Search +=================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: code example, semantic, text, embeddings + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to perform searches on your documents +by using the Atlas Vector Search feature. {+odm-long+} provides an API to +perform Atlas Vector Search queries directly with your models. This guide +describes how to create Atlas Vector Search indexes and provides +examples of how to use the {+odm-short+} to perform searches. + +.. note:: Deployment Compatibility + + You can use the Atlas Vector Search feature only when + you connect to MongoDB Atlas clusters. This feature is not + available for self-managed deployments. + +To learn more about Atlas Vector Search, see the :atlas:`Overview +` in the +Atlas documentation. The Atlas Vector Search API internally uses the +``$vectorSearch`` aggregation operator to perform queries. To learn more about +this operator, see the :atlas:`$vectorSearch +` reference in the Atlas +documentation. + +.. note:: + + You might not be able to use the methods described in + this guide for every type of Atlas Vector Search query. + For more complex use cases, create an aggregation pipeline by using + the :ref:`laravel-aggregation-builder`. + + To perform advanced full-text searches on your documents, you can use the + {+odm-long+} Atlas Search API. To learn about this feature, see + the :ref:`laravel-atlas-search` guide. + +.. _laravel-avs-index: + +Create an Atlas Vector Search Index +----------------------------------- + +.. TODO in DOCSP-46230 + +Perform Queries +--------------- + +In this section, you can learn how to use the Atlas Vector Search API in +the {+odm-short+}. The {+odm-short+} provides the ``vectorSearch()`` +method as a query builder method and as an Eloquent model method. You +can use the ``vectorSearch()`` method to run Atlas Vector Search queries +on documents in your collections. + +You must pass the following parameters to the ``vectorSearch()`` method: + +.. list-table:: + :header-rows: 1 + + * - Parameter + - Type + - Description + + * - ``index`` + - ``string`` + - Name of the vector search index + + * - ``path`` + - ``string`` + - Field that stores vector embeddings + + * - ``queryVector`` + - ``array`` + - Vector representation of your query + + * - ``limit`` + - ``int`` + - Number of results to return + +The following code uses the ``vector`` index created in the preceding +:ref:`laravel-avs-index` section to perform an Atlas Vector Search query on the +``movies`` collection: + +.. io-code-block:: + :copyable: true + + .. input:: + :language: php + + $movies = Book::vectorSearch( + index: 'vector', + path: 'vector_embeddings', + // Vector representation of the query `coming of age` + queryVector: [-0.0016261312, -0.028070757, ...], + limit: 3, + ); + + .. output:: + :language: json + :visible: false + + [ + { "title": "Sunrising", + "plot": "A shy teenager discovers confidence and new friendships during a transformative summer camp experience." + }, + { "title": "Last Semester", + "plot": "High school friends navigate love, identity, and unexpected challenges before graduating together." + } + ] + +You can use the ``vectorSearch()`` method to perform many types of Atlas +Search queries. Depending on your desired query, you can pass the +following optional parameters to ``vectorSearch()``: + +.. list-table:: + :header-rows: 1 + + * - Optional Parameter + - Type + - Description + - Default Value + + * - ``exact`` + - ``bool`` + - Specifies whether to run an Exact Nearest Neighbor (``true``) or + Approximate Nearest Neighbor (``false``) search + - ``false`` + + * - ``filter`` + - ``QueryInterface`` or ``array`` + - Specifies a pre-filter for documents to search on + - no filtering + + * - ``numCandidates`` + - ``int`` or ``null`` + - Specifies the number of nearest neighbors to use during the + search + - ``null`` + +.. note:: + + To construct a ``QueryInterface`` instance, you must import the + ``MongoDB\Builder\Query`` class into your application. + +To learn more about these parameters, see the :atlas:`Fields +` section of the +``$vectorSearch`` operator reference in the Atlas documentation. diff --git a/docs/includes/fundamentals/as-avs/AtlasSearchTest.php b/docs/includes/fundamentals/as-avs/AtlasSearchTest.php new file mode 100644 index 000000000..1d9336f76 --- /dev/null +++ b/docs/includes/fundamentals/as-avs/AtlasSearchTest.php @@ -0,0 +1,157 @@ +getCollection('movies'); + $moviesCollection->drop(); + + Movie::insert([ + ['title' => 'Dreaming of Jakarta', 'year' => 1990], + ['title' => 'See You in My Dreams', 'year' => 1996], + ['title' => 'On the Run', 'year' => 2004], + ['title' => 'Jakob the Liar', 'year' => 1999], + ['title' => 'Emily Calling Jake', 'year' => 2001], + ]); + + Movie::insert($this->addVector([ + ['title' => 'A', 'plot' => 'A shy teenager discovers confidence and new friendships during a transformative summer camp experience.'], + ['title' => 'B', 'plot' => 'A detective teams up with a hacker to unravel a global conspiracy threatening personal freedoms.'], + ['title' => 'C', 'plot' => 'High school friends navigate love, identity, and unexpected challenges before graduating together.'], + ['title' => 'D', 'plot' => 'Stranded on a distant planet, astronauts must repair their ship before supplies run out.'], + ])); + + $moviesCollection = DB::connection('mongodb')->getCollection('movies'); + + try { + $moviesCollection->createSearchIndex([ + 'mappings' => [ + 'fields' => [ + 'title' => [ + ['type' => 'string', 'analyzer' => 'lucene.english'], + ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], + ], + ], + ], + ]); + + $moviesCollection->createSearchIndex([ + 'mappings' => ['dynamic' => true], + ], ['name' => 'dynamic_search']); + + $moviesCollection->createSearchIndex([ + 'fields' => [ + ['type' => 'vector', 'numDimensions' => 4, 'path' => 'vector4', 'similarity' => 'cosine'], + ['type' => 'filter', 'path' => 'title'], + ], + ], ['name' => 'vector', 'type' => 'vectorSearch']); + } catch (ServerException $e) { + if (Builder::isAtlasSearchNotSupportedException($e)) { + self::markTestSkipped('Atlas Search not supported. ' . $e->getMessage()); + } + + throw $e; + } + + // Waits 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); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testSimpleSearch(): void + { + // start-search-query + $movies = Movie::search( + sort: ['title' => 1], + operator: Search::text('title', 'dream'), + )->get(); + // end-search-query + + $this->assertNotNull($movies); + $this->assertCount(2, $movies); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function autocompleteSearchTest(): void + { + // start-auto-query + $movies = Movie::autocomplete('title', 'jak')->get(); + // end-auto-query + + $this->assertNotNull($movies); + $this->assertCount(3, $movies); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function vectorSearchTest(): void + { + $results = Book::vectorSearch( + index: 'vector', + path: 'vector4', + queryVector: $this->vectors[0], + limit: 3, + numCandidates: 10, + filter: Query::query( + title: Query::ne('A'), + ), + ); + + $this->assertNotNull($results); + $this->assertSame('C', $results->first()->title); + } + + /** Generates random vectors using fixed seed to make tests deterministic */ + private function addVector(array $items): array + { + srand(1); + foreach ($items as &$item) { + $this->vectors[] = $item['vector4'] = array_map(fn () => rand() / mt_getrandmax(), range(0, 3)); + } + + return $items; + } +} diff --git a/docs/includes/fundamentals/as-avs/Movie.php b/docs/includes/fundamentals/as-avs/Movie.php new file mode 100644 index 000000000..2098db9ec --- /dev/null +++ b/docs/includes/fundamentals/as-avs/Movie.php @@ -0,0 +1,12 @@ + Date: Tue, 18 Feb 2025 16:58:15 +0100 Subject: [PATCH 23/92] DOCSP-46269 Fix doc examples on atlas search (#3279) --- .../fundamentals/as-avs/AtlasSearchTest.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/includes/fundamentals/as-avs/AtlasSearchTest.php b/docs/includes/fundamentals/as-avs/AtlasSearchTest.php index 1d9336f76..79dfe46df 100644 --- a/docs/includes/fundamentals/as-avs/AtlasSearchTest.php +++ b/docs/includes/fundamentals/as-avs/AtlasSearchTest.php @@ -11,6 +11,7 @@ use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Schema\Builder; use MongoDB\Laravel\Tests\TestCase; +use PHPUnit\Framework\Attributes\Group; use function array_map; use function mt_getrandmax; @@ -19,6 +20,7 @@ use function srand; use function usleep; +#[Group('atlas-search')] class AtlasSearchTest extends TestCase { private array $vectors; @@ -84,7 +86,7 @@ protected function setUp(): void do { $ready = true; usleep(10_000); - foreach ($collection->listSearchIndexes() as $index) { + foreach ($moviesCollection->listSearchIndexes() as $index) { if ($index['status'] !== 'READY') { $ready = false; } @@ -102,7 +104,7 @@ public function testSimpleSearch(): void $movies = Movie::search( sort: ['title' => 1], operator: Search::text('title', 'dream'), - )->get(); + )->all(); // end-search-query $this->assertNotNull($movies); @@ -113,10 +115,10 @@ public function testSimpleSearch(): void * @runInSeparateProcess * @preserveGlobalState disabled */ - public function autocompleteSearchTest(): void + public function testAutocompleteSearch(): void { // start-auto-query - $movies = Movie::autocomplete('title', 'jak')->get(); + $movies = Movie::autocomplete('title', 'jak')->all(); // end-auto-query $this->assertNotNull($movies); @@ -127,9 +129,9 @@ public function autocompleteSearchTest(): void * @runInSeparateProcess * @preserveGlobalState disabled */ - public function vectorSearchTest(): void + public function testVectorSearch(): void { - $results = Book::vectorSearch( + $results = Movie::vectorSearch( index: 'vector', path: 'vector4', queryVector: $this->vectors[0], @@ -141,7 +143,7 @@ public function vectorSearchTest(): void ); $this->assertNotNull($results); - $this->assertSame('C', $results->first()->title); + $this->assertSame('D', $results->first()->title); } /** Generates random vectors using fixed seed to make tests deterministic */ From 11f3cc1478f21da15911f01a55a2994edc73f7eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 18 Feb 2025 19:25:28 +0100 Subject: [PATCH 24/92] PHPORM-296 Enable support for Scout v10 (#3280) --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index dce593ed5..82c980859 100644 --- a/composer.json +++ b/composer.json @@ -35,12 +35,12 @@ }, "require-dev": { "mongodb/builder": "^0.2", - "laravel/scout": "^11", + "laravel/scout": "^10.3", "league/flysystem-gridfs": "^3.28", "league/flysystem-read-only": "^3.0", "phpunit/phpunit": "^10.3", "orchestra/testbench": "^8.0|^9.0", - "mockery/mockery": "^1.4.4", + "mockery/mockery": "^1.4.4@stable", "doctrine/coding-standard": "12.0.x-dev", "spatie/laravel-query-builder": "^5.6", "phpstan/phpstan": "^1.10", From cb3b32c388a16c8fe01323b89ac3313758825864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 19 Feb 2025 11:27:51 +0100 Subject: [PATCH 25/92] PHPORM-268 Add configuration for scout search indexes (#3281) --- src/MongoDBServiceProvider.php | 3 +- src/Scout/ScoutEngine.php | 13 +++-- tests/Scout/ScoutEngineTest.php | 79 ++++++++++++++++++++++++++++ tests/Scout/ScoutIntegrationTest.php | 7 ++- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index b0c085b8e..dc9caf082 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -167,10 +167,11 @@ private function registerScoutEngine(): void $connectionName = $app->get('config')->get('scout.mongodb.connection', 'mongodb'); $connection = $app->get('db')->connection($connectionName); $softDelete = (bool) $app->get('config')->get('scout.soft_delete', false); + $indexDefinitions = $app->get('config')->get('scout.mongodb.index-definitions', []); assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The connection "%s" is not a MongoDB connection.', $connectionName))); - return new ScoutEngine($connection->getMongoDB(), $softDelete); + return new ScoutEngine($connection->getMongoDB(), $softDelete, $indexDefinitions); }); return $engineManager; diff --git a/src/Scout/ScoutEngine.php b/src/Scout/ScoutEngine.php index e3c9c68c3..dc70a39e2 100644 --- a/src/Scout/ScoutEngine.php +++ b/src/Scout/ScoutEngine.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Collection; use Illuminate\Support\LazyCollection; +use InvalidArgumentException; use Laravel\Scout\Builder; use Laravel\Scout\Engines\Engine; use Laravel\Scout\Searchable; @@ -66,9 +67,11 @@ final class ScoutEngine extends Engine private const TYPEMAP = ['root' => 'object', 'document' => 'bson', 'array' => 'bson']; + /** @param array $indexDefinitions */ public function __construct( private Database $database, private bool $softDelete, + private array $indexDefinitions = [], ) { } @@ -435,14 +438,16 @@ public function createIndex($name, array $options = []): void { assert(is_string($name), new TypeError(sprintf('Argument #1 ($name) must be of type string, %s given', get_debug_type($name)))); + $definition = $this->indexDefinitions[$name] ?? self::DEFAULT_DEFINITION; + if (! isset($definition['mappings'])) { + throw new InvalidArgumentException(sprintf('Invalid search index definition for collection "%s", the "mappings" key is required. Find documentation at https://www.mongodb.com/docs/manual/reference/command/createSearchIndexes/#search-index-definition-syntax', $name)); + } + // Ensure the collection exists before creating the search index $this->database->createCollection($name); $collection = $this->database->selectCollection($name); - $collection->createSearchIndex( - self::DEFAULT_DEFINITION, - ['name' => self::INDEX_NAME], - ); + $collection->createSearchIndex($definition, ['name' => self::INDEX_NAME]); if ($options['wait'] ?? true) { $this->wait(function () use ($collection) { diff --git a/tests/Scout/ScoutEngineTest.php b/tests/Scout/ScoutEngineTest.php index a079ae530..f1244d060 100644 --- a/tests/Scout/ScoutEngineTest.php +++ b/tests/Scout/ScoutEngineTest.php @@ -2,6 +2,7 @@ namespace MongoDB\Laravel\Tests\Scout; +use ArrayIterator; use Closure; use DateTimeImmutable; use Illuminate\Database\Eloquent\Collection as EloquentCollection; @@ -9,7 +10,9 @@ use Illuminate\Support\LazyCollection; use Laravel\Scout\Builder; use Laravel\Scout\Jobs\RemoveFromSearch; +use LogicException; use Mockery as m; +use MongoDB\BSON\Document; use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; use MongoDB\Database; @@ -31,6 +34,82 @@ class ScoutEngineTest extends TestCase { private const EXPECTED_TYPEMAP = ['root' => 'object', 'document' => 'bson', 'array' => 'bson']; + public function testCreateIndexInvalidDefinition(): void + { + $database = m::mock(Database::class); + $engine = new ScoutEngine($database, false, ['collection_invalid' => ['foo' => 'bar']]); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid search index definition for collection "collection_invalid", the "mappings" key is required.'); + $engine->createIndex('collection_invalid'); + } + + public function testCreateIndex(): void + { + $collectionName = 'collection_custom'; + $expectedDefinition = [ + 'mappings' => [ + 'dynamic' => true, + ], + ]; + + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('createCollection') + ->once() + ->with($collectionName); + $database->shouldReceive('selectCollection') + ->with($collectionName) + ->andReturn($collection); + $collection->shouldReceive('createSearchIndex') + ->once() + ->with($expectedDefinition, ['name' => 'scout']); + $collection->shouldReceive('listSearchIndexes') + ->once() + ->with(['name' => 'scout', 'typeMap' => ['root' => 'bson']]) + ->andReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); + + $engine = new ScoutEngine($database, false, []); + $engine->createIndex($collectionName); + } + + public function testCreateIndexCustomDefinition(): void + { + $collectionName = 'collection_custom'; + $expectedDefinition = [ + 'mappings' => [ + [ + 'analyzer' => 'lucene.standard', + 'fields' => [ + [ + 'name' => 'wildcard', + 'type' => 'string', + ], + ], + ], + ], + ]; + + $database = m::mock(Database::class); + $collection = m::mock(Collection::class); + $database->shouldReceive('createCollection') + ->once() + ->with($collectionName); + $database->shouldReceive('selectCollection') + ->with($collectionName) + ->andReturn($collection); + $collection->shouldReceive('createSearchIndex') + ->once() + ->with($expectedDefinition, ['name' => 'scout']); + $collection->shouldReceive('listSearchIndexes') + ->once() + ->with(['name' => 'scout', 'typeMap' => ['root' => 'bson']]) + ->andReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); + + $engine = new ScoutEngine($database, false, [$collectionName => $expectedDefinition]); + $engine->createIndex($collectionName); + } + /** @param callable(): Builder $builder */ #[DataProvider('provideSearchPipelines')] public function testSearch(Closure $builder, array $expectedPipeline): void diff --git a/tests/Scout/ScoutIntegrationTest.php b/tests/Scout/ScoutIntegrationTest.php index ff4617352..b40a455ab 100644 --- a/tests/Scout/ScoutIntegrationTest.php +++ b/tests/Scout/ScoutIntegrationTest.php @@ -17,6 +17,7 @@ use function array_merge; use function count; use function env; +use function iterator_to_array; use function Orchestra\Testbench\artisan; use function range; use function sprintf; @@ -38,6 +39,9 @@ protected function getEnvironmentSetUp($app): void $app['config']->set('scout.driver', 'mongodb'); $app['config']->set('scout.prefix', 'prefix_'); + $app['config']->set('scout.mongodb.index-definitions', [ + 'prefix_scout_users' => ['mappings' => ['dynamic' => true, 'fields' => ['bool_field' => ['type' => 'boolean']]]], + ]); } public function setUp(): void @@ -103,8 +107,9 @@ public function testItCanCreateTheCollection() self::assertSame(44, $collection->countDocuments()); - $searchIndexes = $collection->listSearchIndexes(['name' => 'scout']); + $searchIndexes = $collection->listSearchIndexes(['name' => 'scout', 'typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array']]); self::assertCount(1, $searchIndexes); + self::assertSame(['mappings' => ['dynamic' => true, 'fields' => ['bool_field' => ['type' => 'boolean']]]], iterator_to_array($searchIndexes)[0]['latestDefinition']); // Wait for all documents to be indexed asynchronously $i = 100; From 56fa399aeda0e45564bc7a4a43572ffd9117412b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 24 Feb 2025 17:08:35 +0100 Subject: [PATCH 26/92] PHPORM-302 Compatibility with spatie/laravel-query-builder v6 (#3285) --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 82c980859..6df5c0575 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ "orchestra/testbench": "^8.0|^9.0", "mockery/mockery": "^1.4.4@stable", "doctrine/coding-standard": "12.0.x-dev", - "spatie/laravel-query-builder": "^5.6", + "spatie/laravel-query-builder": "^5.6|^6", "phpstan/phpstan": "^1.10", "rector/rector": "^1.2" }, From a8f38d9aecaef29d1adb0a620a6700ce69926898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 24 Feb 2025 21:10:52 +0100 Subject: [PATCH 27/92] PHPORM-303 Require mongodb library v1.21 with aggregation builder (#3287) --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 6df5c0575..6618fac67 100644 --- a/composer.json +++ b/composer.json @@ -30,11 +30,10 @@ "illuminate/database": "^10.30|^11", "illuminate/events": "^10.0|^11", "illuminate/support": "^10.0|^11", - "mongodb/mongodb": "^1.18", + "mongodb/mongodb": "^1.21", "symfony/http-foundation": "^6.4|^7" }, "require-dev": { - "mongodb/builder": "^0.2", "laravel/scout": "^10.3", "league/flysystem-gridfs": "^3.28", "league/flysystem-read-only": "^3.0", @@ -54,6 +53,7 @@ "mongodb/builder": "Provides a fluent aggregation builder for MongoDB pipelines" }, "minimum-stability": "dev", + "prefer-stable": true, "replace": { "jenssegers/mongodb": "self.version" }, From faacf63ac1586cb82f17a8b26bb839d56f68ddca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 24 Feb 2025 21:18:54 +0100 Subject: [PATCH 28/92] PHPORM-299 Enable PHPUnit 11 (#3286) --- composer.json | 2 +- tests/AuthTest.php | 4 ++-- tests/Eloquent/CallBuilderTest.php | 2 ++ tests/Eloquent/MassPrunableTest.php | 2 ++ tests/EmbeddedRelationsTest.php | 2 ++ tests/GeospatialTest.php | 2 ++ tests/HybridRelationsTest.php | 2 ++ tests/ModelTest.php | 2 ++ tests/QueryBuilderTest.php | 2 ++ tests/RelationsTest.php | 2 ++ tests/SchemaTest.php | 18 ++++++++---------- tests/SchemaVersionTest.php | 2 ++ tests/Scout/ScoutEngineTest.php | 12 ++++++------ tests/SeederTest.php | 2 ++ tests/Ticket/GH2489Test.php | 2 ++ tests/ValidationTest.php | 2 ++ 16 files changed, 41 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 6618fac67..2855a9546 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "laravel/scout": "^10.3", "league/flysystem-gridfs": "^3.28", "league/flysystem-read-only": "^3.0", - "phpunit/phpunit": "^10.3", + "phpunit/phpunit": "^10.3|^11.5.3", "orchestra/testbench": "^8.0|^9.0", "mockery/mockery": "^1.4.4@stable", "doctrine/coding-standard": "12.0.x-dev", diff --git a/tests/AuthTest.php b/tests/AuthTest.php index 98d42832e..998c07f2d 100644 --- a/tests/AuthTest.php +++ b/tests/AuthTest.php @@ -17,10 +17,10 @@ class AuthTest extends TestCase { public function tearDown(): void { - parent::setUp(); - User::truncate(); DB::table('password_reset_tokens')->truncate(); + + parent::tearDown(); } public function testAuthAttempt() diff --git a/tests/Eloquent/CallBuilderTest.php b/tests/Eloquent/CallBuilderTest.php index fa4cb4580..39643f1c1 100644 --- a/tests/Eloquent/CallBuilderTest.php +++ b/tests/Eloquent/CallBuilderTest.php @@ -21,6 +21,8 @@ final class CallBuilderTest extends TestCase protected function tearDown(): void { User::truncate(); + + parent::tearDown(); } #[Dataprovider('provideFunctionNames')] diff --git a/tests/Eloquent/MassPrunableTest.php b/tests/Eloquent/MassPrunableTest.php index 0f6f2ab15..884f90ac6 100644 --- a/tests/Eloquent/MassPrunableTest.php +++ b/tests/Eloquent/MassPrunableTest.php @@ -20,6 +20,8 @@ public function tearDown(): void { User::truncate(); Soft::truncate(); + + parent::tearDown(); } public function testPruneWithQuery(): void diff --git a/tests/EmbeddedRelationsTest.php b/tests/EmbeddedRelationsTest.php index 8ee8297f7..1c68e2d34 100644 --- a/tests/EmbeddedRelationsTest.php +++ b/tests/EmbeddedRelationsTest.php @@ -20,6 +20,8 @@ public function tearDown(): void { Mockery::close(); User::truncate(); + + parent::tearDown(); } public function testEmbedsManySave() diff --git a/tests/GeospatialTest.php b/tests/GeospatialTest.php index 724bb580b..b29a3240a 100644 --- a/tests/GeospatialTest.php +++ b/tests/GeospatialTest.php @@ -53,6 +53,8 @@ public function setUp(): void public function tearDown(): void { Schema::drop('locations'); + + parent::tearDown(); } public function testGeoWithin() diff --git a/tests/HybridRelationsTest.php b/tests/HybridRelationsTest.php index 71958d27d..08423007c 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -42,6 +42,8 @@ public function tearDown(): void Skill::truncate(); Experience::truncate(); Label::truncate(); + + parent::tearDown(); } public function testSqlRelations() diff --git a/tests/ModelTest.php b/tests/ModelTest.php index ef71a5fe0..ecfcb2b6a 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -56,6 +56,8 @@ public function tearDown(): void Book::truncate(); Item::truncate(); Guarded::truncate(); + + parent::tearDown(); } public function testNewModel(): void diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 01f937915..9592bbe7c 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -43,6 +43,8 @@ public function tearDown(): void { DB::table('users')->truncate(); DB::table('items')->truncate(); + + parent::tearDown(); } public function testDeleteWithId() diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index a58fef02f..a55c8c0e0 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -35,6 +35,8 @@ public function tearDown(): void Photo::truncate(); Label::truncate(); Skill::truncate(); + + parent::tearDown(); } public function testHasMany(): void diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 34029aa32..e2f4f7b7e 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -26,6 +26,8 @@ public function tearDown(): void assert($database instanceof Database); $database->dropCollection('newcollection'); $database->dropCollection('newcollection_two'); + + parent::tearDown(); } public function testCreate(): void @@ -37,10 +39,8 @@ public function testCreate(): void public function testCreateWithCallback(): void { - $instance = $this; - - Schema::create('newcollection', function ($collection) use ($instance) { - $instance->assertInstanceOf(Blueprint::class, $collection); + Schema::create('newcollection', static function ($collection) { + self::assertInstanceOf(Blueprint::class, $collection); }); $this->assertTrue(Schema::hasCollection('newcollection')); @@ -66,14 +66,12 @@ public function testDrop(): void public function testBluePrint(): void { - $instance = $this; - - Schema::table('newcollection', function ($collection) use ($instance) { - $instance->assertInstanceOf(Blueprint::class, $collection); + Schema::table('newcollection', static function ($collection) { + self::assertInstanceOf(Blueprint::class, $collection); }); - Schema::table('newcollection', function ($collection) use ($instance) { - $instance->assertInstanceOf(Blueprint::class, $collection); + Schema::table('newcollection', static function ($collection) { + self::assertInstanceOf(Blueprint::class, $collection); }); } diff --git a/tests/SchemaVersionTest.php b/tests/SchemaVersionTest.php index 4a205c77b..b8048b71a 100644 --- a/tests/SchemaVersionTest.php +++ b/tests/SchemaVersionTest.php @@ -15,6 +15,8 @@ class SchemaVersionTest extends TestCase public function tearDown(): void { SchemaVersion::truncate(); + + parent::tearDown(); } public function testWithBasicDocument() diff --git a/tests/Scout/ScoutEngineTest.php b/tests/Scout/ScoutEngineTest.php index f1244d060..40d943ffb 100644 --- a/tests/Scout/ScoutEngineTest.php +++ b/tests/Scout/ScoutEngineTest.php @@ -141,7 +141,7 @@ public function testSearch(Closure $builder, array $expectedPipeline): void $this->assertEquals($data, $result); } - public function provideSearchPipelines(): iterable + public static function provideSearchPipelines(): iterable { $defaultPipeline = [ [ @@ -377,11 +377,11 @@ function () { yield 'with callback' => [ fn () => new Builder(new SearchableModel(), 'query', callback: function (...$args) { - $this->assertCount(3, $args); - $this->assertInstanceOf(Collection::class, $args[0]); - $this->assertSame('collection_searchable', $args[0]->getCollectionName()); - $this->assertSame('query', $args[1]); - $this->assertNull($args[2]); + self::assertCount(3, $args); + self::assertInstanceOf(Collection::class, $args[0]); + self::assertSame('collection_searchable', $args[0]->getCollectionName()); + self::assertSame('query', $args[1]); + self::assertNull($args[2]); return $args[0]->aggregate(['pipeline']); }), diff --git a/tests/SeederTest.php b/tests/SeederTest.php index a6122ce17..71f36943c 100644 --- a/tests/SeederTest.php +++ b/tests/SeederTest.php @@ -14,6 +14,8 @@ class SeederTest extends TestCase public function tearDown(): void { User::truncate(); + + parent::tearDown(); } public function testSeed(): void diff --git a/tests/Ticket/GH2489Test.php b/tests/Ticket/GH2489Test.php index 62ce11d0e..09fa111ea 100644 --- a/tests/Ticket/GH2489Test.php +++ b/tests/Ticket/GH2489Test.php @@ -13,6 +13,8 @@ class GH2489Test extends TestCase public function tearDown(): void { Location::truncate(); + + parent::tearDown(); } public function testQuerySubdocumentsUsingWhereInId() diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index d5122ce7b..9d2089af5 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -12,6 +12,8 @@ class ValidationTest extends TestCase public function tearDown(): void { User::truncate(); + + parent::tearDown(); } public function testUnique(): void From 1974aec772fe0a8baefcffb4303032595eb25831 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Tue, 25 Feb 2025 10:26:01 -0500 Subject: [PATCH 29/92] DOCSP-46230: atlas search index mgmt (#3270) * DOCSP-46230: atlas search index mgmt * fix * fix * small fix * wip * wip * wip * wip * test php link * test php link * RM PR fixes 1 * JT suggestion - move code to tests --- docs/eloquent-models/schema-builder.txt | 208 ++++++++++++++++-- docs/fundamentals/atlas-search.txt | 20 +- docs/fundamentals/vector-search.txt | 27 ++- .../schema-builder/galaxies_migration.php | 119 ++++++++++ docs/query-builder.txt | 2 +- 5 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 docs/includes/schema-builder/galaxies_migration.php diff --git a/docs/eloquent-models/schema-builder.txt b/docs/eloquent-models/schema-builder.txt index dad3c8eed..3cdec0f03 100644 --- a/docs/eloquent-models/schema-builder.txt +++ b/docs/eloquent-models/schema-builder.txt @@ -157,10 +157,15 @@ drop various types of indexes on a collection. Create an Index ~~~~~~~~~~~~~~~ -To create indexes, call the ``create()`` method on the ``Schema`` facade -in your migration file. Pass it the collection name and a callback -method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. Specify the -index creation details on the ``Blueprint`` instance. +To create indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade + in your migration file. + +#. Pass it the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Specify the index creation details on the ``Blueprint`` instance. The following example migration creates indexes on the following collection fields: @@ -262,11 +267,16 @@ indexes: - Unique indexes, which prevent inserting documents that contain duplicate values for the indexed field -To create these index types, call the ``create()`` method on the ``Schema`` facade -in your migration file. Pass ``create()`` the collection name and a callback -method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. Call the -appropriate helper method on the ``Blueprint`` instance and pass the -index creation details. +To create these index types, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade + in your migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Call the appropriate helper method for the index type on the + ``Blueprint`` instance and pass the index creation details. The following migration code shows how to create a sparse and a TTL index by using the index helpers. Click the :guilabel:`{+code-output-label+}` button to see @@ -339,10 +349,16 @@ Create a Geospatial Index In MongoDB, geospatial indexes let you query geospatial coordinate data for inclusion, intersection, and proximity. -To create geospatial indexes, call the ``create()`` method on the ``Schema`` facade -in your migration file. Pass ``create()`` the collection name and a callback -method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. Specify the -geospatial index creation details on the ``Blueprint`` instance. +To create geospatial indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade + in your migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Specify the geospatial index creation details on the ``Blueprint`` + instance. The following example migration creates a ``2d`` and ``2dsphere`` geospatial index on the ``spaceports`` collection. Click the :guilabel:`{+code-output-label+}` @@ -379,11 +395,16 @@ the {+server-docs-name+}. Drop an Index ~~~~~~~~~~~~~ -To drop indexes from a collection, call the ``table()`` method on the -``Schema`` facade in your migration file. Pass it the table name and a -callback method with a ``MongoDB\Laravel\Schema\Blueprint`` parameter. -Call the ``dropIndex()`` method with the index name on the ``Blueprint`` -instance. +To drop indexes from a collection, perform the following actions: + +1. Call the ``table()`` method on the ``Schema`` facade in your + migration file. + +#. Pass it the table name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Call the ``dropIndex()`` method with the index name on the + ``Blueprint`` instance. .. note:: @@ -399,4 +420,155 @@ from the ``flights`` collection: :start-after: begin drop index :end-before: end drop index +.. _laravel-schema-builder-atlas-idx: + +Manage Atlas Search and Vector Search Indexes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +In MongoDB, :atlas:`Atlas Search indexes +` support your full-text queries. +:atlas:`Atlas Vector Search indexes +` support similarity +searches that compare query vectors to vector embeddings in your +documents. + +View the following guides to learn more about the Atlas Search and +Vector Search features: + +- :ref:`laravel-atlas-search` guide +- :ref:`laravel-vector-search` guide + +Atlas Search +```````````` + +To create Atlas Search indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade in your + migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Pass the Atlas index creation details to the ``searchIndex()`` method + on the ``Blueprint`` instance. + +This example migration creates the following Atlas Search indexes on the +``galaxies`` collection: + +- ``dynamic_index``: Creates dynamic mappings +- ``auto_index``: Supports autocomplete queries on the ``name`` field + +Click the :guilabel:`{+code-output-label+}` button to see the Search +indexes created by running the migration: + +.. io-code-block:: + + .. input:: /includes/schema-builder/galaxies_migration.php + :language: php + :dedent: + :start-after: begin-create-search-indexes + :end-before: end-create-search-indexes + + .. output:: + :language: json + :visible: false + + { + "id": "...", + "name": "dynamic_index", + "type": "search", + "status": "READY", + "queryable": true, + "latestDefinition": { + "mappings": { "dynamic": true } + }, + ... + } + { + "id": "...", + "name": "auto_index", + "type": "search", + "status": "READY", + "queryable": true, + "latestDefinition": { + "mappings": { + "fields": { "name": [ + { "type": "string", "analyzer": "lucene.english" }, + { "type": "autocomplete", "analyzer": "lucene.english" }, + { "type": "token" } + ] } + } + }, + ... + } + +Vector Search +````````````` + +To create Vector Search indexes, perform the following actions: + +1. Call the ``create()`` method on the ``Schema`` facade in your + migration file. + +#. Pass ``create()`` the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Pass the vector index creation details to the ``vectorSearchIndex()`` + method on the ``Blueprint`` instance. + +The following example migration creates a Vector Search index called +``vs_index`` on the ``galaxies`` collection. + +Click the :guilabel:`{+code-output-label+}` button to see the Search +indexes created by running the migration: + +.. io-code-block:: + .. input:: /includes/schema-builder/galaxies_migration.php + :language: php + :dedent: + :start-after: begin-create-vs-index + :end-before: end-create-vs-index + .. output:: + :language: json + :visible: false + + { + "id": "...", + "name": "vs_index", + "type": "vectorSearch", + "status": "READY", + "queryable": true, + "latestDefinition": { + "fields": [ { + "type": "vector", + "numDimensions": 4, + "path": "embeddings", + "similarity": "cosine" + } ] + }, + ... + } + +Drop a Search Index +``````````````````` + +To drop an Atlas Search or Vector Search index from a collection, +perform the following actions: + +1. Call the ``table()`` method on the ``Schema`` facade in your migration file. + +#. Pass it the collection name and a callback method with a + ``MongoDB\Laravel\Schema\Blueprint`` parameter. + +#. Call the ``dropSearchIndex()`` method with the Search index name on + the ``Blueprint`` instance. + +The following example migration drops an index called ``auto_index`` +from the ``galaxies`` collection: + +.. literalinclude:: /includes/schema-builder/galaxies_migration.php + :language: php + :dedent: + :start-after: begin-drop-search-index + :end-before: end-drop-search-index diff --git a/docs/fundamentals/atlas-search.txt b/docs/fundamentals/atlas-search.txt index 9aaa9156b..ab957f9fa 100644 --- a/docs/fundamentals/atlas-search.txt +++ b/docs/fundamentals/atlas-search.txt @@ -56,7 +56,25 @@ documentation. Create an Atlas Search Index ---------------------------- -.. TODO in DOCSP-46230 +You can create an Atlas Search index in either of the following ways: + +- Call the ``create()`` method on the ``Schema`` facade and pass the + ``searchIndex()`` helper method with index creation details. To learn + more about this strategy, see the + :ref:`laravel-schema-builder-atlas-idx` section of the Schema Builder guide. + +- Access a collection, then call the + :phpmethod:`createSearchIndex() ` + method from the {+php-library+}, as shown in the following code: + + .. code-block:: php + + $collection = DB::connection('mongodb')->getCollection('movies'); + + $collection->createSearchIndex( + ['mappings' => ['dynamic' => true]], + ['name' => 'search_index'] + ); Perform Queries --------------- diff --git a/docs/fundamentals/vector-search.txt b/docs/fundamentals/vector-search.txt index 116cb75a0..c06b28320 100644 --- a/docs/fundamentals/vector-search.txt +++ b/docs/fundamentals/vector-search.txt @@ -56,7 +56,32 @@ documentation. Create an Atlas Vector Search Index ----------------------------------- -.. TODO in DOCSP-46230 +You can create an Atlas Search index in either of the following ways: + +- Call the ``create()`` method on the ``Schema`` facade and pass the + ``vectorSearchIndex()`` helper method with index creation details. To learn + more about this strategy, see the + :ref:`laravel-schema-builder-atlas-idx` section of the Schema Builder guide. + +- Access a collection, then call the + :phpmethod:`createSearchIndex() ` + method from the {+php-library+}. You must specify the ``type`` option as + ``'vectorSearch'``, as shown in the following code: + + .. code-block:: php + + $collection = DB::connection('mongodb')->getCollection('movies'); + + $collection->createSearchIndex([ + 'fields' => [ + [ + 'type' => 'vector', + 'numDimensions' => 4, + 'path' => 'embeddings', + 'similarity' => 'cosine' + ], + ], + ], ['name' => 'vector_index', 'type' => 'vectorSearch']); Perform Queries --------------- diff --git a/docs/includes/schema-builder/galaxies_migration.php b/docs/includes/schema-builder/galaxies_migration.php new file mode 100644 index 000000000..fc92ff026 --- /dev/null +++ b/docs/includes/schema-builder/galaxies_migration.php @@ -0,0 +1,119 @@ +searchIndex([ + 'mappings' => [ + 'dynamic' => true, + ], + ], 'dynamic_index'); + $collection->searchIndex([ + 'mappings' => [ + 'fields' => [ + 'name' => [ + ['type' => 'string', 'analyzer' => 'lucene.english'], + ['type' => 'autocomplete', 'analyzer' => 'lucene.english'], + ['type' => 'token'], + ], + ], + ], + ], 'auto_index'); + }); + // end-create-search-indexes + + $index = $this->getSearchIndex('galaxies', 'dynamic_index'); + self::assertNotNull($index); + + self::assertSame('dynamic_index', $index['name']); + self::assertSame('search', $index['type']); + self::assertTrue($index['latestDefinition']['mappings']['dynamic']); + + $index = $this->getSearchIndex('galaxies', 'auto_index'); + self::assertNotNull($index); + + self::assertSame('auto_index', $index['name']); + self::assertSame('search', $index['type']); + } + + public function testVectorSearchIdx(): void + { + // begin-create-vs-index + Schema::create('galaxies', function (Blueprint $collection) { + $collection->vectorSearchIndex([ + 'fields' => [ + [ + 'type' => 'vector', + 'numDimensions' => 4, + 'path' => 'embeddings', + 'similarity' => 'cosine', + ], + ], + ], 'vs_index'); + }); + // end-create-vs-index + + $index = $this->getSearchIndex('galaxies', 'vs_index'); + self::assertNotNull($index); + + self::assertSame('vs_index', $index['name']); + self::assertSame('vectorSearch', $index['type']); + self::assertSame('vector', $index['latestDefinition']['fields'][0]['type']); + } + + public function testDropIndexes(): void + { + // begin-drop-search-index + Schema::table('galaxies', function (Blueprint $collection) { + $collection->dropSearchIndex('auto_index'); + }); + // end-drop-search-index + + Schema::table('galaxies', function (Blueprint $collection) { + $collection->dropSearchIndex('dynamic_index'); + }); + + Schema::table('galaxies', function (Blueprint $collection) { + $collection->dropSearchIndex('vs_index'); + }); + + $index = $this->getSearchIndex('galaxies', 'auto_index'); + self::assertNull($index); + + $index = $this->getSearchIndex('galaxies', 'dynamic_index'); + self::assertNull($index); + + $index = $this->getSearchIndex('galaxies', 'vs_index'); + self::assertNull($index); + } + + protected function getSearchIndex(string $collection, string $name): ?array + { + $collection = $this->getConnection('mongodb')->getCollection($collection); + assert($collection instanceof Collection); + + foreach ($collection->listSearchIndexes(['name' => $name, 'typeMap' => ['root' => 'array', 'array' => 'array', 'document' => 'array']]) as $index) { + return $index; + } + + return null; + } +} diff --git a/docs/query-builder.txt b/docs/query-builder.txt index 89caf8846..76a0d144a 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -678,7 +678,7 @@ a query: :end-before: end options The query builder accepts the same options that you can set for -the :phpmethod:`MongoDB\Collection::find()` method in the +the :phpmethod:`find() ` method in the {+php-library+}. Some of the options to modify query results, such as ``skip``, ``sort``, and ``limit``, are settable directly as query builder methods and are described in the From 0a80c70d9911de2988eef4f5ab01daa837d193c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 26 Feb 2025 16:42:08 +0100 Subject: [PATCH 30/92] PHPORM-278 Introduce `Connection::getDatabase()` and `getClient` (#3289) Deprecate getMongoDB and get MongoClient Replace selectDatabase with getDatabase --- src/Concerns/ManagesTransactions.php | 11 ++++--- src/Connection.php | 42 ++++++++++++++++++++----- src/MongoDBServiceProvider.php | 8 ++--- src/Schema/Blueprint.php | 2 +- src/Schema/Builder.php | 14 ++++----- tests/ConnectionTest.php | 47 +++++++++++++++++++++++++--- 6 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src/Concerns/ManagesTransactions.php b/src/Concerns/ManagesTransactions.php index ac3c1c6f7..6403cc45d 100644 --- a/src/Concerns/ManagesTransactions.php +++ b/src/Concerns/ManagesTransactions.php @@ -12,15 +12,18 @@ use function MongoDB\with_transaction; -/** @see https://docs.mongodb.com/manual/core/transactions/ */ +/** + * @internal + * + * @see https://docs.mongodb.com/manual/core/transactions/ + */ trait ManagesTransactions { protected ?Session $session = null; protected $transactions = 0; - /** @return Client */ - abstract public function getMongoClient(); + abstract public function getClient(): ?Client; public function getSession(): ?Session { @@ -30,7 +33,7 @@ public function getSession(): ?Session private function getSessionOrCreate(): Session { if ($this->session === null) { - $this->session = $this->getMongoClient()->startSession(); + $this->session = $this->getClient()->startSession(); } return $this->session; diff --git a/src/Connection.php b/src/Connection.php index 592e500e5..980750093 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -22,8 +22,11 @@ use function implode; use function is_array; use function preg_match; +use function sprintf; use function str_contains; +use function trigger_error; +use const E_USER_DEPRECATED; use const FILTER_FLAG_IPV6; use const FILTER_VALIDATE_IP; @@ -65,9 +68,10 @@ public function __construct(array $config) // Create the connection $this->connection = $this->createConnection($dsn, $config, $options); + $this->database = $this->getDefaultDatabaseName($dsn, $config); // Select database - $this->db = $this->connection->selectDatabase($this->getDefaultDatabaseName($dsn, $config)); + $this->db = $this->connection->getDatabase($this->database); $this->tablePrefix = $config['prefix'] ?? ''; @@ -114,29 +118,53 @@ public function getSchemaBuilder() /** * Get the MongoDB database object. * + * @deprecated since mongodb/laravel-mongodb:5.2, use getDatabase() instead + * * @return Database */ public function getMongoDB() { + trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, Method "%s()" is deprecated, use "getDatabase()" instead.', __FUNCTION__), E_USER_DEPRECATED); + + return $this->db; + } + + /** + * Get the MongoDB database object. + * + * @param string|null $name Name of the database, if not provided the default database will be returned. + * + * @return Database + */ + public function getDatabase(?string $name = null): Database + { + if ($name && $name !== $this->database) { + return $this->connection->getDatabase($name); + } + return $this->db; } /** - * return MongoDB object. + * Return MongoDB object. + * + * @deprecated since mongodb/laravel-mongodb:5.2, use getClient() instead * * @return Client */ public function getMongoClient() { - return $this->connection; + trigger_error(sprintf('Since mongodb/laravel-mongodb:5.2, method "%s()" is deprecated, use "getClient()" instead.', __FUNCTION__), E_USER_DEPRECATED); + + return $this->getClient(); } /** - * {@inheritDoc} + * Get the MongoDB client. */ - public function getDatabaseName() + public function getClient(): ?Client { - return $this->getMongoDB()->getDatabaseName(); + return $this->connection; } public function enableQueryLog() @@ -233,7 +261,7 @@ protected function createConnection(string $dsn, array $config, array $options): */ public function ping(): void { - $this->getMongoClient()->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); + $this->getClient()->getManager()->selectServer(new ReadPreference(ReadPreference::PRIMARY_PREFERRED)); } /** @inheritdoc */ diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index dc9caf082..349abadc7 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -67,7 +67,7 @@ public function register() assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The database connection "%s" used for the session does not use the "mongodb" driver.', $connectionName))); return new MongoDbSessionHandler( - $connection->getMongoClient(), + $connection->getClient(), $app->config->get('session.options', []) + [ 'database' => $connection->getDatabaseName(), 'collection' => $app->config->get('session.table') ?: 'sessions', @@ -132,8 +132,8 @@ private function registerFlysystemAdapter(): void throw new InvalidArgumentException(sprintf('The database connection "%s" does not use the "mongodb" driver.', $config['connection'] ?? $app['config']['database.default'])); } - $bucket = $connection->getMongoClient() - ->selectDatabase($config['database'] ?? $connection->getDatabaseName()) + $bucket = $connection->getClient() + ->getDatabase($config['database'] ?? $connection->getDatabaseName()) ->selectGridFSBucket(['bucketName' => $config['bucket'] ?? 'fs', 'disableMD5' => true]); } @@ -171,7 +171,7 @@ private function registerScoutEngine(): void assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The connection "%s" is not a MongoDB connection.', $connectionName))); - return new ScoutEngine($connection->getMongoDB(), $softDelete, $indexDefinitions); + return new ScoutEngine($connection->getDatabase(), $softDelete, $indexDefinitions); }); return $engineManager; diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index e3d7a230b..a525a9cee 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -251,7 +251,7 @@ public function create($options = []) { $collection = $this->collection->getCollectionName(); - $db = $this->connection->getMongoDB(); + $db = $this->connection->getDatabase(); // Ensure the collection is created. $db->createCollection($collection, $options); diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index fe806f0e5..4af15f1f9 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -76,7 +76,7 @@ public function hasColumns($table, array $columns): bool */ public function hasCollection($name) { - $db = $this->connection->getMongoDB(); + $db = $this->connection->getDatabase(); $collections = iterator_to_array($db->listCollections([ 'filter' => ['name' => $name], @@ -139,7 +139,7 @@ public function dropAllTables() public function getTables() { - $db = $this->connection->getMongoDB(); + $db = $this->connection->getDatabase(); $collections = []; foreach ($db->listCollectionNames() as $collectionName) { @@ -167,7 +167,7 @@ public function getTables() public function getTableListing() { - $collections = iterator_to_array($this->connection->getMongoDB()->listCollectionNames()); + $collections = iterator_to_array($this->connection->getDatabase()->listCollectionNames()); sort($collections); @@ -176,7 +176,7 @@ public function getTableListing() public function getColumns($table) { - $stats = $this->connection->getMongoDB()->selectCollection($table)->aggregate([ + $stats = $this->connection->getDatabase()->selectCollection($table)->aggregate([ // Sample 1,000 documents to get a representative sample of the collection ['$sample' => ['size' => 1_000]], // Convert each document to an array of fields @@ -229,7 +229,7 @@ public function getColumns($table) public function getIndexes($table) { - $collection = $this->connection->getMongoDB()->selectCollection($table); + $collection = $this->connection->getDatabase()->selectCollection($table); assert($collection instanceof Collection); $indexList = []; @@ -301,7 +301,7 @@ protected function createBlueprint($table, ?Closure $callback = null) */ public function getCollection($name) { - $db = $this->connection->getMongoDB(); + $db = $this->connection->getDatabase(); $collections = iterator_to_array($db->listCollections([ 'filter' => ['name' => $name], @@ -318,7 +318,7 @@ public function getCollection($name) protected function getAllCollections() { $collections = []; - foreach ($this->connection->getMongoDB()->listCollections() as $collection) { + foreach ($this->connection->getDatabase()->listCollections() as $collection) { $collections[] = $collection->getName(); } diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 1efd17be0..ba5e09804 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -48,15 +48,15 @@ public function testDisconnectAndCreateNewConnection() { $connection = DB::connection('mongodb'); $this->assertInstanceOf(Connection::class, $connection); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertInstanceOf(Client::class, $client); $connection->disconnect(); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertNull($client); DB::purge('mongodb'); $connection = DB::connection('mongodb'); $this->assertInstanceOf(Connection::class, $connection); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertInstanceOf(Client::class, $client); } @@ -64,7 +64,7 @@ public function testDb() { $connection = DB::connection('mongodb'); $this->assertInstanceOf(Database::class, $connection->getMongoDB()); - $this->assertInstanceOf(Client::class, $connection->getMongoClient()); + $this->assertInstanceOf(Client::class, $connection->getClient()); } public static function dataConnectionConfig(): Generator @@ -196,7 +196,7 @@ public static function dataConnectionConfig(): Generator public function testConnectionConfig(string $expectedUri, string $expectedDatabaseName, array $config): void { $connection = new Connection($config); - $client = $connection->getMongoClient(); + $client = $connection->getClient(); $this->assertSame($expectedUri, (string) $client); $this->assertSame($expectedDatabaseName, $connection->getMongoDB()->getDatabaseName()); @@ -204,6 +204,43 @@ public function testConnectionConfig(string $expectedUri, string $expectedDataba $this->assertSame('foo', $connection->table('foo')->raw()->getCollectionName()); } + public function testLegacyGetMongoClient(): void + { + $connection = DB::connection('mongodb'); + $expected = $connection->getClient(); + + $this->assertSame($expected, $connection->getMongoClient()); + } + + public function testLegacyGetMongoDB(): void + { + $connection = DB::connection('mongodb'); + $expected = $connection->getDatabase(); + + $this->assertSame($expected, $connection->getMongoDB()); + } + + public function testGetDatabase(): void + { + $connection = DB::connection('mongodb'); + $defaultName = env('MONGODB_DATABASE', 'unittest'); + $database = $connection->getDatabase(); + + $this->assertInstanceOf(Database::class, $database); + $this->assertSame($defaultName, $database->getDatabaseName()); + $this->assertSame($database, $connection->getDatabase($defaultName), 'Same instance for the default database'); + } + + public function testGetOtherDatabase(): void + { + $connection = DB::connection('mongodb'); + $name = 'other_random_database'; + $database = $connection->getDatabase($name); + + $this->assertInstanceOf(Database::class, $database); + $this->assertSame($name, $database->getDatabaseName($name)); + } + public function testConnectionWithoutConfiguredDatabase(): void { $this->expectException(InvalidArgumentException::class); From 5f877df763cdbd2a3aeed04954f3c85fa0692c7f Mon Sep 17 00:00:00 2001 From: Michael Morisi Date: Thu, 27 Feb 2025 11:37:52 -0500 Subject: [PATCH 31/92] Rename Connection::getMongoDB to getDatabase --- docs/database-collection.txt | 2 +- docs/filesystems.txt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/database-collection.txt b/docs/database-collection.txt index fb6573147..d42a0d52a 100644 --- a/docs/database-collection.txt +++ b/docs/database-collection.txt @@ -225,7 +225,7 @@ the collections in the database: .. code-block:: php - $collections = DB::connection('mongodb')->getMongoDB()->listCollections(); + $collections = DB::connection('mongodb')->getDatabase()->listCollections(); List Collection Fields ---------------------- diff --git a/docs/filesystems.txt b/docs/filesystems.txt index 725b799af..3ec7ee41f 100644 --- a/docs/filesystems.txt +++ b/docs/filesystems.txt @@ -94,7 +94,7 @@ In this case, the options ``connection`` and ``database`` are ignored: 'driver' => 'gridfs', 'bucket' => static function (Application $app): Bucket { return $app['db']->connection('mongodb') - ->getMongoDB() + ->getDatabase() ->selectGridFSBucket([ 'bucketName' => 'avatars', 'chunkSizeBytes' => 261120, @@ -150,7 +150,7 @@ if you need to work with revisions, as shown in the following code: // Create a bucket service from the MongoDB connection /** @var \MongoDB\GridFS\Bucket $bucket */ - $bucket = $app['db']->connection('mongodb')->getMongoDB()->selectGridFSBucket(); + $bucket = $app['db']->connection('mongodb')->getDatabase()->selectGridFSBucket(); // Download the last but one version of a file $bucket->openDownloadStreamByName('hello.txt', ['revision' => -2]) From 4891b5b16ad7f19e9565152b6d5e412f94c74600 Mon Sep 17 00:00:00 2001 From: Michael Morisi Date: Thu, 27 Feb 2025 13:55:54 -0500 Subject: [PATCH 32/92] Jerome suggestion --- docs/database-collection.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/database-collection.txt b/docs/database-collection.txt index d42a0d52a..be081c97b 100644 --- a/docs/database-collection.txt +++ b/docs/database-collection.txt @@ -219,7 +219,7 @@ methods in your application: Example ``````` -The following example accesses a database connection, then calls the +The following example accesses the database of the connection, then calls the ``listCollections()`` query builder method to retrieve information about the collections in the database: From d93a9c2d6406d4b58682100537617f79a53a1f5d Mon Sep 17 00:00:00 2001 From: rustagir Date: Fri, 28 Feb 2025 11:01:09 -0500 Subject: [PATCH 33/92] link fic --- docs/fundamentals/read-operations/read-pref.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/fundamentals/read-operations/read-pref.txt b/docs/fundamentals/read-operations/read-pref.txt index b3081a1c5..075c74380 100644 --- a/docs/fundamentals/read-operations/read-pref.txt +++ b/docs/fundamentals/read-operations/read-pref.txt @@ -137,5 +137,5 @@ Additional Information To learn how to retrieve data based on filter criteria, see the :ref:`laravel-fundamentals-read-retrieve` guide. -To learn how to retrieve data based on filter criteria, see the -:ref:`laravel-fundamentals-read-retrieve` guide. +To learn how to modify the way that the {+odm-short+} returns results, +see the :ref:`laravel-read-modify-results` guide. From e373350f436596402a2b51cd7a8fe68fdd2df848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sat, 1 Mar 2025 23:34:32 +0100 Subject: [PATCH 34/92] PHPORM-289 Support Laravel 12 (#3283) --- .github/workflows/build-ci-atlas.yml | 1 + .github/workflows/build-ci.yml | 7 ++- composer.json | 14 +++--- phpstan-baseline.neon | 10 ++++ src/Connection.php | 6 ++- src/Schema/Blueprint.php | 29 +++--------- src/Schema/BlueprintLaravelCompatibility.php | 50 ++++++++++++++++++++ src/Schema/Builder.php | 37 +++++++++++++-- tests/Query/BuilderTest.php | 2 +- tests/RelationsTest.php | 1 + tests/SchemaTest.php | 26 ++++++++++ 11 files changed, 142 insertions(+), 41 deletions(-) create mode 100644 src/Schema/BlueprintLaravelCompatibility.php diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index 7a4ebd03f..30b4b06b1 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -20,6 +20,7 @@ jobs: - "8.4" laravel: - "11.*" + - "12.*" steps: - uses: "actions/checkout@v4" diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index d16a5885f..659c316d3 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -28,19 +28,18 @@ jobs: laravel: - "10.*" - "11.*" + - "12.*" include: - php: "8.1" laravel: "10.*" mongodb: "5.0" mode: "low-deps" os: "ubuntu-latest" - - php: "8.4" - laravel: "11.*" - mongodb: "7.0" - os: "ubuntu-latest" exclude: - php: "8.1" laravel: "11.*" + - php: "8.1" + laravel: "12.*" steps: - uses: "actions/checkout@v4" diff --git a/composer.json b/composer.json index 2855a9546..64006a47b 100644 --- a/composer.json +++ b/composer.json @@ -25,11 +25,11 @@ "php": "^8.1", "ext-mongodb": "^1.15", "composer-runtime-api": "^2.0.0", - "illuminate/cache": "^10.36|^11", - "illuminate/container": "^10.0|^11", - "illuminate/database": "^10.30|^11", - "illuminate/events": "^10.0|^11", - "illuminate/support": "^10.0|^11", + "illuminate/cache": "^10.36|^11|^12", + "illuminate/container": "^10.0|^11|^12", + "illuminate/database": "^10.30|^11|^12", + "illuminate/events": "^10.0|^11|^12", + "illuminate/support": "^10.0|^11|^12", "mongodb/mongodb": "^1.21", "symfony/http-foundation": "^6.4|^7" }, @@ -38,8 +38,8 @@ "league/flysystem-gridfs": "^3.28", "league/flysystem-read-only": "^3.0", "phpunit/phpunit": "^10.3|^11.5.3", - "orchestra/testbench": "^8.0|^9.0", - "mockery/mockery": "^1.4.4@stable", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "mockery/mockery": "^1.4.4", "doctrine/coding-standard": "12.0.x-dev", "spatie/laravel-query-builder": "^5.6|^6", "phpstan/phpstan": "^1.10", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 67fdd4154..ba1f3b7aa 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,15 @@ parameters: ignoreErrors: + - + message: "#^Class MongoDB\\\\Laravel\\\\Query\\\\Grammar does not have a constructor and must be instantiated without any parameters\\.$#" + count: 1 + path: src/Connection.php + + - + message: "#^Class MongoDB\\\\Laravel\\\\Schema\\\\Grammar does not have a constructor and must be instantiated without any parameters\\.$#" + count: 1 + path: src/Connection.php + - message: "#^Access to an undefined property Illuminate\\\\Container\\\\Container\\:\\:\\$config\\.$#" count: 3 diff --git a/src/Connection.php b/src/Connection.php index 980750093..4dd04120d 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -355,13 +355,15 @@ protected function getDefaultPostProcessor() /** @inheritdoc */ protected function getDefaultQueryGrammar() { - return new Query\Grammar(); + // Argument added in Laravel 12 + return new Query\Grammar($this); } /** @inheritdoc */ protected function getDefaultSchemaGrammar() { - return new Schema\Grammar(); + // Argument added in Laravel 12 + return new Schema\Grammar($this); } /** diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index a525a9cee..1197bfde1 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -4,9 +4,9 @@ namespace MongoDB\Laravel\Schema; -use Illuminate\Database\Connection; -use Illuminate\Database\Schema\Blueprint as SchemaBlueprint; +use Illuminate\Database\Schema\Blueprint as BaseBlueprint; use MongoDB\Collection; +use MongoDB\Laravel\Connection; use function array_flip; use function implode; @@ -16,17 +16,14 @@ use function is_string; use function key; -class Blueprint extends SchemaBlueprint +/** @property Connection $connection */ +class Blueprint extends BaseBlueprint { - /** - * The MongoConnection object for this blueprint. - * - * @var Connection - */ - protected $connection; + // Import $connection property and constructor for Laravel 12 compatibility + use BlueprintLaravelCompatibility; /** - * The Collection object for this blueprint. + * The MongoDB collection object for this blueprint. * * @var Collection */ @@ -39,18 +36,6 @@ class Blueprint extends SchemaBlueprint */ protected $columns = []; - /** - * Create a new schema blueprint. - */ - public function __construct(Connection $connection, string $collection) - { - parent::__construct($collection); - - $this->connection = $connection; - - $this->collection = $this->connection->getCollection($collection); - } - /** @inheritdoc */ public function index($columns = null, $name = null, $algorithm = null, $options = []) { diff --git a/src/Schema/BlueprintLaravelCompatibility.php b/src/Schema/BlueprintLaravelCompatibility.php new file mode 100644 index 000000000..bf288eae8 --- /dev/null +++ b/src/Schema/BlueprintLaravelCompatibility.php @@ -0,0 +1,50 @@ +connection = $connection; + $this->collection = $connection->getCollection($collection); + } + } +} else { + /** @internal For compatibility with Laravel 12+ */ + trait BlueprintLaravelCompatibility + { + public function __construct(Connection $connection, string $collection, ?Closure $callback = null) + { + parent::__construct($connection, $collection, $callback); + + $this->collection = $connection->getCollection($collection); + } + } +} diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 4af15f1f9..ef450745a 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -7,6 +7,7 @@ use Closure; use MongoDB\Collection; use MongoDB\Driver\Exception\ServerException; +use MongoDB\Laravel\Connection; use MongoDB\Model\CollectionInfo; use MongoDB\Model\IndexInfo; @@ -16,11 +17,14 @@ use function array_keys; use function array_map; use function array_merge; +use function array_values; use function assert; use function count; use function current; use function implode; use function in_array; +use function is_array; +use function is_string; use function iterator_to_array; use function sort; use function sprintf; @@ -28,6 +32,7 @@ use function substr; use function usort; +/** @property Connection $connection */ class Builder extends \Illuminate\Database\Schema\Builder { /** @@ -137,9 +142,10 @@ public function dropAllTables() } } - public function getTables() + /** @param string|null $schema Database name */ + public function getTables($schema = null) { - $db = $this->connection->getDatabase(); + $db = $this->connection->getDatabase($schema); $collections = []; foreach ($db->listCollectionNames() as $collectionName) { @@ -150,7 +156,8 @@ public function getTables() $collections[] = [ 'name' => $collectionName, - 'schema' => null, + 'schema' => $db->getDatabaseName(), + 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, 'size' => $stats[0]?->storageStats?->totalSize ?? null, 'comment' => null, 'collation' => null, @@ -165,9 +172,29 @@ public function getTables() return $collections; } - public function getTableListing() + /** + * @param string|null $schema + * @param bool $schemaQualified If a schema is provided, prefix the collection names with the schema name + * + * @return array + */ + public function getTableListing($schema = null, $schemaQualified = false) { - $collections = iterator_to_array($this->connection->getDatabase()->listCollectionNames()); + $collections = []; + + if ($schema === null || is_string($schema)) { + $collections[$schema ?? 0] = iterator_to_array($this->connection->getDatabase($schema)->listCollectionNames()); + } elseif (is_array($schema)) { + foreach ($schema as $db) { + $collections[$db] = iterator_to_array($this->connection->getDatabase($db)->listCollectionNames()); + } + } + + if ($schema && $schemaQualified) { + $collections = array_map(fn ($db, $collections) => array_map(static fn ($collection) => $db . '.' . $collection, $collections), array_keys($collections), $collections); + } + + $collections = array_merge(...array_values($collections)); sort($collections); diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 2cc0c5764..20b5a12fb 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -1605,7 +1605,7 @@ private static function getBuilder(): Builder $connection = m::mock(Connection::class); $processor = m::mock(Processor::class); $connection->shouldReceive('getSession')->andReturn(null); - $connection->shouldReceive('getQueryGrammar')->andReturn(new Grammar()); + $connection->shouldReceive('getQueryGrammar')->andReturn(new Grammar($connection)); return new Builder($connection, null, $processor); } diff --git a/tests/RelationsTest.php b/tests/RelationsTest.php index a55c8c0e0..643e00e6a 100644 --- a/tests/RelationsTest.php +++ b/tests/RelationsTest.php @@ -35,6 +35,7 @@ public function tearDown(): void Photo::truncate(); Label::truncate(); Skill::truncate(); + Soft::truncate(); parent::tearDown(); } diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index e2f4f7b7e..8e91a2f66 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -395,6 +395,7 @@ public function testGetTables() { DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + $dbName = DB::connection('mongodb')->getDatabaseName(); $tables = Schema::getTables(); $this->assertIsArray($tables); @@ -403,9 +404,13 @@ public function testGetTables() foreach ($tables as $table) { $this->assertArrayHasKey('name', $table); $this->assertArrayHasKey('size', $table); + $this->assertArrayHasKey('schema', $table); + $this->assertArrayHasKey('schema_qualified_name', $table); if ($table['name'] === 'newcollection') { $this->assertEquals(8192, $table['size']); + $this->assertEquals($dbName, $table['schema']); + $this->assertEquals($dbName . '.newcollection', $table['schema_qualified_name']); $found = true; } } @@ -428,6 +433,27 @@ public function testGetTableListing() $this->assertContains('newcollection_two', $tables); } + public function testGetTableListingBySchema() + { + DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); + DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + $dbName = DB::connection('mongodb')->getDatabaseName(); + + $tables = Schema::getTableListing([$dbName, 'database__that_does_not_exists'], schemaQualified: true); + + $this->assertIsArray($tables); + $this->assertGreaterThanOrEqual(2, count($tables)); + $this->assertContains($dbName . '.newcollection', $tables); + $this->assertContains($dbName . '.newcollection_two', $tables); + + $tables = Schema::getTableListing([$dbName, 'database__that_does_not_exists'], schemaQualified: false); + + $this->assertIsArray($tables); + $this->assertGreaterThanOrEqual(2, count($tables)); + $this->assertContains('newcollection', $tables); + $this->assertContains('newcollection_two', $tables); + } + public function testGetColumns() { $collection = DB::connection('mongodb')->table('newcollection'); From c97005e9ea29e9084ec597121c8b064af61a6e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 3 Mar 2025 15:02:55 +0100 Subject: [PATCH 35/92] Remove suggestion of archived package mongodb/builder (#3296) Now part of the mongodb/mongodb package --- composer.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 64006a47b..a6f5470aa 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "license": "MIT", "require": { "php": "^8.1", - "ext-mongodb": "^1.15", + "ext-mongodb": "^1.21", "composer-runtime-api": "^2.0.0", "illuminate/cache": "^10.36|^11|^12", "illuminate/container": "^10.0|^11|^12", @@ -49,8 +49,7 @@ "illuminate/bus": "< 10.37.2" }, "suggest": { - "league/flysystem-gridfs": "Filesystem storage in MongoDB with GridFS", - "mongodb/builder": "Provides a fluent aggregation builder for MongoDB pipelines" + "league/flysystem-gridfs": "Filesystem storage in MongoDB with GridFS" }, "minimum-stability": "dev", "prefer-stable": true, From 824e2fc1f3c34b48724e091ac87029b7b4e2bba6 Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 4 Mar 2025 14:51:10 +0100 Subject: [PATCH 36/92] Fix releasing from development branch (#3299) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4afbe78f1..bc60a79cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: run: | echo RELEASE_VERSION=${{ inputs.version }} >> $GITHUB_ENV echo RELEASE_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' -f-2) >> $GITHUB_ENV - echo DEV_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' - f-1).x >> $GITHUB_ENV + echo DEV_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' -f-1).x >> $GITHUB_ENV - name: "Ensure release tag does not already exist" run: | From 3d8d0954925873020e08873b4540ceeb8f80996f Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Tue, 4 Mar 2025 09:48:48 -0500 Subject: [PATCH 37/92] DOCSP-48028: v5.2 release (#3297) * DOCSP-48028: v5.2 release * wip * wip * add keyword --- docs/compatibility.txt | 8 ++++++- docs/filesystems.txt | 4 ++-- docs/fundamentals/aggregation-builder.txt | 22 ------------------- .../framework-compatibility-laravel.rst | 10 +++++++++ docs/query-builder.txt | 2 +- docs/quick-start.txt | 2 +- docs/quick-start/download-and-install.txt | 2 +- docs/user-authentication.txt | 5 +++-- 8 files changed, 25 insertions(+), 30 deletions(-) diff --git a/docs/compatibility.txt b/docs/compatibility.txt index fd3e2da02..9ee891e20 100644 --- a/docs/compatibility.txt +++ b/docs/compatibility.txt @@ -15,7 +15,7 @@ Compatibility :class: singlecol .. meta:: - :keywords: laravel 9, laravel 10, laravel 11, 4.0, 4.1, 4.2, 5.0, 5.1 + :keywords: laravel 9, laravel 10, laravel 11, laravel 12, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2 Laravel Compatibility --------------------- @@ -28,3 +28,9 @@ the {+odm-short+} that you can use together. To find compatibility information for unmaintained versions of the {+odm-short+}, see `Laravel Version Compatibility <{+mongodb-laravel-gh+}/blob/3.9/README.md#installation>`__ on GitHub. + +PHP Driver Compatibility +------------------------ + +To use {+odm-long+} v5.2 or later, you must install v1.21 of the +{+php-library+} and {+php-extension+}. diff --git a/docs/filesystems.txt b/docs/filesystems.txt index 3ec7ee41f..c62853f58 100644 --- a/docs/filesystems.txt +++ b/docs/filesystems.txt @@ -79,7 +79,7 @@ You can configure the following settings in ``config/filesystems.php``: * - ``throw`` - If ``true``, exceptions are thrown when an operation cannot be performed. If ``false``, - operations return ``true`` on success and ``false`` on error. Defaults to ``false``. + operations return ``true`` on success and ``false`` on error. Defaults to ``false``. You can also use a factory or a service name to create an instance of ``MongoDB\GridFS\Bucket``. In this case, the options ``connection`` and ``database`` are ignored: @@ -133,7 +133,7 @@ metadata, including the file name and a unique ObjectId. If multiple documents share the same file name, they are considered "revisions" and further distinguished by creation timestamps. -The Laravel MongoDB integration uses the GridFS Flysystem adapter. It interacts +{+odm-long+} uses the GridFS Flysystem adapter. It interacts with file revisions in the following ways: - Reading a file reads the last revision of this file name diff --git a/docs/fundamentals/aggregation-builder.txt b/docs/fundamentals/aggregation-builder.txt index 3169acfeb..9ae31f0c1 100644 --- a/docs/fundamentals/aggregation-builder.txt +++ b/docs/fundamentals/aggregation-builder.txt @@ -37,7 +37,6 @@ The {+odm-long+} aggregation builder lets you build aggregation stages and aggregation pipelines. The following sections show examples of how to use the aggregation builder to create the stages of an aggregation pipeline: -- :ref:`laravel-add-aggregation-dependency` - :ref:`laravel-build-aggregation` - :ref:`laravel-aggregation-examples` - :ref:`laravel-create-custom-operator-factory` @@ -49,27 +48,6 @@ aggregation builder to create the stages of an aggregation pipeline: aggregation builder, see :ref:`laravel-query-builder-aggregations` in the Query Builder guide. -.. _laravel-add-aggregation-dependency: - -Add the Aggregation Builder Dependency --------------------------------------- - -The aggregation builder is part of the {+agg-builder-package-name+} package. -You must add this package as a dependency to your project to use it. Run the -following command to add the aggregation builder dependency to your -application: - -.. code-block:: bash - - composer require {+agg-builder-package-name+}:{+agg-builder-version+} - -When the installation completes, verify that the ``composer.json`` file -includes the following line in the ``require`` object: - -.. code-block:: json - - "{+agg-builder-package-name+}": "{+agg-builder-version+}", - .. _laravel-build-aggregation: Create Aggregation Stages diff --git a/docs/includes/framework-compatibility-laravel.rst b/docs/includes/framework-compatibility-laravel.rst index 16c405e21..c642a6763 100644 --- a/docs/includes/framework-compatibility-laravel.rst +++ b/docs/includes/framework-compatibility-laravel.rst @@ -3,21 +3,31 @@ :stub-columns: 1 * - {+odm-long+} Version + - Laravel 12.x - Laravel 11.x - Laravel 10.x - Laravel 9.x + * - 5.2 + - ✓ + - ✓ + - ✓ + - + * - 4.2 to 5.1 + - - ✓ - ✓ - * - 4.1 + - - - ✓ - * - 4.0 + - - - ✓ - diff --git a/docs/query-builder.txt b/docs/query-builder.txt index 76a0d144a..c641323dc 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -227,7 +227,7 @@ value greater than ``8.5`` and a ``year`` value of less than .. tip:: - For compatibility with Laravel, Laravel MongoDB v5.1 supports both arrow + For compatibility with Laravel, {+odm-long+} v5.1 supports both arrow (``->``) and dot (``.``) notation to access nested fields in a query filter. The preceding example uses dot notation to query the ``imdb.rating`` nested field, which is the recommended syntax. diff --git a/docs/quick-start.txt b/docs/quick-start.txt index 1d188ad84..83b0c3937 100644 --- a/docs/quick-start.txt +++ b/docs/quick-start.txt @@ -47,7 +47,7 @@ read and write operations on the data. MongoDB University Learning Byte. If you prefer to connect to MongoDB by using the {+php-library+} without - Laravel, see `Connecting to MongoDB `__ + Laravel, see `Connect to MongoDB `__ in the {+php-library+} documentation. The {+odm-short+} extends the Laravel Eloquent and Query Builder syntax to diff --git a/docs/quick-start/download-and-install.txt b/docs/quick-start/download-and-install.txt index 696861a43..293425791 100644 --- a/docs/quick-start/download-and-install.txt +++ b/docs/quick-start/download-and-install.txt @@ -31,7 +31,7 @@ to a Laravel web application. .. tip:: As an alternative to the following installation steps, you can use Laravel Herd - to install MongoDB and configure a Laravel MongoDB development environment. For + to install MongoDB and configure a development environment for {+odm-long+}. For more information about using Laravel Herd with MongoDB, see the following resources: - `Installing MongoDB via Herd Pro diff --git a/docs/user-authentication.txt b/docs/user-authentication.txt index 88b0da603..63e883d13 100644 --- a/docs/user-authentication.txt +++ b/docs/user-authentication.txt @@ -224,7 +224,7 @@ to the ``guards`` array: ], ], -Use Laravel Passport with Laravel MongoDB +Use Laravel Passport with {+odm-long+} ````````````````````````````````````````` After installing Laravel Passport, you must enable Passport compatibility with MongoDB by @@ -300,4 +300,5 @@ Additional Information To learn more about user authentication, see `Authentication `__ in the Laravel documentation. -To learn more about Eloquent models, see the :ref:`laravel-eloquent-model-class` guide. \ No newline at end of file +To learn more about Eloquent models, see the +:ref:`laravel-eloquent-model-class` guide. From f06d944955fed946fdf94f0a6f01fa48142b1357 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Tue, 4 Mar 2025 10:56:29 -0500 Subject: [PATCH 38/92] Merges the read operation reorganization into 5.2 (#3301) * DOCSP-35945: read operations reorg (#3293) * DOCSP-35945: read operations reorg * skip * small fixes * small fixes * fixes - RM and moved a section * link fic * Fix releasing from development branch (#3299) --------- Co-authored-by: MongoDB PHP Bot <162451593+mongodb-php-bot@users.noreply.github.com> Co-authored-by: Andreas Braun --- .github/workflows/release.yml | 2 +- docs/fundamentals/read-operations.txt | 749 +++--------------- .../read-operations/modify-results.txt | 227 ++++++ .../read-operations/read-pref.txt | 141 ++++ .../fundamentals/read-operations/retrieve.txt | 304 +++++++ .../read-operations/search-text.txt | 157 ++++ docs/fundamentals/write-operations.txt | 3 +- .../before-you-get-started.rst | 15 + 8 files changed, 960 insertions(+), 638 deletions(-) create mode 100644 docs/fundamentals/read-operations/modify-results.txt create mode 100644 docs/fundamentals/read-operations/read-pref.txt create mode 100644 docs/fundamentals/read-operations/retrieve.txt create mode 100644 docs/fundamentals/read-operations/search-text.txt create mode 100644 docs/includes/fundamentals/read-operations/before-you-get-started.rst diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4afbe78f1..bc60a79cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: run: | echo RELEASE_VERSION=${{ inputs.version }} >> $GITHUB_ENV echo RELEASE_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' -f-2) >> $GITHUB_ENV - echo DEV_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' - f-1).x >> $GITHUB_ENV + echo DEV_BRANCH=$(echo ${{ inputs.version }} | cut -d '.' -f-1).x >> $GITHUB_ENV - name: "Ensure release tag does not already exist" run: | diff --git a/docs/fundamentals/read-operations.txt b/docs/fundamentals/read-operations.txt index f3b02c5ec..367e2d38d 100644 --- a/docs/fundamentals/read-operations.txt +++ b/docs/fundamentals/read-operations.txt @@ -10,7 +10,14 @@ Read Operations :values: tutorial .. meta:: - :keywords: find one, find many, code example + :keywords: find one, find many, skip, limit, paginate, string, code example + +.. toctree:: + + Retrieve Data + Search Text + Modify Query Results + Set Read Preference .. contents:: On this page :local: @@ -21,697 +28,169 @@ Read Operations Overview -------- -In this guide, you can learn how to use {+odm-long+} to perform **find operations** -on your MongoDB collections. Find operations allow you to retrieve documents based on -criteria that you specify. - -This guide shows you how to perform the following tasks: - -- :ref:`laravel-retrieve-matching` -- :ref:`laravel-retrieve-all` -- :ref:`laravel-retrieve-text-search` -- :ref:`Modify Find Operation Behavior ` - -Before You Get Started ----------------------- - -To run the code examples in this guide, complete the :ref:`Quick Start ` -tutorial. This tutorial provides instructions on setting up a MongoDB Atlas instance with -sample data and creating the following files in your Laravel web application: - -- ``Movie.php`` file, which contains a ``Movie`` model to represent documents in the ``movies`` - collection -- ``MovieController.php`` file, which contains a ``show()`` function to run database operations -- ``browse_movies.blade.php`` file, which contains HTML code to display the results of database - operations - -The following sections describe how to edit the files in your Laravel application to run -the find operation code examples and view the expected output. - -.. _laravel-retrieve-matching: - -Retrieve Documents that Match a Query -------------------------------------- - -You can use Laravel's Eloquent object-relational mapper (ORM) to create models -that represent MongoDB collections and chain methods on them to specify -query criteria. - -To retrieve documents that match a set of criteria, call the ``where()`` -method on the collection's corresponding Eloquent model, then pass a query -filter to the method. - -A query filter specifies field value requirements and instructs the find -operation to return only documents that meet these requirements. - -You can use one of the following ``where()`` method calls to build a query: - -- ``where('', )`` builds a query that matches documents in - which the target field has the exact specified value - -- ``where('', '', )`` builds a query - that matches documents in which the target field's value meets the comparison - criteria - -To apply multiple sets of criteria to the find operation, you can chain a series -of ``where()`` methods together. - -After building your query by using the ``where()`` method, chain the ``get()`` -method to retrieve the query results. - -This example calls two ``where()`` methods on the ``Movie`` Eloquent model to -retrieve documents that meet the following criteria: - -- ``year`` field has a value of ``2010`` -- ``imdb.rating`` nested field has a value greater than ``8.5`` - -.. tabs:: - - .. tab:: Query Syntax - :tabid: query-syntax - - Use the following syntax to specify the query: - - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-query - :end-before: end-query - - .. tab:: Controller Method - :tabid: controller - - To see the query results in the ``browse_movies`` view, edit the ``show()`` function - in the ``MovieController.php`` file to resemble the following code: - - .. io-code-block:: - :copyable: true - - .. input:: - :language: php - - class MovieController - { - public function show() - { - $movies = Movie::where('year', 2010) - ->where('imdb.rating', '>', 8.5) - ->get(); - - return view('browse_movies', [ - 'movies' => $movies - ]); - } - } - - .. output:: - :language: none - :visible: false - - Title: Inception - Year: 2010 - Runtime: 148 - IMDB Rating: 8.8 - IMDB Votes: 1294646 - Plot: A thief who steals corporate secrets through use of dream-sharing - technology is given the inverse task of planting an idea into the mind of a CEO. - - Title: Senna - Year: 2010 - Runtime: 106 - IMDB Rating: 8.6 - IMDB Votes: 41904 - Plot: A documentary on Brazilian Formula One racing driver Ayrton Senna, who won the - F1 world championship three times before his death at age 34. - -To learn how to query by using the Laravel query builder instead of the -Eloquent ORM, see the :ref:`laravel-query-builder` page. - -Match Array Field Elements -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can specify a query filter to match array field elements when -retrieving documents. If your documents contain an array field, you can -match documents based on if the value contains all or some specified -array elements. - -You can use one of the following ``where()`` method calls to build a -query on an array field: - -- ``where('', )`` builds a query that matches documents in - which the array field value is exactly the specified array +In this guide, you can see code templates of common +methods that you can use to read data from MongoDB by using +{+odm-long+}. -- ``where('', 'in', )`` builds a query - that matches documents in which the array field value contains one or - more of the specified array elements - -After building your query by using the ``where()`` method, chain the ``get()`` -method to retrieve the query results. - -Select from the following :guilabel:`Exact Array Match` and -:guilabel:`Element Match` tabs to view the query syntax for each pattern: - -.. tabs:: - - .. tab:: Exact Array Match - :tabid: exact-array - - This example retrieves documents in which the ``countries`` array is - exactly ``['Indonesia', 'Canada']``: - - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-exact-array - :end-before: end-exact-array - - .. tab:: Element Match - :tabid: element-match - - This example retrieves documents in which the ``countries`` array - contains one of the values in the array ``['Canada', 'Egypt']``: - - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-elem-match - :end-before: end-elem-match - -To learn how to query array fields by using the Laravel query builder instead of the -Eloquent ORM, see the :ref:`laravel-query-builder-elemMatch` section in -the Query Builder guide. - -.. _laravel-retrieve-all: +.. tip:: -Retrieve All Documents in a Collection --------------------------------------- + To learn more about any of the methods included in this guide, + see the links provided in each section. -You can retrieve all documents in a collection by omitting the query filter. -To return the documents, call the ``get()`` method on an Eloquent model that -represents your collection. Alternatively, you can use the ``get()`` method's -alias ``all()`` to perform the same operation. +Find One +-------- -Use the following syntax to run a find operation that matches all documents: +The following code shows how to retrieve the first matching document +from a collection: .. code-block:: php - $movies = Movie::get(); - -.. warning:: - - The ``movies`` collection in the Atlas sample dataset contains a large amount of data. - Retrieving and displaying all documents in this collection might cause your web - application to time out. - - To avoid this issue, specify a document limit by using the ``take()`` method. For - more information about ``take()``, see the :ref:`laravel-modify-find` section of this - guide. - -.. _laravel-retrieve-text-search: - -Search Text Fields ------------------- - -A text search retrieves documents that contain a **term** or a **phrase** in the -text-indexed fields. A term is a sequence of characters that excludes -whitespace characters. A phrase is a sequence of terms with any number -of whitespace characters. + SampleModel::where('', '') + ->first(); -.. note:: +To view a runnable example that finds one document, see the +:ref:`laravel-find-one-usage` usage example. - Before you can perform a text search, you must create a :manual:`text - index ` on - the text-valued field. To learn more about creating - indexes, see the :ref:`laravel-eloquent-indexes` section of the - Schema Builder guide. +To learn more about retrieving documents and the ``first()`` method, see +the :ref:`laravel-fundamentals-read-retrieve` guide. -You can perform a text search by using the :manual:`$text -` operator followed -by the ``$search`` field in your query filter that you pass to the -``where()`` method. The ``$text`` operator performs a text search on the -text-indexed fields. The ``$search`` field specifies the text to search for. +Find Multiple +------------- -After building your query by using the ``where()`` method, chain the ``get()`` -method to retrieve the query results. +The following code shows how to retrieve all documents that match a +query filter from a collection: -This example calls the ``where()`` method on the ``Movie`` Eloquent model to -retrieve documents in which the ``plot`` field contains the phrase -``"love story"``. To perform this text search, the collection must have -a text index on the ``plot`` field. - -.. tabs:: - - .. tab:: Query Syntax - :tabid: query-syntax +.. code-block:: php - Use the following syntax to specify the query: + SampleModel::where('', '') + ->get(); - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-text - :end-before: end-text +To view a runnable example that finds documents, see the +:ref:`laravel-find-usage` usage example. - .. tab:: Controller Method - :tabid: controller +To learn more about retrieving documents, see the +:ref:`laravel-fundamentals-read-retrieve` guide. - To see the query results in the ``browse_movies`` view, edit the ``show()`` function - in the ``MovieController.php`` file to resemble the following code: +Return All Documents +-------------------- - .. io-code-block:: - :copyable: true +The following code shows how to retrieve all documents from a +collection: - .. input:: - :language: php +.. code-block:: php - class MovieController - { - public function show() - { - $movies = Movie::where('$text', ['$search' => '"love story"']) - ->get(); + SampleModel::get(); - return view('browse_movies', [ - 'movies' => $movies - ]); - } - } + // Or, use the all() method. + SampleModel::all(); - .. output:: - :language: none - :visible: false +To view a runnable example that finds documents, see the +:ref:`laravel-find-usage` usage example. - Title: Cafè de Flore - Year: 2011 - Runtime: 120 - IMDB Rating: 7.4 - IMDB Votes: 9663 - Plot: A love story between a man and woman ... +To learn more about retrieving documents, see the +:ref:`laravel-fundamentals-read-retrieve` guide. - Title: Paheli - Year: 2005 - Runtime: 140 - IMDB Rating: 6.7 - IMDB Votes: 8909 - Plot: A folk tale - supernatural love story about a ghost ... +Search Text +----------- - Title: Por un puèado de besos - Year: 2014 - Runtime: 98 - IMDB Rating: 6.1 - IMDB Votes: 223 - Plot: A girl. A boy. A love story ... - - ... - -A text search assigns a numerical :manual:`text score ` to indicate how closely -each result matches the string in your query filter. You can sort the -results by relevance by using the ``orderBy()`` method to sort on the -``textScore`` metadata field. You can access this metadata by using the -:manual:`$meta ` operator: - -.. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-text-relevance - :end-before: end-text-relevance - :emphasize-lines: 2 +The following code shows how to perform a full-text search on a string +field in a collection's documents: -.. tip:: +.. code-block:: php - To learn more about the ``orderBy()`` method, see the - :ref:`laravel-sort` section of this guide. + SampleModel::where('$text', ['$search' => '']) + ->get(); -.. _laravel-modify-find: +To learn more about searching on text fields, see the +:ref:`laravel-retrieve-text-search` guide. -Modify Behavior +Count Documents --------------- -You can modify the results of a find operation by chaining more methods -to ``where()``. - -The following sections demonstrate how to modify the behavior of the ``where()`` -method: - -- :ref:`laravel-skip-limit` uses the ``skip()`` method to set the number of documents - to skip and the ``take()`` method to set the total number of documents to return -- :ref:`laravel-sort` uses the ``orderBy()`` method to return query - results in a specified order based on field values -- :ref:`laravel-retrieve-one` uses the ``first()`` method to return the first document - that matches the query filter -- :ref:`laravel-read-pref` uses the ``readPreference()`` method to direct the query - to specific replica set members - -.. _laravel-skip-limit: - -Skip and Limit Results -~~~~~~~~~~~~~~~~~~~~~~ - -This example queries for documents in which the ``year`` value is ``1999``. -The operation skips the first ``2`` matching documents and outputs a total of ``3`` -documents. - -.. tabs:: - - .. tab:: Query Syntax - :tabid: query-syntax - - Use the following syntax to specify the query: - - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-skip-limit - :end-before: end-skip-limit - - .. tab:: Controller Method - :tabid: controller +The following code shows how to count documents in a collection: - To see the query results in the ``browse_movies`` view, edit the ``show()`` function - in the ``MovieController.php`` file to resemble the following code: - - .. io-code-block:: - :copyable: true - - .. input:: - :language: php - - class MovieController - { - public function show() - { - $movies = Movie::where('year', 1999) - ->skip(2) - ->take(3) - ->get(); - - return view('browse_movies', [ - 'movies' => $movies - ]); - } - } - - .. output:: - :language: none - :visible: false - - Title: Three Kings - Year: 1999 - Runtime: 114 - IMDB Rating: 7.2 - IMDB Votes: 130677 - Plot: In the aftermath of the Persian Gulf War, 4 soldiers set out to steal gold - that was stolen from Kuwait, but they discover people who desperately need their help. - - Title: Toy Story 2 - Year: 1999 - Runtime: 92 - IMDB Rating: 7.9 - IMDB Votes: 346655 - Plot: When Woody is stolen by a toy collector, Buzz and his friends vow to rescue him, - but Woody finds the idea of immortality in a museum tempting. - - Title: Beowulf - Year: 1999 - Runtime: 95 - IMDB Rating: 4 - IMDB Votes: 9296 - Plot: A sci-fi update of the famous 6th Century poem. In a besieged land, Beowulf must - battle against the hideous creature Grendel and his vengeance seeking mother. - -.. _laravel-sort: - -Sort Query Results -~~~~~~~~~~~~~~~~~~ - -To order query results based on the values of specified fields, use the ``where()`` method -followed by the ``orderBy()`` method. - -You can set an **ascending** or **descending** sort direction on -results. By default, the ``orderBy()`` method sets an ascending sort on -the supplied field name, but you can explicitly specify an ascending -sort by passing ``"asc"`` as the second parameter. To -specify a descending sort, pass ``"desc"`` as the second parameter. - -If your documents contain duplicate values in a specific field, you can -handle the tie by specifying more fields to sort on. This ensures consistent -results if the other fields contain unique values. - -This example queries for documents in which the value of the ``countries`` field contains -``"Indonesia"`` and orders results first by an ascending sort on the -``year`` field, then a descending sort on the ``title`` field. - -.. tabs:: - - .. tab:: Query Syntax - :tabid: query-syntax - - Use the following syntax to specify the query: - - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-sort - :end-before: end-sort - - .. tab:: Controller Method - :tabid: controller - - To see the query results in the ``browse_movies`` view, edit the ``show()`` function - in the ``MovieController.php`` file to resemble the following code: - - .. io-code-block:: - :copyable: true - - .. input:: - :language: php - - class MovieController - { - public function show() - { - $movies = Movie::where('countries', 'Indonesia') - ->orderBy('year') - ->orderBy('title', 'desc') - ->get(); - - return view('browse_movies', [ - 'movies' => $movies - ]); - } - } - - .. output:: - :language: none - :visible: false - - Title: Joni's Promise - Year: 2005 - Runtime: 83 - IMDB Rating: 7.6 - IMDB Votes: 702 - Plot: A film delivery man promises ... - - Title: Gie - Year: 2005 - Runtime: 147 - IMDB Rating: 7.5 - IMDB Votes: 470 - Plot: Soe Hok Gie is an activist who lived in the sixties ... - - Title: Requiem from Java - Year: 2006 - Runtime: 120 - IMDB Rating: 6.6 - IMDB Votes: 316 - Plot: Setyo (Martinus Miroto) and Siti (Artika Sari Dewi) - are young married couple ... - - ... +.. code-block:: php -.. tip:: + SampleModel::count(); - To learn more about sorting, see the following resources: + // You can also count documents that match a filter. + SampleModel::where('', '') + ->count(); - - :manual:`Natural order ` - in the {+server-docs-name+} glossary - - `Ordering, Grouping, Limit, and Offset `__ - in the Laravel documentation +To view a runnable example that counts documents, see the +:ref:`laravel-count-usage` usage example. -.. _laravel-retrieve-one: +Retrieve Distinct Values +------------------------ -Return the First Result -~~~~~~~~~~~~~~~~~~~~~~~ +The following code shows how to retrieve the distinct values of a +specified field: -To retrieve the first document that matches a set of criteria, use the ``where()`` method -followed by the ``first()`` method. +.. code-block:: php -Chain the ``orderBy()`` method to ``first()`` to get consistent results when you query on a unique -value. If you omit the ``orderBy()`` method, MongoDB returns the matching documents according to -the documents' natural order, or as they appear in the collection. + SampleModel::select('') + ->distinct() + ->get(); -This example queries for documents in which the value of the ``runtime`` field is -``30`` and returns the first matching document according to the value of the ``_id`` -field. +To view a runnable example that returns distinct field values, see the +:ref:`laravel-distinct-usage` usage example. -.. tabs:: +Skip Results +------------ - .. tab:: Query Syntax - :tabid: query-syntax +The following code shows how to skip a specified number of documents +returned from MongoDB: - Use the following syntax to specify the query: +.. code-block:: php - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-first - :end-before: end-first + SampleModel::where('', '') + ->skip() + ->get(); - .. tab:: Controller Method - :tabid: controller +To learn more about modifying how {+odm-long+} returns results, see the +:ref:`laravel-read-modify-results` guide. - To see the query results in the ``browse_movies`` view, edit the ``show()`` function - in the ``MovieController.php`` file to resemble the following code: +Limit Results +------------- - .. io-code-block:: - :copyable: true +The following code shows how to return only a specified number of +documents from MongoDB: - .. input:: - :language: php +.. code-block:: php - class MovieController - { - public function show() - { - $movie = Movie::where('runtime', 30) - ->orderBy('_id') - ->first(); + SampleModel::where('', '') + ->take() + ->get(); - return view('browse_movies', [ - 'movies' => $movie - ]); - } - } +To learn more about modifying how {+odm-long+} returns results, see the +:ref:`laravel-read-modify-results` guide. - .. output:: - :language: none - :visible: false +Sort Results +------------ - Title: Statues also Die - Year: 1953 - Runtime: 30 - IMDB Rating: 7.6 - IMDB Votes: 620 - Plot: A documentary of black art. +The following code shows how to set a sort order on results returned +from MongoDB: -.. tip:: +.. code-block:: php - To learn more about the ``orderBy()`` method, see the - :ref:`laravel-sort` section of this guide. + SampleModel::where('field name', '') + ->orderBy('') + ->get(); -.. _laravel-read-pref: +To learn more about modifying how {+odm-long+} returns results, see the +:ref:`laravel-read-modify-results` guide. Set a Read Preference -~~~~~~~~~~~~~~~~~~~~~ +--------------------- -To specify which replica set members receive your read operations, -set a read preference by using the ``readPreference()`` method. +The following code shows how to set a read preference when performing a +find operation: -The ``readPreference()`` method accepts the following parameters: - -- ``mode``: *(Required)* A string value specifying the read preference - mode. - -- ``tagSets``: *(Optional)* An array value specifying key-value tags that correspond to - certain replica set members. - -- ``options``: *(Optional)* An array value specifying additional read preference options. +.. code-block:: php -.. tip:: + SampleModel::where('field name', '') + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); - To view a full list of available read preference modes and options, see - :php:`MongoDB\Driver\ReadPreference::__construct ` - in the MongoDB PHP extension documentation. - -The following example queries for documents in which the value of the ``title`` -field is ``"Carrie"`` and sets the read preference to ``ReadPreference::SECONDARY_PREFERRED``. -As a result, the query retrieves the results from secondary replica set -members or the primary member if no secondaries are available: - -.. tabs:: - - .. tab:: Query Syntax - :tabid: query-syntax - - Use the following syntax to specify the query: - - .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php - :language: php - :dedent: - :start-after: start-read-pref - :end-before: end-read-pref - - .. tab:: Controller Method - :tabid: controller - - To see the query results in the ``browse_movies`` view, edit the ``show()`` function - in the ``MovieController.php`` file to resemble the following code: - - .. io-code-block:: - :copyable: true - - .. input:: - :language: php - - class MovieController - { - public function show() - { - $movies = Movie::where('title', 'Carrie') - ->readPreference(ReadPreference::SECONDARY_PREFERRED) - ->get(); - - return view('browse_movies', [ - 'movies' => $movies - ]); - } - } - - .. output:: - :language: none - :visible: false - - Title: Carrie - Year: 1952 - Runtime: 118 - IMDB Rating: 7.5 - IMDB Votes: 1458 - Plot: Carrie boards the train to Chicago with big ambitions. She gets a - job stitching shoes and her sister's husband takes almost all of her pay - for room and board. Then she injures a finger and ... - - Title: Carrie - Year: 1976 - Runtime: 98 - IMDB Rating: 7.4 - IMDB Votes: 115528 - Plot: A shy, outcast 17-year old girl is humiliated by her classmates for the - last time. - - Title: Carrie - Year: 2002 - Runtime: 132 - IMDB Rating: 5.5 - IMDB Votes: 7412 - Plot: Carrie White is a lonely and painfully shy teenage girl with telekinetic - powers who is slowly pushed to the edge of insanity by frequent bullying from - both her classmates and her domineering, religious mother. - - Title: Carrie - Year: 2013 - Runtime: 100 - IMDB Rating: 6 - IMDB Votes: 98171 - Plot: A reimagining of the classic horror tale about Carrie White, a shy girl - outcast by her peers and sheltered by her deeply religious mother, who unleashes - telekinetic terror on her small town after being pushed too far at her senior prom. +To learn more about read preferences, see the :ref:`laravel-read-pref` +guide. diff --git a/docs/fundamentals/read-operations/modify-results.txt b/docs/fundamentals/read-operations/modify-results.txt new file mode 100644 index 000000000..fd67422ae --- /dev/null +++ b/docs/fundamentals/read-operations/modify-results.txt @@ -0,0 +1,227 @@ +.. _laravel-modify-find: +.. _laravel-read-modify-results: + +==================== +Modify Query Results +==================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: filter, criteria, CRUD, code example + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to customize the way that {+odm-long+} +returns results from queries. You can modify the results of a find +operation by chaining more methods to the ``where()`` method. + +The following sections demonstrate how to modify the behavior of the +``where()`` method: + +- :ref:`laravel-skip-limit` uses the ``skip()`` method to set the number of documents + to skip and the ``take()`` method to set the total number of documents to return +- :ref:`laravel-sort` uses the ``orderBy()`` method to return query + results in a specified order based on field values + +To learn more about Eloquent models in the {+odm-short+}, see the +:ref:`laravel-eloquent-models` section. + +.. include:: /includes/fundamentals/read-operations/before-you-get-started.rst + +.. _laravel-skip-limit: + +Skip and Limit Results +---------------------- + +This example queries for documents in which the ``year`` value is ``1999``. +The operation skips the first ``2`` matching documents and outputs a total of ``3`` +documents. + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-skip-limit + :end-before: end-skip-limit + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movies = Movie::where('year', 1999) + ->skip(2) + ->take(3) + ->get(); + + return view('browse_movies', [ + 'movies' => $movies + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Three Kings + Year: 1999 + Runtime: 114 + IMDB Rating: 7.2 + IMDB Votes: 130677 + Plot: In the aftermath of the Persian Gulf War, 4 soldiers set out to steal gold + that was stolen from Kuwait, but they discover people who desperately need their help. + + Title: Toy Story 2 + Year: 1999 + Runtime: 92 + IMDB Rating: 7.9 + IMDB Votes: 346655 + Plot: When Woody is stolen by a toy collector, Buzz and his friends vow to rescue him, + but Woody finds the idea of immortality in a museum tempting. + + Title: Beowulf + Year: 1999 + Runtime: 95 + IMDB Rating: 4 + IMDB Votes: 9296 + Plot: A sci-fi update of the famous 6th Century poem. In a besieged land, Beowulf must + battle against the hideous creature Grendel and his vengeance seeking mother. + +.. _laravel-sort: + +Sort Query Results +------------------ + +To order query results based on the values of specified fields, use the ``where()`` method +followed by the ``orderBy()`` method. + +You can set an **ascending** or **descending** sort direction on +results. By default, the ``orderBy()`` method sets an ascending sort on +the supplied field name, but you can explicitly specify an ascending +sort by passing ``"asc"`` as the second parameter. To +specify a descending sort, pass ``"desc"`` as the second parameter. + +If your documents contain duplicate values in a specific field, you can +handle the tie by specifying more fields to sort on. This ensures consistent +results if the other fields contain unique values. + +This example queries for documents in which the value of the ``countries`` field contains +``"Indonesia"`` and orders results first by an ascending sort on the +``year`` field, then a descending sort on the ``title`` field. + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-sort + :end-before: end-sort + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movies = Movie::where('countries', 'Indonesia') + ->orderBy('year') + ->orderBy('title', 'desc') + ->get(); + + return view('browse_movies', [ + 'movies' => $movies + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Joni's Promise + Year: 2005 + Runtime: 83 + IMDB Rating: 7.6 + IMDB Votes: 702 + Plot: A film delivery man promises ... + + Title: Gie + Year: 2005 + Runtime: 147 + IMDB Rating: 7.5 + IMDB Votes: 470 + Plot: Soe Hok Gie is an activist who lived in the sixties ... + + Title: Requiem from Java + Year: 2006 + Runtime: 120 + IMDB Rating: 6.6 + IMDB Votes: 316 + Plot: Setyo (Martinus Miroto) and Siti (Artika Sari Dewi) + are young married couple ... + + ... + +.. tip:: + + To learn more about sorting, see the following resources: + + - :manual:`Natural order ` + in the {+server-docs-name+} glossary + - `Ordering, Grouping, Limit, and Offset `__ + in the Laravel documentation + +Additional Information +---------------------- + +To view runnable code examples that demonstrate how to perform find +operations by using the {+odm-short+}, see the following usage examples: + +- :ref:`laravel-find-one-usage` +- :ref:`laravel-find-usage` + +To learn how to retrieve data based on filter criteria, see the +:ref:`laravel-fundamentals-read-retrieve` guide. diff --git a/docs/fundamentals/read-operations/read-pref.txt b/docs/fundamentals/read-operations/read-pref.txt new file mode 100644 index 000000000..075c74380 --- /dev/null +++ b/docs/fundamentals/read-operations/read-pref.txt @@ -0,0 +1,141 @@ +.. _laravel-read-pref: + +===================== +Set a Read Preference +===================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: consistency, durability, CRUD, code example + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to set a read preference when +performing find operations with {+odm-long+}. + +.. include:: /includes/fundamentals/read-operations/before-you-get-started.rst + +Set a Read Preference +--------------------- + +To specify which replica set members receive your read operations, +set a read preference by using the ``readPreference()`` method. + +The ``readPreference()`` method accepts the following parameters: + +- ``mode``: *(Required)* A string value specifying the read preference + mode. + +- ``tagSets``: *(Optional)* An array value specifying key-value tags that correspond to + certain replica set members. + +- ``options``: *(Optional)* An array value specifying additional read preference options. + +.. tip:: + + To view a full list of available read preference modes and options, see + :php:`MongoDB\Driver\ReadPreference::__construct ` + in the MongoDB PHP extension documentation. + +The following example queries for documents in which the value of the ``title`` +field is ``"Carrie"`` and sets the read preference to ``ReadPreference::SECONDARY_PREFERRED``. +As a result, the query retrieves the results from secondary replica set +members or the primary member if no secondaries are available: + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-read-pref + :end-before: end-read-pref + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movies = Movie::where('title', 'Carrie') + ->readPreference(ReadPreference::SECONDARY_PREFERRED) + ->get(); + + return view('browse_movies', [ + 'movies' => $movies + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Carrie + Year: 1952 + Runtime: 118 + IMDB Rating: 7.5 + IMDB Votes: 1458 + Plot: Carrie boards the train to Chicago with big ambitions. She gets a + job stitching shoes and her sister's husband takes almost all of her pay + for room and board. Then she injures a finger and ... + + Title: Carrie + Year: 1976 + Runtime: 98 + IMDB Rating: 7.4 + IMDB Votes: 115528 + Plot: A shy, outcast 17-year old girl is humiliated by her classmates for the + last time. + + Title: Carrie + Year: 2002 + Runtime: 132 + IMDB Rating: 5.5 + IMDB Votes: 7412 + Plot: Carrie White is a lonely and painfully shy teenage girl with telekinetic + powers who is slowly pushed to the edge of insanity by frequent bullying from + both her classmates and her domineering, religious mother. + + Title: Carrie + Year: 2013 + Runtime: 100 + IMDB Rating: 6 + IMDB Votes: 98171 + Plot: A reimagining of the classic horror tale about Carrie White, a shy girl + outcast by her peers and sheltered by her deeply religious mother, who unleashes + telekinetic terror on her small town after being pushed too + far at her senior prom. + +Additional Information +---------------------- + +To learn how to retrieve data based on filter criteria, see the +:ref:`laravel-fundamentals-read-retrieve` guide. + +To learn how to modify the way that the {+odm-short+} returns results, +see the :ref:`laravel-read-modify-results` guide. diff --git a/docs/fundamentals/read-operations/retrieve.txt b/docs/fundamentals/read-operations/retrieve.txt new file mode 100644 index 000000000..a4ca31091 --- /dev/null +++ b/docs/fundamentals/read-operations/retrieve.txt @@ -0,0 +1,304 @@ +.. _laravel-fundamentals-retrieve-documents: +.. _laravel-fundamentals-read-retrieve: + +============= +Retrieve Data +============= + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: filter, criteria, CRUD, code example + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to retrieve data from MongoDB +collections by using {+odm-long+}. This guide describes the Eloquent +model methods that you can use to retrieve data and provides examples +of different types of find operations. + +To learn more about Eloquent models in the {+odm-short+}, see the +:ref:`laravel-eloquent-models` section. + +.. include:: /includes/fundamentals/read-operations/before-you-get-started.rst + +.. _laravel-retrieve-matching: + +Retrieve Documents that Match a Query +------------------------------------- + +You can use Laravel's Eloquent object-relational mapper (ORM) to create models +that represent MongoDB collections and chain methods on them to specify +query criteria. + +To retrieve documents that match a set of criteria, call the ``where()`` +method on the collection's corresponding Eloquent model, then pass a query +filter to the method. + +.. tip:: Retrieve One Document + + The ``where()`` method retrieves all matching documents. To retrieve + the first matching document, you can chain the ``first()`` method. To + learn more and view an example, see the :ref:`laravel-retrieve-one` + section of this guide. + +A query filter specifies field value requirements and instructs the find +operation to return only documents that meet these requirements. + +You can use one of the following ``where()`` method calls to build a query: + +- ``where('', )`` builds a query that matches documents in + which the target field has the exact specified value + +- ``where('', '', )`` builds a query + that matches documents in which the target field's value meets the comparison + criteria + +To apply multiple sets of criteria to the find operation, you can chain a series +of ``where()`` methods together. + +After building your query by using the ``where()`` method, chain the ``get()`` +method to retrieve the query results. + +This example calls two ``where()`` methods on the ``Movie`` Eloquent model to +retrieve documents that meet the following criteria: + +- ``year`` field has a value of ``2010`` +- ``imdb.rating`` nested field has a value greater than ``8.5`` + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-query + :end-before: end-query + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movies = Movie::where('year', 2010) + ->where('imdb.rating', '>', 8.5) + ->get(); + + return view('browse_movies', [ + 'movies' => $movies + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Inception + Year: 2010 + Runtime: 148 + IMDB Rating: 8.8 + IMDB Votes: 1294646 + Plot: A thief who steals corporate secrets through use of dream-sharing + technology is given the inverse task of planting an idea into the mind of a CEO. + + Title: Senna + Year: 2010 + Runtime: 106 + IMDB Rating: 8.6 + IMDB Votes: 41904 + Plot: A documentary on Brazilian Formula One racing driver Ayrton Senna, who won the + F1 world championship three times before his death at age 34. + +To learn how to query by using the Laravel query builder instead of the +Eloquent ORM, see the :ref:`laravel-query-builder` page. + +Match Array Field Elements +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +You can specify a query filter to match array field elements when +retrieving documents. If your documents contain an array field, you can +match documents based on if the value contains all or some specified +array elements. + +You can use one of the following ``where()`` method calls to build a +query on an array field: + +- ``where('', )`` builds a query that matches documents in + which the array field value is exactly the specified array + +- ``where('', 'in', )`` builds a query + that matches documents in which the array field value contains one or + more of the specified array elements + +After building your query by using the ``where()`` method, chain the ``get()`` +method to retrieve the query results. + +Select from the following :guilabel:`Exact Array Match` and +:guilabel:`Element Match` tabs to view the query syntax for each pattern: + +.. tabs:: + + .. tab:: Exact Array Match + :tabid: exact-array + + This example retrieves documents in which the ``countries`` array is + exactly ``['Indonesia', 'Canada']``: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-exact-array + :end-before: end-exact-array + + .. tab:: Element Match + :tabid: element-match + + This example retrieves documents in which the ``countries`` array + contains one of the values in the array ``['Canada', 'Egypt']``: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-elem-match + :end-before: end-elem-match + +To learn how to query array fields by using the Laravel query builder instead of the +Eloquent ORM, see the :ref:`laravel-query-builder-elemMatch` section in +the Query Builder guide. + +.. _laravel-retrieve-one: + +Retrieve the First Result +------------------------- + +To retrieve the first document that matches a set of criteria, use the ``where()`` method +followed by the ``first()`` method. + +Chain the ``orderBy()`` method to ``first()`` to get consistent results when you query on a unique +value. If you omit the ``orderBy()`` method, MongoDB returns the matching documents according to +the documents' natural order, or as they appear in the collection. + +This example queries for documents in which the value of the ``runtime`` field is +``30`` and returns the first matching document according to the value of the ``_id`` +field. + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-first + :end-before: end-first + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movie = Movie::where('runtime', 30) + ->orderBy('_id') + ->first(); + + return view('browse_movies', [ + 'movies' => $movie + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Statues also Die + Year: 1953 + Runtime: 30 + IMDB Rating: 7.6 + IMDB Votes: 620 + Plot: A documentary of black art. + +.. tip:: + + To learn more about the ``orderBy()`` method, see the + :ref:`laravel-sort` section of the Modify Query Results guide. + +.. _laravel-retrieve-all: + +Retrieve All Documents in a Collection +-------------------------------------- + +You can retrieve all documents in a collection by omitting the query filter. +To return the documents, call the ``get()`` method on an Eloquent model that +represents your collection. Alternatively, you can use the ``get()`` method's +alias ``all()`` to perform the same operation. + +Use the following syntax to run a find operation that matches all documents: + +.. code-block:: php + + $movies = Movie::get(); + +.. warning:: + + The ``movies`` collection in the Atlas sample dataset contains a large amount of data. + Retrieving and displaying all documents in this collection might cause your web + application to time out. + + To avoid this issue, specify a document limit by using the ``take()`` method. For + more information about ``take()``, see the :ref:`laravel-modify-find` + section of the Modify Query Output guide. + +Additional Information +---------------------- + +To view runnable code examples that demonstrate how to perform find +operations by using the {+odm-short+}, see the following usage examples: + +- :ref:`laravel-find-one-usage` +- :ref:`laravel-find-usage` + +To learn how to insert data into MongoDB, see the +:ref:`laravel-fundamentals-write-ops` guide. + +To learn how to modify the way that the {+odm-short+} returns results, +see the :ref:`laravel-read-modify-results` guide. diff --git a/docs/fundamentals/read-operations/search-text.txt b/docs/fundamentals/read-operations/search-text.txt new file mode 100644 index 000000000..4b465e737 --- /dev/null +++ b/docs/fundamentals/read-operations/search-text.txt @@ -0,0 +1,157 @@ +.. _laravel-fundamentals-search-text: +.. _laravel-retrieve-text-search: + +=========== +Search Text +=========== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: filter, string, CRUD, code example + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to run a **text search** by using +{+odm-long+}. + +You can use a text search to retrieve documents that contain a term or a +phrase in a specified field. A term is a sequence of characters that +excludes whitespace characters. A phrase is a sequence of terms with any +number of whitespace characters. + +This guide describes the Eloquent model methods that you can use to +search text and provides examples. To learn more about Eloquent models +in the {+odm-short+}, see the :ref:`laravel-eloquent-models` section. + +.. include:: /includes/fundamentals/read-operations/before-you-get-started.rst + +Search Text Fields +------------------ + +Before you can perform a text search, you must create a :manual:`text +index ` on +the text-valued field. To learn more about creating +indexes, see the :ref:`laravel-eloquent-indexes` section of the +Schema Builder guide. + +You can perform a text search by using the :manual:`$text +` operator followed +by the ``$search`` field in your query filter that you pass to the +``where()`` method. The ``$text`` operator performs a text search on the +text-indexed fields. The ``$search`` field specifies the text to search for. + +After building your query by using the ``where()`` method, chain the ``get()`` +method to retrieve the query results. + +This example calls the ``where()`` method on the ``Movie`` Eloquent model to +retrieve documents in which the ``plot`` field contains the phrase +``"love story"``. To perform this text search, the collection must have +a text index on the ``plot`` field. + +.. tabs:: + + .. tab:: Query Syntax + :tabid: query-syntax + + Use the following syntax to specify the query: + + .. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-text + :end-before: end-text + + .. tab:: Controller Method + :tabid: controller + + To see the query results in the ``browse_movies`` view, edit the ``show()`` function + in the ``MovieController.php`` file to resemble the following code: + + .. io-code-block:: + :copyable: true + + .. input:: + :language: php + + class MovieController + { + public function show() + { + $movies = Movie::where('$text', ['$search' => '"love story"']) + ->get(); + + return view('browse_movies', [ + 'movies' => $movies + ]); + } + } + + .. output:: + :language: none + :visible: false + + Title: Cafè de Flore + Year: 2011 + Runtime: 120 + IMDB Rating: 7.4 + IMDB Votes: 9663 + Plot: A love story between a man and woman ... + + Title: Paheli + Year: 2005 + Runtime: 140 + IMDB Rating: 6.7 + IMDB Votes: 8909 + Plot: A folk tale - supernatural love story about a ghost ... + + Title: Por un puèado de besos + Year: 2014 + Runtime: 98 + IMDB Rating: 6.1 + IMDB Votes: 223 + Plot: A girl. A boy. A love story ... + + ... + +Search Score +------------ + +A text search assigns a numerical :manual:`text score ` to indicate how closely +each result matches the string in your query filter. You can sort the +results by relevance by using the ``orderBy()`` method to sort on the +``textScore`` metadata field. You can access this metadata by using the +:manual:`$meta ` operator: + +.. literalinclude:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-text-relevance + :end-before: end-text-relevance + :emphasize-lines: 2 + +.. tip:: + + To learn more about the ``orderBy()`` method, see the + :ref:`laravel-sort` section of the Modify Query Output guide. + +Additional Information +---------------------- + +To view runnable code examples that demonstrate how to perform find +operations by using the {+odm-short+}, see the following usage examples: + +- :ref:`laravel-find-one-usage` +- :ref:`laravel-find-usage` + +To learn how to retrieve data based on filter criteria, see the +:ref:`laravel-fundamentals-read-retrieve` guide. diff --git a/docs/fundamentals/write-operations.txt b/docs/fundamentals/write-operations.txt index 0a4d8a6ca..1b2f163be 100644 --- a/docs/fundamentals/write-operations.txt +++ b/docs/fundamentals/write-operations.txt @@ -133,8 +133,7 @@ matching document doesn't exist: ['upsert' => true], ); - /* Or, use the upsert() method. */ - + // Or, use the upsert() method. SampleModel::upsert( [], '', diff --git a/docs/includes/fundamentals/read-operations/before-you-get-started.rst b/docs/includes/fundamentals/read-operations/before-you-get-started.rst new file mode 100644 index 000000000..9555856fc --- /dev/null +++ b/docs/includes/fundamentals/read-operations/before-you-get-started.rst @@ -0,0 +1,15 @@ +Before You Get Started +---------------------- + +To run the code examples in this guide, complete the :ref:`Quick Start ` +tutorial. This tutorial provides instructions on setting up a MongoDB Atlas instance with +sample data and creating the following files in your Laravel web application: + +- ``Movie.php`` file, which contains a ``Movie`` model to represent documents in the ``movies`` + collection +- ``MovieController.php`` file, which contains a ``show()`` function to run database operations +- ``browse_movies.blade.php`` file, which contains HTML code to display the results of database + operations + +The following sections describe how to edit the files in your Laravel application to run +the find operation code examples and view the expected output. From 937fb27f6e1c75f1e2fd5e3b1dd11f86f5bd1081 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:44:34 -0500 Subject: [PATCH 39/92] DOCSP-46479: document Scout integration (#3261) * DOCSP-46479: document Scout integration * NR PR fixes 1 * fix spacing * fix spacing * fix spacing * fix spacing * NR PR fixes 2 * JT tech comment * fix spacing * JT tech review 1 * JT tech review 1 * custom index * link to atlas doc --- docs/index.txt | 2 + docs/scout.txt | 259 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 docs/scout.txt diff --git a/docs/index.txt b/docs/index.txt index 2937968a7..1eb1d8657 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -22,6 +22,7 @@ Databases & Collections User Authentication Cache & Locks + Scout Integration HTTP Sessions Queues Transactions @@ -86,6 +87,7 @@ see the following content: - :ref:`laravel-aggregation-builder` - :ref:`laravel-user-authentication` - :ref:`laravel-cache` +- :ref:`laravel-scout` - :ref:`laravel-sessions` - :ref:`laravel-queues` - :ref:`laravel-transactions` diff --git a/docs/scout.txt b/docs/scout.txt new file mode 100644 index 000000000..8f409148b --- /dev/null +++ b/docs/scout.txt @@ -0,0 +1,259 @@ +.. _laravel-scout: + +=========================== +Full-Text Search with Scout +=========================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: php framework, odm, code example, text search, atlas + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to use the Laravel Scout feature in +your {+odm-long+} application. Scout enables you to implement full-text +search on your Eloquent models. To learn more, see `Laravel Scout +`__ in the +Laravel documentation. + +The Scout integration for {+odm-long+} provides the following +functionality: + +- Provides an abstraction to create :atlas:`Atlas Search indexes + ` from any MongoDB or SQL model. + + .. important:: Use Schema Builder to Create Search Indexes + + If your documents are already in MongoDB, create Search indexes + by using {+php-library+} or ``Schema`` builder methods to improve + search query performance. To learn more about creating Search + indexes, see the :ref:`laravel-as-index` section of the Atlas + Search guide. + +- Enables you to automatically replicate data from MongoDB into a + search engine such as `Meilisearch `__ + or `Algolia `__. You can use a MongoDB Eloquent + model as the source to import and index. To learn more about indexing + to a search engine, see the `Indexing + `__ + section of the Laravel Scout documentation. + +.. important:: Deployment Compatibility + + You can use Laravel Scout only when you connect to MongoDB Atlas + deployments. This feature is not available for self-managed + deployments. + +Scout for Atlas Search Tutorial +------------------------------- + +This tutorial demonstrates how to use Scout to compound and index +documents for MongoDB Atlas Search from Eloquent models (MongoDB or SQL). + +.. procedure:: + :style: connected + + .. step:: Install the Scout package + + Before you can use Scout in your application, run the following + command from your application's root directory to install the + ``laravel/scout`` package: + + .. code-block:: bash + + composer require laravel/scout + + .. step:: Add the Searchable trait to your model + + Add the ``Laravel\Scout\Searchable`` trait to an Eloquent model to make + it searchable. The following example adds this trait to the ``Movie`` + model, which represents documents in the ``sample_mflix.movies`` + collection: + + .. code-block:: php + :emphasize-lines: 6, 10 + + `__ + section of the Laravel Scout documentation. + + .. step:: Configure Scout in your application + + Ensure that your application is configured to use MongoDB as its + database connection. To learn how to configure MongoDB, see the + :ref:`laravel-quick-start-connect-to-mongodb` section of the Quick Start + guide. + + To configure Scout in your application, create a file named + ``scout.php`` in your application's ``config`` directory. Paste the + following code into the file to configure Scout: + + .. code-block:: php + :caption: config/scout.php + + env('SCOUT_DRIVER', 'mongodb'), + 'mongodb' => [ + 'connection' => env('SCOUT_MONGODB_CONNECTION', 'mongodb'), + ], + 'prefix' => env('SCOUT_PREFIX', 'scout_'), + ]; + + The preceding code specifies the following configuration: + + - Uses the value of the ``SCOUT_DRIVER`` environment variable as + the default search driver, or ``mongodb`` if the environment + variable is not set + + - Specifies ``scout_`` as the prefix for the collection name of the + searchable collection + + In the ``config/scout.php`` file, you can also specify a custom + Atlas Search index definition. To learn more, see the :ref:`custom + index definition example ` in the + following step. + + Set the following environment variable in your application's + ``.env`` file to select ``mongodb`` as the default search driver: + + .. code-block:: none + :caption: .env + + SCOUT_DRIVER=mongodb + + .. tip:: Queueing + + When using Scout, consider configuring a queue driver to reduce + response times for your application's web interface. To learn more, + see the `Queuing section + `__ + of the Laravel Scout documentation and the :ref:`laravel-queues` guide. + + .. step:: Create the Atlas Search index + + After you configure Scout and set your default search driver, you can + create your searchable collection and search index by running the + following command from your application's root directory: + + .. code-block:: bash + + php artisan scout:index 'App\Models\Movie' + + Because you set MongoDB as the default search driver, the preceding + command creates the search collection with an Atlas Search index in your + MongoDB database. The collection is named ``scout_movies``, based on the prefix + set in the preceding step. The Atlas Search index is named ``scout`` + and has the following configuration by default: + + .. code-block:: json + + { + "mappings": { + "dynamic": true + } + } + + .. _laravel-scout-custom-index: + + To customize the index definition, add the ``index-definitions`` + configuration to the ``mongodb`` entry in your + ``config/scout.php`` file. The following code demonstrates how to + specify a custom index definition to create on the + ``scout_movies`` collection: + + .. code-block:: php + + 'mongodb' => [ + 'connection' => env('SCOUT_MONGODB_CONNECTION', 'mongodb'), + 'index-definitions' => [ + 'scout_movies' => [ + 'mappings' => [ + 'dynamic' => false, + 'fields' => ['title' => ['type' => 'string']] + ] + ] + ] + ], ... + + To learn more about defining Atlas Search index definitions, see the + :atlas:`Define Field Mappings + ` guide in the Atlas + documentation. + + .. note:: + + MongoDB can take up to a minute to create and finalize + an Atlas Search index, so the ``scout:index`` command might not + return a success message immediately. + + .. step:: Import data into the searchable collection + + You can use Scout to replicate data from a source collection + modeled by your Eloquent model into a searchable collection. The + following command replicates and indexes data from the ``movies`` + collection into the ``scout_movies`` collection indexed in the + preceding step: + + .. code-block:: bash + + php artisan scout:import 'App\Models\Movie' + + The documents are automatically indexed for Atlas Search queries. + + .. tip:: Select Fields to Import + + You might not need all the fields from your source documents in your + searchable collection. Limiting the amount of data you replicate can improve + your application's speed and performance. + + You can select specific fields to import by defining the + ``toSearchableArray()`` method in your Eloquent model class. The + following code demonstrates how to define ``toSearchableArray()`` to + select only the ``plot`` and ``title`` fields for replication: + + .. code-block:: php + + class Movie extends Model + { + .... + public function toSearchableArray(): array + { + return [ + 'plot' => $this->plot, + 'title' => $this->title, + ]; + } + } + +After completing these steps, you can perform Atlas Search queries on the +``scout_movies`` collection in your {+odm-long+} application. To learn +how to perform full-text searches, see the :ref:`laravel-atlas-search` +guide. From 536327d0ef77d5fc3d8e421f5e8dd35e972daa45 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Wed, 5 Mar 2025 11:09:19 -0500 Subject: [PATCH 40/92] DOCSP-48018: laravel 12 feature compat (#3304) * DOCSP-48018: laravel 12 feature compat * small fixes * JT fix --- docs/eloquent-models/model-class.txt | 7 ++++--- docs/feature-compatibility.txt | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/eloquent-models/model-class.txt b/docs/eloquent-models/model-class.txt index a2a9861bc..6f686e88a 100644 --- a/docs/eloquent-models/model-class.txt +++ b/docs/eloquent-models/model-class.txt @@ -200,9 +200,10 @@ model attribute, stored in MongoDB as a :php:`MongoDB\\BSON\\UTCDateTime .. tip:: Casts in Laravel 11 - In Laravel 11, you can define a ``casts()`` method to specify data type conversions - instead of using the ``$casts`` attribute. The following code performs the same - conversion as the preceding example by using a ``casts()`` method: + Starting in Laravel 11, you can define a ``casts()`` method to + specify data type conversions instead of using the ``$casts`` + attribute. The following code performs the same conversion as the + preceding example by using a ``casts()`` method: .. code-block:: php diff --git a/docs/feature-compatibility.txt b/docs/feature-compatibility.txt index 57c8c7486..965be2ebb 100644 --- a/docs/feature-compatibility.txt +++ b/docs/feature-compatibility.txt @@ -21,7 +21,7 @@ Overview -------- This guide describes the Laravel features that are supported by -{+odm-long+}. This page discusses Laravel version 11.x feature +{+odm-long+}. This page discusses Laravel version 12.x feature availability in the {+odm-short+}. The following sections contain tables that describe whether individual @@ -32,6 +32,7 @@ Database Features .. list-table:: :header-rows: 1 + :widths: 40 60 * - Eloquent Feature - Availability @@ -63,6 +64,12 @@ Database Features * - Database Monitoring - *Unsupported* + * - Multi-database Support / Multiple Schemas + - *Unsupported* Laravel uses a dot separator (``.``) + between SQL schema and table names, but MongoDB allows ``.`` + characters within collection names, which might lead to + unexpected namespace parsing. + Query Features -------------- @@ -114,19 +121,19 @@ The following Eloquent methods are not supported in the {+odm-short+}: * - Unions - *Unsupported* - * - `Basic Where Clauses `__ + * - `Basic Where Clauses `__ - ✓ - * - `Additional Where Clauses `__ + * - `Additional Where Clauses `__ - ✓ * - Logical Grouping - ✓ - * - `Advanced Where Clauses `__ + * - `Advanced Where Clauses `__ - ✓ - * - `Subquery Where Clauses `__ + * - `Subquery Where Clauses `__ - *Unsupported* * - Ordering @@ -136,7 +143,7 @@ The following Eloquent methods are not supported in the {+odm-short+}: - *Unsupported* * - Grouping - - Partially supported, use :ref:`Aggregations `. + - Partially supported. Use :ref:`Aggregations `. * - Limit and Offset - ✓ From 89772e239af7d9d3f51d29816ace06a34ca260ef Mon Sep 17 00:00:00 2001 From: Nora Reidy Date: Thu, 6 Mar 2025 10:38:47 -0500 Subject: [PATCH 41/92] DOCSP-47950: Fix all operator section (#3308) * DOCSP-47950: Fix all operator section * review feedback --- docs/includes/query-builder/QueryBuilderTest.php | 2 +- docs/query-builder.txt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/includes/query-builder/QueryBuilderTest.php b/docs/includes/query-builder/QueryBuilderTest.php index 574fe060f..3f7ea2274 100644 --- a/docs/includes/query-builder/QueryBuilderTest.php +++ b/docs/includes/query-builder/QueryBuilderTest.php @@ -351,7 +351,7 @@ public function testAll(): void { // begin query all $result = DB::table('movies') - ->where('movies', 'all', ['title', 'rated', 'imdb.rating']) + ->where('writers', 'all', ['Ben Affleck', 'Matt Damon']) ->get(); // end query all diff --git a/docs/query-builder.txt b/docs/query-builder.txt index c641323dc..68a9b2102 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -869,7 +869,8 @@ Contains All Fields Example The following example shows how to use the ``all`` query operator with the ``where()`` query builder method to match -documents that contain all the specified fields: +documents that have a ``writers`` array field containing all +the specified values: .. literalinclude:: /includes/query-builder/QueryBuilderTest.php :language: php From 4fd1b811d1b20eeaafde32f02c2501bf84b59d63 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Thu, 6 Mar 2025 16:17:17 -0500 Subject: [PATCH 42/92] Remove link to builder package/repo (#3312) --- docs/fundamentals/aggregation-builder.txt | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/fundamentals/aggregation-builder.txt b/docs/fundamentals/aggregation-builder.txt index 9ae31f0c1..47994ce9e 100644 --- a/docs/fundamentals/aggregation-builder.txt +++ b/docs/fundamentals/aggregation-builder.txt @@ -66,12 +66,6 @@ to build aggregation stages: - ``MongoDB\Builder\Query`` - ``MongoDB\Builder\Type`` -.. tip:: - - To learn more about builder classes, see the - :github:`mongodb/mongodb-php-builder ` - GitHub repository. - This section features the following examples that show how to use common aggregation stages: From 90ad73f7f93a0d86b7f0fa6c19653f1d666a746a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:17:12 +0100 Subject: [PATCH 43/92] Bump ramsey/composer-install from 3.0.0 to 3.1.0 (#3317) Bumps [ramsey/composer-install](https://github.com/ramsey/composer-install) from 3.0.0 to 3.1.0. - [Release notes](https://github.com/ramsey/composer-install/releases) - [Commits](https://github.com/ramsey/composer-install/compare/3.0.0...3.1.0) --- updated-dependencies: - dependency-name: ramsey/composer-install dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/coding-standards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 24d397294..1d7b89b2d 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -49,7 +49,7 @@ jobs: run: "php --ri mongodb" - name: "Install dependencies with Composer" - uses: "ramsey/composer-install@3.0.0" + uses: "ramsey/composer-install@3.1.0" with: composer-options: "--no-suggest" From b91a3c5f9afc49e54426e834256e91249feaeb8d Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Tue, 11 Mar 2025 09:35:38 -0400 Subject: [PATCH 44/92] fix line spacing in feature compat doc (#3315) --- docs/feature-compatibility.txt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/feature-compatibility.txt b/docs/feature-compatibility.txt index 965be2ebb..c36d30812 100644 --- a/docs/feature-compatibility.txt +++ b/docs/feature-compatibility.txt @@ -65,10 +65,11 @@ Database Features - *Unsupported* * - Multi-database Support / Multiple Schemas - - *Unsupported* Laravel uses a dot separator (``.``) - between SQL schema and table names, but MongoDB allows ``.`` - characters within collection names, which might lead to - unexpected namespace parsing. + - | *Unsupported* + | Laravel uses a dot separator (``.``) + between SQL schema and table names, but MongoDB allows ``.`` + characters within collection names, which might lead to + unexpected namespace parsing. Query Features -------------- From 1265bb1e9d5904e585822eb79e2d4d98c8254ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 31 Mar 2025 10:39:05 +0200 Subject: [PATCH 45/92] PHPORM-306 Test with MongoDB Driver v2 (#3319) --- .github/workflows/build-ci-atlas.yml | 23 ++- .github/workflows/build-ci.yml | 25 ++- .github/workflows/coding-standards.yml | 2 +- .github/workflows/static-analysis.yml | 17 +- composer.json | 4 +- src/Eloquent/Builder.php | 4 +- tests/QueryBuilderTest.php | 6 +- tests/Scout/ScoutEngineTest.php | 230 +++++++++++++------------ 8 files changed, 189 insertions(+), 122 deletions(-) diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index 30b4b06b1..339f8fc38 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -4,11 +4,15 @@ on: push: pull_request: +env: + MONGODB_EXT_V1: mongodb-1.21.0 + MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x + jobs: build: runs-on: "${{ matrix.os }}" - name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} Atlas" + name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }}" strategy: matrix: @@ -21,6 +25,13 @@ jobs: laravel: - "11.*" - "12.*" + driver: + - 1 + include: + - php: "8.4" + laravel: "12.*" + os: "ubuntu-latest" + driver: 2 steps: - uses: "actions/checkout@v4" @@ -39,11 +50,19 @@ jobs: 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 }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + - name: "Installing php" uses: "shivammathur/setup-php@v2" with: php-version: ${{ matrix.php }} - extensions: "curl,mbstring,xdebug" + extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" coverage: "xdebug" tools: "composer" diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 659c316d3..bc799c70e 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -4,11 +4,15 @@ on: push: pull_request: +env: + MONGODB_EXT_V1: mongodb-1.21.0 + MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x + jobs: build: runs-on: "${{ matrix.os }}" - name: "PHP ${{ matrix.php }} Laravel ${{ matrix.laravel }} MongoDB ${{ matrix.mongodb }} ${{ matrix.mode }}" + name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }} Server/${{ matrix.mongodb }} ${{ matrix.mode }}" strategy: matrix: @@ -29,12 +33,21 @@ jobs: - "10.*" - "11.*" - "12.*" + driver: + - 1 include: - php: "8.1" laravel: "10.*" mongodb: "5.0" mode: "low-deps" os: "ubuntu-latest" + driver: 1.x + driver_version: "1.21.0" + - php: "8.4" + laravel: "12.*" + mongodb: "8.0" + os: "ubuntu-latest" + driver: 2 exclude: - php: "8.1" laravel: "11.*" @@ -59,11 +72,19 @@ jobs: if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ serverStatus: 1 })" + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + - name: "Installing php" uses: "shivammathur/setup-php@v2" with: php-version: ${{ matrix.php }} - extensions: "curl,mbstring,xdebug" + extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" coverage: "xdebug" tools: "composer" diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 24d397294..946e84971 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -5,7 +5,7 @@ on: pull_request: env: - PHP_VERSION: "8.2" + PHP_VERSION: "8.4" DRIVER_VERSION: "stable" jobs: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index a66100d93..e0c907953 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -13,9 +13,12 @@ on: env: PHP_VERSION: "8.2" DRIVER_VERSION: "stable" + MONGODB_EXT_V1: mongodb-1.21.0 + MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x jobs: phpstan: + name: "PHP/${{ matrix.php }} Driver/${{ matrix.driver }}" runs-on: "ubuntu-22.04" continue-on-error: true strategy: @@ -24,6 +27,10 @@ jobs: - '8.1' - '8.2' - '8.3' + - '8.4' + driver: + - 1 + - 2 steps: - name: Checkout uses: actions/checkout@v4 @@ -35,11 +42,19 @@ jobs: run: | echo CHECKED_OUT_SHA=$(git rev-parse HEAD) >> $GITHUB_ENV + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: curl, mbstring + extensions: "curl,mbstring,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" tools: composer:v2 coverage: none diff --git a/composer.json b/composer.json index a6f5470aa..2542b51bb 100644 --- a/composer.json +++ b/composer.json @@ -23,14 +23,14 @@ "license": "MIT", "require": { "php": "^8.1", - "ext-mongodb": "^1.21", + "ext-mongodb": "^1.21|^2", "composer-runtime-api": "^2.0.0", "illuminate/cache": "^10.36|^11|^12", "illuminate/container": "^10.0|^11|^12", "illuminate/database": "^10.30|^11|^12", "illuminate/events": "^10.0|^11|^12", "illuminate/support": "^10.0|^11|^12", - "mongodb/mongodb": "^1.21", + "mongodb/mongodb": "^1.21|^2", "symfony/http-foundation": "^6.4|^7" }, "require-dev": { diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index eedbe8712..f85570575 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -11,7 +11,7 @@ use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\CursorInterface; -use MongoDB\Driver\Exception\WriteException; +use MongoDB\Driver\Exception\BulkWriteException; use MongoDB\Laravel\Connection; use MongoDB\Laravel\Helpers\QueriesRelationships; use MongoDB\Laravel\Query\AggregationBuilder; @@ -285,7 +285,7 @@ public function createOrFirst(array $attributes = [], array $values = []) try { return $this->create(array_merge($attributes, $values)); - } catch (WriteException $e) { + } catch (BulkWriteException $e) { if ($e->getCode() === self::DUPLICATE_KEY_ERROR) { return $this->where($attributes)->first() ?? throw $e; } diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 9592bbe7c..46beebab1 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -161,7 +161,7 @@ public function testFindWithTimeout() $id = DB::table('users')->insertGetId(['name' => 'John Doe']); $subscriber = new class implements CommandSubscriber { - public function commandStarted(CommandStartedEvent $event) + public function commandStarted(CommandStartedEvent $event): void { if ($event->getCommandName() !== 'find') { return; @@ -171,11 +171,11 @@ public function commandStarted(CommandStartedEvent $event) Assert::assertSame(1000, $event->getCommand()->maxTimeMS); } - public function commandFailed(CommandFailedEvent $event) + public function commandFailed(CommandFailedEvent $event): void { } - public function commandSucceeded(CommandSucceededEvent $event) + public function commandSucceeded(CommandSucceededEvent $event): void { } }; diff --git a/tests/Scout/ScoutEngineTest.php b/tests/Scout/ScoutEngineTest.php index 40d943ffb..7b254ec9c 100644 --- a/tests/Scout/ScoutEngineTest.php +++ b/tests/Scout/ScoutEngineTest.php @@ -11,13 +11,11 @@ use Laravel\Scout\Builder; use Laravel\Scout\Jobs\RemoveFromSearch; use LogicException; -use Mockery as m; use MongoDB\BSON\Document; use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; use MongoDB\Database; use MongoDB\Driver\CursorInterface; -use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Scout\ScoutEngine; use MongoDB\Laravel\Tests\Scout\Models\ScoutUser; use MongoDB\Laravel\Tests\Scout\Models\SearchableModel; @@ -36,7 +34,7 @@ class ScoutEngineTest extends TestCase public function testCreateIndexInvalidDefinition(): void { - $database = m::mock(Database::class); + $database = $this->createMock(Database::class); $engine = new ScoutEngine($database, false, ['collection_invalid' => ['foo' => 'bar']]); $this->expectException(LogicException::class); @@ -53,21 +51,22 @@ public function testCreateIndex(): void ], ]; - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $database->shouldReceive('createCollection') - ->once() + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('createCollection') ->with($collectionName); - $database->shouldReceive('selectCollection') + $database->expects($this->once()) + ->method('selectCollection') ->with($collectionName) - ->andReturn($collection); - $collection->shouldReceive('createSearchIndex') - ->once() + ->willReturn($collection); + $collection->expects($this->once()) + ->method('createSearchIndex') ->with($expectedDefinition, ['name' => 'scout']); - $collection->shouldReceive('listSearchIndexes') - ->once() + $collection->expects($this->once()) + ->method('listSearchIndexes') ->with(['name' => 'scout', 'typeMap' => ['root' => 'bson']]) - ->andReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); + ->willReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); $engine = new ScoutEngine($database, false, []); $engine->createIndex($collectionName); @@ -90,21 +89,22 @@ public function testCreateIndexCustomDefinition(): void ], ]; - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $database->shouldReceive('createCollection') - ->once() + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('createCollection') ->with($collectionName); - $database->shouldReceive('selectCollection') + $database->expects($this->once()) + ->method('selectCollection') ->with($collectionName) - ->andReturn($collection); - $collection->shouldReceive('createSearchIndex') - ->once() + ->willReturn($collection); + $collection->expects($this->once()) + ->method('createSearchIndex') ->with($expectedDefinition, ['name' => 'scout']); - $collection->shouldReceive('listSearchIndexes') - ->once() + $collection->expects($this->once()) + ->method('listSearchIndexes') ->with(['name' => 'scout', 'typeMap' => ['root' => 'bson']]) - ->andReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); + ->willReturn(new ArrayIterator([Document::fromPHP(['name' => 'scout', 'status' => 'READY'])])); $engine = new ScoutEngine($database, false, [$collectionName => $expectedDefinition]); $engine->createIndex($collectionName); @@ -115,26 +115,28 @@ public function testCreateIndexCustomDefinition(): void public function testSearch(Closure $builder, array $expectedPipeline): void { $data = [['_id' => 'key_1', '__count' => 15], ['_id' => 'key_2', '__count' => 15]]; - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $database->shouldReceive('selectCollection') + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') ->with('collection_searchable') - ->andReturn($collection); - $cursor = m::mock(CursorInterface::class); - $cursor->shouldReceive('setTypeMap')->once()->with(self::EXPECTED_TYPEMAP); - $cursor->shouldReceive('toArray')->once()->with()->andReturn($data); - - $collection->shouldReceive('getCollectionName') - ->zeroOrMoreTimes() - ->andReturn('collection_searchable'); - $collection->shouldReceive('aggregate') - ->once() - ->withArgs(function ($pipeline) use ($expectedPipeline) { - self::assertEquals($expectedPipeline, $pipeline); - - return true; - }) - ->andReturn($cursor); + ->willReturn($collection); + $cursor = $this->createMock(CursorInterface::class); + $cursor->expects($this->once()) + ->method('setTypeMap') + ->with(self::EXPECTED_TYPEMAP); + $cursor->expects($this->once()) + ->method('toArray') + ->with() + ->willReturn($data); + + $collection->expects($this->any()) + ->method('getCollectionName') + ->willReturn('collection_searchable'); + $collection->expects($this->once()) + ->method('aggregate') + ->with($expectedPipeline) + ->willReturn($cursor); $engine = new ScoutEngine($database, softDelete: false); $result = $engine->search($builder()); @@ -414,15 +416,15 @@ public function testPaginate() $perPage = 5; $page = 3; - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $cursor = m::mock(CursorInterface::class); - $database->shouldReceive('selectCollection') + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $cursor = $this->createMock(CursorInterface::class); + $database->method('selectCollection') ->with('collection_searchable') - ->andReturn($collection); - $collection->shouldReceive('aggregate') - ->once() - ->withArgs(function (...$args) { + ->willReturn($collection); + $collection->expects($this->once()) + ->method('aggregate') + ->willReturnCallback(function (...$args) use ($cursor) { self::assertSame([ [ '$search' => [ @@ -468,14 +470,11 @@ public function testPaginate() ], ], $args[0]); - return true; - }) - ->andReturn($cursor); - $cursor->shouldReceive('setTypeMap')->once()->with(self::EXPECTED_TYPEMAP); - $cursor->shouldReceive('toArray') - ->once() - ->with() - ->andReturn([['_id' => 'key_1', '__count' => 17], ['_id' => 'key_2', '__count' => 17]]); + return $cursor; + }); + $cursor->expects($this->once())->method('setTypeMap')->with(self::EXPECTED_TYPEMAP); + $cursor->expects($this->once())->method('toArray')->with() + ->willReturn([['_id' => 'key_1', '__count' => 17], ['_id' => 'key_2', '__count' => 17]]); $engine = new ScoutEngine($database, softDelete: false); $builder = new Builder(new SearchableModel(), 'mustang'); @@ -485,20 +484,27 @@ public function testPaginate() public function testMapMethodRespectsOrder() { - $database = m::mock(Database::class); + $database = $this->createMock(Database::class); + $query = $this->createMock(Builder::class); $engine = new ScoutEngine($database, false); - $model = m::mock(Model::class); - $model->shouldReceive(['getScoutKeyName' => 'id']); - $model->shouldReceive('queryScoutModelsByIds->get') - ->andReturn(LaravelCollection::make([ + $model = $this->createMock(SearchableModel::class); + $model->expects($this->any()) + ->method('getScoutKeyName') + ->willReturn('id'); + $model->expects($this->once()) + ->method('queryScoutModelsByIds') + ->willReturn($query); + $query->expects($this->once()) + ->method('get') + ->willReturn(LaravelCollection::make([ new ScoutUser(['id' => 1]), new ScoutUser(['id' => 2]), new ScoutUser(['id' => 3]), new ScoutUser(['id' => 4]), ])); - $builder = m::mock(Builder::class); + $builder = $this->createMock(Builder::class); $results = $engine->map($builder, [ ['_id' => 1, '__count' => 4], @@ -518,21 +524,27 @@ public function testMapMethodRespectsOrder() public function testLazyMapMethodRespectsOrder() { - $lazy = false; - $database = m::mock(Database::class); + $database = $this->createMock(Database::class); + $query = $this->createMock(Builder::class); $engine = new ScoutEngine($database, false); - $model = m::mock(Model::class); - $model->shouldReceive(['getScoutKeyName' => 'id']); - $model->shouldReceive('queryScoutModelsByIds->cursor') - ->andReturn(LazyCollection::make([ + $model = $this->createMock(SearchableModel::class); + $model->expects($this->any()) + ->method('getScoutKeyName') + ->willReturn('id'); + $model->expects($this->once()) + ->method('queryScoutModelsByIds') + ->willReturn($query); + $query->expects($this->once()) + ->method('cursor') + ->willReturn(LazyCollection::make([ new ScoutUser(['id' => 1]), new ScoutUser(['id' => 2]), new ScoutUser(['id' => 3]), new ScoutUser(['id' => 4]), ])); - $builder = m::mock(Builder::class); + $builder = $this->createMock(Builder::class); $results = $engine->lazyMap($builder, [ ['_id' => 1, '__count' => 4], @@ -553,13 +565,14 @@ public function testLazyMapMethodRespectsOrder() public function testUpdate(): void { $date = new DateTimeImmutable('2000-01-02 03:04:05'); - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $database->shouldReceive('selectCollection') + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') ->with('collection_indexable') - ->andReturn($collection); - $collection->shouldReceive('bulkWrite') - ->once() + ->willReturn($collection); + $collection->expects($this->once()) + ->method('bulkWrite') ->with([ [ 'updateOne' => [ @@ -592,26 +605,23 @@ public function testUpdate(): void public function testUpdateWithSoftDelete(): void { $date = new DateTimeImmutable('2000-01-02 03:04:05'); - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $database->shouldReceive('selectCollection') + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') ->with('collection_indexable') - ->andReturn($collection); - $collection->shouldReceive('bulkWrite') - ->once() - ->withArgs(function ($pipeline) { - $this->assertSame([ - [ - 'updateOne' => [ - ['_id' => 'key_1'], - ['$set' => ['id' => 1, '__soft_deleted' => false]], - ['upsert' => true], - ], + ->willReturn($collection); + $collection->expects($this->once()) + ->method('bulkWrite') + ->with([ + [ + 'updateOne' => [ + ['_id' => 'key_1'], + ['$set' => ['id' => 1, '__soft_deleted' => false]], + ['upsert' => true], ], - ], $pipeline); - - return true; - }); + ], + ]); $model = new SearchableModel(['id' => 1]); $model->delete(); @@ -622,13 +632,14 @@ public function testUpdateWithSoftDelete(): void public function testDelete(): void { - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $database->shouldReceive('selectCollection') + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') ->with('collection_indexable') - ->andReturn($collection); - $collection->shouldReceive('deleteMany') - ->once() + ->willReturn($collection); + $collection->expects($this->once()) + ->method('deleteMany') ->with(['_id' => ['$in' => ['key_1', 'key_2']]]); $engine = new ScoutEngine($database, softDelete: false); @@ -646,13 +657,14 @@ public function testDeleteWithRemoveableScoutCollection(): void $job = unserialize(serialize($job)); - $database = m::mock(Database::class); - $collection = m::mock(Collection::class); - $database->shouldReceive('selectCollection') + $database = $this->createMock(Database::class); + $collection = $this->createMock(Collection::class); + $database->expects($this->once()) + ->method('selectCollection') ->with('collection_indexable') - ->andReturn($collection); - $collection->shouldReceive('deleteMany') - ->once() + ->willReturn($collection); + $collection->expects($this->once()) + ->method('deleteMany') ->with(['_id' => ['$in' => ['key_5']]]); $engine = new ScoutEngine($database, softDelete: false); From 583200745cd698ad03ae0025aa928f84c64fc6e8 Mon Sep 17 00:00:00 2001 From: Ivan Todorovic Date: Tue, 1 Apr 2025 13:36:48 +0200 Subject: [PATCH 46/92] Remove manual dirty _id check when updating a model (#3329) --- src/Query/Builder.php | 7 ------- tests/Ticket/GH3326Test.php | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 7 deletions(-) create mode 100644 tests/Ticket/GH3326Test.php diff --git a/src/Query/Builder.php b/src/Query/Builder.php index f613b6467..5c873380b 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -783,13 +783,6 @@ public function update(array $values, array $options = []) unset($values[$key]); } - // Since "id" is an alias for "_id", we prevent updating it - foreach ($values as $fields) { - if (array_key_exists('id', $fields)) { - throw new InvalidArgumentException('Cannot update "id" field.'); - } - } - return $this->performUpdate($values, $options); } diff --git a/tests/Ticket/GH3326Test.php b/tests/Ticket/GH3326Test.php new file mode 100644 index 000000000..d3f339acc --- /dev/null +++ b/tests/Ticket/GH3326Test.php @@ -0,0 +1,42 @@ +foo = 'bar'; + $model->save(); + + $fresh = $model->fresh(); + + $this->assertEquals('bar', $fresh->foo); + $this->assertEquals('written-in-created', $fresh->extra); + } +} + +class GH3326Model extends Model +{ + protected $connection = 'mongodb'; + protected $collection = 'test_gh3326'; + protected $guarded = []; + + protected static function booted(): void + { + static::created(function ($model) { + $model->extra = 'written-in-created'; + $model->saveQuietly(); + }); + } +} From 3aa95bec5b7e7cbf9d43b208e9dc1460895b9062 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:28:18 -0400 Subject: [PATCH 47/92] DOCSP-48956: replace tutorial link (#3333) --- docs/quick-start.txt | 5 ----- docs/quick-start/next-steps.txt | 10 +++++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/quick-start.txt b/docs/quick-start.txt index 83b0c3937..ebfcb7348 100644 --- a/docs/quick-start.txt +++ b/docs/quick-start.txt @@ -41,11 +41,6 @@ read and write operations on the data. `How to Build a Laravel + MongoDB Back End Service `__ MongoDB Developer Center tutorial. - You can learn how to set up a local Laravel development environment - and perform CRUD operations by viewing the - :mdbu-course:`Getting Started with Laravel and MongoDB ` - MongoDB University Learning Byte. - If you prefer to connect to MongoDB by using the {+php-library+} without Laravel, see `Connect to MongoDB `__ in the {+php-library+} documentation. diff --git a/docs/quick-start/next-steps.txt b/docs/quick-start/next-steps.txt index 1a7f45c6e..2853777fb 100644 --- a/docs/quick-start/next-steps.txt +++ b/docs/quick-start/next-steps.txt @@ -21,6 +21,15 @@ You can download the web application project by cloning the `laravel-quickstart `__ GitHub repository. +.. tip:: Build a Full Stack Application + + Learn how to build a full stack application that uses {+odm-long+} by + following along with the `Full Stack Instagram Clone with Laravel and + MongoDB `__ tutorial on YouTube. + +Further Learning +---------------- + Learn more about {+odm-long+} features from the following resources: - :ref:`laravel-fundamentals-connection`: learn how to configure your MongoDB @@ -34,4 +43,3 @@ Learn more about {+odm-long+} features from the following resources: - :ref:`laravel-query-builder`: use the query builder to specify MongoDB queries and aggregations. - From b5e2132a1d13d32fdac881077420febf5b74e152 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 8 Apr 2025 16:41:50 +0200 Subject: [PATCH 48/92] PHPORM-311 Fix Update of numeric field name (#3336) * PHPORM-311 Fix Update of numeric field name * Use array_replace where keys must be preserved --- src/Eloquent/Builder.php | 8 ++++---- src/Eloquent/DocumentModel.php | 4 ++-- src/Query/Builder.php | 7 ++++--- src/Relations/BelongsToMany.php | 4 ++-- src/Relations/MorphToMany.php | 4 ++-- src/Scout/ScoutEngine.php | 4 ++-- tests/Ticket/GH3335Test.php | 33 +++++++++++++++++++++++++++++++++ 7 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 tests/Ticket/GH3335Test.php diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index f85570575..aabc526f7 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -18,7 +18,7 @@ use MongoDB\Model\BSONDocument; use function array_key_exists; -use function array_merge; +use function array_replace; use function collect; use function is_array; use function is_object; @@ -270,7 +270,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) // createOrFirst is not supported in transaction. if ($this->getConnection()->getSession()?->isInTransaction()) { - return $this->create(array_merge($attributes, $values)); + return $this->create(array_replace($attributes, $values)); } return $this->createOrFirst($attributes, $values); @@ -284,7 +284,7 @@ public function createOrFirst(array $attributes = [], array $values = []) } try { - return $this->create(array_merge($attributes, $values)); + return $this->create(array_replace($attributes, $values)); } catch (BulkWriteException $e) { if ($e->getCode() === self::DUPLICATE_KEY_ERROR) { return $this->where($attributes)->first() ?? throw $e; @@ -309,7 +309,7 @@ protected function addUpdatedAtColumn(array $values) } $column = $this->model->getUpdatedAtColumn(); - $values = array_merge( + $values = array_replace( [$column => $this->model->freshTimestampString()], $values, ); diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php index 930ed6286..d39a12401 100644 --- a/src/Eloquent/DocumentModel.php +++ b/src/Eloquent/DocumentModel.php @@ -30,7 +30,7 @@ use function array_key_exists; use function array_keys; -use function array_merge; +use function array_replace; use function array_unique; use function array_values; use function class_basename; @@ -192,7 +192,7 @@ protected function transformModelValue($key, $value) // to a Carbon or CarbonImmutable instance. // @see Model::setAttribute() if ($this->hasCast($key) && $value instanceof CarbonInterface) { - $value->settings(array_merge($value->getSettings(), ['toStringFormat' => $this->getDateFormat()])); + $value->settings(array_replace($value->getSettings(), ['toStringFormat' => $this->getDateFormat()])); // "date" cast resets the time to 00:00:00. $castType = $this->getCasts()[$key]; diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 5c873380b..07a3483b0 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -40,6 +40,7 @@ use function array_key_exists; use function array_map; use function array_merge; +use function array_replace; use function array_values; use function assert; use function blank; @@ -426,7 +427,7 @@ public function toMql(): array // Add custom query options if (count($this->options)) { - $options = array_merge($options, $this->options); + $options = array_replace($options, $this->options); } $options = $this->inheritConnectionOptions($options); @@ -450,7 +451,7 @@ public function toMql(): array // Add custom projections. if ($this->projections) { - $projection = array_merge($projection, $this->projections); + $projection = array_replace($projection, $this->projections); } $options = []; @@ -484,7 +485,7 @@ public function toMql(): array // Add custom query options if (count($this->options)) { - $options = array_merge($options, $this->options); + $options = array_replace($options, $this->options); } $options = $this->inheritConnectionOptions($options); diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php index a150fccf7..042ec22ce 100644 --- a/src/Relations/BelongsToMany.php +++ b/src/Relations/BelongsToMany.php @@ -14,7 +14,7 @@ use function array_diff; use function array_keys; use function array_map; -use function array_merge; +use function array_replace; use function array_values; use function assert; use function count; @@ -164,7 +164,7 @@ public function sync($ids, $detaching = true) // Now we are finally ready to attach the new records. Note that we'll disable // touching until after the entire operation is complete so we don't fire a // ton of touch operations until we are totally done syncing the records. - $changes = array_merge( + $changes = array_replace( $changes, $this->attachNew($records, $current, false), ); diff --git a/src/Relations/MorphToMany.php b/src/Relations/MorphToMany.php index 929738360..a1514d235 100644 --- a/src/Relations/MorphToMany.php +++ b/src/Relations/MorphToMany.php @@ -15,8 +15,8 @@ use function array_key_exists; use function array_keys; use function array_map; -use function array_merge; use function array_reduce; +use function array_replace; use function array_values; use function collect; use function count; @@ -190,7 +190,7 @@ public function sync($ids, $detaching = true) // Now we are finally ready to attach the new records. Note that we'll disable // touching until after the entire operation is complete so we don't fire a // ton of touch operations until we are totally done syncing the records. - $changes = array_merge( + $changes = array_replace( $changes, $this->attachNew($records, $current, false), ); diff --git a/src/Scout/ScoutEngine.php b/src/Scout/ScoutEngine.php index dc70a39e2..9455608bb 100644 --- a/src/Scout/ScoutEngine.php +++ b/src/Scout/ScoutEngine.php @@ -29,7 +29,7 @@ use function array_column; use function array_flip; use function array_map; -use function array_merge; +use function array_replace; use function assert; use function call_user_func; use function class_uses_recursive; @@ -117,7 +117,7 @@ public function update($models) unset($searchableData['_id']); - $searchableData = array_merge($searchableData, $model->scoutMetadata()); + $searchableData = array_replace($searchableData, $model->scoutMetadata()); /** Convert the __soft_deleted set by {@see Searchable::pushSoftDeleteMetadata()} * into a boolean for efficient storage and indexing. */ diff --git a/tests/Ticket/GH3335Test.php b/tests/Ticket/GH3335Test.php new file mode 100644 index 000000000..f37782a4b --- /dev/null +++ b/tests/Ticket/GH3335Test.php @@ -0,0 +1,33 @@ +id = 'foo'; + $model->save(); + + $model = Location::find('foo'); + $model->{'38'} = 'PHP'; + $model->save(); + + $model = Location::find('foo'); + self::assertSame('PHP', $model->{'38'}); + } +} From 921e1a2fa77eb86ce30dd80910cf2f375238c233 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Tue, 8 Apr 2025 13:49:32 -0400 Subject: [PATCH 49/92] DOCSP-48817: php v2 extension compat (#3339) * DOCSP-48817: php v2 extension compat * small fix --- docs/compatibility.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/compatibility.txt b/docs/compatibility.txt index 9ee891e20..5580e6856 100644 --- a/docs/compatibility.txt +++ b/docs/compatibility.txt @@ -34,3 +34,9 @@ PHP Driver Compatibility To use {+odm-long+} v5.2 or later, you must install v1.21 of the {+php-library+} and {+php-extension+}. + +.. important:: {+php-extension+} v2.0 Compatibility + + If you upgrade the {+php-extension+} to v2.0, you must also upgrade + {+odm-long+} to v5.2.1. {+odm-long+} v5.2.1 still supports v1.x + versions of the extension. From bd8705ac1e53a94ce54eebff1be2614b3aa54476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 9 Apr 2025 16:12:24 +0200 Subject: [PATCH 50/92] PHPORM-255 Enable disabling the `id` to `_id` field rename in embedded documents (#3332) --- src/Connection.php | 17 +++++++ src/Eloquent/Builder.php | 3 +- src/Query/Builder.php | 26 +++++++---- tests/Query/BuilderTest.php | 43 ++++++++++++++---- tests/QueryBuilderTest.php | 89 +++++++++++++++++++++++++++++++++++++ 5 files changed, 159 insertions(+), 19 deletions(-) diff --git a/src/Connection.php b/src/Connection.php index 4dd04120d..29b72ae44 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -53,6 +53,9 @@ class Connection extends BaseConnection private ?CommandSubscriber $commandSubscriber = null; + /** @var bool Whether to rename the rename "id" into "_id" for embedded documents. */ + private bool $renameEmbeddedIdField; + /** * Create a new database connection instance. */ @@ -80,6 +83,8 @@ public function __construct(array $config) $this->useDefaultSchemaGrammar(); $this->useDefaultQueryGrammar(); + + $this->renameEmbeddedIdField = $config['rename_embedded_id_field'] ?? true; } /** @@ -395,6 +400,18 @@ public function __call($method, $parameters) return $this->db->$method(...$parameters); } + /** Set whether to rename "id" field into "_id" for embedded documents. */ + public function setRenameEmbeddedIdField(bool $rename): void + { + $this->renameEmbeddedIdField = $rename; + } + + /** Get whether to rename "id" field into "_id" for embedded documents. */ + public function getRenameEmbeddedIdField(): bool + { + return $this->renameEmbeddedIdField; + } + /** * Return the server version of one of the MongoDB servers: primary for * replica sets and standalone, and the selected server for sharded clusters. diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index aabc526f7..f3ffd7012 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -18,6 +18,7 @@ use MongoDB\Model\BSONDocument; use function array_key_exists; +use function array_map; use function array_replace; use function collect; use function is_array; @@ -237,7 +238,7 @@ public function raw($value = null) // Convert MongoCursor results to a collection of models. if ($results instanceof CursorInterface) { $results->setTypeMap(['root' => 'array', 'document' => 'array', 'array' => 'array']); - $results = $this->query->aliasIdForResult(iterator_to_array($results)); + $results = array_map(fn ($document) => $this->query->aliasIdForResult($document), iterator_to_array($results)); return $this->model->hydrate($results); } diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 07a3483b0..7d0fdce74 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -29,6 +29,7 @@ use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\Cursor; use MongoDB\Driver\ReadPreference; +use MongoDB\Laravel\Connection; use Override; use RuntimeException; use stdClass; @@ -83,6 +84,7 @@ use function trait_exists; use function var_export; +/** @property Connection $connection */ class Builder extends BaseBuilder { private const REGEX_DELIMITERS = ['/', '#', '~']; @@ -1764,9 +1766,9 @@ public function orWhereIntegerNotInRaw($column, $values, $boolean = 'and') throw new BadMethodCallException('This method is not supported by MongoDB'); } - private function aliasIdForQuery(array $values): array + private function aliasIdForQuery(array $values, bool $root = true): array { - if (array_key_exists('id', $values)) { + if (array_key_exists('id', $values) && ($root || $this->connection->getRenameEmbeddedIdField())) { if (array_key_exists('_id', $values) && $values['id'] !== $values['_id']) { throw new InvalidArgumentException('Cannot have both "id" and "_id" fields.'); } @@ -1793,7 +1795,7 @@ private function aliasIdForQuery(array $values): array } // ".id" subfield are alias for "._id" - if (str_ends_with($key, '.id')) { + if (str_ends_with($key, '.id') && ($root || $this->connection->getRenameEmbeddedIdField())) { $newkey = substr($key, 0, -3) . '._id'; if (array_key_exists($newkey, $values) && $value !== $values[$newkey]) { throw new InvalidArgumentException(sprintf('Cannot have both "%s" and "%s" fields.', $key, $newkey)); @@ -1806,7 +1808,7 @@ private function aliasIdForQuery(array $values): array foreach ($values as &$value) { if (is_array($value)) { - $value = $this->aliasIdForQuery($value); + $value = $this->aliasIdForQuery($value, false); } elseif ($value instanceof DateTimeInterface) { $value = new UTCDateTime($value); } @@ -1824,10 +1826,13 @@ private function aliasIdForQuery(array $values): array * * @template T of array|object */ - public function aliasIdForResult(array|object $values): array|object + public function aliasIdForResult(array|object $values, bool $root = true): array|object { if (is_array($values)) { - if (array_key_exists('_id', $values) && ! array_key_exists('id', $values)) { + if ( + array_key_exists('_id', $values) && ! array_key_exists('id', $values) + && ($root || $this->connection->getRenameEmbeddedIdField()) + ) { $values['id'] = $values['_id']; unset($values['_id']); } @@ -1837,13 +1842,16 @@ public function aliasIdForResult(array|object $values): array|object $values[$key] = Date::instance($value->toDateTime()) ->setTimezone(new DateTimeZone(date_default_timezone_get())); } elseif (is_array($value) || is_object($value)) { - $values[$key] = $this->aliasIdForResult($value); + $values[$key] = $this->aliasIdForResult($value, false); } } } if ($values instanceof stdClass) { - if (property_exists($values, '_id') && ! property_exists($values, 'id')) { + if ( + property_exists($values, '_id') && ! property_exists($values, 'id') + && ($root || $this->connection->getRenameEmbeddedIdField()) + ) { $values->id = $values->_id; unset($values->_id); } @@ -1853,7 +1861,7 @@ public function aliasIdForResult(array|object $values): array|object $values->{$key} = Date::instance($value->toDateTime()) ->setTimezone(new DateTimeZone(date_default_timezone_get())); } elseif (is_array($value) || is_object($value)) { - $values->{$key} = $this->aliasIdForResult($value); + $values->{$key} = $this->aliasIdForResult($value, false); } } } diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 20b5a12fb..6e68d42c7 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -12,7 +12,6 @@ use Illuminate\Tests\Database\DatabaseQueryBuilderTest; use InvalidArgumentException; use LogicException; -use Mockery as m; use MongoDB\BSON\Regex; use MongoDB\BSON\UTCDateTime; use MongoDB\Driver\ReadPreference; @@ -39,7 +38,7 @@ public function testMql(array $expected, Closure $build, ?string $requiredMethod $this->markTestSkipped(sprintf('Method "%s::%s()" does not exist.', Builder::class, $requiredMethod)); } - $builder = $build(self::getBuilder()); + $builder = $build($this->getBuilder()); $this->assertInstanceOf(Builder::class, $builder); $mql = $builder->toMql(); @@ -1447,7 +1446,7 @@ function (Builder $elemMatchQuery): void { #[DataProvider('provideExceptions')] public function testException($class, $message, Closure $build): void { - $builder = self::getBuilder(); + $builder = $this->getBuilder(); $this->expectException($class); $this->expectExceptionMessage($message); @@ -1545,7 +1544,7 @@ public static function provideExceptions(): iterable #[DataProvider('getEloquentMethodsNotSupported')] public function testEloquentMethodsNotSupported(Closure $callback) { - $builder = self::getBuilder(); + $builder = $this->getBuilder(); $this->expectException(BadMethodCallException::class); $this->expectExceptionMessage('This method is not supported by MongoDB'); @@ -1600,12 +1599,38 @@ public static function getEloquentMethodsNotSupported() yield 'orWhereIntegerNotInRaw' => [fn (Builder $builder) => $builder->orWhereIntegerNotInRaw('id', ['1a', 2])]; } - private static function getBuilder(): Builder + public function testRenameEmbeddedIdFieldCanBeDisabled() { - $connection = m::mock(Connection::class); - $processor = m::mock(Processor::class); - $connection->shouldReceive('getSession')->andReturn(null); - $connection->shouldReceive('getQueryGrammar')->andReturn(new Grammar($connection)); + $builder = $this->getBuilder(false); + $this->assertFalse($builder->getConnection()->getRenameEmbeddedIdField()); + + $mql = $builder + ->where('id', '=', 10) + ->where('nested.id', '=', 20) + ->where('embed', '=', ['id' => 30]) + ->toMql(); + + $this->assertEquals([ + 'find' => [ + [ + '$and' => [ + ['_id' => 10], + ['nested.id' => 20], + ['embed' => ['id' => 30]], + ], + ], + ['typeMap' => ['root' => 'object', 'document' => 'array']], + ], + ], $mql); + } + + private function getBuilder(bool $renameEmbeddedIdField = true): Builder + { + $connection = $this->createStub(Connection::class); + $connection->method('getRenameEmbeddedIdField')->willReturn($renameEmbeddedIdField); + $processor = $this->createStub(Processor::class); + $connection->method('getSession')->willReturn(null); + $connection->method('getQueryGrammar')->willReturn(new Grammar($connection)); return new Builder($connection, null, $processor); } diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 46beebab1..1233cda75 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -20,10 +20,12 @@ use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; use MongoDB\Driver\Cursor; +use MongoDB\Driver\CursorInterface; use MongoDB\Driver\Monitoring\CommandFailedEvent; use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber; use MongoDB\Driver\Monitoring\CommandSucceededEvent; +use MongoDB\Laravel\Connection; use MongoDB\Laravel\Query\Builder; use MongoDB\Laravel\Tests\Models\Item; use MongoDB\Laravel\Tests\Models\User; @@ -336,6 +338,93 @@ public function testRaw() $this->assertEquals('Jane Doe', $results[0]->name); } + public function testRawResultRenameId() + { + $connection = DB::connection('mongodb'); + self::assertInstanceOf(Connection::class, $connection); + + $date = Carbon::createFromDate(1986, 12, 31)->setTime(12, 0, 0); + User::insert([ + ['id' => 1, 'name' => 'Jane Doe', 'address' => ['id' => 11, 'city' => 'Ghent'], 'birthday' => $date], + ['id' => 2, 'name' => 'John Doe', 'address' => ['id' => 12, 'city' => 'Brussels'], 'birthday' => $date], + ]); + + // Using raw database query, result is not altered + $results = $connection->table('users')->raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(CursorInterface::class, $results); + $results = $results->toArray(); + self::assertCount(2, $results); + + self::assertObjectHasProperty('_id', $results[0]); + self::assertObjectNotHasProperty('id', $results[0]); + self::assertSame(1, $results[0]->_id); + + self::assertObjectHasProperty('_id', $results[0]->address); + self::assertObjectNotHasProperty('id', $results[0]->address); + self::assertSame(11, $results[0]->address->_id); + + self::assertInstanceOf(UTCDateTime::class, $results[0]->birthday); + + // Using Eloquent query, result is transformed + self::assertTrue($connection->getRenameEmbeddedIdField()); + $results = User::raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(2, $results); + + $attributes = $results->first()->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('id', $attributes['address']); + self::assertArrayNotHasKey('_id', $attributes['address']); + self::assertSame(11, $attributes['address']['id']); + + self::assertEquals($date, $attributes['birthday']); + + // Single result + $result = User::raw(fn (Collection $collection) => $collection->findOne([], ['typeMap' => ['root' => 'object', 'document' => 'array']])); + self::assertInstanceOf(User::class, $result); + + $attributes = $result->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('id', $attributes['address']); + self::assertArrayNotHasKey('_id', $attributes['address']); + self::assertSame(11, $attributes['address']['id']); + + // Change the renameEmbeddedIdField option + $connection->setRenameEmbeddedIdField(false); + + $results = User::raw(fn (Collection $collection) => $collection->find([])); + self::assertInstanceOf(LaravelCollection::class, $results); + self::assertCount(2, $results); + + $attributes = $results->first()->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('_id', $attributes['address']); + self::assertArrayNotHasKey('id', $attributes['address']); + self::assertSame(11, $attributes['address']['_id']); + + // Single result + $result = User::raw(fn (Collection $collection) => $collection->findOne([])); + self::assertInstanceOf(User::class, $result); + + $attributes = $result->getAttributes(); + self::assertArrayHasKey('id', $attributes); + self::assertArrayNotHasKey('_id', $attributes); + self::assertSame(1, $attributes['id']); + + self::assertArrayHasKey('_id', $attributes['address']); + self::assertArrayNotHasKey('id', $attributes['address']); + self::assertSame(11, $attributes['address']['_id']); + } + public function testPush() { $id = DB::table('users')->insertGetId([ From 1be4cef77d8e59081273e9eaa4cf10128ca062b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 10 Apr 2025 14:17:32 +0200 Subject: [PATCH 51/92] Use stable version of mongodb extension v2 (#3343) --- .github/workflows/build-ci-atlas.yml | 2 +- .github/workflows/build-ci.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index 339f8fc38..c699d2023 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -6,7 +6,7 @@ on: env: MONGODB_EXT_V1: mongodb-1.21.0 - MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x + MONGODB_EXT_V2: stable jobs: build: diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index bc799c70e..6ff13a7f0 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -6,7 +6,7 @@ on: env: MONGODB_EXT_V1: mongodb-1.21.0 - MONGODB_EXT_V2: mongodb-mongodb/mongo-php-driver@v2.x + MONGODB_EXT_V2: stable jobs: build: From 8b5ab5989aa7d8c2c2fdf21b4eb78f7bd191f01f Mon Sep 17 00:00:00 2001 From: Sainesh Mamgain Date: Thu, 10 Apr 2025 17:54:34 +0530 Subject: [PATCH 52/92] Fixes: getting immutable_datetime property fails if `Date::use(CarbonImmutable::class)` is set (#3342) --- src/Eloquent/DocumentModel.php | 3 +-- tests/DateTimeImmutableTest.php | 43 +++++++++++++++++++++++++++++++++ tests/Models/Anniversary.php | 30 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/DateTimeImmutableTest.php create mode 100644 tests/Models/Anniversary.php diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php index d39a12401..965b1a444 100644 --- a/src/Eloquent/DocumentModel.php +++ b/src/Eloquent/DocumentModel.php @@ -5,7 +5,6 @@ namespace MongoDB\Laravel\Eloquent; use BackedEnum; -use Carbon\Carbon; use Carbon\CarbonInterface; use DateTimeInterface; use DateTimeZone; @@ -128,7 +127,7 @@ public function fromDateTime($value): UTCDateTime * * @param mixed $value */ - protected function asDateTime($value): Carbon + protected function asDateTime($value): DateTimeInterface { // Convert UTCDateTime instances to Carbon. if ($value instanceof UTCDateTime) { diff --git a/tests/DateTimeImmutableTest.php b/tests/DateTimeImmutableTest.php new file mode 100644 index 000000000..a4dffb168 --- /dev/null +++ b/tests/DateTimeImmutableTest.php @@ -0,0 +1,43 @@ + 'John', + 'anniversary' => new CarbonImmutable('2020-01-01 00:00:00'), + ]); + + $anniversary = Anniversary::sole(); + assert($anniversary instanceof Anniversary); + self::assertInstanceOf(CarbonImmutable::class, $anniversary->anniversary); + } +} diff --git a/tests/Models/Anniversary.php b/tests/Models/Anniversary.php new file mode 100644 index 000000000..fb78c9a55 --- /dev/null +++ b/tests/Models/Anniversary.php @@ -0,0 +1,30 @@ + 'immutable_datetime']; +} From c3bab3cb5d817406ac8ac78b13602f569ae30fa7 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Thu, 10 Apr 2025 09:54:40 -0400 Subject: [PATCH 53/92] DOCSP-49236: v5.3 release (#3344) --- docs/compatibility.txt | 2 +- docs/includes/framework-compatibility-laravel.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/compatibility.txt b/docs/compatibility.txt index 5580e6856..5197deab7 100644 --- a/docs/compatibility.txt +++ b/docs/compatibility.txt @@ -15,7 +15,7 @@ Compatibility :class: singlecol .. meta:: - :keywords: laravel 9, laravel 10, laravel 11, laravel 12, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2 + :keywords: laravel 9, laravel 10, laravel 11, laravel 12, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2, 5.3 Laravel Compatibility --------------------- diff --git a/docs/includes/framework-compatibility-laravel.rst b/docs/includes/framework-compatibility-laravel.rst index c642a6763..e8d59469d 100644 --- a/docs/includes/framework-compatibility-laravel.rst +++ b/docs/includes/framework-compatibility-laravel.rst @@ -8,7 +8,7 @@ - Laravel 10.x - Laravel 9.x - * - 5.2 + * - 5.2 to 5.3 - ✓ - ✓ - ✓ From 9d540b63d2e0dac5e6fefca8f2c3c5a474691400 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Fri, 11 Apr 2025 10:37:13 -0400 Subject: [PATCH 54/92] DOCSP-49217: disable id alias conversion in embedded docs (#3346) * DOCSP-49217: disable id alias conversion in embedded docs * add cross link * typo fix * JT tech review comment * JT tech review comment 2 --- .../connection/connection-options.txt | 45 +++++++++++++++++++ docs/query-builder.txt | 5 ++- docs/upgrade.txt | 5 +++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/fundamentals/connection/connection-options.txt b/docs/fundamentals/connection/connection-options.txt index 03e98ed06..1a2cdb085 100644 --- a/docs/fundamentals/connection/connection-options.txt +++ b/docs/fundamentals/connection/connection-options.txt @@ -32,6 +32,7 @@ This guide covers the following topics: - :ref:`laravel-connection-auth-options` - :ref:`laravel-driver-options` +- :ref:`laravel-disable-id-alias` .. _laravel-connection-auth-options: @@ -349,3 +350,47 @@ item, as shown in the following example: See the `$driverOptions: array `__ section of the {+php-library+} documentation for a list of driver options. + +.. _laravel-disable-id-alias: + +Disable Use of id Field Name Conversion +--------------------------------------- + +Starting in {+odm-long+} v5.0, ``id`` is an alias for the ``_id`` field +in MongoDB documents, and the library automatically converts ``id`` +to ``_id`` for both top level and embedded fields when querying and +storing data. + +When using {+odm-long+} v5.3 or later, you can disable the automatic +conversion of ``id`` to ``_id`` for embedded documents. To do so, +perform either of the following actions: + +1. Set the ``rename_embedded_id_field`` setting to ``false`` in your + ``config/database.php`` file: + + .. code-block:: php + :emphasize-lines: 6 + + 'connections' => [ + 'mongodb' => [ + 'dsn' => 'mongodb+srv://mongodb0.example.com/', + 'driver' => 'mongodb', + 'database' => 'sample_mflix', + 'rename_embedded_id_field' => false, + // Other settings + ], + ], + +#. Pass ``false`` to the ``setRenameEmbeddedIdField()`` method in your + application: + + .. code-block:: php + + DB::connection('mongodb')->setRenameEmbeddedIdField(false); + +.. important:: + + We recommend using this option only to provide backwards + compatibility with existing document schemas. In new projects, + avoid using ``id`` for field names in embedded documents so that + you can maintain {+odm-long+}'s default behavior. diff --git a/docs/query-builder.txt b/docs/query-builder.txt index 68a9b2102..a73d5e791 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -195,7 +195,7 @@ the value of the ``title`` field is ``"Back to the Future"``: :start-after: begin query orWhere :end-before: end query orWhere -.. note:: +.. note:: id Alias You can use the ``id`` alias in your queries to represent the ``_id`` field in MongoDB documents, as shown in the preceding @@ -208,6 +208,9 @@ the value of the ``title`` field is ``"Back to the Future"``: Because of this behavior, you cannot have two separate ``id`` and ``_id`` fields in your documents. + To learn how to disable this behavior for embedded documents, see the + :ref:`laravel-disable-id-alias` section of the Connection Options guide. + .. _laravel-query-builder-logical-and: Logical AND Example diff --git a/docs/upgrade.txt b/docs/upgrade.txt index a87d314a2..3c6ec40a4 100644 --- a/docs/upgrade.txt +++ b/docs/upgrade.txt @@ -127,6 +127,11 @@ This library version introduces the following breaking changes: method results before hydrating a Model instance. When passing a complex query filter, use the ``DB::where()`` method instead of ``Model::raw()``. + Starting in v5.3, you can disable automatic conversion of ``id`` to + ``_id`` for embedded documents. To learn more, see the + :ref:`laravel-disable-id-alias` section of the Connection Options + guide. + - Removes support for the ``$collection`` property. The following code shows how to assign a MongoDB collection to a variable in your ``User`` class in older versions compared to v5.0: From af499af30640896d73eef6648f5c6a6634530915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 15 Apr 2025 10:48:13 +0200 Subject: [PATCH 55/92] PHPORM-320 Fix aliasing .id field path to ._id (#3353) --- .github/workflows/build-ci.yml | 12 +++-- src/Query/Builder.php | 2 +- tests/Query/BuilderTest.php | 89 ++++++++++++++++++++++++++++------ 3 files changed, 82 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 6ff13a7f0..6fec7bb28 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -34,20 +34,24 @@ jobs: - "11.*" - "12.*" driver: - - 1 + - 2 include: - php: "8.1" laravel: "10.*" mongodb: "5.0" mode: "low-deps" os: "ubuntu-latest" - driver: 1.x - driver_version: "1.21.0" + driver: 1 + - php: "8.3" + laravel: "11.*" + mongodb: "8.0" + os: "ubuntu-latest" + driver: 1 - php: "8.4" laravel: "12.*" mongodb: "8.0" os: "ubuntu-latest" - driver: 2 + driver: 1 exclude: - php: "8.1" laravel: "11.*" diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 7d0fdce74..6823998fd 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -1795,7 +1795,7 @@ private function aliasIdForQuery(array $values, bool $root = true): array } // ".id" subfield are alias for "._id" - if (str_ends_with($key, '.id') && ($root || $this->connection->getRenameEmbeddedIdField())) { + if (str_ends_with($key, '.id') && $this->connection->getRenameEmbeddedIdField()) { $newkey = substr($key, 0, -3) . '._id'; if (array_key_exists($newkey, $values) && $value !== $values[$newkey]) { throw new InvalidArgumentException(sprintf('Cannot have both "%s" and "%s" fields.', $key, $newkey)); diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 6e68d42c7..7595976f3 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -1599,29 +1599,86 @@ public static function getEloquentMethodsNotSupported() yield 'orWhereIntegerNotInRaw' => [fn (Builder $builder) => $builder->orWhereIntegerNotInRaw('id', ['1a', 2])]; } - public function testRenameEmbeddedIdFieldCanBeDisabled() + #[DataProvider('provideDisableRenameEmbeddedIdField')] + public function testDisableRenameEmbeddedIdField(array $expected, Closure $build) { $builder = $this->getBuilder(false); $this->assertFalse($builder->getConnection()->getRenameEmbeddedIdField()); - $mql = $builder - ->where('id', '=', 10) - ->where('nested.id', '=', 20) - ->where('embed', '=', ['id' => 30]) - ->toMql(); - - $this->assertEquals([ - 'find' => [ - [ - '$and' => [ - ['_id' => 10], - ['nested.id' => 20], - ['embed' => ['id' => 30]], + $mql = $build($builder)->toMql(); + + $this->assertEquals($expected, $mql); + } + + public static function provideDisableRenameEmbeddedIdField() + { + yield 'rename embedded id field' => [ + [ + 'find' => [ + [ + '$and' => [ + ['_id' => 10], + ['nested.id' => 20], + ['embed' => ['id' => 30]], + ], + ], + ['typeMap' => ['root' => 'object', 'document' => 'array']], + ], + ], + fn (Builder $builder) => $builder->where('id', '=', 10) + ->where('nested.id', '=', 20) + ->where('embed', '=', ['id' => 30]), + ]; + + yield 'rename root id' => [ + ['find' => [['_id' => 10], ['typeMap' => ['root' => 'object', 'document' => 'array']]]], + fn (Builder $builder) => $builder->where('id', '=', 10), + ]; + + yield 'nested id not renamed' => [ + ['find' => [['nested.id' => 20], ['typeMap' => ['root' => 'object', 'document' => 'array']]]], + fn (Builder $builder) => $builder->where('nested.id', '=', 20), + ]; + + yield 'embed id not renamed' => [ + ['find' => [['embed' => ['id' => 30]], ['typeMap' => ['root' => 'object', 'document' => 'array']]]], + fn (Builder $builder) => $builder->where('embed', '=', ['id' => 30]), + ]; + + yield 'nested $and in $or' => [ + [ + 'find' => [ + [ + '$or' => [ + [ + '$and' => [ + ['_id' => 10], + ['nested.id' => 20], + ['embed' => ['id' => 30]], + ], + ], + [ + '$and' => [ + ['_id' => 40], + ['nested.id' => 50], + ['embed' => ['id' => 60]], + ], + ], + ], ], + ['typeMap' => ['root' => 'object', 'document' => 'array']], ], - ['typeMap' => ['root' => 'object', 'document' => 'array']], ], - ], $mql); + fn (Builder $builder) => $builder->orWhere(function (Builder $builder) { + return $builder->where('id', '=', 10) + ->where('nested.id', '=', 20) + ->where('embed', '=', ['id' => 30]); + })->orWhere(function (Builder $builder) { + return $builder->where('id', '=', 40) + ->where('nested.id', '=', 50) + ->where('embed', '=', ['id' => 60]); + }), + ]; } private function getBuilder(bool $renameEmbeddedIdField = true): Builder From 94d4fb28b5c0a93480f3350d49c9f6a3fef76eae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 15 Apr 2025 10:52:46 +0200 Subject: [PATCH 56/92] PHPORM-310 Create dedicated session handler (#3348) --- phpstan.neon.dist | 3 + src/MongoDBServiceProvider.php | 12 ++- src/Session/MongoDbSessionHandler.php | 115 ++++++++++++++++++++++++++ tests/Query/BuilderTest.php | 2 +- tests/SessionTest.php | 30 +++++-- 5 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 src/Session/MongoDbSessionHandler.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 926d9e726..03228b162 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,9 @@ parameters: editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%' + universalObjectCratesClasses: + - MongoDB\BSON\Document + ignoreErrors: - '#Unsafe usage of new static#' - '#Call to an undefined method [a-zA-Z0-9\\_\<\>\(\)]+::[a-zA-Z]+\(\)#' diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index 349abadc7..a51a63919 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -23,8 +23,8 @@ use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Queue\MongoConnector; use MongoDB\Laravel\Scout\ScoutEngine; +use MongoDB\Laravel\Session\MongoDbSessionHandler; use RuntimeException; -use Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler; use function assert; use function class_exists; @@ -67,12 +67,10 @@ public function register() assert($connection instanceof Connection, new InvalidArgumentException(sprintf('The database connection "%s" used for the session does not use the "mongodb" driver.', $connectionName))); return new MongoDbSessionHandler( - $connection->getClient(), - $app->config->get('session.options', []) + [ - 'database' => $connection->getDatabaseName(), - 'collection' => $app->config->get('session.table') ?: 'sessions', - 'ttl' => $app->config->get('session.lifetime'), - ], + $connection, + $app->config->get('session.table', 'sessions'), + $app->config->get('session.lifetime'), + $app, ); }); }); diff --git a/src/Session/MongoDbSessionHandler.php b/src/Session/MongoDbSessionHandler.php new file mode 100644 index 000000000..517d422a6 --- /dev/null +++ b/src/Session/MongoDbSessionHandler.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace MongoDB\Laravel\Session; + +use Illuminate\Session\DatabaseSessionHandler; +use MongoDB\BSON\Binary; +use MongoDB\BSON\Document; +use MongoDB\BSON\UTCDateTime; +use MongoDB\Collection; + +use function assert; +use function tap; +use function time; + +/** + * Session handler using the MongoDB driver extension. + */ +final class MongoDbSessionHandler extends DatabaseSessionHandler +{ + private Collection $collection; + + public function close(): bool + { + return true; + } + + public function gc($lifetime): int + { + $result = $this->getCollection()->deleteMany(['last_activity' => ['$lt' => $this->getUTCDateTime(-$lifetime)]]); + + return $result->getDeletedCount() ?? 0; + } + + public function destroy($sessionId): bool + { + $this->getCollection()->deleteOne(['_id' => (string) $sessionId]); + + return true; + } + + public function read($sessionId): string|false + { + $result = $this->getCollection()->findOne( + ['_id' => (string) $sessionId, 'expires_at' => ['$gte' => $this->getUTCDateTime()]], + [ + 'projection' => ['_id' => false, 'payload' => true], + 'typeMap' => ['root' => 'bson'], + ], + ); + assert($result instanceof Document); + + return $result ? (string) $result->payload : false; + } + + public function write($sessionId, $data): bool + { + $payload = $this->getDefaultPayload($data); + + $this->getCollection()->replaceOne( + ['_id' => (string) $sessionId], + $payload, + ['upsert' => true], + ); + + return true; + } + + /** Creates a TTL index that automatically deletes expired objects. */ + public function createTTLIndex(): void + { + $this->collection->createIndex( + // UTCDateTime field that holds the expiration date + ['expires_at' => 1], + // Delay to remove items after expiration + ['expireAfterSeconds' => 0], + ); + } + + protected function getDefaultPayload($data): array + { + $payload = [ + 'payload' => new Binary($data), + 'last_activity' => $this->getUTCDateTime(), + 'expires_at' => $this->getUTCDateTime($this->minutes * 60), + ]; + + if (! $this->container) { + return $payload; + } + + return tap($payload, function (&$payload) { + $this->addUserInformation($payload) + ->addRequestInformation($payload); + }); + } + + private function getCollection(): Collection + { + return $this->collection ??= $this->connection->getCollection($this->table); + } + + private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime + { + return new UTCDateTime((time() + $additionalSeconds) * 1000); + } +} diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php index 7595976f3..a7bcc64cb 100644 --- a/tests/Query/BuilderTest.php +++ b/tests/Query/BuilderTest.php @@ -868,7 +868,7 @@ function (Builder $builder) { [], ], ], - fn (Builder $builder) => $builder->whereDate('created_at', '=', new DateTimeImmutable('2018-09-30 15:00:00 +02:00')), + fn (Builder $builder) => $builder->whereDate('created_at', '=', new DateTimeImmutable('2018-09-30 15:00:00 +00:00')), ]; yield 'where date !=' => [ diff --git a/tests/SessionTest.php b/tests/SessionTest.php index ee086f5b8..f334dc746 100644 --- a/tests/SessionTest.php +++ b/tests/SessionTest.php @@ -5,7 +5,9 @@ use Illuminate\Session\DatabaseSessionHandler; use Illuminate\Session\SessionManager; use Illuminate\Support\Facades\DB; -use Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler; +use MongoDB\Laravel\Session\MongoDbSessionHandler; +use PHPUnit\Framework\Attributes\TestWith; +use SessionHandlerInterface; class SessionTest extends TestCase { @@ -16,21 +18,31 @@ protected function tearDown(): void parent::tearDown(); } - public function testDatabaseSessionHandlerCompatibility() + /** @param class-string $class */ + #[TestWith([DatabaseSessionHandler::class])] + #[TestWith([MongoDbSessionHandler::class])] + public function testSessionHandlerFunctionality(string $class) { - $sessionId = '123'; - - $handler = new DatabaseSessionHandler( + $handler = new $class( $this->app['db']->connection('mongodb'), 'sessions', 10, ); + $sessionId = '123'; + $handler->write($sessionId, 'foo'); $this->assertEquals('foo', $handler->read($sessionId)); $handler->write($sessionId, 'bar'); $this->assertEquals('bar', $handler->read($sessionId)); + + $handler->destroy($sessionId); + $this->assertEmpty($handler->read($sessionId)); + + $handler->write($sessionId, 'bar'); + $handler->gc(-1); + $this->assertEmpty($handler->read($sessionId)); } public function testDatabaseSessionHandlerRegistration() @@ -70,5 +82,13 @@ private function assertSessionCanStoreInMongoDB(SessionManager $session): void self::assertIsObject($data); self::assertSame($session->getId(), $data->_id); + + $session->remove('foo'); + $data = DB::connection('mongodb') + ->getCollection('sessions') + ->findOne(['_id' => $session->getId()]); + + self::assertIsObject($data); + self::assertSame($session->getId(), $data->_id); } } From fa6c0c28a090cff29a07753deb01a9f6789dfb4c Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Tue, 15 Apr 2025 13:02:15 -0400 Subject: [PATCH 57/92] DOCSP-49293: feature compatibility joins (#3355) --- docs/feature-compatibility.txt | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/feature-compatibility.txt b/docs/feature-compatibility.txt index c36d30812..707f12c70 100644 --- a/docs/feature-compatibility.txt +++ b/docs/feature-compatibility.txt @@ -27,6 +27,12 @@ availability in the {+odm-short+}. The following sections contain tables that describe whether individual features are available in the {+odm-short+}. +.. tip:: SQL Concepts in MongoDB + + To learn about how MongoDB represents SQL terminology, concepts, and + functionality, see the :manual:`SQL to MongoDB Mapping Chart + `. + Database Features ----------------- @@ -117,10 +123,18 @@ The following Eloquent methods are not supported in the {+odm-short+}: - *Unsupported* * - Joins - - *Unsupported* + - Use the ``$lookup`` aggregation stage. To learn more, see the + :manual:`$lookup reference + ` in the + {+server-docs-name+}. {+odm-long+} provides the + :ref:`laravel-aggregation-builder` to perform aggregations. * - Unions - - *Unsupported* + - Use the ``$unionWith`` aggregation stage. To learn more, see the + :manual:`$unionWith reference + ` in the + {+server-docs-name+}. {+odm-long+} provides the + :ref:`laravel-aggregation-builder` to perform aggregations. * - `Basic Where Clauses `__ - ✓ @@ -144,7 +158,11 @@ The following Eloquent methods are not supported in the {+odm-short+}: - *Unsupported* * - Grouping - - Partially supported. Use :ref:`Aggregations `. + - Use the ``$group`` aggregation stage. To learn more, see the + :manual:`$group reference + ` in the + {+server-docs-name+}. {+odm-long+} provides the + :ref:`laravel-aggregation-builder` to perform aggregations. * - Limit and Offset - ✓ From c49a73fc9b316b3d1dca016c3095a3e04ce5ddb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 17 Apr 2025 10:30:20 +0200 Subject: [PATCH 58/92] Fix namespace of test classes (#3361) Spotted with composer dump-autoload --strict-psr --optimize --- tests/Casts/EncryptionTest.php | 2 +- tests/DateTimeImmutableTest.php | 3 +-- tests/PropertyTest.php | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/Casts/EncryptionTest.php b/tests/Casts/EncryptionTest.php index 0c40254f1..acb7520cc 100644 --- a/tests/Casts/EncryptionTest.php +++ b/tests/Casts/EncryptionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Casts; +namespace MongoDB\Laravel\Tests\Casts; use Illuminate\Database\Eloquent\Casts\Json; use Illuminate\Encryption\Encrypter; diff --git a/tests/DateTimeImmutableTest.php b/tests/DateTimeImmutableTest.php index a4dffb168..7fd6fa2b1 100644 --- a/tests/DateTimeImmutableTest.php +++ b/tests/DateTimeImmutableTest.php @@ -2,12 +2,11 @@ declare(strict_types=1); -namespace MongoDB\Laravel\Tests\Eloquent; +namespace MongoDB\Laravel\Tests; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use MongoDB\Laravel\Tests\Models\Anniversary; -use MongoDB\Laravel\Tests\TestCase; use function assert; diff --git a/tests/PropertyTest.php b/tests/PropertyTest.php index c71fd68c9..67153006b 100644 --- a/tests/PropertyTest.php +++ b/tests/PropertyTest.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace MongoDB\Laravel\Tests\Eloquent; +namespace MongoDB\Laravel\Tests; use MongoDB\Laravel\Tests\Models\HiddenAnimal; -use MongoDB\Laravel\Tests\TestCase; use function assert; From bcf97d233b7394a7eee09285a96ab54665cdf5bc Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 18 Apr 2025 17:03:22 +0800 Subject: [PATCH 59/92] PHPORM-325 Add `getViews` and categorize table types (#3327) --- src/Schema/Builder.php | 75 ++++++++++++++++++++++++++++++++++++------ tests/SchemaTest.php | 44 ++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index ef450745a..746fda99e 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -21,6 +21,7 @@ use function assert; use function count; use function current; +use function explode; use function implode; use function in_array; use function is_array; @@ -28,10 +29,14 @@ use function iterator_to_array; use function sort; use function sprintf; +use function str_contains; use function str_ends_with; use function substr; +use function trigger_error; use function usort; +use const E_USER_DEPRECATED; + /** @property Connection $connection */ class Builder extends \Illuminate\Database\Schema\Builder { @@ -47,7 +52,7 @@ public function hasColumn($table, $column): bool } /** - * Check if columns exists in the collection schema. + * Check if columns exist in the collection schema. * * @param string $table * @param string[] $columns @@ -134,12 +139,18 @@ public function drop($table) $blueprint->drop(); } - /** @inheritdoc */ + /** + * @inheritdoc + * + * Drops the entire database instead of deleting each collection individually. + * + * In MongoDB, dropping the whole database is much faster than dropping collections + * one by one. The database will be automatically recreated when a new connection + * writes to it. + */ public function dropAllTables() { - foreach ($this->getAllCollections() as $collection) { - $this->drop($collection); - } + $this->connection->getDatabase()->drop(); } /** @param string|null $schema Database name */ @@ -148,7 +159,14 @@ public function getTables($schema = null) $db = $this->connection->getDatabase($schema); $collections = []; - foreach ($db->listCollectionNames() as $collectionName) { + foreach ($db->listCollections() as $collectionInfo) { + $collectionName = $collectionInfo->getName(); + + // Skip views, which don't support aggregate + if ($collectionInfo->getType() === 'view') { + continue; + } + $stats = $db->selectCollection($collectionName)->aggregate([ ['$collStats' => ['storageStats' => ['scale' => 1]]], ['$project' => ['storageStats.totalSize' => 1]], @@ -165,9 +183,37 @@ public function getTables($schema = null) ]; } - usort($collections, function ($a, $b) { - return $a['name'] <=> $b['name']; - }); + usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); + + return $collections; + } + + /** @param string|null $schema Database name */ + public function getViews($schema = null) + { + $db = $this->connection->getDatabase($schema); + $collections = []; + + foreach ($db->listCollections() as $collectionInfo) { + $collectionName = $collectionInfo->getName(); + + // Skip normal type collection + if ($collectionInfo->getType() !== 'view') { + continue; + } + + $collections[] = [ + 'name' => $collectionName, + 'schema' => $db->getDatabaseName(), + 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, + 'size' => null, + 'comment' => null, + 'collation' => null, + 'engine' => null, + ]; + } + + usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); return $collections; } @@ -203,7 +249,12 @@ public function getTableListing($schema = null, $schemaQualified = false) public function getColumns($table) { - $stats = $this->connection->getDatabase()->selectCollection($table)->aggregate([ + $db = null; + if (str_contains($table, '.')) { + [$db, $table] = explode('.', $table, 2); + } + + $stats = $this->connection->getDatabase($db)->selectCollection($table)->aggregate([ // Sample 1,000 documents to get a representative sample of the collection ['$sample' => ['size' => 1_000]], // Convert each document to an array of fields @@ -340,10 +391,14 @@ public function getCollection($name) /** * Get all of the collections names for the database. * + * @deprecated + * * @return array */ protected function getAllCollections() { + trigger_error(sprintf('Since mongodb/laravel-mongodb:5.4, Method "%s()" is deprecated without replacement.', __METHOD__), E_USER_DEPRECATED); + $collections = []; foreach ($this->connection->getDatabase()->listCollections() as $collection) { $collections[] = $collection->getName(); diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 8e91a2f66..3257a671e 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -22,10 +22,11 @@ class SchemaTest extends TestCase { public function tearDown(): void { - $database = $this->getConnection('mongodb')->getMongoDB(); + $database = $this->getConnection('mongodb')->getDatabase(); assert($database instanceof Database); $database->dropCollection('newcollection'); $database->dropCollection('newcollection_two'); + $database->dropCollection('test_view'); parent::tearDown(); } @@ -395,6 +396,7 @@ public function testGetTables() { DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + DB::connection('mongodb')->getDatabase()->createCollection('test_view', ['viewOn' => 'newcollection']); $dbName = DB::connection('mongodb')->getDatabaseName(); $tables = Schema::getTables(); @@ -406,6 +408,7 @@ public function testGetTables() $this->assertArrayHasKey('size', $table); $this->assertArrayHasKey('schema', $table); $this->assertArrayHasKey('schema_qualified_name', $table); + $this->assertNotEquals('test_view', $table['name'], 'Standard views should not be included in the result of getTables.'); if ($table['name'] === 'newcollection') { $this->assertEquals(8192, $table['size']); @@ -420,6 +423,40 @@ public function testGetTables() } } + public function testGetViews() + { + DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); + DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + $dbName = DB::connection('mongodb')->getDatabaseName(); + + DB::connection('mongodb')->getDatabase()->createCollection('test_view', ['viewOn' => 'newcollection']); + + $tables = Schema::getViews(); + + $this->assertIsArray($tables); + $this->assertGreaterThanOrEqual(1, count($tables)); + $found = false; + foreach ($tables as $table) { + $this->assertArrayHasKey('name', $table); + $this->assertArrayHasKey('size', $table); + $this->assertArrayHasKey('schema', $table); + $this->assertArrayHasKey('schema_qualified_name', $table); + + // Ensure "normal collections" are not in the views list + $this->assertNotEquals('newcollection', $table['name'], 'Normal collections should not be included in the result of getViews.'); + + if ($table['name'] === 'test_view') { + $this->assertEquals($dbName, $table['schema']); + $this->assertEquals($dbName . '.test_view', $table['schema_qualified_name']); + $found = true; + } + } + + if (! $found) { + $this->fail('Collection "test_view" not found'); + } + } + public function testGetTableListing() { DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); @@ -489,6 +526,11 @@ public function testGetColumns() // Non-existent collection $columns = Schema::getColumns('missing'); $this->assertSame([], $columns); + + // Qualified table name + $columns = Schema::getColumns(DB::getDatabaseName() . '.newcollection'); + $this->assertIsArray($columns); + $this->assertCount(5, $columns); } /** @see AtlasSearchTest::testGetIndexes() */ From db71580531db6a97b6eac9705f70f7eea0a8b1ed Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:34:59 -0400 Subject: [PATCH 60/92] DOCSP-49324: dedicated session handler (#3364) --- docs/sessions.txt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/sessions.txt b/docs/sessions.txt index e8ed10e7a..0f334b873 100644 --- a/docs/sessions.txt +++ b/docs/sessions.txt @@ -24,9 +24,18 @@ In this guide, you can learn how to set up HTTP sessions by using {+odm-long+}. Sessions allow your application to store information about a user across multiple server requests. Your application stores this information in a specified location that it can access in future -requests that the user makes. The session driver in {+odm-long+} uses -the ``MongoDbSessionHandler`` class from the Symfony framework to store -session information. +requests that the user makes. + +.. note:: Session Handler Implementation + + The v5.4 {+odm-long+} introduces the dedicated + ``MongoDbSessionHandler`` class that extends the Laravel + ``DatabaseSessionHandler`` class to store session information. + The ``mongodb`` session driver saves user IDs, IP addresses, and user + agents if present. + + In v5.3 and earlier, the session driver uses the + ``MongoDbSessionHandler`` class from the Symfony framework. To learn more about support for sessions, see `HTTP Session `__ in the From 012503193e592287a469b2d7df49c435e607af27 Mon Sep 17 00:00:00 2001 From: Toni Vega <1808267+tonivega@users.noreply.github.com> Date: Sun, 20 Apr 2025 10:46:04 +0200 Subject: [PATCH 61/92] Replaced a too strict assertion in DatabaseSessionHandler that prevented to use the sessions at all with a more simple sanity check (#3366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set zend.assertions=1 --------- Co-authored-by: Toni Vega Co-authored-by: Jérôme Tamarelle --- .github/workflows/build-ci-atlas.yml | 2 +- .github/workflows/build-ci.yml | 2 +- phpunit.xml.dist | 2 ++ src/Session/MongoDbSessionHandler.php | 8 +++++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index c699d2023..46f30150d 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -91,4 +91,4 @@ jobs: - name: "Run tests" run: | export MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" - ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 6fec7bb28..bbc8b53d1 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -118,4 +118,4 @@ jobs: - name: "Run tests" run: | export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" - ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7044f9069..d7f066483 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -23,6 +23,8 @@ + + ['root' => 'bson'], ], ); - assert($result instanceof Document); - return $result ? (string) $result->payload : false; + if ($result instanceof Document) { + return (string) $result->payload; + } + + return false; } public function write($sessionId, $data): bool From 5a789519793fdaa7041e0a8683fb12b0b5031fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 22 Apr 2025 10:21:57 +0200 Subject: [PATCH 62/92] Replaced a too strict assertion in DatabaseSessionHandler that prevented to use the sessions at all with a more simple sanity check (#3366) (#3368) Set zend.assertions=1 --------- Co-authored-by: Toni Vega <1808267+tonivega@users.noreply.github.com> --- .github/workflows/build-ci-atlas.yml | 2 +- .github/workflows/build-ci.yml | 2 +- phpunit.xml.dist | 2 ++ src/Session/MongoDbSessionHandler.php | 8 +++++--- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index c699d2023..46f30150d 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -91,4 +91,4 @@ jobs: - name: "Run tests" run: | export MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" - ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 6fec7bb28..bbc8b53d1 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -118,4 +118,4 @@ jobs: - name: "Run tests" run: | export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" - ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7044f9069..d7f066483 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -23,6 +23,8 @@ + + ['root' => 'bson'], ], ); - assert($result instanceof Document); - return $result ? (string) $result->payload : false; + if ($result instanceof Document) { + return (string) $result->payload; + } + + return false; } public function write($sessionId, $data): bool From d56aefaa2471dd8231bd052e46be366c5d797b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 22 Apr 2025 10:22:29 +0200 Subject: [PATCH 63/92] Remove usage of deprecated getMongoDB method (#3367) --- tests/ConnectionTest.php | 4 ++-- tests/FilesystemsTest.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index ba5e09804..75761080e 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -63,7 +63,7 @@ public function testDisconnectAndCreateNewConnection() public function testDb() { $connection = DB::connection('mongodb'); - $this->assertInstanceOf(Database::class, $connection->getMongoDB()); + $this->assertInstanceOf(Database::class, $connection->getDatabase()); $this->assertInstanceOf(Client::class, $connection->getClient()); } @@ -199,7 +199,7 @@ public function testConnectionConfig(string $expectedUri, string $expectedDataba $client = $connection->getClient(); $this->assertSame($expectedUri, (string) $client); - $this->assertSame($expectedDatabaseName, $connection->getMongoDB()->getDatabaseName()); + $this->assertSame($expectedDatabaseName, $connection->getDatabase()->getDatabaseName()); $this->assertSame('foo', $connection->getCollection('foo')->getCollectionName()); $this->assertSame('foo', $connection->table('foo')->raw()->getCollectionName()); } diff --git a/tests/FilesystemsTest.php b/tests/FilesystemsTest.php index 3b9fa8e5f..7b8141905 100644 --- a/tests/FilesystemsTest.php +++ b/tests/FilesystemsTest.php @@ -56,7 +56,7 @@ public static function provideValidOptions(): Generator 'driver' => 'gridfs', 'bucket' => static fn (Application $app) => $app['db'] ->connection('mongodb') - ->getMongoDB() + ->getDatabase() ->selectGridFSBucket(), ], ]; @@ -68,7 +68,7 @@ public function testValidOptions(array $options) // Service used by "bucket-service" $this->app->singleton('bucket', static fn (Application $app) => $app['db'] ->connection('mongodb') - ->getMongoDB() + ->getDatabase() ->selectGridFSBucket()); $this->app['config']->set('filesystems.disks.' . $this->dataName(), $options); @@ -145,6 +145,6 @@ public function testPrefix() private function getBucket(): Bucket { - return DB::connection('mongodb')->getMongoDB()->selectGridFSBucket(); + return DB::connection('mongodb')->getDatabase()->selectGridFSBucket(); } } From d8b509ebb3a8c31bfa204796698ccc28fbaa40fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 30 Apr 2025 10:27:47 +0200 Subject: [PATCH 64/92] Validate PSR namespaces in CI (#3363) --- .github/workflows/coding-standards.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 068415f37..a2211312e 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -53,6 +53,9 @@ jobs: with: composer-options: "--no-suggest" + - name: "Validate PSR class names" + run: "composer dump-autoload --optimize --strict-psr" + - name: "Format the code" continue-on-error: true run: | From 397a3e3826ea1e1268a1365f2bd53601e3598b6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Wed, 30 Apr 2025 13:31:34 +0200 Subject: [PATCH 65/92] PHPORM-331 Fix test on query payload (#3375) New properties added by https://github.com/laravel/framework/pull/55529 --- tests/QueueTest.php | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/QueueTest.php b/tests/QueueTest.php index efc8f07ff..4de63391d 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -15,7 +15,7 @@ use MongoDB\Laravel\Queue\MongoQueue; use function app; -use function json_encode; +use function json_decode; class QueueTest extends TestCase { @@ -42,17 +42,16 @@ public function testQueueJobLifeCycle(): void $job = Queue::pop('test'); $this->assertInstanceOf(MongoJob::class, $job); $this->assertEquals(1, $job->isReserved()); - $this->assertEquals(json_encode([ - 'uuid' => $uuid, - 'displayName' => 'test', - 'job' => 'test', - 'maxTries' => null, - 'maxExceptions' => null, - 'failOnTimeout' => false, - 'backoff' => null, - 'timeout' => null, - 'data' => ['action' => 'QueueJobLifeCycle'], - ]), $job->getRawBody()); + $payload = json_decode($job->getRawBody(), true); + $this->assertEquals($uuid, $payload['uuid']); + $this->assertEquals('test', $payload['displayName']); + $this->assertEquals('test', $payload['job']); + $this->assertNull($payload['maxTries']); + $this->assertNull($payload['maxExceptions']); + $this->assertFalse($payload['failOnTimeout']); + $this->assertNull($payload['backoff']); + $this->assertNull($payload['timeout']); + $this->assertEquals(['action' => 'QueueJobLifeCycle'], $payload['data']); // Remove reserved job $job->delete(); From 9d7d4dcdff31c2df506fd32997a5baa2def1e6f7 Mon Sep 17 00:00:00 2001 From: Amir Reza Mehrbakhsh Date: Wed, 30 Apr 2025 13:39:14 +0200 Subject: [PATCH 66/92] PHPORM-330 Fix: Convert query duration time to milliseconds (#3374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Convert query duration time to milliseconds * Test that query time is expressed in milliseconds --------- Co-authored-by: Jérôme Tamarelle --- src/CommandSubscriber.php | 2 +- tests/ConnectionTest.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/CommandSubscriber.php b/src/CommandSubscriber.php index 569c7c909..5daa6e97a 100644 --- a/src/CommandSubscriber.php +++ b/src/CommandSubscriber.php @@ -48,6 +48,6 @@ private function logQuery(CommandSucceededEvent|CommandFailedEvent $event): void } } - $this->connection->logQuery(Document::fromPHP($command)->toCanonicalExtendedJSON(), [], $event->getDurationMicros()); + $this->connection->logQuery(Document::fromPHP($command)->toCanonicalExtendedJSON(), [], $event->getDurationMicros() / 1000); } } diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 75761080e..1f970d819 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -289,6 +289,8 @@ public function testQueryLog() DB::table('items')->get(); $this->assertCount(1, $logs = DB::getQueryLog()); $this->assertJsonStringEqualsJsonString('{"find":"items","filter":{}}', $logs[0]['query']); + $this->assertLessThan(10, $logs[0]['time'], 'Query time is in milliseconds'); + $this->assertGreaterThan(0.01, $logs[0]['time'], 'Query time is in milliseconds'); DB::table('items')->insert(['id' => $id = new ObjectId(), 'name' => 'test']); $this->assertCount(2, $logs = DB::getQueryLog()); From d6f593effb186ea223a80c90625d8950ece1a380 Mon Sep 17 00:00:00 2001 From: Nora Reidy Date: Tue, 6 May 2025 09:49:07 -0400 Subject: [PATCH 67/92] DOCSP-49784: View file typo (#3376) (#3378) * DOCSP-49784: View file typo (#3376) (cherry picked from commit 0dc4a99819f2efad886dab7f6aa7c9a01fd2ddd3) * fix test --- docs/quick-start/view-data.txt | 6 +++--- tests/QueueTest.php | 23 +++++++++++------------ 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/quick-start/view-data.txt b/docs/quick-start/view-data.txt index f29b2bd12..34be94e9e 100644 --- a/docs/quick-start/view-data.txt +++ b/docs/quick-start/view-data.txt @@ -105,9 +105,9 @@ View MongoDB Data .. code-block:: none :copyable: false - INFO View [resources/views/browse_movie.blade.php] created successfully. + INFO View [resources/views/browse_movies.blade.php] created successfully. - Open the ``browse_movie.blade.php`` view file in the ``resources/views`` + Open the ``browse_movies.blade.php`` view file in the ``resources/views`` directory. Replace the contents with the following code and save the changes: @@ -141,7 +141,7 @@ View MongoDB Data .. step:: Optionally, view your results as JSON documents - Rather than generating a view and editing the ``browse_movie.blade.php`` file, you can + Rather than generating a view and editing the ``browse_movies.blade.php`` file, you can use the ``toJson()`` method to display your results in JSON format. Replace the ``show()`` function with the following code to retrieve results and diff --git a/tests/QueueTest.php b/tests/QueueTest.php index efc8f07ff..4de63391d 100644 --- a/tests/QueueTest.php +++ b/tests/QueueTest.php @@ -15,7 +15,7 @@ use MongoDB\Laravel\Queue\MongoQueue; use function app; -use function json_encode; +use function json_decode; class QueueTest extends TestCase { @@ -42,17 +42,16 @@ public function testQueueJobLifeCycle(): void $job = Queue::pop('test'); $this->assertInstanceOf(MongoJob::class, $job); $this->assertEquals(1, $job->isReserved()); - $this->assertEquals(json_encode([ - 'uuid' => $uuid, - 'displayName' => 'test', - 'job' => 'test', - 'maxTries' => null, - 'maxExceptions' => null, - 'failOnTimeout' => false, - 'backoff' => null, - 'timeout' => null, - 'data' => ['action' => 'QueueJobLifeCycle'], - ]), $job->getRawBody()); + $payload = json_decode($job->getRawBody(), true); + $this->assertEquals($uuid, $payload['uuid']); + $this->assertEquals('test', $payload['displayName']); + $this->assertEquals('test', $payload['job']); + $this->assertNull($payload['maxTries']); + $this->assertNull($payload['maxExceptions']); + $this->assertFalse($payload['failOnTimeout']); + $this->assertNull($payload['backoff']); + $this->assertNull($payload['timeout']); + $this->assertEquals(['action' => 'QueueJobLifeCycle'], $payload['data']); // Remove reserved job $job->delete(); From 6260b4751d0c7046f54f7a0d4e42435ee766eee8 Mon Sep 17 00:00:00 2001 From: fergusean Date: Mon, 19 May 2025 08:31:18 -0400 Subject: [PATCH 68/92] Fix database name extraction from DSN containing a CA file path (#3381) --- src/Connection.php | 2 +- tests/ConnectionTest.php | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Connection.php b/src/Connection.php index 29b72ae44..3fa99e94b 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -214,7 +214,7 @@ protected function withFreshQueryLog($callback) protected function getDefaultDatabaseName(string $dsn, array $config): string { if (empty($config['database'])) { - if (! preg_match('/^mongodb(?:[+]srv)?:\\/\\/.+\\/([^?&]+)/s', $dsn, $matches)) { + if (! preg_match('/^mongodb(?:[+]srv)?:\\/\\/.+?\\/([^?&]+)/s', $dsn, $matches)) { throw new InvalidArgumentException('Database is not properly configured.'); } diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index 1f970d819..de77da7f7 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -190,6 +190,12 @@ public static function dataConnectionConfig(): Generator 'expectedDatabaseName' => 'tests', 'config' => ['dsn' => 'mongodb://some-host:12345/tests'], ]; + + yield 'Database is extracted from DSN with CA path in options' => [ + 'expectedUri' => 'mongodb://some-host:12345/tests?tls=true&tlsCAFile=/path/to/ca.pem&retryWrites=false', + 'expectedDatabaseName' => 'tests', + 'config' => ['dsn' => 'mongodb://some-host:12345/tests?tls=true&tlsCAFile=/path/to/ca.pem&retryWrites=false'], + ]; } #[DataProvider('dataConnectionConfig')] From 12ab12fd547cfa4ee3f4beb6cef581c51ce939ea Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Thu, 22 May 2025 11:30:04 -0400 Subject: [PATCH 69/92] DOCSP-50023: remove eol server version mentions (#3387) --- docs/compatibility.txt | 2 +- docs/includes/framework-compatibility-laravel.rst | 2 +- docs/transactions.txt | 13 ------------- 3 files changed, 2 insertions(+), 15 deletions(-) diff --git a/docs/compatibility.txt b/docs/compatibility.txt index 5197deab7..55971c9dd 100644 --- a/docs/compatibility.txt +++ b/docs/compatibility.txt @@ -15,7 +15,7 @@ Compatibility :class: singlecol .. meta:: - :keywords: laravel 9, laravel 10, laravel 11, laravel 12, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2, 5.3 + :keywords: laravel 9, laravel 10, laravel 11, laravel 12, 4.0, 4.1, 4.2, 5.0, 5.1, 5.2, 5.3, 5.4 Laravel Compatibility --------------------- diff --git a/docs/includes/framework-compatibility-laravel.rst b/docs/includes/framework-compatibility-laravel.rst index e8d59469d..4b0055692 100644 --- a/docs/includes/framework-compatibility-laravel.rst +++ b/docs/includes/framework-compatibility-laravel.rst @@ -8,7 +8,7 @@ - Laravel 10.x - Laravel 9.x - * - 5.2 to 5.3 + * - 5.2 to 5.4 - ✓ - ✓ - ✓ diff --git a/docs/transactions.txt b/docs/transactions.txt index b4a7827ba..e2aa894eb 100644 --- a/docs/transactions.txt +++ b/docs/transactions.txt @@ -60,21 +60,8 @@ This guide contains the following sections: Requirements and Limitations ---------------------------- -To perform transactions in MongoDB, you must use the following MongoDB -version and topology: - -- MongoDB version 4.0 or later -- A replica set deployment or sharded cluster - MongoDB Server and the {+odm-short+} have the following limitations: -- In MongoDB versions 4.2 and earlier, write operations performed within a - transaction must be on existing collections. In MongoDB versions 4.4 and - later, the server automatically creates collections as necessary when - you perform write operations in a transaction. To learn more about this - limitation, see :manual:`Create Collections and Indexes in a Transaction ` - in the {+server-docs-name+}. - - MongoDB does not support nested transactions. If you attempt to start a transaction within another one, the extension raises a ``RuntimeException``. To learn more about this limitation, see :manual:`Transactions and Sessions ` From 02a0bcac381afc068bec9dd6ad299d6f8f33d000 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Thu, 22 May 2025 13:21:58 -0400 Subject: [PATCH 70/92] [docs] add limitation to transactions page (#3389) --- docs/transactions.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/transactions.txt b/docs/transactions.txt index e2aa894eb..d42151d41 100644 --- a/docs/transactions.txt +++ b/docs/transactions.txt @@ -62,6 +62,10 @@ Requirements and Limitations MongoDB Server and the {+odm-short+} have the following limitations: +- MongoDB standalone deployments do not support transactions. To use + transactions, your deployment must be a multiple node replica set or + sharded cluster. + - MongoDB does not support nested transactions. If you attempt to start a transaction within another one, the extension raises a ``RuntimeException``. To learn more about this limitation, see :manual:`Transactions and Sessions ` From 72d6e5783d8ae2045b8c6b3f01dbbb07c5fa92b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 09:23:07 +0200 Subject: [PATCH 71/92] Bump ramsey/composer-install from 3.1.0 to 3.1.1 (#3393) Bumps [ramsey/composer-install](https://github.com/ramsey/composer-install) from 3.1.0 to 3.1.1. - [Release notes](https://github.com/ramsey/composer-install/releases) - [Commits](https://github.com/ramsey/composer-install/compare/3.1.0...3.1.1) --- updated-dependencies: - dependency-name: ramsey/composer-install dependency-version: 3.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/coding-standards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index a2211312e..dee6d9aab 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -49,7 +49,7 @@ jobs: run: "php --ri mongodb" - name: "Install dependencies with Composer" - uses: "ramsey/composer-install@3.1.0" + uses: "ramsey/composer-install@3.1.1" with: composer-options: "--no-suggest" From cf75f9664a92b45af0b8dd108176585ff1849e4d Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Tue, 27 May 2025 14:21:25 +0200 Subject: [PATCH 72/92] Extract duplicated collection methods in `Builder` The logic for fetching views and collections are very nearly the same bar aggregation support. --- src/Schema/Builder.php | 97 +++++++++++++++++------------------------- 1 file changed, 40 insertions(+), 57 deletions(-) diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 746fda99e..2a16d4466 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -6,6 +6,7 @@ use Closure; use MongoDB\Collection; +use MongoDB\Database; use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Connection; use MongoDB\Model\CollectionInfo; @@ -156,66 +157,13 @@ public function dropAllTables() /** @param string|null $schema Database name */ public function getTables($schema = null) { - $db = $this->connection->getDatabase($schema); - $collections = []; - - foreach ($db->listCollections() as $collectionInfo) { - $collectionName = $collectionInfo->getName(); - - // Skip views, which don't support aggregate - if ($collectionInfo->getType() === 'view') { - continue; - } - - $stats = $db->selectCollection($collectionName)->aggregate([ - ['$collStats' => ['storageStats' => ['scale' => 1]]], - ['$project' => ['storageStats.totalSize' => 1]], - ])->toArray(); - - $collections[] = [ - 'name' => $collectionName, - 'schema' => $db->getDatabaseName(), - 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, - 'size' => $stats[0]?->storageStats?->totalSize ?? null, - 'comment' => null, - 'collation' => null, - 'engine' => null, - ]; - } - - usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); - - return $collections; + return $this->getCollectionRows('collection', $schema); } /** @param string|null $schema Database name */ public function getViews($schema = null) { - $db = $this->connection->getDatabase($schema); - $collections = []; - - foreach ($db->listCollections() as $collectionInfo) { - $collectionName = $collectionInfo->getName(); - - // Skip normal type collection - if ($collectionInfo->getType() !== 'view') { - continue; - } - - $collections[] = [ - 'name' => $collectionName, - 'schema' => $db->getDatabaseName(), - 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, - 'size' => null, - 'comment' => null, - 'collation' => null, - 'engine' => null, - ]; - } - - usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); - - return $collections; + return $this->getCollectionRows('view', $schema); } /** @@ -254,7 +202,7 @@ public function getColumns($table) [$db, $table] = explode('.', $table, 2); } - $stats = $this->connection->getDatabase($db)->selectCollection($table)->aggregate([ + $stats = $this->connection->getDatabase($db)->getCollection($table)->aggregate([ // Sample 1,000 documents to get a representative sample of the collection ['$sample' => ['size' => 1_000]], // Convert each document to an array of fields @@ -389,7 +337,7 @@ public function getCollection($name) } /** - * Get all of the collections names for the database. + * Get all the collections names for the database. * * @deprecated * @@ -418,4 +366,39 @@ public static function isAtlasSearchNotSupportedException(ServerException $e): b 31082, // MongoDB 8: Using Atlas Search Database Commands and the $listSearchIndexes aggregation stage requires additional configuration. ], true); } + + /** @param string|null $schema Database name */ + private function getCollectionRows(string $collectionType, $schema = null) + { + $db = $this->connection->getDatabase($schema); + $collections = []; + + foreach ($db->listCollections() as $collectionInfo) { + $collectionName = $collectionInfo->getName(); + + if ($collectionInfo->getType() !== $collectionType) { + continue; + } + + // Aggregation is not supported on views + $stats = $collectionType !== 'view' ? $db->selectCollection($collectionName)->aggregate([ + ['$collStats' => ['storageStats' => ['scale' => 1]]], + ['$project' => ['storageStats.totalSize' => 1]], + ])->toArray() : null; + + $collections[] = [ + 'name' => $collectionName, + 'schema' => $db->getDatabaseName(), + 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, + 'size' => $stats[0]?->storageStats?->totalSize ?? null, + 'comment' => null, + 'collation' => null, + 'engine' => null, + ]; + } + + usort($collections, fn ($a, $b) => $a['name'] <=> $b['name']); + + return $collections; + } } From c494c19119805a7ee281350a98c991508ceb3411 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 28 May 2025 10:52:39 +0200 Subject: [PATCH 73/92] Add collation to `getCollections` and `getViews` Although it's not displayed anywhere other than `db:table` for now, the `collation` column is present in some of laravel's artisan dabase commands. --- src/Schema/Builder.php | 33 ++++++++++++++++++++++++++++++++- tests/SchemaTest.php | 18 +++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 2a16d4466..78cb3a4c1 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -15,6 +15,7 @@ use function array_column; use function array_fill_keys; use function array_filter; +use function array_key_exists; use function array_keys; use function array_map; use function array_merge; @@ -26,6 +27,7 @@ use function implode; use function in_array; use function is_array; +use function is_bool; use function is_string; use function iterator_to_array; use function sort; @@ -380,6 +382,9 @@ private function getCollectionRows(string $collectionType, $schema = null) continue; } + $options = $collectionInfo->getOptions(); + $collation = $options['collation'] ?? []; + // Aggregation is not supported on views $stats = $collectionType !== 'view' ? $db->selectCollection($collectionName)->aggregate([ ['$collStats' => ['storageStats' => ['scale' => 1]]], @@ -392,7 +397,7 @@ private function getCollectionRows(string $collectionType, $schema = null) 'schema_qualified_name' => $db->getDatabaseName() . '.' . $collectionName, 'size' => $stats[0]?->storageStats?->totalSize ?? null, 'comment' => null, - 'collation' => null, + 'collation' => $this->collationToString($collation), 'engine' => null, ]; } @@ -401,4 +406,30 @@ private function getCollectionRows(string $collectionType, $schema = null) return $collections; } + + private function collationToString(array $collation): string + { + $map = [ + 'locale' => 'l', + 'strength' => 's', + 'caseLevel' => 'cl', + 'caseFirst' => 'cf', + 'numericOrdering' => 'no', + 'alternate' => 'a', + 'maxVariable' => 'mv', + 'normalization' => 'n', + 'backwards' => 'b', + ]; + + $parts = []; + foreach ($collation as $key => $value) { + if (array_key_exists($key, $map)) { + $shortKey = $map[$key]; + $shortValue = is_bool($value) ? ($value ? '1' : '0') : $value; + $parts[] = $shortKey . '=' . $shortValue; + } + } + + return implode(';', $parts); + } } diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 3257a671e..bc9666e5f 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -20,12 +20,15 @@ class SchemaTest extends TestCase { + private const COLL_WITH_COLLATION = 'collection_with_collation'; + public function tearDown(): void { $database = $this->getConnection('mongodb')->getDatabase(); assert($database instanceof Database); $database->dropCollection('newcollection'); $database->dropCollection('newcollection_two'); + $database->dropCollection(self::COLL_WITH_COLLATION); $database->dropCollection('test_view'); parent::tearDown(); @@ -394,9 +397,17 @@ public function testHasColumns(): void public function testGetTables() { + $db = DB::connection('mongodb')->getDatabase(); + $db->createCollection(self::COLL_WITH_COLLATION, [ + 'collation' => [ + 'locale' => 'fr', + 'strength' => 2, + ], + ]); + DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); - DB::connection('mongodb')->getDatabase()->createCollection('test_view', ['viewOn' => 'newcollection']); + $db->createCollection('test_view', ['viewOn' => 'newcollection']); $dbName = DB::connection('mongodb')->getDatabaseName(); $tables = Schema::getTables(); @@ -407,6 +418,7 @@ public function testGetTables() $this->assertArrayHasKey('name', $table); $this->assertArrayHasKey('size', $table); $this->assertArrayHasKey('schema', $table); + $this->assertArrayHasKey('collation', $table); $this->assertArrayHasKey('schema_qualified_name', $table); $this->assertNotEquals('test_view', $table['name'], 'Standard views should not be included in the result of getTables.'); @@ -416,6 +428,10 @@ public function testGetTables() $this->assertEquals($dbName . '.newcollection', $table['schema_qualified_name']); $found = true; } + + if ($table['name'] === self::COLL_WITH_COLLATION) { + $this->assertEquals('l=fr;cl=0;cf=off;s=2;no=0;a=non-ignorable;mv=punct;n=0;b=0', $table['collation']); + } } if (! $found) { From f22577243009b72b40c040d27ff75bb47f2a4471 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 28 May 2025 11:52:32 +0200 Subject: [PATCH 74/92] Add Pauline as a maintainer --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 2542b51bb..6edd8d484 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ }, "authors": [ { "name": "Andreas Braun", "email": "andreas.braun@mongodb.com", "role": "Leader" }, + { "name": "Pauline Vos", "email": "pauline.vos@mongodb.com", "role": "Maintainer" }, { "name": "Jérôme Tamarelle", "email": "jerome.tamarelle@mongodb.com", "role": "Maintainer" }, { "name": "Jeremy Mikola", "email": "jmikola@gmail.com", "role": "Maintainer" }, { "name": "Jens Segers", "homepage": "https://jenssegers.com", "role": "Creator" } From f6e3c1825c4fce7c7205cece473f766e9d2ccc6c Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 28 May 2025 15:57:27 +0200 Subject: [PATCH 75/92] Change string literals in `SchemaTest` to constants --- tests/SchemaTest.php | 252 ++++++++++++++++++++++--------------------- 1 file changed, 127 insertions(+), 125 deletions(-) diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index bc9666e5f..860daf19e 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -20,14 +20,16 @@ class SchemaTest extends TestCase { + private const COLL_1 = 'new_collection'; + private const COLL_2 = 'new_collection_two'; private const COLL_WITH_COLLATION = 'collection_with_collation'; public function tearDown(): void { $database = $this->getConnection('mongodb')->getDatabase(); assert($database instanceof Database); - $database->dropCollection('newcollection'); - $database->dropCollection('newcollection_two'); + $database->dropCollection(self::COLL_1); + $database->dropCollection(self::COLL_2); $database->dropCollection(self::COLL_WITH_COLLATION); $database->dropCollection('test_view'); @@ -36,204 +38,204 @@ public function tearDown(): void public function testCreate(): void { - Schema::create('newcollection'); - $this->assertTrue(Schema::hasCollection('newcollection')); - $this->assertTrue(Schema::hasTable('newcollection')); + Schema::create(self::COLL_1); + $this->assertTrue(Schema::hasCollection(self::COLL_1)); + $this->assertTrue(Schema::hasTable(self::COLL_1)); } public function testCreateWithCallback(): void { - Schema::create('newcollection', static function ($collection) { + Schema::create(self::COLL_1, static function ($collection) { self::assertInstanceOf(Blueprint::class, $collection); }); - $this->assertTrue(Schema::hasCollection('newcollection')); + $this->assertTrue(Schema::hasCollection(self::COLL_1)); } public function testCreateWithOptions(): void { - Schema::create('newcollection_two', null, ['capped' => true, 'size' => 1024]); - $this->assertTrue(Schema::hasCollection('newcollection_two')); - $this->assertTrue(Schema::hasTable('newcollection_two')); + Schema::create(self::COLL_2, null, ['capped' => true, 'size' => 1024]); + $this->assertTrue(Schema::hasCollection(self::COLL_2)); + $this->assertTrue(Schema::hasTable(self::COLL_2)); - $collection = Schema::getCollection('newcollection_two'); + $collection = Schema::getCollection(self::COLL_2); $this->assertTrue($collection['options']['capped']); $this->assertEquals(1024, $collection['options']['size']); } public function testDrop(): void { - Schema::create('newcollection'); - Schema::drop('newcollection'); - $this->assertFalse(Schema::hasCollection('newcollection')); + Schema::create(self::COLL_1); + Schema::drop(self::COLL_1); + $this->assertFalse(Schema::hasCollection(self::COLL_1)); } public function testBluePrint(): void { - Schema::table('newcollection', static function ($collection) { + Schema::table(self::COLL_1, static function ($collection) { self::assertInstanceOf(Blueprint::class, $collection); }); - Schema::table('newcollection', static function ($collection) { + Schema::table(self::COLL_1, static function ($collection) { self::assertInstanceOf(Blueprint::class, $collection); }); } public function testIndex(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->index('mykey1'); }); - $index = $this->assertIndexExists('newcollection', 'mykey1_1'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey1_1'); $this->assertEquals(1, $index['key']['mykey1']); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->index(['mykey2']); }); - $index = $this->assertIndexExists('newcollection', 'mykey2_1'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey2_1'); $this->assertEquals(1, $index['key']['mykey2']); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('mykey3')->index(); }); - $index = $this->assertIndexExists('newcollection', 'mykey3_1'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey3_1'); $this->assertEquals(1, $index['key']['mykey3']); } public function testPrimary(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('mykey', 100)->primary(); }); - $index = $this->assertIndexExists('newcollection', 'mykey_1'); + $index = $this->assertIndexExists(self::COLL_1, 'mykey_1'); $this->assertEquals(1, $index['unique']); } public function testUnique(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->unique('uniquekey'); }); - $index = $this->assertIndexExists('newcollection', 'uniquekey_1'); + $index = $this->assertIndexExists(self::COLL_1, 'uniquekey_1'); $this->assertEquals(1, $index['unique']); } public function testDropIndex(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->unique('uniquekey'); $collection->dropIndex('uniquekey_1'); }); - $this->assertIndexNotExists('newcollection', 'uniquekey_1'); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey_1'); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->unique('uniquekey'); $collection->dropIndex(['uniquekey']); }); - $this->assertIndexNotExists('newcollection', 'uniquekey_1'); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey_1'); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->index(['field_a', 'field_b']); }); - $this->assertIndexExists('newcollection', 'field_a_1_field_b_1'); + $this->assertIndexExists(self::COLL_1, 'field_a_1_field_b_1'); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->dropIndex(['field_a', 'field_b']); }); - $this->assertIndexNotExists('newcollection', 'field_a_1_field_b_1'); + $this->assertIndexNotExists(self::COLL_1, 'field_a_1_field_b_1'); $indexName = 'field_a_-1_field_b_1'; - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->index(['field_a' => -1, 'field_b' => 1]); }); - $this->assertIndexExists('newcollection', $indexName); + $this->assertIndexExists(self::COLL_1, $indexName); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->dropIndex(['field_a' => -1, 'field_b' => 1]); }); - $this->assertIndexNotExists('newcollection', $indexName); + $this->assertIndexNotExists(self::COLL_1, $indexName); $indexName = 'custom_index_name'; - Schema::table('newcollection', function ($collection) use ($indexName) { + Schema::table(self::COLL_1, function ($collection) use ($indexName) { $collection->index(['field_a', 'field_b'], $indexName); }); - $this->assertIndexExists('newcollection', $indexName); + $this->assertIndexExists(self::COLL_1, $indexName); - Schema::table('newcollection', function ($collection) use ($indexName) { + Schema::table(self::COLL_1, function ($collection) use ($indexName) { $collection->dropIndex($indexName); }); - $this->assertIndexNotExists('newcollection', $indexName); + $this->assertIndexNotExists(self::COLL_1, $indexName); } public function testDropIndexIfExists(): void { - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->unique('uniquekey'); $collection->dropIndexIfExists('uniquekey_1'); }); - $this->assertIndexNotExists('newcollection', 'uniquekey'); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->unique('uniquekey'); $collection->dropIndexIfExists(['uniquekey']); }); - $this->assertIndexNotExists('newcollection', 'uniquekey'); + $this->assertIndexNotExists(self::COLL_1, 'uniquekey'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index(['field_a', 'field_b']); }); - $this->assertIndexExists('newcollection', 'field_a_1_field_b_1'); + $this->assertIndexExists(self::COLL_1, 'field_a_1_field_b_1'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->dropIndexIfExists(['field_a', 'field_b']); }); - $this->assertIndexNotExists('newcollection', 'field_a_1_field_b_1'); + $this->assertIndexNotExists(self::COLL_1, 'field_a_1_field_b_1'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index(['field_a', 'field_b'], 'custom_index_name'); }); - $this->assertIndexExists('newcollection', 'custom_index_name'); + $this->assertIndexExists(self::COLL_1, 'custom_index_name'); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->dropIndexIfExists('custom_index_name'); }); - $this->assertIndexNotExists('newcollection', 'custom_index_name'); + $this->assertIndexNotExists(self::COLL_1, 'custom_index_name'); } public function testHasIndex(): void { - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index('myhaskey1'); $this->assertTrue($collection->hasIndex('myhaskey1_1')); $this->assertFalse($collection->hasIndex('myhaskey1')); }); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index('myhaskey2'); $this->assertTrue($collection->hasIndex(['myhaskey2'])); $this->assertFalse($collection->hasIndex(['myhaskey2_1'])); }); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->index(['field_a', 'field_b']); $this->assertTrue($collection->hasIndex(['field_a_1_field_b'])); $this->assertFalse($collection->hasIndex(['field_a_1_field_b_1'])); @@ -242,74 +244,74 @@ public function testHasIndex(): void public function testSparse(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->sparse('sparsekey'); }); - $index = $this->assertIndexExists('newcollection', 'sparsekey_1'); + $index = $this->assertIndexExists(self::COLL_1, 'sparsekey_1'); $this->assertEquals(1, $index['sparse']); } public function testExpire(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->expire('expirekey', 60); }); - $index = $this->assertIndexExists('newcollection', 'expirekey_1'); + $index = $this->assertIndexExists(self::COLL_1, 'expirekey_1'); $this->assertEquals(60, $index['expireAfterSeconds']); } public function testSoftDeletes(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->softDeletes(); }); - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('email')->nullable()->index(); }); - $index = $this->assertIndexExists('newcollection', 'email_1'); + $index = $this->assertIndexExists(self::COLL_1, 'email_1'); $this->assertEquals(1, $index['key']['email']); } public function testFluent(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->string('email')->index(); $collection->string('token')->index(); $collection->timestamp('created_at'); }); - $index = $this->assertIndexExists('newcollection', 'email_1'); + $index = $this->assertIndexExists(self::COLL_1, 'email_1'); $this->assertEquals(1, $index['key']['email']); - $index = $this->assertIndexExists('newcollection', 'token_1'); + $index = $this->assertIndexExists(self::COLL_1, 'token_1'); $this->assertEquals(1, $index['key']['token']); } public function testGeospatial(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->geospatial('point'); $collection->geospatial('area', '2d'); $collection->geospatial('continent', '2dsphere'); }); - $index = $this->assertIndexExists('newcollection', 'point_2d'); + $index = $this->assertIndexExists(self::COLL_1, 'point_2d'); $this->assertEquals('2d', $index['key']['point']); - $index = $this->assertIndexExists('newcollection', 'area_2d'); + $index = $this->assertIndexExists(self::COLL_1, 'area_2d'); $this->assertEquals('2d', $index['key']['area']); - $index = $this->assertIndexExists('newcollection', 'continent_2dsphere'); + $index = $this->assertIndexExists(self::COLL_1, 'continent_2dsphere'); $this->assertEquals('2dsphere', $index['key']['continent']); } public function testDummies(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->boolean('activated')->default(0); $collection->integer('user_id')->unsigned(); }); @@ -318,22 +320,22 @@ public function testDummies(): void public function testSparseUnique(): void { - Schema::table('newcollection', function ($collection) { + Schema::table(self::COLL_1, function ($collection) { $collection->sparse_and_unique('sparseuniquekey'); }); - $index = $this->assertIndexExists('newcollection', 'sparseuniquekey_1'); + $index = $this->assertIndexExists(self::COLL_1, 'sparseuniquekey_1'); $this->assertEquals(1, $index['sparse']); $this->assertEquals(1, $index['unique']); } public function testRenameColumn(): void { - DB::connection()->table('newcollection')->insert(['test' => 'value']); - DB::connection()->table('newcollection')->insert(['test' => 'value 2']); - DB::connection()->table('newcollection')->insert(['column' => 'column value']); + DB::connection()->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection()->table(self::COLL_1)->insert(['test' => 'value 2']); + DB::connection()->table(self::COLL_1)->insert(['column' => 'column value']); - $check = DB::connection()->table('newcollection')->get(); + $check = DB::connection()->table(self::COLL_1)->get(); $this->assertCount(3, $check); $this->assertObjectHasProperty('test', $check[0]); @@ -346,11 +348,11 @@ public function testRenameColumn(): void $this->assertObjectNotHasProperty('test', $check[2]); $this->assertObjectNotHasProperty('newtest', $check[2]); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->renameColumn('test', 'newtest'); }); - $check2 = DB::connection()->table('newcollection')->get(); + $check2 = DB::connection()->table(self::COLL_1)->get(); $this->assertCount(3, $check2); $this->assertObjectHasProperty('newtest', $check2[0]); @@ -369,30 +371,30 @@ public function testRenameColumn(): void public function testHasColumn(): void { - $this->assertTrue(Schema::hasColumn('newcollection', '_id')); - $this->assertTrue(Schema::hasColumn('newcollection', 'id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, '_id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'id')); - DB::connection()->table('newcollection')->insert(['column1' => 'value', 'embed' => ['_id' => 1]]); + DB::connection()->table(self::COLL_1)->insert(['column1' => 'value', 'embed' => ['_id' => 1]]); - $this->assertTrue(Schema::hasColumn('newcollection', 'column1')); - $this->assertFalse(Schema::hasColumn('newcollection', 'column2')); - $this->assertTrue(Schema::hasColumn('newcollection', 'embed._id')); - $this->assertTrue(Schema::hasColumn('newcollection', 'embed.id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'column1')); + $this->assertFalse(Schema::hasColumn(self::COLL_1, 'column2')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'embed._id')); + $this->assertTrue(Schema::hasColumn(self::COLL_1, 'embed.id')); } public function testHasColumns(): void { - $this->assertTrue(Schema::hasColumns('newcollection', ['_id'])); - $this->assertTrue(Schema::hasColumns('newcollection', ['id'])); + $this->assertTrue(Schema::hasColumns(self::COLL_1, ['_id'])); + $this->assertTrue(Schema::hasColumns(self::COLL_1, ['id'])); // Insert documents with both column1 and column2 - DB::connection()->table('newcollection')->insert([ + DB::connection()->table(self::COLL_1)->insert([ ['column1' => 'value1', 'column2' => 'value2'], ['column1' => 'value3'], ]); - $this->assertTrue(Schema::hasColumns('newcollection', ['column1', 'column2'])); - $this->assertFalse(Schema::hasColumns('newcollection', ['column1', 'column3'])); + $this->assertTrue(Schema::hasColumns(self::COLL_1, ['column1', 'column2'])); + $this->assertFalse(Schema::hasColumns(self::COLL_1, ['column1', 'column3'])); } public function testGetTables() @@ -405,9 +407,9 @@ public function testGetTables() ], ]); - DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); - DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); - $db->createCollection('test_view', ['viewOn' => 'newcollection']); + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); + $db->createCollection('test_view', ['viewOn' => self::COLL_1]); $dbName = DB::connection('mongodb')->getDatabaseName(); $tables = Schema::getTables(); @@ -422,10 +424,10 @@ public function testGetTables() $this->assertArrayHasKey('schema_qualified_name', $table); $this->assertNotEquals('test_view', $table['name'], 'Standard views should not be included in the result of getTables.'); - if ($table['name'] === 'newcollection') { + if ($table['name'] === self::COLL_1) { $this->assertEquals(8192, $table['size']); $this->assertEquals($dbName, $table['schema']); - $this->assertEquals($dbName . '.newcollection', $table['schema_qualified_name']); + $this->assertEquals($dbName . '.' . self::COLL_1, $table['schema_qualified_name']); $found = true; } @@ -435,17 +437,17 @@ public function testGetTables() } if (! $found) { - $this->fail('Collection "newcollection" not found'); + $this->fail('Collection "' . self::COLL_1 . '" not found'); } } public function testGetViews() { - DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); - DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); $dbName = DB::connection('mongodb')->getDatabaseName(); - DB::connection('mongodb')->getDatabase()->createCollection('test_view', ['viewOn' => 'newcollection']); + DB::connection('mongodb')->getDatabase()->createCollection('test_view', ['viewOn' => self::COLL_1]); $tables = Schema::getViews(); @@ -459,7 +461,7 @@ public function testGetViews() $this->assertArrayHasKey('schema_qualified_name', $table); // Ensure "normal collections" are not in the views list - $this->assertNotEquals('newcollection', $table['name'], 'Normal collections should not be included in the result of getViews.'); + $this->assertNotEquals(self::COLL_1, $table['name'], 'Normal collections should not be included in the result of getViews.'); if ($table['name'] === 'test_view') { $this->assertEquals($dbName, $table['schema']); @@ -475,45 +477,45 @@ public function testGetViews() public function testGetTableListing() { - DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); - DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); $tables = Schema::getTableListing(); $this->assertIsArray($tables); $this->assertGreaterThanOrEqual(2, count($tables)); - $this->assertContains('newcollection', $tables); - $this->assertContains('newcollection_two', $tables); + $this->assertContains(self::COLL_1, $tables); + $this->assertContains(self::COLL_2, $tables); } public function testGetTableListingBySchema() { - DB::connection('mongodb')->table('newcollection')->insert(['test' => 'value']); - DB::connection('mongodb')->table('newcollection_two')->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_1)->insert(['test' => 'value']); + DB::connection('mongodb')->table(self::COLL_2)->insert(['test' => 'value']); $dbName = DB::connection('mongodb')->getDatabaseName(); $tables = Schema::getTableListing([$dbName, 'database__that_does_not_exists'], schemaQualified: true); $this->assertIsArray($tables); $this->assertGreaterThanOrEqual(2, count($tables)); - $this->assertContains($dbName . '.newcollection', $tables); - $this->assertContains($dbName . '.newcollection_two', $tables); + $this->assertContains($dbName . '.' . self::COLL_1, $tables); + $this->assertContains($dbName . '.' . self::COLL_2, $tables); $tables = Schema::getTableListing([$dbName, 'database__that_does_not_exists'], schemaQualified: false); $this->assertIsArray($tables); $this->assertGreaterThanOrEqual(2, count($tables)); - $this->assertContains('newcollection', $tables); - $this->assertContains('newcollection_two', $tables); + $this->assertContains(self::COLL_1, $tables); + $this->assertContains(self::COLL_2, $tables); } public function testGetColumns() { - $collection = DB::connection('mongodb')->table('newcollection'); + $collection = DB::connection('mongodb')->table(self::COLL_1); $collection->insert(['text' => 'value', 'mixed' => ['key' => 'value']]); $collection->insert(['date' => new UTCDateTime(), 'binary' => new Binary('binary'), 'mixed' => true]); - $columns = Schema::getColumns('newcollection'); + $columns = Schema::getColumns(self::COLL_1); $this->assertIsArray($columns); $this->assertCount(5, $columns); @@ -544,7 +546,7 @@ public function testGetColumns() $this->assertSame([], $columns); // Qualified table name - $columns = Schema::getColumns(DB::getDatabaseName() . '.newcollection'); + $columns = Schema::getColumns(DB::getDatabaseName() . '.' . self::COLL_1); $this->assertIsArray($columns); $this->assertCount(5, $columns); } @@ -552,12 +554,12 @@ public function testGetColumns() /** @see AtlasSearchTest::testGetIndexes() */ public function testGetIndexes() { - Schema::create('newcollection', function (Blueprint $collection) { + Schema::create(self::COLL_1, function (Blueprint $collection) { $collection->index('mykey1'); $collection->string('mykey2')->unique('unique_index'); $collection->string('mykey3')->index(); }); - $indexes = Schema::getIndexes('newcollection'); + $indexes = Schema::getIndexes(self::COLL_1); self::assertIsArray($indexes); self::assertCount(4, $indexes); @@ -603,7 +605,7 @@ public function testSearchIndex(): void { $this->skipIfSearchIndexManagementIsNotSupported(); - Schema::create('newcollection', function (Blueprint $collection) { + Schema::create(self::COLL_1, function (Blueprint $collection) { $collection->searchIndex([ 'mappings' => [ 'dynamic' => false, @@ -614,7 +616,7 @@ public function testSearchIndex(): void ]); }); - $index = $this->getSearchIndex('newcollection', 'default'); + $index = $this->getSearchIndex(self::COLL_1, 'default'); self::assertNotNull($index); self::assertSame('default', $index['name']); @@ -622,11 +624,11 @@ public function testSearchIndex(): void self::assertFalse($index['latestDefinition']['mappings']['dynamic']); self::assertSame('lucene.whitespace', $index['latestDefinition']['mappings']['fields']['foo']['analyzer']); - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->dropSearchIndex('default'); }); - $index = $this->getSearchIndex('newcollection', 'default'); + $index = $this->getSearchIndex(self::COLL_1, 'default'); self::assertNull($index); } @@ -634,7 +636,7 @@ public function testVectorSearchIndex() { $this->skipIfSearchIndexManagementIsNotSupported(); - Schema::create('newcollection', function (Blueprint $collection) { + Schema::create(self::COLL_1, function (Blueprint $collection) { $collection->vectorSearchIndex([ 'fields' => [ ['type' => 'vector', 'path' => 'foo', 'numDimensions' => 128, 'similarity' => 'euclidean', 'quantization' => 'none'], @@ -642,7 +644,7 @@ public function testVectorSearchIndex() ], 'vector'); }); - $index = $this->getSearchIndex('newcollection', 'vector'); + $index = $this->getSearchIndex(self::COLL_1, 'vector'); self::assertNotNull($index); self::assertSame('vector', $index['name']); @@ -650,11 +652,11 @@ public function testVectorSearchIndex() self::assertSame('vector', $index['latestDefinition']['fields'][0]['type']); // Drop the index - Schema::table('newcollection', function (Blueprint $collection) { + Schema::table(self::COLL_1, function (Blueprint $collection) { $collection->dropSearchIndex('vector'); }); - $index = $this->getSearchIndex('newcollection', 'vector'); + $index = $this->getSearchIndex(self::COLL_1, 'vector'); self::assertNull($index); } From 7ca536bb466c07909b4feda3d1216f7bd1f906f3 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Mon, 2 Jun 2025 16:21:50 +0200 Subject: [PATCH 76/92] Add conditional return type to `Builder::raw()` (#3395) for both the Eloquent and the Query builder, so that developers can understand that the return type will either be a `Collection` or an `Expression` based on the argument that's passed to `raw()` --- src/Eloquent/Builder.php | 10 +++++++++- src/Query/Builder.php | 8 +++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index f3ffd7012..7dba7b7ab 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -4,10 +4,12 @@ namespace MongoDB\Laravel\Eloquent; +use Closure; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; use MongoDB\BSON\Document; +use MongoDB\Builder\Expression; use MongoDB\Builder\Type\QueryInterface; use MongoDB\Builder\Type\SearchOperatorInterface; use MongoDB\Driver\CursorInterface; @@ -229,7 +231,13 @@ public function decrement($column, $amount = 1, array $extra = []) return parent::decrement($column, $amount, $extra); } - /** @inheritdoc */ + /** + * @param (Closure():T)|Expression|null $value + * + * @return ($value is Closure ? T : ($value is null ? Collection : Expression)) + * + * @template T + */ public function raw($value = null) { // Get raw results from the query builder. diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 6823998fd..9a6701b87 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -964,7 +964,13 @@ public function lists($column, $key = null) return $this->pluck($column, $key); } - /** @inheritdoc */ + /** + * @param (Closure():T)|Expression|null $value + * + * @return ($value is Closure ? T : ($value is null ? Collection : Expression)) + * + * @template T + */ public function raw($value = null) { // Execute the closure on the mongodb collection From 522b2ff14367103484090c5438b1ca55e39cf826 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:35:18 -0400 Subject: [PATCH 77/92] DOCSP-48860: DOCS+ tutorial - Laravel MongoDB backend (#3386) * DOCSP-48860: DOCS+ tutorial WIP * wip * add to toc * LM PR fixes 1 * fix spacing * LM PR fixes 2 --- docs/cache.txt | 2 +- docs/fundamentals/atlas-search.txt | 2 +- docs/index.txt | 2 + docs/quick-start.txt | 1 + docs/quick-start/backend-service-tutorial.txt | 481 ++++++++++++++++++ docs/quick-start/next-steps.txt | 6 +- 6 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 docs/quick-start/backend-service-tutorial.txt diff --git a/docs/cache.txt b/docs/cache.txt index d3fd0f6e6..629065f09 100644 --- a/docs/cache.txt +++ b/docs/cache.txt @@ -14,7 +14,7 @@ Cache and Locks Configuration ------------- -To use MongoDB as a backend for `Laravel Cache and Locks `__, +To use MongoDB as a back end for `Laravel Cache and Locks `__, add a store configuration by specifying the ``mongodb`` driver in ``config/cache.php``: .. code-block:: php diff --git a/docs/fundamentals/atlas-search.txt b/docs/fundamentals/atlas-search.txt index ab957f9fa..a41385fda 100644 --- a/docs/fundamentals/atlas-search.txt +++ b/docs/fundamentals/atlas-search.txt @@ -179,7 +179,7 @@ following optional parameters to ``search()``: * - ``returnStoredSource`` - ``bool`` - Specifies whether to perform a full document lookup on the - backend database or return only stored source fields directly + back end database or return only stored source fields directly from Atlas Search * - ``tracking`` diff --git a/docs/index.txt b/docs/index.txt index 1eb1d8657..0b01b7349 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -1,3 +1,5 @@ +.. _laravel-docs-landing: + =============== {+odm-long+} =============== diff --git a/docs/quick-start.txt b/docs/quick-start.txt index ebfcb7348..5bd04f353 100644 --- a/docs/quick-start.txt +++ b/docs/quick-start.txt @@ -26,6 +26,7 @@ Quick Start View Data Write Data Next Steps + Tutorial: Build a Back End Overview -------- diff --git a/docs/quick-start/backend-service-tutorial.txt b/docs/quick-start/backend-service-tutorial.txt new file mode 100644 index 000000000..9236c698a --- /dev/null +++ b/docs/quick-start/backend-service-tutorial.txt @@ -0,0 +1,481 @@ +.. _laravel-tutorial-backend-service: + +========================================================== +Tutorial: Build a Back End Service by Using {+odm-long+} +========================================================== + +.. facet:: + :name: genre + :values: tutorial + +.. meta:: + :keywords: php framework, odm, code example, crud + :description: Learn how to set up a back end and perform CRUD operations by using Laravel MongoDB. + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this tutorial, you create a simple REST back end for a front-end app +by using {+odm-long+}. The tutorial uses Laravel's built-in API routing +features. + +Prerequisites +------------- + +Before you can start this tutorial, you need the following software +installed in your development environment: + +- MongoDB Atlas cluster with sample data loaded. To learn how to create + a cluster, see the :ref:`laravel-quick-start-create-deployment` step + of the Quick Start guide. +- `PHP `__. +- `Composer `__. +- `{+php-extension+} `__. +- A terminal app and shell. For MacOS users, use Terminal or a similar app. + For Windows users, use PowerShell. + +Steps +----- + +.. procedure:: + :style: connected + + .. step:: Create a Laravel project. + + First, create a Laravel project directory. Then, run the following + command to create a new Laravel project called ``laraproject``: + + .. code-block:: bash + + composer create-project laravel/laravel laraproject + + .. step:: Install {+odm-long+}. + + To check that {+odm-long+} is running in the web server, + add a webpage to your Laravel website. In your project, + navigate to ``/routes/web.php`` and add the following route: + + .. code-block:: php + + Route::get('/info', function () { + phpinfo(); + }); + + Then, run the following command in your shell to start + your application: + + .. code-block:: bash + + php artisan serve + + After the application begins running, navigate to + http://127.0.0.1:8000/info to view the PHPinfo page. Scroll down + to or search for the **mongodb** entry to verify that + the {+php-extension+} is installed. + + Run the following command in your shell to install {+odm-long+}: + + .. code-block:: bash + + composer require mongodb/laravel-mongodb:^{+package-version+} + + .. step:: Configure your MongoDB connection. + + Open your project's ``config/database.php`` file and update the + ``connections`` array as shown in the following code: + + .. code-block:: php + + 'connections' => [ + 'mongodb' => [ + 'driver' => 'mongodb', + 'dsn' => '', + 'database' => 'db', + ], + + Ensure that you replace the connection string placeholder + in the preceding code with your connection string before you run + your application. To learn how to locate your connection string, see + :ref:`laravel-quick-start-connection-string` in the Quick Start + guide. + + You can also set the default database connection. At the top of + the ``config/database.php`` file, change 'default' to the following: + + .. code-block:: php + + 'default' => 'mongodb', + + The Laravel application can now connect to the ``db`` database in + your MongoDB cluster. + + .. step:: Create an endpoint to ping your deployment. + + Run the following shell command to set up API routing: + + .. code-block:: bash + + php artisan install:api + + In the newly created ``routes/api.php`` file, add the following + route: + + .. code-block:: php + + // Add the DB use statement to the top of the file. + use Illuminate\Support\Facades\DB; + + Route::get('/ping', function (Request $request) { + $connection = DB::connection('mongodb'); + $msg = 'MongoDB is accessible!'; + try { + $connection->command(['ping' => 1]); + } catch (\Exception $e) { + $msg = 'MongoDB is not accessible. Error: ' . $e->getMessage(); + } + return ['msg' => $msg]; + }); + + Reload the application, then verify that + http://127.0.0.1:8000/api/ping shows the succesful ping message. + + .. step:: Create Eloquent models. + + Laravel is integrated with Eloquent, an ORM that abstracts the + database back end so that you can connect to different databases by + using a common interface. + + Eloquent provides a ``Model`` class to serve as the interface + between your code and a specific collection. Instances of the + ``Model`` classes represent rows of tables in relational + databases. In MongoDB, they are documents in the collection. + + .. tip:: + + You can define fillable fields in your Eloquent models + to enforce a document schema in your application and prevent + errors such as name typos. To learn more, see the + :ref:`laravel-model-mass-assignment` section of the Eloquent + Model Class guide. + + Create an Eloquent model called ``CustomerMongoDB`` by running + the following command from the project root: + + .. code-block:: bash + + php artisan make:model CustomerMongoDB + + Laravel creates the ``CustomerMongoDB`` class in the ``/models`` + directory. By default, models use the ``default`` database + connection, but you can specify which connection to use by adding + the ``$connection`` member to the class. You can also + specify the collection name by adding the ``$collection`` member. + + Ensure you include the ``use`` statement for the MongoDB Eloquent + model. This is necessary to set ``_id`` as the primary key. + + Replace the contents of the ``CustomerMongoDB.php`` file with the + following code: + + .. code-block:: php + + use MongoDB\Laravel\Eloquent\Model; + + class CustomerMongoDB extends Model + { + // the selected database as defined in /config/database.php + protected $connection = 'mongodb'; + + // equivalent to $table for MySQL + protected $collection = 'laracoll'; + + // defines the schema for top-level properties (optional). + protected $fillable = ['guid', 'first_name', 'family_name', 'email', 'address']; + } + + .. step:: Perform CRUD operations. + + After you create your models, you can perform data operations. + + Create the following route in your ``api.php`` file: + + .. code-block:: php + + Route::get('/create_eloquent_mongo/', function (Request $request) { + $success = CustomerMongoDB::create([ + 'guid'=> 'cust_1111', + 'first_name'=> 'John', + 'family_name' => 'Doe', + 'email' => 'j.doe@gmail.com', + 'address' => '123 my street, my city, zip, state, country' + ]); + }); + + After you insert the document, you can retrieve it by using the + ``where()`` method as shown in the following code: + + .. code-block:: php + + Route::get('/find_eloquent/', function (Request $request) { + $customer = CustomerMongoDB::where('guid', 'cust_1111')->get(); + }); + + Eloquent allows you to find data by using complex queries with + multiple matching conditions. + + You can also update and delete data shown in the following routes: + + .. code-block:: php + + Route::get('/update_eloquent/', function (Request $request) { + $result = CustomerMongoDB::where('guid', 'cust_1111')->update( ['first_name' => 'Jimmy'] ); + }); + + Route::get('/delete_eloquent/', function (Request $request) { + $result = CustomerMongoDB::where('guid', 'cust_1111')->delete(); + }); + + At this point, your MongoDB-connected back-end service is + running, but MongoDB provides more functionality to support your + operations. + + .. step:: Perform operations on nested data. + + {+odm-long+} offers MongoDB-specific operations for nested data. + However, adding nested data is also intuitive without using + the ``embedsMany()`` and ``embedsOne()`` methods. + + As shown in the preceding step, you can define top-level schema + attributes. However, it is more complicated when to define these + attribute if your documents include arrays and embedded documents. + + You can create the model's data structures in PHP. In the + following example, the ``address`` field is an object type. + The ``email`` field is an array of strings: + + .. code-block:: php + + Route::get('/create_nested/', function (Request $request) { + $message = "executed"; + $success = null; + + $address = new stdClass; + $address->street = '123 my street name'; + $address->city = 'my city'; + $address->zip= '12345'; + $emails = ['j.doe@gmail.com', 'j.doe@work.com']; + + try { + $customer = new CustomerMongoDB(); + $customer->guid = 'cust_2222'; + $customer->first_name = 'John'; + $customer->family_name= 'Doe'; + $customer->email= $emails; + $customer->address= $address; + $success = $customer->save(); // save() returns 1 or 0 + } + catch (\Exception $e) { + $message = $e->getMessage(); + } + return ['msg' => $message, 'data' => $success]; + }); + + When you access the ``/api/create_nested/`` endpoint, it creates a + document in MongoDB: + + .. code-block:: json + + { + "_id": {...}, + "guid": "cust_2222", + "first_name": "John", + "family_name": "Doe", + "email": [ + "j.doe@gmail.com", + "j.doe@work.com" + ], + "address": { + "street": "123 my street name", + "city": "my city", + "zip": "12345" + }, + "updated_at": { + "$date": "2025-05-27T17:38:28.793Z" + }, + "created_at": { + "$date": "2025-05-27T17:38:28.793Z" + } + } + + .. step:: Use the MongoDB Query API. + + MongoDB provides the Query API for optimized queries. + + You can begin to build a query by using a ``collection`` object. + Eloquent exposes the full capabilities of the underlying database + by using "raw queries," which Laravel sends to the database + without any processing from the Eloquent Query Builder. + + You can perform a raw native MongoDB query from the model as shown + in the following code: + + .. code-block:: php + + $mongodbquery = ['guid' => 'cust_1111']; + + // returns a "Illuminate\Database\Eloquent\Collection" Object + $results = CustomerMongoDB::whereRaw( $mongodbquery )->get(); + + You can also access the native MongoDB collection object and + perform a query that returns objects such as native MongoDB + documents or cursors: + + .. code-block:: php + + $mongodbquery = ['guid' => 'cust_1111', ]; + $mongodb_native_collection = DB::connection('mongodb')->getCollection('laracoll'); + $document = $mongodb_native_collection->findOne( $mongodbquery ); + $cursor = $mongodb_native_collection->find( $mongodbquery ); + + The following code demonstrates multiple ways to perform queries: + + .. code-block:: php + + Route::get('/find_native/', function (Request $request) { + // a simple MongoDB query that looks for a customer based on the guid + $mongodbquery = ['guid' => 'cust_2222']; + + // Option #1 + // ========= + // use Eloquent's whereRaw() function + // returns a "Illuminate\Database\Eloquent\Collection" Object + + $results = CustomerMongoDB::whereRaw( $mongodbquery )->get(); + + // Option #2 & #3 + // ============== + // use the native MongoDB driver Collection object and the Query API + + $mdb_collection = DB::connection('mongodb')->getCollection('laracoll'); + + // find the first document that matches the query + $mdb_bsondoc = $mdb_collection->findOne( $mongodbquery ); // returns a "MongoDB\Model\BSONDocument" Object + + // to convert the MongoDB Document to a Laravel Model, use the Model's newFromBuilder() method + $cust = new CustomerMongoDB(); + $one_doc = $cust->newFromBuilder((array) $mdb_bsondoc); + + // find all documents because you pass an empty query + $mdb_cursor = $mdb_collection->find(); // returns a "MongoDB\Driver\Cursor" object + $cust_array = array(); + foreach ($mdb_cursor->toArray() as $bson) { + $cust_array[] = $cust->newFromBuilder( $bson ); + } + + return ['msg' => 'executed', 'whereraw' => $results, 'document' => $one_doc, 'cursor_array' => $cust_array]; + }); + + The following code demonstrates how to use the ``updateOne()`` + method to update documents: + + .. code-block:: php + + Route::get('/update_native/', function (Request $request) { + $mdb_collection = DB::connection('mongodb')->getCollection('laracoll'); + $match = ['guid' => 'cust_2222']; + $update = ['$set' => ['first_name' => 'Henry', 'address.street' => '777 new street name'] ]; + $result = $mdb_collection->updateOne($match, $update ); + return ['msg' => 'executed', 'matched_docs' => $result->getMatchedCount(), 'modified_docs' => $result->getModifiedCount()]; + }); + + The following code demonstrates how to use the ``deleteOne()`` + method to delete documents: + + .. code-block:: php + + Route::get('/delete_native/', function (Request $request) { + $mdb_collection = DB::connection('mongodb')->getCollection('laracoll'); + $match = ['guid' => 'cust_2222']; + $result = $mdb_collection->deleteOne($match ); + return ['msg' => 'executed', 'deleted_docs' => + $result->getDeletedCount() ]; + }); + + To learn more about how to perform CRUD operations, see the + :ref:`laravel-fundamentals-write-ops` and + :ref:`laravel-fundamentals-read-ops` guides. + + .. step:: Use the aggregation framework. + + An aggregation pipeline is a task in MongoDB's aggregation + framework. You can use the aggregation framework to perform + various tasks such as real-time dashboards and big data analysis. + + An aggregation pipeline consists of multiple stages in which the + output of each stage is the input of the following stage. + This step uses the ``sample_mflix`` from the :atlas:`Atlas sample + datasets `. Laravel allows you to access multiple + MongoDB databases in the same app, so add the ``sample_mflix`` + database connection to ``database.php``: + + .. code-block:: php + + 'mongodb_mflix' => [ + 'driver' => 'mongodb', + 'dsn' => env('DB_URI'), + 'database' => 'sample_mflix', + ], + + Next, create the ``/aggregate/`` API endpoint and define an + aggregation pipeline to retrieve data from the ``movies`` + collection, compute the average movie rating for each genre, and + return a list. + + .. code-block:: php + + Route::get('/aggregate/', function (Request $request) { + $mdb_collection = DB::connection('mongodb_mflix')->getCollection('movies'); + + $stage0 = ['$unwind' => ['path' => '$genres']]; + $stage1 = ['$group' => ['_id' => '$genres', 'averageGenreRating' => ['$avg' => '$imdb.rating']]]; + $stage2 = ['$sort' => ['averageGenreRating' => -1]]; + $aggregation = [$stage0, $stage1, $stage2]; + + $mdb_cursor = $mdb_collection->aggregate( $aggregation ); + + return ['msg' => 'executed', 'data' => $mdb_cursor->toArray() ]; + }); + + {+odm-long+} provides the :ref:`laravel-aggregation-builder` to + build type-safe aggregation pipelines directly from your models. + We recommend using the aggregation builder to perform + aggregations. + + .. step:: Use indexes to optimize query performance. + + You can create indexes to support your queries and improve + performance. To learn more about how to create indexes + programmatically, see the :ref:`laravel-eloquent-indexes` section + of the Schema Builder guide. + +Conclusion +---------- + +In this tutorial, you learned how to create a back-end service by using +Laravel and MongoDB for a front-end web application. +This tutorial also showed how you can use the document model to improve +database efficiency and scalability. You can use the document model with the +MongoDB Query API to create better apps with less downtime. + +You can access the full code for this tutorial in the +:github:`laravel-mongodb-tutorial +` repository on GitHub. + +Navigate through the rest of the :ref:`laravel-docs-landing` +documentation to learn more about {+odm-long+}'s features. diff --git a/docs/quick-start/next-steps.txt b/docs/quick-start/next-steps.txt index 2853777fb..dc155326f 100644 --- a/docs/quick-start/next-steps.txt +++ b/docs/quick-start/next-steps.txt @@ -21,7 +21,11 @@ You can download the web application project by cloning the `laravel-quickstart `__ GitHub repository. -.. tip:: Build a Full Stack Application +.. tip:: Tutorials + + Learn how to implement more CRUD functionality in a {+odm-long+} + application by following the :ref:`Build a Back End Service by Using + {+odm-long+} ` tutorial. Learn how to build a full stack application that uses {+odm-long+} by following along with the `Full Stack Instagram Clone with Laravel and From 7a0f0bc2599dac6a0b2f865aeb8366e057b58f2b Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 4 Jun 2025 10:16:41 +0200 Subject: [PATCH 78/92] Support adding schema validation (#3397) To support the '$jsonSchema' operation on collections --- src/Schema/Blueprint.php | 19 +++++++++++++++++++ tests/SchemaTest.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index 1197bfde1..1ae46cf6c 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -9,6 +9,7 @@ use MongoDB\Laravel\Connection; use function array_flip; +use function array_merge; use function implode; use function in_array; use function is_array; @@ -117,6 +118,24 @@ public function hasIndex($indexOrColumns = null) return false; } + public function jsonSchema( + array $schema = [], + ?string $validationLevel = null, + ?string $validationAction = null, + ): void { + $options = array_merge( + [ + 'validator' => [ + '$jsonSchema' => $schema, + ], + ], + $validationLevel ? ['validationLevel' => $validationLevel] : [], + $validationAction ? ['validationAction' => $validationAction] : [], + ); + + $this->connection->getDatabase()->modifyCollection($this->collection->getCollectionName(), $options); + } + /** * @param string|array $indexOrColumns * diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index 860daf19e..9726eb705 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -63,6 +63,39 @@ public function testCreateWithOptions(): void $this->assertEquals(1024, $collection['options']['size']); } + public function testCreateWithSchemaValidator(): void + { + $schema = [ + 'bsonType' => 'object', + 'required' => [ 'username' ], + 'properties' => [ + 'username' => [ + 'bsonType' => 'string', + 'description' => 'must be a string and is required', + ], + ], + ]; + + Schema::create(self::COLL_2, function (Blueprint $collection) use ($schema) { + $collection->string('username'); + $collection->jsonSchema(schema: $schema, validationAction: 'warn'); + }); + + $this->assertTrue(Schema::hasCollection(self::COLL_2)); + $this->assertTrue(Schema::hasTable(self::COLL_2)); + + $collection = Schema::getCollection(self::COLL_2); + $this->assertEquals( + ['$jsonSchema' => $schema], + $collection['options']['validator'], + ); + + $this->assertEquals( + 'warn', + $collection['options']['validationAction'], + ); + } + public function testDrop(): void { Schema::create(self::COLL_1); From 5fe1f5dcffe8b7e22ce0dd81bb3d9beaa122fd49 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:08:57 -0400 Subject: [PATCH 79/92] DOCSP-50472: schema validation (#3400) * DOCSP-50472: schema validation * apply phpcbf formatting * small wording fix * fixes * log error * fix int type * wip * PV tech review 1 --- docs/eloquent-models/schema-builder.txt | 73 ++++++++++++++++--- .../schema-builder/flights_migration.php | 19 +++++ docs/quick-start/backend-service-tutorial.txt | 4 +- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/docs/eloquent-models/schema-builder.txt b/docs/eloquent-models/schema-builder.txt index 3cdec0f03..e9c1dff17 100644 --- a/docs/eloquent-models/schema-builder.txt +++ b/docs/eloquent-models/schema-builder.txt @@ -21,8 +21,9 @@ Overview -------- Laravel provides a **facade** to access the schema builder class ``Schema``, -which lets you create and modify tables. Facades are static interfaces to -classes that make the syntax more concise and improve testability. +which lets you create and modify tables, or collections in MongoDB. +Facades are static interfaces to classes that make the syntax more +concise and improve testability. The {+odm-short+} supports a subset of the index and collection management methods in the Laravel ``Schema`` facade. @@ -33,16 +34,10 @@ in the Laravel documentation. The following sections describe the Laravel schema builder features available in the {+odm-short+} and show examples of how to use them: -- :ref:`` -- :ref:`` -- :ref:`` - -.. note:: - - The {+odm-short+} supports managing indexes and collections, but - excludes support for MongoDB JSON schemas for data validation. To learn - more about JSON schema validation, see :manual:`Schema Validation ` - in the {+server-docs-name+}. +- :ref:`laravel-eloquent-migrations` +- :ref:`laravel-eloquent-schema-validation` +- :ref:`laravel-eloquent-collection-exists` +- :ref:`laravel-eloquent-indexes` .. _laravel-eloquent-migrations: @@ -117,6 +112,60 @@ To learn more about Laravel migrations, see `Database: Migrations `__ in the Laravel documentation. +.. _laravel-eloquent-schema-validation: + +Implement Schema Validation +--------------------------- + +You can use the ``jsonSchema()`` method to implement :manual:`schema +validation ` when using the following schema +builder methods: + +- ``Schema::create()``: When creating a new collection +- ``Schema::table()``: When updating collection properties + +You can use schema validation to restrict data types and value ranges of +document fields in a specified collection. After you implement schema +validation, the server restricts write operations that don't follow the +validation rules. + +You can pass the following parameters to ``jsonSchema()``: + +- ``schema``: Array that specifies the validation rules for the + collection. To learn more about constructing a schema, see + the :manual:`$jsonSchema ` + reference in the {+server-docs-name+}. + +- ``validationLevel``: Sets the level of validation enforcement. + Accepted values are ``"strict"`` (default) and ``"moderate"``. + +- ``validationAction``: Specifies the action to take when invalid + operations are attempted. Accepted values are ``"error"`` (default) and + ``"warn"``. + +This example demonstrates how to specify a schema in the +``jsonSchema()`` method when creating a collection. The schema +validation has the following specifications: + +- Documents in the ``pilots`` collection must + contain the ``license_number`` field. + +- The ``license_number`` field must have an integer value between + ``1000`` and ``9999``. + +- If you attempt to perform invalid write operations, the server raises + an error. + +.. literalinclude:: /includes/schema-builder/flights_migration.php + :language: php + :dedent: + :start-after: begin-json-schema + :end-before: end-json-schema + +If you attempt to insert a document into the ``pilots`` collection that +violates the schema validation rule, {+odm-long+} returns a +:php:`BulkWriteException `. + .. _laravel-eloquent-collection-exists: Check Whether a Collection Exists diff --git a/docs/includes/schema-builder/flights_migration.php b/docs/includes/schema-builder/flights_migration.php index 861c339ef..4f776f260 100644 --- a/docs/includes/schema-builder/flights_migration.php +++ b/docs/includes/schema-builder/flights_migration.php @@ -19,6 +19,25 @@ public function up(): void $collection->unique('mission_id', options: ['name' => 'unique_mission_id_idx']); }); // end create index + + // begin-json-schema + Schema::create('pilots', function (Blueprint $collection) { + $collection->jsonSchema( + schema: [ + 'bsonType' => 'object', + 'required' => ['license_number'], + 'properties' => [ + 'license_number' => [ + 'bsonType' => 'int', + 'minimum' => 1000, + 'maximum' => 9999, + ], + ], + ], + validationAction: 'error', + ); + }); + // end-json-schema } public function down(): void diff --git a/docs/quick-start/backend-service-tutorial.txt b/docs/quick-start/backend-service-tutorial.txt index 9236c698a..7ecdf8cf8 100644 --- a/docs/quick-start/backend-service-tutorial.txt +++ b/docs/quick-start/backend-service-tutorial.txt @@ -1,8 +1,8 @@ .. _laravel-tutorial-backend-service: -========================================================== +=========================================================== Tutorial: Build a Back End Service by Using {+odm-long+} -========================================================== +=========================================================== .. facet:: :name: genre From e2b119a18a0d8d745da9f72855207266071c81b2 Mon Sep 17 00:00:00 2001 From: Faissal Wahabali Date: Tue, 10 Jun 2025 10:06:45 +0100 Subject: [PATCH 80/92] PHPORM-351 `QueryBuilder` multiply and divide support (#3373) --- src/Query/Builder.php | 41 +++++++++++++++++++++++++++++++ tests/QueryBuilderTest.php | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 9a6701b87..8d57ba4c8 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -890,6 +890,47 @@ public function decrementEach(array $columns, array $extra = [], array $options return $this->incrementEach($decrement, $extra, $options); } + /** + * Multiply a column's value by a given amount. + * + * @param string $column + * @param float|int $amount + * + * @return int + */ + public function multiply($column, $amount, array $extra = [], array $options = []) + { + $query = ['$mul' => [(string) $column => $amount]]; + + if (! empty($extra)) { + $query['$set'] = $extra; + } + + // Protect + $this->where(function ($query) use ($column) { + $query->where($column, 'exists', true); + + $query->whereNotNull($column); + }); + + $options = $this->inheritConnectionOptions($options); + + return $this->performUpdate($query, $options); + } + + /** + * Divide a column's value by a given amount. + * + * @param string $column + * @param float|int $amount + * + * @return int + */ + public function divide($column, $amount, array $extra = [], array $options = []) + { + return $this->multiply($column, 1 / $amount, $extra, $options); + } + /** @inheritdoc */ public function pluck($column, $key = null) { diff --git a/tests/QueryBuilderTest.php b/tests/QueryBuilderTest.php index 1233cda75..5c52b9003 100644 --- a/tests/QueryBuilderTest.php +++ b/tests/QueryBuilderTest.php @@ -1052,6 +1052,55 @@ public function testIncrement() $this->assertEquals(1, $user->age); } + public function testMultiplyAndDivide() + { + DB::table('users')->insert([ + ['name' => 'John Doe', 'salary' => 88000, 'note' => 'senior'], + ['name' => 'Jane Doe', 'salary' => 64000, 'note' => 'junior'], + ['name' => 'Robert Roe', 'salary' => null], + ['name' => 'Mark Moe'], + ]); + + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->multiply('salary', 1); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->divide('salary', 1); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->multiply('salary', 2); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(176000, $user->salary); + + DB::table('users')->where('name', 'John Doe')->divide('salary', 2); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(88000, $user->salary); + + DB::table('users')->where('name', 'Jane Doe')->multiply('salary', 10, ['note' => 'senior']); + $user = DB::table('users')->where('name', 'Jane Doe')->first(); + $this->assertEquals(640000, $user->salary); + $this->assertEquals('senior', $user->note); + + DB::table('users')->where('name', 'John Doe')->divide('salary', 2, ['note' => 'junior']); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(44000, $user->salary); + $this->assertEquals('junior', $user->note); + + DB::table('users')->multiply('salary', 1); + $user = DB::table('users')->where('name', 'John Doe')->first(); + $this->assertEquals(44000, $user->salary); + $user = DB::table('users')->where('name', 'Jane Doe')->first(); + $this->assertEquals(640000, $user->salary); + $user = DB::table('users')->where('name', 'Robert Roe')->first(); + $this->assertNull($user->salary); + $user = DB::table('users')->where('name', 'Mark Moe')->first(); + $this->assertFalse(isset($user->salary)); + } + public function testProjections() { DB::table('items')->insert([ From 765328934be2476728e857f92854f2d9e23d4fc7 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Tue, 10 Jun 2025 09:46:10 -0400 Subject: [PATCH 81/92] DOCSP-43518: logging (#3316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * DOCSP-43518: query logging * formatting * remove controller * tests * tests * tests * tests * test * test * test * test * test * formatting * Fix test on output --------- Co-authored-by: Jérôme Tamarelle --- docs/fundamentals/read-operations.txt | 3 +- .../read-operations/query-logging.txt | 82 +++++++++++++++++++ .../read-operations/ReadOperationsTest.php | 40 +++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 docs/fundamentals/read-operations/query-logging.txt diff --git a/docs/fundamentals/read-operations.txt b/docs/fundamentals/read-operations.txt index 367e2d38d..674615ffb 100644 --- a/docs/fundamentals/read-operations.txt +++ b/docs/fundamentals/read-operations.txt @@ -17,7 +17,8 @@ Read Operations Retrieve Data Search Text Modify Query Results - Set Read Preference + Read Preference + Query Logging .. contents:: On this page :local: diff --git a/docs/fundamentals/read-operations/query-logging.txt b/docs/fundamentals/read-operations/query-logging.txt new file mode 100644 index 000000000..27816b298 --- /dev/null +++ b/docs/fundamentals/read-operations/query-logging.txt @@ -0,0 +1,82 @@ +.. _laravel-query-logging: + +==================== +Enable Query Logging +==================== + +.. facet:: + :name: genre + :values: reference + +.. meta:: + :keywords: monitoring, CRUD, code example + +.. contents:: On this page + :local: + :backlinks: none + :depth: 2 + :class: singlecol + +Overview +-------- + +In this guide, you can learn how to enable query logging in +{+odm-long+}. Query logging can help you debug your queries and monitor +database interactions. + +.. include:: /includes/fundamentals/read-operations/before-you-get-started.rst + +Enable Logs On a Connection +--------------------------- + +To enable logs on a connection, you can use the ``enableQueryLog()`` +method on the ``DB`` facade. This method enables MongoDB command logging +on any queries that you perform on the database connection. + +After you enable query logging, any queries you perform are stored in +memory. To retrieve the logs, use one of the following methods: + +- ``getQueryLog()``: Returns a log of MongoDB queries +- ``getRawQueryLog()``: Returns a log of raw MongoDB queries + +The following example enables query logging, performs some queries, then +prints the query log: + +.. io-code-block:: + :copyable: true + + .. input:: /includes/fundamentals/read-operations/ReadOperationsTest.php + :language: php + :dedent: + :start-after: start-query-log + :end-before: end-query-log + :emphasize-lines: 1, 7 + + .. output:: + :language: json + :visible: false + + { + "query": "{ \"find\" : \"movies\", \"filter\" : { \"title\" : \"Carrie\" } }", + "bindings": [], + "time": 29476 + } + { + "query": "{ \"find\" : \"movies\", \"filter\" : { \"year\" : { \"$lt\" : { \"$numberInt\" : \"2005\" } } } }", + "bindings": [], + "time": 29861 + } + { + "query": "{ \"find\" : \"movies\", \"filter\" : { \"imdb.rating\" : { \"$gt\" : { \"$numberDouble\" : \"8.5\" } } } }", + "bindings": [], + "time": 27251 + } + +Additional Information +---------------------- + +To learn more about connecting to MongoDB, see the +:ref:`laravel-connect-to-mongodb`. + +To learn how to retrieve data based on filter criteria, see the +:ref:`laravel-fundamentals-read-retrieve` guide. diff --git a/docs/includes/fundamentals/read-operations/ReadOperationsTest.php b/docs/includes/fundamentals/read-operations/ReadOperationsTest.php index 207fd442e..414b21d31 100644 --- a/docs/includes/fundamentals/read-operations/ReadOperationsTest.php +++ b/docs/includes/fundamentals/read-operations/ReadOperationsTest.php @@ -9,6 +9,13 @@ use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Tests\TestCase; +use function json_encode; +use function ob_get_flush; +use function ob_start; + +use const JSON_PRETTY_PRINT; +use const PHP_EOL; + class ReadOperationsTest extends TestCase { protected function setUp(): void @@ -183,4 +190,37 @@ public function testReadPreference(): void $this->assertNotNull($movies); $this->assertCount(2, $movies); } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testQueryLog(): void + { + $output = ''; + ob_start(function (string $buffer) use (&$output) { + $output .= $buffer; + }); + // start-query-log + DB::connection('mongodb')->enableQueryLog(); + + Movie::where('title', 'Carrie')->get(); + Movie::where('year', '<', 2005)->get(); + Movie::where('imdb.rating', '>', 8.5)->get(); + + $logs = DB::connection('mongodb')->getQueryLog(); + foreach ($logs as $log) { + echo json_encode($log, JSON_PRETTY_PRINT) . PHP_EOL; + } + + // end-query-log + $output = ob_get_flush(); + $this->assertNotNull($logs); + $this->assertNotEmpty($output); + + $this->assertStringContainsString('"query": "{ \"find\" : \"movies\", \"filter\" : { \"title\" : \"Carrie\" } }"', $output); + $this->assertStringContainsString('"query": "{ \"find\" : \"movies\", \"filter\" : { \"imdb.rating\" : { \"$gt\" : { \"$numberDouble\" : \"8.5\" } } } }"', $output); + $this->assertStringContainsString('"query": "{ \"find\" : \"movies\", \"filter\" : { \"imdb.rating\" : { \"$gt\" : { \"$numberDouble\" : \"8.5\" } } } }"', $output); + $this->assertMatchesRegularExpression('/"time": \d+/', $output); + } } From b952e768cf861de83cb33b68511caff899758f0f Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 11 Jun 2025 17:36:44 +0200 Subject: [PATCH 82/92] Bump PHP to 8.2 in Docker test container (#3399) --- Dockerfile | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 43529d9e4..39e37531d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG PHP_VERSION=8.1 +ARG PHP_VERSION=8.2 FROM php:${PHP_VERSION}-cli diff --git a/docker-compose.yml b/docker-compose.yml index fc0f0e49a..463da5f79 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: mongodb: container_name: mongodb - image: mongodb/mongodb-atlas-local:latest + image: mongodb/mongodb-atlas-local:8 ports: - "27017:27017" healthcheck: From 456326b75b5d74143878c4fcd2ea8f014adc189c Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:32:22 -0400 Subject: [PATCH 83/92] DOCSP-50607: multiply/divide QB methods (#3403) * DOCSP-50607: multiply/divide QB methods * separate mul & div examples * add versioning * wip --- docs/eloquent-models/schema-builder.txt | 6 +-- .../query-builder/QueryBuilderTest.php | 39 +++++++++++++++---- docs/query-builder.txt | 36 +++++++++++++++++ 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/docs/eloquent-models/schema-builder.txt b/docs/eloquent-models/schema-builder.txt index e9c1dff17..a3e1df913 100644 --- a/docs/eloquent-models/schema-builder.txt +++ b/docs/eloquent-models/schema-builder.txt @@ -117,9 +117,9 @@ in the Laravel documentation. Implement Schema Validation --------------------------- -You can use the ``jsonSchema()`` method to implement :manual:`schema -validation ` when using the following schema -builder methods: +Starting in {+odm-short+} v5.5, you can use the ``jsonSchema()`` method +to implement :manual:`schema validation ` when +using the following schema builder methods: - ``Schema::create()``: When creating a new collection - ``Schema::table()``: When updating collection properties diff --git a/docs/includes/query-builder/QueryBuilderTest.php b/docs/includes/query-builder/QueryBuilderTest.php index 3f7ea2274..a90f1685f 100644 --- a/docs/includes/query-builder/QueryBuilderTest.php +++ b/docs/includes/query-builder/QueryBuilderTest.php @@ -213,10 +213,10 @@ public function testGroupBy(): void { // begin query groupBy $result = DB::table('movies') - ->where('rated', 'G') - ->groupBy('runtime') - ->orderBy('runtime', 'asc') - ->get(['title']); + ->where('rated', 'G') + ->groupBy('runtime') + ->orderBy('runtime', 'asc') + ->get(['title']); // end query groupBy $this->assertInstanceOf(\Illuminate\Support\Collection::class, $result); @@ -420,10 +420,10 @@ public function testWhereRaw(): void // begin query raw $result = DB::table('movies') ->whereRaw([ - 'imdb.votes' => ['$gte' => 1000 ], + 'imdb.votes' => ['$gte' => 1000], '$or' => [ ['imdb.rating' => ['$gt' => 7]], - ['directors' => ['$in' => [ 'Yasujiro Ozu', 'Sofia Coppola', 'Federico Fellini' ]]], + ['directors' => ['$in' => ['Yasujiro Ozu', 'Sofia Coppola', 'Federico Fellini']]], ], ])->get(); // end query raw @@ -470,7 +470,7 @@ public function testNear(): void { $this->importTheaters(); - // begin query near + // begin query near $results = DB::table('theaters') ->where('location.geo', 'near', [ '$geometry' => [ @@ -588,7 +588,7 @@ public function testUpdateUpsert(): void [ 'plot' => 'An autobiographical movie', 'year' => 1998, - 'writers' => [ 'Will Hunting' ], + 'writers' => ['Will Hunting'], ], ['upsert' => true], ); @@ -597,6 +597,29 @@ public function testUpdateUpsert(): void $this->assertIsInt($result); } + public function testMultiplyDivide(): void + { + // begin multiply divide + $result = DB::table('movies') + ->where('year', 2001) + ->multiply('imdb.votes', 5); + + $result = DB::table('movies') + ->where('year', 2001) + ->divide('runtime', 2); + // end multiply divide + + $this->assertIsInt($result); + + // begin multiply with set + $result = DB::table('movies') + ->where('year', 1958) + ->multiply('runtime', 1.5, ['note' => 'Adds recovered footage.']); + // end multiply with set + + $this->assertIsInt($result); + } + public function testIncrement(): void { // begin increment diff --git a/docs/query-builder.txt b/docs/query-builder.txt index a73d5e791..2358ed7d5 100644 --- a/docs/query-builder.txt +++ b/docs/query-builder.txt @@ -1169,6 +1169,7 @@ This section includes query builder examples that show how to use the following MongoDB-specific write operations: - :ref:`Upsert a document ` +- :ref:`Multiply and divide values ` - :ref:`Increment a numerical value ` - :ref:`Decrement a numerical value ` - :ref:`Add an array element ` @@ -1252,6 +1253,41 @@ and the ``title`` field and value specified in the ``where()`` query operation: The ``update()`` query builder method returns the number of documents that the operation updated or inserted. +.. _laravel-mongodb-query-builder-mul-div: + +Multiply and Divide Numerical Values Example +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Starting in {+odm-short+} v5.5, you can perform multiplication and +division operations on numerical values by using the ``multiply()`` and +``divide()`` query builder methods. + +The following example shows how to use the ``multiply()`` and +``divide()`` methods to manipulate the values of the +``imdb.votes`` and ``runtime`` fields: + +.. literalinclude:: /includes/query-builder/QueryBuilderTest.php + :language: php + :dedent: + :start-after: begin multiply divide + :end-before: end multiply divide + +.. tip:: update() Method + + You can perform the same operations by using the ``update()`` + method and passing an update document that includes the :manual:`$mul + ` operator. To learn more about + ``update()``, see the :ref:`laravel-fundamentals-write-modify` guide. + +You can optionally pass an array parameter to perform a ``$set`` update +in the same operation, as shown in the following example: + +.. literalinclude:: /includes/query-builder/QueryBuilderTest.php + :language: php + :dedent: + :start-after: begin multiply with set + :end-before: end multiply with set + .. _laravel-mongodb-query-builder-increment: Increment a Numerical Value Example From 3a7443f054374061cd745ee18cce8c937f3cf2ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:55:09 +0200 Subject: [PATCH 84/92] Bump stefanzweifel/git-auto-commit-action from 5 to 6 (#3409) Bumps [stefanzweifel/git-auto-commit-action](https://github.com/stefanzweifel/git-auto-commit-action) from 5 to 6. - [Release notes](https://github.com/stefanzweifel/git-auto-commit-action/releases) - [Changelog](https://github.com/stefanzweifel/git-auto-commit-action/blob/master/CHANGELOG.md) - [Commits](https://github.com/stefanzweifel/git-auto-commit-action/compare/v5...v6) --- updated-dependencies: - dependency-name: stefanzweifel/git-auto-commit-action dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/coding-standards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index dee6d9aab..c79d91bb8 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -67,6 +67,6 @@ jobs: run: "vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr" - name: "Commit the changes" - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@v6 with: commit_message: "apply phpcbf formatting" From aad17bb7ac964a41022db95815ed043f4497462b Mon Sep 17 00:00:00 2001 From: Andreas Braun Date: Tue, 17 Jun 2025 18:55:39 +0200 Subject: [PATCH 85/92] Limit CI workflows to supported branches (#3411) * Fix YAML indentation * Limit CI workflows to supported branches * Run CI for feature branches --- .github/workflows/build-ci-atlas.yml | 176 ++++++++++--------- .github/workflows/build-ci.yml | 230 +++++++++++++------------ .github/workflows/coding-standards.yml | 6 + .github/workflows/merge-up.yml | 1 + .github/workflows/static-analysis.yml | 6 + 5 files changed, 222 insertions(+), 197 deletions(-) diff --git a/.github/workflows/build-ci-atlas.yml b/.github/workflows/build-ci-atlas.yml index 46f30150d..41f14e376 100644 --- a/.github/workflows/build-ci-atlas.yml +++ b/.github/workflows/build-ci-atlas.yml @@ -1,94 +1,100 @@ name: "Atlas CI" on: - push: - pull_request: + push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" + pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" env: MONGODB_EXT_V1: mongodb-1.21.0 MONGODB_EXT_V2: stable jobs: - build: - runs-on: "${{ matrix.os }}" - - name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }}" - - strategy: - matrix: - os: - - "ubuntu-latest" - php: - - "8.2" - - "8.3" - - "8.4" - laravel: - - "11.*" - - "12.*" - driver: - - 1 - include: - - php: "8.4" - laravel: "12.*" - os: "ubuntu-latest" - driver: 2 - - steps: - - uses: "actions/checkout@v4" - - - name: "Create MongoDB Atlas Local" - run: | - docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:latest - 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 }} - extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} - key: "extcache-v1" - - - name: "Installing php" - uses: "shivammathur/setup-php@v2" - with: - php-version: ${{ matrix.php }} - extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" - coverage: "xdebug" - tools: "composer" - - - name: "Show Docker version" - if: ${{ runner.debug }} - run: "docker version && env" - - - name: "Restrict Laravel version" - run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" - - - name: "Download Composer cache dependencies from cache" - id: "composer-cache" - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: "Cache Composer dependencies" - uses: "actions/cache@v4" - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" - restore-keys: "${{ matrix.os }}-composer-" - - - name: "Install dependencies" - run: | - composer update --no-interaction - - - name: "Run tests" - run: | - export MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" - php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search + build: + runs-on: "${{ matrix.os }}" + + name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }}" + + strategy: + matrix: + os: + - "ubuntu-latest" + php: + - "8.2" + - "8.3" + - "8.4" + laravel: + - "11.*" + - "12.*" + driver: + - 1 + include: + - php: "8.4" + laravel: "12.*" + os: "ubuntu-latest" + driver: 2 + + steps: + - uses: "actions/checkout@v4" + + - name: "Create MongoDB Atlas Local" + run: | + docker run --name mongodb -p 27017:27017 --detach mongodb/mongodb-atlas-local:latest + 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 }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + + - name: "Installing php" + uses: "shivammathur/setup-php@v2" + with: + php-version: ${{ matrix.php }} + extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" + coverage: "xdebug" + tools: "composer" + + - name: "Show Docker version" + if: ${{ runner.debug }} + run: "docker version && env" + + - name: "Restrict Laravel version" + run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" + + - name: "Download Composer cache dependencies from cache" + id: "composer-cache" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: "Cache Composer dependencies" + uses: "actions/cache@v4" + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ matrix.os }}-composer-" + + - name: "Install dependencies" + run: | + composer update --no-interaction + + - name: "Run tests" + run: | + export MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --group atlas-search diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index bbc8b53d1..f94a32e79 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -1,121 +1,127 @@ name: "CI" on: - push: - pull_request: + push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" + pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" env: MONGODB_EXT_V1: mongodb-1.21.0 MONGODB_EXT_V2: stable jobs: - build: - runs-on: "${{ matrix.os }}" - - name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }} Server/${{ matrix.mongodb }} ${{ matrix.mode }}" - - strategy: - matrix: - os: - - "ubuntu-latest" - mongodb: - - "4.4" - - "5.0" - - "6.0" - - "7.0" - - "8.0" - php: - - "8.1" - - "8.2" - - "8.3" - - "8.4" - laravel: - - "10.*" - - "11.*" - - "12.*" - driver: - - 2 - include: - - php: "8.1" - laravel: "10.*" - mongodb: "5.0" - mode: "low-deps" - os: "ubuntu-latest" - driver: 1 - - php: "8.3" - laravel: "11.*" - mongodb: "8.0" - os: "ubuntu-latest" - driver: 1 - - php: "8.4" - laravel: "12.*" - mongodb: "8.0" - os: "ubuntu-latest" - driver: 1 - exclude: - - php: "8.1" - laravel: "11.*" - - php: "8.1" - laravel: "12.*" - - steps: - - uses: "actions/checkout@v4" - - - name: "Create MongoDB Replica Set" - run: | - docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 - - if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi - until docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ ping: 1 })"; do - sleep 1 - done - sudo docker exec --tty mongodb $MONGOSH_BIN --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" - - - name: "Show MongoDB server status" - run: | - if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi - docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ serverStatus: 1 })" - - - name: Setup cache environment - id: extcache - uses: shivammathur/cache-extensions@v1 - with: - php-version: ${{ matrix.php }} - extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} - key: "extcache-v1" - - - name: "Installing php" - uses: "shivammathur/setup-php@v2" - with: - php-version: ${{ matrix.php }} - extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" - coverage: "xdebug" - tools: "composer" - - - name: "Show Docker version" - if: ${{ runner.debug }} - run: "docker version && env" - - - name: "Restrict Laravel version" - run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" - - - name: "Download Composer cache dependencies from cache" - id: "composer-cache" - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: "Cache Composer dependencies" - uses: "actions/cache@v4" - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" - restore-keys: "${{ matrix.os }}-composer-" - - - name: "Install dependencies" - run: | - composer update --no-interaction \ - $([[ "${{ matrix.mode }}" == low-deps ]] && echo ' --prefer-lowest') \ - $([[ "${{ matrix.mode }}" == ignore-php-req ]] && echo ' --ignore-platform-req=php+') - - name: "Run tests" - run: | - export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" - php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search + build: + runs-on: "${{ matrix.os }}" + + name: "PHP/${{ matrix.php }} Laravel/${{ matrix.laravel }} Driver/${{ matrix.driver }} Server/${{ matrix.mongodb }} ${{ matrix.mode }}" + + strategy: + matrix: + os: + - "ubuntu-latest" + mongodb: + - "4.4" + - "5.0" + - "6.0" + - "7.0" + - "8.0" + php: + - "8.1" + - "8.2" + - "8.3" + - "8.4" + laravel: + - "10.*" + - "11.*" + - "12.*" + driver: + - 2 + include: + - php: "8.1" + laravel: "10.*" + mongodb: "5.0" + mode: "low-deps" + os: "ubuntu-latest" + driver: 1 + - php: "8.3" + laravel: "11.*" + mongodb: "8.0" + os: "ubuntu-latest" + driver: 1 + - php: "8.4" + laravel: "12.*" + mongodb: "8.0" + os: "ubuntu-latest" + driver: 1 + exclude: + - php: "8.1" + laravel: "11.*" + - php: "8.1" + laravel: "12.*" + + steps: + - uses: "actions/checkout@v4" + + - name: "Create MongoDB Replica Set" + run: | + docker run --name mongodb -p 27017:27017 -e MONGO_INITDB_DATABASE=unittest --detach mongo:${{ matrix.mongodb }} mongod --replSet rs --setParameter transactionLifetimeLimitSeconds=5 + + if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi + until docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ ping: 1 })"; do + sleep 1 + done + sudo docker exec --tty mongodb $MONGOSH_BIN --eval "rs.initiate({\"_id\":\"rs\",\"members\":[{\"_id\":0,\"host\":\"127.0.0.1:27017\" }]})" + + - name: "Show MongoDB server status" + run: | + if [ "${{ matrix.mongodb }}" = "4.4" ]; then MONGOSH_BIN="mongo"; else MONGOSH_BIN="mongosh"; fi + docker exec --tty mongodb $MONGOSH_BIN --eval "db.runCommand({ serverStatus: 1 })" + + - name: Setup cache environment + id: extcache + uses: shivammathur/cache-extensions@v1 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }} + key: "extcache-v1" + + - name: "Installing php" + uses: "shivammathur/setup-php@v2" + with: + php-version: ${{ matrix.php }} + extensions: "curl,mbstring,xdebug,${{ matrix.driver == 1 && env.MONGODB_EXT_V1 || env.MONGODB_EXT_V2 }}" + coverage: "xdebug" + tools: "composer" + + - name: "Show Docker version" + if: ${{ runner.debug }} + run: "docker version && env" + + - name: "Restrict Laravel version" + run: "composer require --dev --no-update 'laravel/framework:${{ matrix.laravel }}'" + + - name: "Download Composer cache dependencies from cache" + id: "composer-cache" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: "Cache Composer dependencies" + uses: "actions/cache@v4" + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: "${{ matrix.os }}-composer-${{ hashFiles('**/composer.json') }}" + restore-keys: "${{ matrix.os }}-composer-" + + - name: "Install dependencies" + run: | + composer update --no-interaction \ + $([[ "${{ matrix.mode }}" == low-deps ]] && echo ' --prefer-lowest') \ + $([[ "${{ matrix.mode }}" == ignore-php-req ]] && echo ' --ignore-platform-req=php+') + - name: "Run tests" + run: | + export MONGODB_URI="mongodb://127.0.0.1:27017/?replicaSet=rs" + php -d zend.assertions=1 ./vendor/bin/phpunit --coverage-clover coverage.xml --exclude-group atlas-search diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index c79d91bb8..4d0eda3f9 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -2,7 +2,13 @@ name: "Coding Standards" on: push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" env: PHP_VERSION: "8.4" diff --git a/.github/workflows/merge-up.yml b/.github/workflows/merge-up.yml index 1ddbb7228..2ed3feaea 100644 --- a/.github/workflows/merge-up.yml +++ b/.github/workflows/merge-up.yml @@ -4,6 +4,7 @@ on: push: branches: - "[0-9]+.[0-9x]+" + - "feature/*" env: GH_TOKEN: ${{ secrets.MERGE_UP_TOKEN }} diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index e0c907953..8bc18e0f9 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -2,7 +2,13 @@ name: "Static Analysis" on: push: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" pull_request: + branches: + - "[0-9]+.[0-9x]+" + - "feature/*" workflow_call: inputs: ref: From b02b40c120be6dee9ebf63ff0b7bc42464c8fce4 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Thu, 19 Jun 2025 15:31:48 +0200 Subject: [PATCH 86/92] Improve error handling on unsupported hybrid queries (#3404) Hybrid belongs-to-many relationships are not supported for query constraints. However, the support check was done downstream of a bunch of Eloquent stuff, resulting in the user getting an exception that didn't tell them anything about the usage being unsupported. This moves that check further up the chain so that the user is alerted to the lack of support before we do anything else. --- src/Helpers/QueriesRelationships.php | 27 +++++++++++++++++++++++- tests/HybridRelationsTest.php | 31 +++++++++++++++++++++------- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/Helpers/QueriesRelationships.php b/src/Helpers/QueriesRelationships.php index 1f1ffa34b..29d708e3c 100644 --- a/src/Helpers/QueriesRelationships.php +++ b/src/Helpers/QueriesRelationships.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasOneOrMany; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Collection; +use LogicException; use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Relations\MorphToMany; @@ -104,6 +105,8 @@ protected function isAcrossConnections(Relation $relation) */ public function addHybridHas(Relation $relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null) { + $this->assertHybridRelationSupported($relation); + $hasQuery = $relation->getQuery(); if ($callback) { $hasQuery->callScope($callback); @@ -128,6 +131,26 @@ public function addHybridHas(Relation $relation, $operator = '>=', $count = 1, $ return $this->whereIn($this->getRelatedConstraintKey($relation), $relatedIds, $boolean, $not); } + /** + * @param Relation $relation + * + * @return void + * + * @throws Exception + */ + private function assertHybridRelationSupported(Relation $relation): void + { + if ( + $relation instanceof HasOneOrMany + || $relation instanceof BelongsTo + || ($relation instanceof BelongsToMany && ! $this->isAcrossConnections($relation)) + ) { + return; + } + + throw new LogicException(class_basename($relation) . ' is not supported for hybrid query constraints.'); + } + /** * @param Builder $hasQuery * @param Relation $relation @@ -213,6 +236,8 @@ protected function getConstrainedRelatedIds($relations, $operator, $count) */ protected function getRelatedConstraintKey(Relation $relation) { + $this->assertHybridRelationSupported($relation); + if ($relation instanceof HasOneOrMany) { return $relation->getLocalKeyName(); } @@ -221,7 +246,7 @@ protected function getRelatedConstraintKey(Relation $relation) return $relation->getForeignKeyName(); } - if ($relation instanceof BelongsToMany && ! $this->isAcrossConnections($relation)) { + if ($relation instanceof BelongsToMany) { return $this->model->getKeyName(); } diff --git a/tests/HybridRelationsTest.php b/tests/HybridRelationsTest.php index 08423007c..71fb0830b 100644 --- a/tests/HybridRelationsTest.php +++ b/tests/HybridRelationsTest.php @@ -78,7 +78,7 @@ public function testSqlRelations() $this->assertEquals('John Doe', $role->sqlUser->name); // MongoDB User - $user = new User(); + $user = new User(); $user->name = 'John Doe'; $user->save(); @@ -105,7 +105,7 @@ public function testSqlRelations() public function testHybridWhereHas() { - $user = new SqlUser(); + $user = new SqlUser(); $otherUser = new SqlUser(); $this->assertInstanceOf(SqlUser::class, $user); $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); @@ -114,11 +114,11 @@ public function testHybridWhereHas() // SQL User $user->name = 'John Doe'; - $user->id = 2; + $user->id = 2; $user->save(); // Other user $otherUser->name = 'Other User'; - $otherUser->id = 3; + $otherUser->id = 3; $otherUser->save(); // Make sure they are created $this->assertIsInt($user->id); @@ -159,7 +159,7 @@ public function testHybridWhereHas() public function testHybridWith() { - $user = new SqlUser(); + $user = new SqlUser(); $otherUser = new SqlUser(); $this->assertInstanceOf(SqlUser::class, $user); $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); @@ -168,11 +168,11 @@ public function testHybridWith() // SQL User $user->name = 'John Doe'; - $user->id = 2; + $user->id = 2; $user->save(); // Other user $otherUser->name = 'Other User'; - $otherUser->id = 3; + $otherUser->id = 3; $otherUser->save(); // Make sure they are created $this->assertIsInt($user->id); @@ -268,6 +268,23 @@ public function testHybridBelongsToMany() $this->assertEquals(1, $check->skills->count()); } + public function testQueryingHybridBelongsToManyRelationFails() + { + $user = new SqlUser(); + $this->assertInstanceOf(SQLiteConnection::class, $user->getConnection()); + + // Create Mysql Users + $user->fill(['name' => 'John Doe'])->save(); + $skill = Skill::query()->create(['name' => 'MongoDB']); + $user->skills()->save($skill); + + $this->expectExceptionMessage('BelongsToMany is not supported for hybrid query constraints.'); + + SqlUser::whereHas('skills', function ($query) { + return $query->where('name', 'LIKE', 'MongoDB'); + }); + } + public function testHybridMorphToManySqlModelToMongoModel() { // SqlModel -> MorphToMany -> MongoModel From 21d4d280702d119f2b33132e33d87f29c9936d5b Mon Sep 17 00:00:00 2001 From: Michael Morisi Date: Mon, 23 Jun 2025 09:44:43 -0400 Subject: [PATCH 87/92] DOCSP-50781: Update SoftDeletes references (#3413) --- docs/eloquent-models/model-class.txt | 2 +- docs/includes/eloquent-models/PlanetSoftDelete.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/eloquent-models/model-class.txt b/docs/eloquent-models/model-class.txt index 6f686e88a..da820b18c 100644 --- a/docs/eloquent-models/model-class.txt +++ b/docs/eloquent-models/model-class.txt @@ -168,7 +168,7 @@ Eloquent includes a soft delete feature that changes the behavior of the database. It sets a timestamp on the ``deleted_at`` field to exclude it from retrieve operations automatically. -To enable soft deletes on a class, add the ``MongoDB\Laravel\Eloquent\SoftDeletes`` +To enable soft deletes on a class, add the ``Illuminate\Database\Eloquent\SoftDeletes`` trait as shown in the following code example: .. literalinclude:: /includes/eloquent-models/PlanetSoftDelete.php diff --git a/docs/includes/eloquent-models/PlanetSoftDelete.php b/docs/includes/eloquent-models/PlanetSoftDelete.php index 05d106206..70ccba24b 100644 --- a/docs/includes/eloquent-models/PlanetSoftDelete.php +++ b/docs/includes/eloquent-models/PlanetSoftDelete.php @@ -2,8 +2,8 @@ namespace App\Models; +use Illuminate\Database\Eloquent\SoftDeletes; use MongoDB\Laravel\Eloquent\Model; -use MongoDB\Laravel\Eloquent\SoftDeletes; class Planet extends Model { From 01d7af93969dca0d6d6eeb8ea4abeaa22b1731a6 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Tue, 1 Jul 2025 12:35:18 +0200 Subject: [PATCH 88/92] Deprecate Mongo soft deletes trait (#3408) In favor of the Laravel one. Ours is now obsolete; the SoftDeletes trait is only necessary to remove the call to qualifyColumn in the parent trait. But the DocumentModel::qualifyColumn is already disabled --- src/Eloquent/MassPrunable.php | 1 + src/Eloquent/SoftDeletes.php | 8 ++++++++ tests/Models/Soft.php | 2 +- tests/Scout/Models/ScoutUser.php | 2 +- 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Eloquent/MassPrunable.php b/src/Eloquent/MassPrunable.php index 98e947842..ecf033a3b 100644 --- a/src/Eloquent/MassPrunable.php +++ b/src/Eloquent/MassPrunable.php @@ -5,6 +5,7 @@ namespace MongoDB\Laravel\Eloquent; use Illuminate\Database\Eloquent\MassPrunable as EloquentMassPrunable; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Events\ModelsPruned; use function class_uses_recursive; diff --git a/src/Eloquent/SoftDeletes.php b/src/Eloquent/SoftDeletes.php index 135c55dcf..438219f3c 100644 --- a/src/Eloquent/SoftDeletes.php +++ b/src/Eloquent/SoftDeletes.php @@ -4,6 +4,14 @@ namespace MongoDB\Laravel\Eloquent; +use function sprintf; +use function trigger_error; + +use const E_USER_DEPRECATED; + +trigger_error(sprintf('Since mongodb/laravel-mongodb:5.5, trait "%s" is deprecated, use "%s" instead.', SoftDeletes::class, \Illuminate\Database\Eloquent\SoftDeletes::class), E_USER_DEPRECATED); + +/** @deprecated since mongodb/laravel-mongodb:5.5, use \Illuminate\Database\Eloquent\SoftDeletes instead */ trait SoftDeletes { use \Illuminate\Database\Eloquent\SoftDeletes; diff --git a/tests/Models/Soft.php b/tests/Models/Soft.php index f887d05a9..999d13fd4 100644 --- a/tests/Models/Soft.php +++ b/tests/Models/Soft.php @@ -6,10 +6,10 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; use MongoDB\Laravel\Eloquent\Builder; use MongoDB\Laravel\Eloquent\DocumentModel; use MongoDB\Laravel\Eloquent\MassPrunable; -use MongoDB\Laravel\Eloquent\SoftDeletes; /** @property Carbon $deleted_at */ class Soft extends Model diff --git a/tests/Scout/Models/ScoutUser.php b/tests/Scout/Models/ScoutUser.php index 50fa39a94..581606f75 100644 --- a/tests/Scout/Models/ScoutUser.php +++ b/tests/Scout/Models/ScoutUser.php @@ -5,11 +5,11 @@ namespace MongoDB\Laravel\Tests\Scout\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Schema\SQLiteBuilder; use Illuminate\Support\Facades\Schema; use Laravel\Scout\Searchable; -use MongoDB\Laravel\Eloquent\SoftDeletes; use function assert; From 08d21aa8e5ca0f169547563ff6a61e86d9f3fd2d Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 2 Jul 2025 08:49:00 +0200 Subject: [PATCH 89/92] Add Mongo builder mixin to `DocumentModel` (#3417) It helps with autocompletion as IDEs will recognize Mongo builder methods that are not present on the base builder. --- src/Eloquent/Builder.php | 2 +- src/Eloquent/DocumentModel.php | 1 + tests/Models/Anniversary.php | 6 ------ tests/Models/HiddenAnimal.php | 6 ------ 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 7dba7b7ab..998baef05 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -69,7 +69,7 @@ class Builder extends EloquentBuilder ]; /** - * @return ($function is null ? AggregationBuilder : self) + * @return ($function is null ? AggregationBuilder : $this) * * @inheritdoc */ diff --git a/src/Eloquent/DocumentModel.php b/src/Eloquent/DocumentModel.php index 965b1a444..f8d399e62 100644 --- a/src/Eloquent/DocumentModel.php +++ b/src/Eloquent/DocumentModel.php @@ -50,6 +50,7 @@ use function strlen; use function var_export; +/** @mixin Builder */ trait DocumentModel { use HybridRelations; diff --git a/tests/Models/Anniversary.php b/tests/Models/Anniversary.php index fb78c9a55..c37196c16 100644 --- a/tests/Models/Anniversary.php +++ b/tests/Models/Anniversary.php @@ -6,16 +6,10 @@ use Illuminate\Database\Eloquent\Model; use MongoDB\Laravel\Eloquent\DocumentModel; -use MongoDB\Laravel\Eloquent\Model as Eloquent; -use MongoDB\Laravel\Query\Builder; /** * @property string $name * @property string $anniversary - * @mixin Eloquent - * @method static Builder create(...$values) - * @method static Builder truncate() - * @method static Eloquent sole(...$parameters) */ class Anniversary extends Model { diff --git a/tests/Models/HiddenAnimal.php b/tests/Models/HiddenAnimal.php index 240238da0..f6217177c 100644 --- a/tests/Models/HiddenAnimal.php +++ b/tests/Models/HiddenAnimal.php @@ -6,17 +6,11 @@ use Illuminate\Database\Eloquent\Model; use MongoDB\Laravel\Eloquent\DocumentModel; -use MongoDB\Laravel\Eloquent\Model as Eloquent; -use MongoDB\Laravel\Query\Builder; /** * @property string $name * @property string $country * @property bool $can_be_eaten - * @mixin Eloquent - * @method static Builder create(...$values) - * @method static Builder truncate() - * @method static Eloquent sole(...$parameters) */ final class HiddenAnimal extends Model { From 63da42ce1d66f24cb914300a6f526308055fd351 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Thu, 3 Jul 2025 15:43:46 +0200 Subject: [PATCH 90/92] PHPORM-146: Add override attribute everywhere (#3412) * Add `#[Override]` attributes to `Query\Builder` to keep track of the methods we're overriding on the base builder * Add `#[Override]` attribute to `MongoBatchRepository` * Add `#[Override]` attribute to `MongoLock` * Add attribute to Eloquent builder * Add `#[Override]` attribute to `MongoQueue` * Add `#[Override]` attribute to relationship classes * Add `#[Override]` attribute to `Schema` namespace classes * Add attribute to session handler * Add `#[Override]` attribute to Validation namespace * Add `#[Override]` attribute to `CommandSubscriber` * Add `#[Override] attribute to `Connection` * Add `#[Override]` attribute to `ServiceProvider` implementations --- src/Bus/MongoBatchRepository.php | 2 + src/Cache/MongoLock.php | 1 + src/CommandSubscriber.php | 4 ++ src/Connection.php | 11 +++++ src/Eloquent/Builder.php | 14 ++++-- src/MongoDBBusServiceProvider.php | 4 ++ src/MongoDBServiceProvider.php | 2 + src/Query/Builder.php | 49 +++++++++++++++++++- src/Queue/MongoQueue.php | 10 +++- src/Relations/BelongsTo.php | 6 +++ src/Relations/BelongsToMany.php | 20 ++++++-- src/Relations/EmbedsOneOrMany.php | 19 ++++---- src/Relations/HasMany.php | 8 ++-- src/Relations/HasOne.php | 12 ++--- src/Relations/MorphMany.php | 9 +--- src/Relations/MorphTo.php | 12 ++--- src/Relations/MorphToMany.php | 29 ++++++------ src/Schema/Blueprint.php | 9 ++++ src/Schema/Builder.php | 33 +++++++++++-- src/Session/MongoDbSessionHandler.php | 6 +++ src/Validation/DatabasePresenceVerifier.php | 26 ++--------- src/Validation/ValidationServiceProvider.php | 2 + 22 files changed, 208 insertions(+), 80 deletions(-) diff --git a/src/Bus/MongoBatchRepository.php b/src/Bus/MongoBatchRepository.php index 2656bbc30..afd95e0b2 100644 --- a/src/Bus/MongoBatchRepository.php +++ b/src/Bus/MongoBatchRepository.php @@ -216,6 +216,7 @@ public function prune(DateTimeInterface $before): int } /** Prune all the unfinished entries older than the given date. */ + #[Override] public function pruneUnfinished(DateTimeInterface $before): int { $result = $this->collection->deleteMany( @@ -229,6 +230,7 @@ public function pruneUnfinished(DateTimeInterface $before): int } /** Prune all the cancelled entries older than the given date. */ + #[Override] public function pruneCancelled(DateTimeInterface $before): int { $result = $this->collection->deleteMany( diff --git a/src/Cache/MongoLock.php b/src/Cache/MongoLock.php index d273b4d99..50d04c7ce 100644 --- a/src/Cache/MongoLock.php +++ b/src/Cache/MongoLock.php @@ -41,6 +41,7 @@ public function __construct( /** * Attempt to acquire the lock. */ + #[Override] public function acquire(): bool { // The lock can be acquired if: it doesn't exist, it has expired, diff --git a/src/CommandSubscriber.php b/src/CommandSubscriber.php index 5daa6e97a..1cad23280 100644 --- a/src/CommandSubscriber.php +++ b/src/CommandSubscriber.php @@ -7,6 +7,7 @@ use MongoDB\Driver\Monitoring\CommandStartedEvent; use MongoDB\Driver\Monitoring\CommandSubscriber as CommandSubscriberInterface; use MongoDB\Driver\Monitoring\CommandSucceededEvent; +use Override; use function get_object_vars; use function in_array; @@ -21,16 +22,19 @@ public function __construct(private Connection $connection) { } + #[Override] public function commandStarted(CommandStartedEvent $event): void { $this->commands[$event->getOperationId()] = $event; } + #[Override] public function commandFailed(CommandFailedEvent $event): void { $this->logQuery($event); } + #[Override] public function commandSucceeded(CommandSucceededEvent $event): void { $this->logQuery($event); diff --git a/src/Connection.php b/src/Connection.php index 3fa99e94b..780cad321 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -16,6 +16,7 @@ use MongoDB\Driver\ReadPreference; use MongoDB\Laravel\Concerns\ManagesTransactions; use OutOfBoundsException; +use Override; use Throwable; use function filter_var; @@ -95,6 +96,7 @@ public function __construct(array $config) * * @return Query\Builder */ + #[Override] public function table($table, $as = null) { $query = new Query\Builder($this, $this->getQueryGrammar(), $this->getPostProcessor()); @@ -115,6 +117,7 @@ public function getCollection($name): Collection } /** @inheritdoc */ + #[Override] public function getSchemaBuilder() { return new Schema\Builder($this); @@ -172,6 +175,8 @@ public function getClient(): ?Client return $this->connection; } + /** @inheritdoc */ + #[Override] public function enableQueryLog() { parent::enableQueryLog(); @@ -182,6 +187,7 @@ public function enableQueryLog() } } + #[Override] public function disableQueryLog() { parent::disableQueryLog(); @@ -192,6 +198,7 @@ public function disableQueryLog() } } + #[Override] protected function withFreshQueryLog($callback) { try { @@ -340,6 +347,7 @@ protected function getDsn(array $config): string } /** @inheritdoc */ + #[Override] public function getDriverName() { return 'mongodb'; @@ -352,12 +360,14 @@ public function getDriverTitle() } /** @inheritdoc */ + #[Override] protected function getDefaultPostProcessor() { return new Query\Processor(); } /** @inheritdoc */ + #[Override] protected function getDefaultQueryGrammar() { // Argument added in Laravel 12 @@ -365,6 +375,7 @@ protected function getDefaultQueryGrammar() } /** @inheritdoc */ + #[Override] protected function getDefaultSchemaGrammar() { // Argument added in Laravel 12 diff --git a/src/Eloquent/Builder.php b/src/Eloquent/Builder.php index 998baef05..5d4018f9d 100644 --- a/src/Eloquent/Builder.php +++ b/src/Eloquent/Builder.php @@ -18,6 +18,7 @@ use MongoDB\Laravel\Helpers\QueriesRelationships; use MongoDB\Laravel\Query\AggregationBuilder; use MongoDB\Model\BSONDocument; +use Override; use function array_key_exists; use function array_map; @@ -127,7 +128,12 @@ public function vectorSearch( return $this->model->hydrate($results->all()); } - /** @inheritdoc */ + /** + * @param array $options + * + * @inheritdoc + */ + #[Override] public function update(array $values, array $options = []) { // Intercept operations on embedded models and delegate logic @@ -270,6 +276,7 @@ public function raw($value = null) return $results; } + #[Override] public function firstOrCreate(array $attributes = [], array $values = []) { $instance = (clone $this)->where($attributes)->first(); @@ -285,6 +292,7 @@ public function firstOrCreate(array $attributes = [], array $values = []) return $this->createOrFirst($attributes, $values); } + #[Override] public function createOrFirst(array $attributes = [], array $values = []) { // The duplicate key error would abort the transaction. Using the regular firstOrCreate in that case. @@ -308,9 +316,8 @@ public function createOrFirst(array $attributes = [], array $values = []) * TODO Remove if https://github.com/laravel/framework/commit/6484744326531829341e1ff886cc9b628b20d73e * will be reverted * Issue in laravel/frawework https://github.com/laravel/framework/issues/27791. - * - * @return array */ + #[Override] protected function addUpdatedAtColumn(array $values) { if (! $this->model->usesTimestamps() || $this->model->getUpdatedAtColumn() === null) { @@ -332,6 +339,7 @@ public function getConnection(): Connection } /** @inheritdoc */ + #[Override] protected function ensureOrderForCursorPagination($shouldReverse = false) { if (empty($this->query->orders)) { diff --git a/src/MongoDBBusServiceProvider.php b/src/MongoDBBusServiceProvider.php index d3d6f25fc..ab0afb588 100644 --- a/src/MongoDBBusServiceProvider.php +++ b/src/MongoDBBusServiceProvider.php @@ -10,6 +10,7 @@ use Illuminate\Support\ServiceProvider; use InvalidArgumentException; use MongoDB\Laravel\Bus\MongoBatchRepository; +use Override; use function sprintf; @@ -18,6 +19,7 @@ class MongoDBBusServiceProvider extends ServiceProvider implements DeferrablePro /** * Register the service provider. */ + #[Override] public function register() { $this->app->singleton(MongoBatchRepository::class, function (Container $app) { @@ -46,6 +48,8 @@ public function register() }); } + /** @inheritdoc */ + #[Override] public function provides() { return [ diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index a51a63919..644eb7a56 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -24,6 +24,7 @@ use MongoDB\Laravel\Queue\MongoConnector; use MongoDB\Laravel\Scout\ScoutEngine; use MongoDB\Laravel\Session\MongoDbSessionHandler; +use Override; use RuntimeException; use function assert; @@ -47,6 +48,7 @@ public function boot() /** * Register the service provider. */ + #[Override] public function register() { // Add database driver. diff --git a/src/Query/Builder.php b/src/Query/Builder.php index 8d57ba4c8..6fb38fba1 100644 --- a/src/Query/Builder.php +++ b/src/Query/Builder.php @@ -243,12 +243,14 @@ public function hint($index) } /** @inheritdoc */ + #[Override] public function find($id, $columns = []) { return $this->where('_id', '=', $this->convertKey($id))->first($columns); } /** @inheritdoc */ + #[Override] public function value($column) { $result = (array) $this->first([$column]); @@ -257,12 +259,14 @@ public function value($column) } /** @inheritdoc */ + #[Override] public function get($columns = []) { return $this->getFresh($columns); } /** @inheritdoc */ + #[Override] public function cursor($columns = []) { $result = $this->getFresh($columns, true); @@ -579,6 +583,7 @@ public function generateCacheKey() } /** @return ($function is null ? AggregationBuilder : mixed) */ + #[Override] public function aggregate($function = null, $columns = ['*']) { assert(is_array($columns), new TypeError(sprintf('Argument #2 ($columns) must be of type array, %s given', get_debug_type($columns)))); @@ -640,9 +645,10 @@ public function aggregate($function = null, $columns = ['*']) } /** - * {@inheritDoc} + * @param string $function + * @param array $columns * - * @see \Illuminate\Database\Query\Builder::aggregateByGroup() + * @return mixed */ public function aggregateByGroup(string $function, array $columns = ['*']) { @@ -654,6 +660,7 @@ public function aggregateByGroup(string $function, array $columns = ['*']) } /** @inheritdoc */ + #[Override] public function exists() { return $this->first(['id']) !== null; @@ -676,6 +683,7 @@ public function distinct($column = false) * * @inheritdoc */ + #[Override] public function orderBy($column, $direction = 'asc') { if (is_string($direction)) { @@ -697,6 +705,7 @@ public function orderBy($column, $direction = 'asc') } /** @inheritdoc */ + #[Override] public function whereBetween($column, iterable $values, $boolean = 'and', $not = false) { $type = 'between'; @@ -721,6 +730,7 @@ public function whereBetween($column, iterable $values, $boolean = 'and', $not = } /** @inheritdoc */ + #[Override] public function insert(array $values) { // Allow empty insert batch for consistency with Eloquent SQL @@ -755,6 +765,7 @@ public function insert(array $values) } /** @inheritdoc */ + #[Override] public function insertGetId(array $values, $sequence = null) { $options = $this->inheritConnectionOptions(); @@ -774,6 +785,7 @@ public function insertGetId(array $values, $sequence = null) } /** @inheritdoc */ + #[Override] public function update(array $values, array $options = []) { // Use $set as default operator for field names that are not in an operator @@ -790,6 +802,7 @@ public function update(array $values, array $options = []) } /** @inheritdoc */ + #[Override] public function upsert(array $values, $uniqueBy, $update = null): int { if ($values === []) { @@ -836,6 +849,7 @@ public function upsert(array $values, $uniqueBy, $update = null): int } /** @inheritdoc */ + #[Override] public function increment($column, $amount = 1, array $extra = [], array $options = []) { $query = ['$inc' => [(string) $column => $amount]]; @@ -856,6 +870,12 @@ public function increment($column, $amount = 1, array $extra = [], array $option return $this->performUpdate($query, $options); } + /** + * @param array $options + * + * @inheritdoc + */ + #[Override] public function incrementEach(array $columns, array $extra = [], array $options = []) { $stage['$addFields'] = $extra; @@ -873,12 +893,14 @@ public function incrementEach(array $columns, array $extra = [], array $options } /** @inheritdoc */ + #[Override] public function decrement($column, $amount = 1, array $extra = [], array $options = []) { return $this->increment($column, -1 * $amount, $extra, $options); } /** @inheritdoc */ + #[Override] public function decrementEach(array $columns, array $extra = [], array $options = []) { $decrement = []; @@ -932,6 +954,7 @@ public function divide($column, $amount, array $extra = [], array $options = []) } /** @inheritdoc */ + #[Override] public function pluck($column, $key = null) { $results = $this->get($key === null ? [$column] : [$column, $key]); @@ -942,6 +965,7 @@ public function pluck($column, $key = null) } /** @inheritdoc */ + #[Override] public function delete($id = null) { // If an ID is passed to the method, we will set the where clause to check @@ -973,6 +997,7 @@ public function delete($id = null) } /** @inheritdoc */ + #[Override] public function from($collection, $as = null) { if ($collection) { @@ -1012,6 +1037,7 @@ public function lists($column, $key = null) * * @template T */ + #[Override] public function raw($value = null) { // Execute the closure on the mongodb collection @@ -1114,11 +1140,13 @@ public function drop($columns) * * @inheritdoc */ + #[Override] public function newQuery() { return new static($this->connection, $this->grammar, $this->processor); } + #[Override] public function runPaginationCountQuery($columns = ['*']) { if ($this->distinct) { @@ -1201,6 +1229,7 @@ public function convertKey($id) * * @return $this */ + #[Override] public function where($column, $operator = null, $value = null, $boolean = 'and') { $params = func_get_args(); @@ -1714,6 +1743,7 @@ private function inheritConnectionOptions(array $options = []): array } /** @inheritdoc */ + #[Override] public function __call($method, $parameters) { if ($method === 'unset') { @@ -1724,90 +1754,105 @@ public function __call($method, $parameters) } /** @internal This method is not supported by MongoDB. */ + #[Override] public function toSql() { throw new BadMethodCallException('This method is not supported by MongoDB. Try "toMql()" instead.'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function toRawSql() { throw new BadMethodCallException('This method is not supported by MongoDB. Try "toMql()" instead.'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereColumn($first, $operator = null, $second = null, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereFullText($columns, $value, array $options = [], $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function groupByRaw($sql, array $bindings = []) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function orderByRaw($sql, $bindings = []) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function unionAll($query) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function union($query, $all = false) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function having($column, $operator = null, $value = null, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function havingRaw($sql, array $bindings = [], $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function havingBetween($column, iterable $values, $boolean = 'and', $not = false) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereIntegerInRaw($column, $values, $boolean = 'and', $not = false) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function orWhereIntegerInRaw($column, $values) { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function whereIntegerNotInRaw($column, $values, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); } /** @internal This method is not supported by MongoDB. */ + #[Override] public function orWhereIntegerNotInRaw($column, $values, $boolean = 'and') { throw new BadMethodCallException('This method is not supported by MongoDB'); diff --git a/src/Queue/MongoQueue.php b/src/Queue/MongoQueue.php index 7810aab92..1e353bd65 100644 --- a/src/Queue/MongoQueue.php +++ b/src/Queue/MongoQueue.php @@ -8,6 +8,7 @@ use Illuminate\Queue\DatabaseQueue; use MongoDB\Laravel\Connection; use MongoDB\Operation\FindOneAndUpdate; +use Override; use stdClass; class MongoQueue extends DatabaseQueue @@ -34,7 +35,12 @@ public function __construct(Connection $database, $table, $default = 'default', $this->retryAfter = $retryAfter; } - /** @inheritdoc */ + /** + * @return MongoJob|null + * + * @inheritdoc + */ + #[Override] public function pop($queue = null) { $queue = $this->getQueue($queue); @@ -138,12 +144,14 @@ protected function releaseJob($id, $attempts) } /** @inheritdoc */ + #[Override] public function deleteReserved($queue, $id) { $this->database->table($this->table)->where('_id', $id)->delete(); } /** @inheritdoc */ + #[Override] public function deleteAndRelease($queue, $job, $delay) { $this->deleteReserved($queue, $job->getJobId()); diff --git a/src/Relations/BelongsTo.php b/src/Relations/BelongsTo.php index 93eb11f8e..15447c219 100644 --- a/src/Relations/BelongsTo.php +++ b/src/Relations/BelongsTo.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo as EloquentBelongsTo; +use Override; /** * @template TRelatedModel of Model @@ -26,6 +27,7 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -37,6 +39,7 @@ public function addConstraints() } /** @inheritdoc */ + #[Override] public function addEagerConstraints(array $models) { // We'll grab the primary key name of the related models since it could be set to @@ -46,6 +49,7 @@ public function addEagerConstraints(array $models) } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return $query; @@ -58,11 +62,13 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, * * @return string */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; } + #[Override] public function getQualifiedForeignKeyName(): string { return $this->foreignKey; diff --git a/src/Relations/BelongsToMany.php b/src/Relations/BelongsToMany.php index 042ec22ce..8978483ec 100644 --- a/src/Relations/BelongsToMany.php +++ b/src/Relations/BelongsToMany.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany as EloquentBelongsToMany; use Illuminate\Support\Arr; use MongoDB\Laravel\Eloquent\Model as DocumentModel; +use Override; use function array_diff; use function array_keys; @@ -39,12 +40,14 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return $query; } /** @inheritdoc */ + #[Override] protected function hydratePivotRelation(array $models) { // Do nothing. @@ -61,12 +64,14 @@ protected function getSelectColumns(array $columns = ['*']) } /** @inheritdoc */ + #[Override] protected function shouldSelect(array $columns = ['*']) { return $columns; } /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -89,6 +94,7 @@ protected function setWhere() } /** @inheritdoc */ + #[Override] public function save(Model $model, array $pivotAttributes = [], $touch = true) { $model->save(['touch' => false]); @@ -99,6 +105,7 @@ public function save(Model $model, array $pivotAttributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function create(array $attributes = [], array $joining = [], $touch = true) { $instance = $this->related->newInstance($attributes); @@ -114,6 +121,7 @@ public function create(array $attributes = [], array $joining = [], $touch = tru } /** @inheritdoc */ + #[Override] public function sync($ids, $detaching = true) { $changes = [ @@ -177,6 +185,7 @@ public function sync($ids, $detaching = true) } /** @inheritdoc */ + #[Override] public function updateExistingPivot($id, array $attributes, $touch = true) { // Do nothing, we have no pivot table. @@ -184,6 +193,7 @@ public function updateExistingPivot($id, array $attributes, $touch = true) } /** @inheritdoc */ + #[Override] public function attach($id, array $attributes = [], $touch = true) { if ($id instanceof Model) { @@ -224,6 +234,7 @@ public function attach($id, array $attributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function detach($ids = [], $touch = true) { if ($ids instanceof Model) { @@ -264,6 +275,7 @@ public function detach($ids = [], $touch = true) } /** @inheritdoc */ + #[Override] protected function buildDictionary(Collection $results) { $foreign = $this->foreignPivotKey; @@ -283,6 +295,7 @@ protected function buildDictionary(Collection $results) } /** @inheritdoc */ + #[Override] public function newPivotQuery() { return $this->newRelatedQuery(); @@ -309,12 +322,14 @@ public function getForeignKey() } /** @inheritdoc */ + #[Override] public function getQualifiedForeignPivotKeyName() { return $this->foreignPivotKey; } /** @inheritdoc */ + #[Override] public function getQualifiedRelatedPivotKeyName() { return $this->relatedPivotKey; @@ -323,10 +338,9 @@ public function getQualifiedRelatedPivotKeyName() /** * Get the name of the "where in" method for eager loading. * - * @param string $key - * - * @return string + * @inheritdoc */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/EmbedsOneOrMany.php b/src/Relations/EmbedsOneOrMany.php index a46593cf4..cc9376dcc 100644 --- a/src/Relations/EmbedsOneOrMany.php +++ b/src/Relations/EmbedsOneOrMany.php @@ -12,6 +12,7 @@ use Illuminate\Database\Query\Expression; use MongoDB\Driver\Exception\LogicException; use MongoDB\Laravel\Eloquent\Model as DocumentModel; +use Override; use Throwable; use function array_merge; @@ -78,6 +79,7 @@ public function __construct(Builder $query, Model $parent, Model $related, strin } /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -86,12 +88,14 @@ public function addConstraints() } /** @inheritdoc */ + #[Override] public function addEagerConstraints(array $models) { // There are no eager loading constraints. } /** @inheritdoc */ + #[Override] public function match(array $models, Collection $results, $relation) { foreach ($models as $model) { @@ -105,13 +109,7 @@ public function match(array $models, Collection $results, $relation) return $models; } - /** - * Shorthand to get the results of the relationship. - * - * @param array $columns - * - * @return Collection - */ + #[Override] public function get($columns = ['*']) { return $this->getResults(); @@ -324,6 +322,7 @@ protected function getParentRelation() } /** @inheritdoc */ + #[Override] public function getQuery() { // Because we are sharing this relation instance to models, we need @@ -332,6 +331,7 @@ public function getQuery() } /** @inheritdoc */ + #[Override] public function toBase() { // Because we are sharing this relation instance to models, we need @@ -367,6 +367,7 @@ protected function getPathHierarchy($glue = '.') } /** @inheritdoc */ + #[Override] public function getQualifiedParentKeyName() { $parentRelation = $this->getParentRelation(); @@ -425,10 +426,10 @@ public function getQualifiedForeignKeyName() * Get the name of the "where in" method for eager loading. * * @param EloquentModel $model - * @param string $key * - * @return string + * @inheritdoc */ + #[Override] protected function whereInMethod(EloquentModel $model, $key) { return 'whereIn'; diff --git a/src/Relations/HasMany.php b/src/Relations/HasMany.php index c8e7e0590..052230495 100644 --- a/src/Relations/HasMany.php +++ b/src/Relations/HasMany.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany as EloquentHasMany; +use Override; /** * @template TRelatedModel of Model @@ -20,6 +21,7 @@ class HasMany extends EloquentHasMany * * @return string */ + #[Override] public function getForeignKeyName() { return $this->foreignKey; @@ -36,6 +38,7 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { $foreignKey = $this->getHasCompareKey(); @@ -46,10 +49,9 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, /** * Get the name of the "where in" method for eager loading. * - * @param string $key - * - * @return string + * @inheritdoc */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/HasOne.php b/src/Relations/HasOne.php index ea26761d3..bfa297c4e 100644 --- a/src/Relations/HasOne.php +++ b/src/Relations/HasOne.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasOne as EloquentHasOne; +use Override; /** * @template TRelatedModel of Model @@ -20,6 +21,7 @@ class HasOne extends EloquentHasOne * * @return string */ + #[Override] public function getForeignKeyName() { return $this->foreignKey; @@ -36,6 +38,7 @@ public function getHasCompareKey() } /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { $foreignKey = $this->getForeignKeyName(); @@ -43,13 +46,8 @@ public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, return $query->select($foreignKey)->where($foreignKey, 'exists', true); } - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + /** Get the name of the "where in" method for eager loading. */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/MorphMany.php b/src/Relations/MorphMany.php index 5f395950f..925ebcfa9 100644 --- a/src/Relations/MorphMany.php +++ b/src/Relations/MorphMany.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany as EloquentMorphMany; +use Override; /** * @template TRelatedModel of Model @@ -14,13 +15,7 @@ */ class MorphMany extends EloquentMorphMany { - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/MorphTo.php b/src/Relations/MorphTo.php index 4888b2d97..9f1bf1441 100644 --- a/src/Relations/MorphTo.php +++ b/src/Relations/MorphTo.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo as EloquentMorphTo; +use Override; /** * @template TRelatedModel of Model @@ -15,6 +16,7 @@ class MorphTo extends EloquentMorphTo { /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -30,6 +32,7 @@ public function addConstraints() } /** @inheritdoc */ + #[Override] protected function getResultsByType($type) { $instance = $this->createModelByType($type); @@ -41,13 +44,8 @@ protected function getResultsByType($type) return $query->whereIn($key, $this->gatherKeysByType($type, $instance->getKeyType()))->get(); } - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + /** Get the name of the "where in" method for eager loading. */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Relations/MorphToMany.php b/src/Relations/MorphToMany.php index a1514d235..724dad912 100644 --- a/src/Relations/MorphToMany.php +++ b/src/Relations/MorphToMany.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany as EloquentMorphToMany; use Illuminate\Support\Arr; use MongoDB\BSON\ObjectId; +use Override; use function array_diff; use function array_key_exists; @@ -31,25 +32,25 @@ */ class MorphToMany extends EloquentMorphToMany { - /** @inheritdoc */ + #[Override] public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, $columns = ['*']) { return $query; } - /** @inheritdoc */ + #[Override] protected function hydratePivotRelation(array $models) { // Do nothing. } - /** @inheritdoc */ + #[Override] protected function shouldSelect(array $columns = ['*']) { return $columns; } - /** @inheritdoc */ + #[Override] public function addConstraints() { if (static::$constraints) { @@ -57,7 +58,7 @@ public function addConstraints() } } - /** @inheritdoc */ + #[Override] public function addEagerConstraints(array $models) { // To load relation's data, we act normally on MorphToMany relation, @@ -102,6 +103,7 @@ protected function setWhere() } /** @inheritdoc */ + #[Override] public function save(Model $model, array $pivotAttributes = [], $touch = true) { $model->save(['touch' => false]); @@ -112,6 +114,7 @@ public function save(Model $model, array $pivotAttributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function create(array $attributes = [], array $joining = [], $touch = true) { $instance = $this->related->newInstance($attributes); @@ -127,6 +130,7 @@ public function create(array $attributes = [], array $joining = [], $touch = tru } /** @inheritdoc */ + #[Override] public function sync($ids, $detaching = true) { $changes = [ @@ -203,12 +207,14 @@ public function sync($ids, $detaching = true) } /** @inheritdoc */ + #[Override] public function updateExistingPivot($id, array $attributes, $touch = true): void { // Do nothing, we have no pivot table. } /** @inheritdoc */ + #[Override] public function attach($id, array $attributes = [], $touch = true) { if ($id instanceof Model) { @@ -302,6 +308,7 @@ public function attach($id, array $attributes = [], $touch = true) } /** @inheritdoc */ + #[Override] public function detach($ids = [], $touch = true) { if ($ids instanceof Model) { @@ -376,6 +383,7 @@ public function detach($ids = [], $touch = true) } /** @inheritdoc */ + #[Override] protected function buildDictionary(Collection $results) { $foreign = $this->foreignPivotKey; @@ -403,6 +411,7 @@ protected function buildDictionary(Collection $results) } /** @inheritdoc */ + #[Override] public function newPivotQuery() { return $this->newRelatedQuery(); @@ -418,19 +427,13 @@ public function newRelatedQuery() return $this->related->newQuery(); } - /** @inheritdoc */ + #[Override] public function getQualifiedRelatedPivotKeyName() { return $this->relatedPivotKey; } - /** - * Get the name of the "where in" method for eager loading. - * - * @param string $key - * - * @return string - */ + #[Override] protected function whereInMethod(Model $model, $key) { return 'whereIn'; diff --git a/src/Schema/Blueprint.php b/src/Schema/Blueprint.php index 1ae46cf6c..24e23d50e 100644 --- a/src/Schema/Blueprint.php +++ b/src/Schema/Blueprint.php @@ -7,6 +7,7 @@ use Illuminate\Database\Schema\Blueprint as BaseBlueprint; use MongoDB\Collection; use MongoDB\Laravel\Connection; +use Override; use function array_flip; use function array_merge; @@ -38,6 +39,7 @@ class Blueprint extends BaseBlueprint protected $columns = []; /** @inheritdoc */ + #[Override] public function index($columns = null, $name = null, $algorithm = null, $options = []) { $columns = $this->fluent($columns); @@ -64,12 +66,14 @@ public function index($columns = null, $name = null, $algorithm = null, $options } /** @inheritdoc */ + #[Override] public function primary($columns = null, $name = null, $algorithm = null, $options = []) { return $this->unique($columns, $name, $algorithm, $options); } /** @inheritdoc */ + #[Override] public function dropIndex($index = null) { $index = $this->transformColumns($index); @@ -170,6 +174,7 @@ protected function transformColumns($indexOrColumns) } /** @inheritdoc */ + #[Override] public function unique($columns = null, $name = null, $algorithm = null, $options = []) { $columns = $this->fluent($columns); @@ -251,6 +256,7 @@ public function expire($columns, $seconds) * * @return void */ + #[Override] public function create($options = []) { $collection = $this->collection->getCollectionName(); @@ -262,6 +268,7 @@ public function create($options = []) } /** @inheritdoc */ + #[Override] public function drop() { $this->collection->drop(); @@ -270,6 +277,7 @@ public function drop() } /** @inheritdoc */ + #[Override] public function renameColumn($from, $to) { $this->collection->updateMany([$from => ['$exists' => true]], ['$rename' => [$from => $to]]); @@ -278,6 +286,7 @@ public function renameColumn($from, $to) } /** @inheritdoc */ + #[Override] public function addColumn($type, $name, array $parameters = []) { $this->fluent($name); diff --git a/src/Schema/Builder.php b/src/Schema/Builder.php index 78cb3a4c1..207f4f1b3 100644 --- a/src/Schema/Builder.php +++ b/src/Schema/Builder.php @@ -6,11 +6,11 @@ use Closure; use MongoDB\Collection; -use MongoDB\Database; use MongoDB\Driver\Exception\ServerException; use MongoDB\Laravel\Connection; use MongoDB\Model\CollectionInfo; use MongoDB\Model\IndexInfo; +use Override; use function array_column; use function array_fill_keys; @@ -99,12 +99,14 @@ public function hasCollection($name) } /** @inheritdoc */ + #[Override] public function hasTable($table) { return $this->hasCollection($table); } /** @inheritdoc */ + #[Override] public function table($table, Closure $callback) { $blueprint = $this->createBlueprint($table); @@ -115,6 +117,7 @@ public function table($table, Closure $callback) } /** @inheritdoc */ + #[Override] public function create($table, ?Closure $callback = null, array $options = []) { $blueprint = $this->createBlueprint($table); @@ -127,6 +130,7 @@ public function create($table, ?Closure $callback = null, array $options = []) } /** @inheritdoc */ + #[Override] public function dropIfExists($table) { if ($this->hasCollection($table)) { @@ -135,6 +139,7 @@ public function dropIfExists($table) } /** @inheritdoc */ + #[Override] public function drop($table) { $blueprint = $this->createBlueprint($table); @@ -151,18 +156,29 @@ public function drop($table) * one by one. The database will be automatically recreated when a new connection * writes to it. */ + #[Override] public function dropAllTables() { $this->connection->getDatabase()->drop(); } - /** @param string|null $schema Database name */ + /** + * @param string|null $schema Database name + * + * @inheritdoc + */ + #[Override] public function getTables($schema = null) { return $this->getCollectionRows('collection', $schema); } - /** @param string|null $schema Database name */ + /** + * @param string|null $schema Database name + * + * @inheritdoc + */ + #[Override] public function getViews($schema = null) { return $this->getCollectionRows('view', $schema); @@ -174,6 +190,7 @@ public function getViews($schema = null) * * @return array */ + #[Override] public function getTableListing($schema = null, $schemaQualified = false) { $collections = []; @@ -197,6 +214,7 @@ public function getTableListing($schema = null, $schemaQualified = false) return $collections; } + #[Override] public function getColumns($table) { $db = null; @@ -255,6 +273,7 @@ public function getColumns($table) return $columns; } + #[Override] public function getIndexes($table) { $collection = $this->connection->getDatabase()->selectCollection($table); @@ -309,12 +328,18 @@ public function getIndexes($table) return $indexList; } + #[Override] public function getForeignKeys($table) { return []; } - /** @inheritdoc */ + /** + * @return Blueprint + * + * @inheritdoc + */ + #[Override] protected function createBlueprint($table, ?Closure $callback = null) { return new Blueprint($this->connection, $table); diff --git a/src/Session/MongoDbSessionHandler.php b/src/Session/MongoDbSessionHandler.php index dd57f2a3c..3677ea758 100644 --- a/src/Session/MongoDbSessionHandler.php +++ b/src/Session/MongoDbSessionHandler.php @@ -16,6 +16,7 @@ use MongoDB\BSON\Document; use MongoDB\BSON\UTCDateTime; use MongoDB\Collection; +use Override; use function tap; use function time; @@ -32,6 +33,7 @@ public function close(): bool return true; } + #[Override] public function gc($lifetime): int { $result = $this->getCollection()->deleteMany(['last_activity' => ['$lt' => $this->getUTCDateTime(-$lifetime)]]); @@ -39,6 +41,7 @@ public function gc($lifetime): int return $result->getDeletedCount() ?? 0; } + #[Override] public function destroy($sessionId): bool { $this->getCollection()->deleteOne(['_id' => (string) $sessionId]); @@ -46,6 +49,7 @@ public function destroy($sessionId): bool return true; } + #[Override] public function read($sessionId): string|false { $result = $this->getCollection()->findOne( @@ -63,6 +67,7 @@ public function read($sessionId): string|false return false; } + #[Override] public function write($sessionId, $data): bool { $payload = $this->getDefaultPayload($data); @@ -87,6 +92,7 @@ public function createTTLIndex(): void ); } + #[Override] protected function getDefaultPayload($data): array { $payload = [ diff --git a/src/Validation/DatabasePresenceVerifier.php b/src/Validation/DatabasePresenceVerifier.php index c5c378539..fdd783ab5 100644 --- a/src/Validation/DatabasePresenceVerifier.php +++ b/src/Validation/DatabasePresenceVerifier.php @@ -5,6 +5,7 @@ namespace MongoDB\Laravel\Validation; use MongoDB\BSON\Regex; +use Override; use function array_map; use function implode; @@ -12,17 +13,8 @@ class DatabasePresenceVerifier extends \Illuminate\Validation\DatabasePresenceVerifier { - /** - * Count the number of objects in a collection having the given value. - * - * @param string $collection - * @param string $column - * @param string $value - * @param int $excludeId - * @param string $idColumn - * - * @return int - */ + /** Count the number of objects in a collection having the given value. */ + #[Override] public function getCount($collection, $column, $value, $excludeId = null, $idColumn = null, array $extra = []) { $query = $this->table($collection)->where($column, new Regex('^' . preg_quote($value) . '$', '/i')); @@ -38,16 +30,8 @@ public function getCount($collection, $column, $value, $excludeId = null, $idCol return $query->count(); } - /** - * Count the number of objects in a collection with the given values. - * - * @param string $collection - * @param string $column - * @param array $values - * @param array $extra - * - * @return int - */ + /** Count the number of objects in a collection with the given values. */ + #[Override] public function getMultiCount($collection, $column, array $values, array $extra = []) { // Nothing can match an empty array. Return early to avoid matching an empty string. diff --git a/src/Validation/ValidationServiceProvider.php b/src/Validation/ValidationServiceProvider.php index 1095e93a3..6f7ebd980 100644 --- a/src/Validation/ValidationServiceProvider.php +++ b/src/Validation/ValidationServiceProvider.php @@ -5,9 +5,11 @@ namespace MongoDB\Laravel\Validation; use Illuminate\Validation\ValidationServiceProvider as BaseProvider; +use Override; class ValidationServiceProvider extends BaseProvider { + #[Override] protected function registerPresenceVerifier() { $this->app->singleton('validation.presence', function ($app) { From 46fa408d8b725324b16b1af730a453b65705002b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Thu, 3 Jul 2025 13:13:29 +0200 Subject: [PATCH 91/92] PHPORM-361 Remove autocommit of CS fixes (#3420) --- .github/workflows/coding-standards.yml | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 4d0eda3f9..f3fc3e369 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -56,23 +56,9 @@ jobs: - name: "Install dependencies with Composer" uses: "ramsey/composer-install@3.1.1" - with: - composer-options: "--no-suggest" - - - name: "Validate PSR class names" - run: "composer dump-autoload --optimize --strict-psr" - - - name: "Format the code" - continue-on-error: true - run: | - mkdir .cache - ./vendor/bin/phpcbf # The -q option is required until phpcs v4 is released - name: "Run PHP_CodeSniffer" - run: "vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr" - - - name: "Commit the changes" - uses: stefanzweifel/git-auto-commit-action@v6 - with: - commit_message: "apply phpcbf formatting" + run: | + mkdir .cache + vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr From 98b75683f0c68a0294f9b63e2a8bcc5b2a5a4d93 Mon Sep 17 00:00:00 2001 From: Rea Rustagi <85902999+rustagir@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:45:22 -0400 Subject: [PATCH 92/92] DOCSP-51402: schema-flexible terminology (#3424) --- docs/database-collection.txt | 11 ++++++----- docs/feature-compatibility.txt | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/database-collection.txt b/docs/database-collection.txt index be081c97b..e8d97711a 100644 --- a/docs/database-collection.txt +++ b/docs/database-collection.txt @@ -213,8 +213,8 @@ methods in your application: .. note:: - MongoDB is a schemaless database, so the preceding schema builder methods - query the database data rather than the schema. + MongoDB is a schema-flexible database, so the preceding schema + builder methods query the database data rather than the schema. Example ``````` @@ -269,9 +269,10 @@ collection fields: - ``Schema::hasColumns(string $, string[] $)``: checks if each specified field exists in at least one document -MongoDB is a schemaless database, so the preceding methods query the collection -data rather than the database schema. If the specified collection doesn't exist -or is empty, these methods return a value of ``false``. +MongoDB is a schema-flexible database, so the preceding methods query +the collection data rather than the database schema. If the specified +collection doesn't exist or is empty, these methods return a value of +``false``. .. note:: id Alias diff --git a/docs/feature-compatibility.txt b/docs/feature-compatibility.txt index 707f12c70..cce0932cc 100644 --- a/docs/feature-compatibility.txt +++ b/docs/feature-compatibility.txt @@ -201,7 +201,7 @@ Migration Features ------------------ The {+odm-short+} supports all Laravel migration features, but the -implementation is specific to MongoDB's schemaless model. +implementation is specific to MongoDB's schema-flexible model. Seeding Features ----------------