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 - ✓ 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/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/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/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; 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/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() */ 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); } }