diff --git a/CHANGELOG.md b/CHANGELOG.md index d8626f27a..0345701b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [4.5.0] - upcoming + +* Add GridFS integration for Laravel File Storage by @GromNaN in [#2984](https://github.com/mongodb/laravel-mongodb/pull/2985) + ## [4.4.0] - 2024-05-31 * Support collection name prefix by @GromNaN in [#2930](https://github.com/mongodb/laravel-mongodb/pull/2930) diff --git a/composer.json b/composer.json index 84229b00f..af060bb3c 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,8 @@ }, "require-dev": { "mongodb/builder": "^0.2", + "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", @@ -45,6 +47,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" }, "minimum-stability": "dev", diff --git a/docs/filesystems.txt b/docs/filesystems.txt new file mode 100644 index 000000000..065b5c08f --- /dev/null +++ b/docs/filesystems.txt @@ -0,0 +1,159 @@ +.. _laravel-filesystems: + +================== +GridFS Filesystems +================== + +.. facet:: + :name: genre + :values: tutorial + +.. meta:: + :keywords: php framework, gridfs, code example + +Overview +-------- + +You can use the +`GridFS Adapter for Flysystem `__ +to store large files in MongoDB. +GridFS lets you store files of unlimited size in the same database as your data. + + +Configuration +------------- + +Before using the GridFS driver, install the Flysystem GridFS package through the +Composer package manager by running the following command: + +.. code-block:: bash + + composer require league/flysystem-gridfs + +Configure `Laravel File Storage `__ +to use the ``gridfs`` driver in ``config/filesystems.php``: + +.. code-block:: php + + 'disks' => [ + 'gridfs' => [ + 'driver' => 'gridfs', + 'connection' => 'mongodb', + // 'database' => null, + // 'bucket' => 'fs', + // 'prefix' => '', + // 'read-only' => false, + // 'throw' => false, + ], + ], + +You can configure the disk the following settings in ``config/filesystems.php``: + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - Setting + - Description + + * - ``driver`` + - **Required**. Specifies the filesystem driver to use. Must be ``gridfs`` for MongoDB. + + * - ``connection`` + - The database connection used to store jobs. It must be a ``mongodb`` connection. The driver uses the default connection if a connection is not specified. + + * - ``database`` + - Name of the MongoDB database for the GridFS bucket. The driver uses the database of the connection if a database is not specified. + + * - ``bucket`` + - Name or instance of the GridFS bucket. A database can contain multiple buckets identified by their name. Defaults to ``fs``. + + * - ``prefix`` + - Specifies a prefix for the name of the files that are stored in the bucket. Using a distinct bucket is recommended + in order to store the files in a different collection, instead of using a prefix. + The prefix should not start with a leading slash ``/``. + + * - ``read-only`` + - If ``true``, writing to the GridFS bucket is disabled. Write operations will return ``false`` or throw exceptions + depending on the configuration of ``throw``. Defaults to ``false``. + + * - ``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``. + +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: + +.. code-block:: php + + use Illuminate\Foundation\Application; + use MongoDB\GridFS\Bucket; + + 'disks' => [ + 'gridfs' => [ + 'driver' => 'gridfs', + 'bucket' => static function (Application $app): Bucket { + return $app['db']->connection('mongodb') + ->getMongoDB() + ->selectGridFSBucket([ + 'bucketName' => 'avatars', + 'chunkSizeBytes' => 261120, + ]); + }, + ], + ], + +Usage +----- + +A benefit of using Laravel Filesystem is that it provides a common interface +for all the supported file systems. You can use the ``gridfs`` disk in the same +way as the ``local`` disk. + +.. code-block:: php + + $disk = Storage::disk('gridfs'); + + // Write the file "hello.txt" into GridFS + $disk->put('hello.txt', 'Hello World!'); + + // Read the file + echo $disk->get('hello.txt'); // Hello World! + +To learn more Laravel File Storage, see +`Laravel File Storage `__ +in the Laravel documentation. + +Versioning +---------- + +File names in GridFS are metadata in file documents, which are identified by +unique ObjectIDs. 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 +with file revisions in the following ways: + +- Reading a file reads the last revision of this file name +- Writing a file creates a new revision for this file name +- Renaming a file renames all the revisions of this file name +- Deleting a file deletes all the revisions of this file name + +The GridFS Adapter for Flysystem does not provide access to a specific revision +of a filename. You must use the +`GridFS API `__ +if you need to work with revisions, as shown in the following code: + +.. code-block:: php + + // Create a bucket service from the MongoDB connection + /** @var \MongoDB\GridFS\Bucket $bucket */ + $bucket = $app['db']->connection('mongodb')->getMongoDB()->selectGridFSBucket(); + + // Download the last but one version of a file + $bucket->openDownloadStreamByName('hello.txt', ['revision' => -2]) + +.. note:: + + If you use a prefix the Filesystem component, you will have to handle it + by yourself when using the GridFS API directly. diff --git a/src/MongoDBServiceProvider.php b/src/MongoDBServiceProvider.php index 50c042230..0932048c9 100644 --- a/src/MongoDBServiceProvider.php +++ b/src/MongoDBServiceProvider.php @@ -4,15 +4,28 @@ namespace MongoDB\Laravel; +use Closure; use Illuminate\Cache\CacheManager; use Illuminate\Cache\Repository; +use Illuminate\Filesystem\FilesystemAdapter; +use Illuminate\Filesystem\FilesystemManager; use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; +use InvalidArgumentException; +use League\Flysystem\Filesystem; +use League\Flysystem\GridFS\GridFSAdapter; +use League\Flysystem\ReadOnly\ReadOnlyFilesystemAdapter; +use MongoDB\GridFS\Bucket; use MongoDB\Laravel\Cache\MongoStore; use MongoDB\Laravel\Eloquent\Model; use MongoDB\Laravel\Queue\MongoConnector; +use RuntimeException; use function assert; +use function class_exists; +use function get_debug_type; +use function is_string; +use function sprintf; class MongoDBServiceProvider extends ServiceProvider { @@ -66,5 +79,59 @@ public function register() return new MongoConnector($this->app['db']); }); }); + + $this->registerFlysystemAdapter(); + } + + private function registerFlysystemAdapter(): void + { + // GridFS adapter for filesystem + $this->app->resolving('filesystem', static function (FilesystemManager $filesystemManager) { + $filesystemManager->extend('gridfs', static function (Application $app, array $config) { + if (! class_exists(GridFSAdapter::class)) { + throw new RuntimeException('GridFS adapter for Flysystem is missing. Try running "composer require league/flysystem-gridfs"'); + } + + $bucket = $config['bucket'] ?? null; + + if ($bucket instanceof Closure) { + // Get the bucket from a factory function + $bucket = $bucket($app, $config); + } elseif (is_string($bucket) && $app->has($bucket)) { + // Get the bucket from a service + $bucket = $app->get($bucket); + } elseif (is_string($bucket) || $bucket === null) { + // Get the bucket from the database connection + $connection = $app['db']->connection($config['connection']); + if (! $connection instanceof Connection) { + 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()) + ->selectGridFSBucket(['bucketName' => $config['bucket'] ?? 'fs', 'disableMD5' => true]); + } + + if (! $bucket instanceof Bucket) { + throw new InvalidArgumentException(sprintf('Unexpected value for GridFS "bucket" configuration. Expecting "%s". Got "%s"', Bucket::class, get_debug_type($bucket))); + } + + $adapter = new GridFSAdapter($bucket, $config['prefix'] ?? ''); + + /** @see FilesystemManager::createFlysystem() */ + if ($config['read-only'] ?? false) { + if (! class_exists(ReadOnlyFilesystemAdapter::class)) { + throw new RuntimeException('Read-only Adapter for Flysystem is missing. Try running "composer require league/flysystem-read-only"'); + } + + $adapter = new ReadOnlyFilesystemAdapter($adapter); + } + + /** Prevent using backslash on Windows in {@see FilesystemAdapter::__construct()} */ + $config['directory_separator'] = '/'; + + return new FilesystemAdapter(new Filesystem($adapter, $config), $adapter, $config); + }); + }); } } diff --git a/tests/FilesystemsTest.php b/tests/FilesystemsTest.php new file mode 100644 index 000000000..3b9fa8e5f --- /dev/null +++ b/tests/FilesystemsTest.php @@ -0,0 +1,150 @@ +getBucket()->drop(); + + parent::tearDown(); + } + + public static function provideValidOptions(): Generator + { + yield 'connection-minimal' => [ + [ + 'driver' => 'gridfs', + 'connection' => 'mongodb', + ], + ]; + + yield 'connection-full' => [ + [ + 'driver' => 'gridfs', + 'connection' => 'mongodb', + 'database' => env('MONGODB_DATABASE', 'unittest'), + 'bucket' => 'fs', + 'prefix' => 'foo/', + ], + ]; + + yield 'bucket-service' => [ + [ + 'driver' => 'gridfs', + 'bucket' => 'bucket', + ], + ]; + + yield 'bucket-factory' => [ + [ + 'driver' => 'gridfs', + 'bucket' => static fn (Application $app) => $app['db'] + ->connection('mongodb') + ->getMongoDB() + ->selectGridFSBucket(), + ], + ]; + } + + #[DataProvider('provideValidOptions')] + public function testValidOptions(array $options) + { + // Service used by "bucket-service" + $this->app->singleton('bucket', static fn (Application $app) => $app['db'] + ->connection('mongodb') + ->getMongoDB() + ->selectGridFSBucket()); + + $this->app['config']->set('filesystems.disks.' . $this->dataName(), $options); + + $disk = Storage::disk($this->dataName()); + $disk->put($filename = $this->dataName() . '.txt', $value = microtime()); + $this->assertEquals($value, $disk->get($filename), 'File saved'); + } + + public static function provideInvalidOptions(): Generator + { + yield 'not-mongodb-connection' => [ + ['driver' => 'gridfs', 'connection' => 'sqlite'], + 'The database connection "sqlite" does not use the "mongodb" driver.', + ]; + + yield 'factory-not-bucket' => [ + ['driver' => 'gridfs', 'bucket' => static fn () => new stdClass()], + 'Unexpected value for GridFS "bucket" configuration. Expecting "MongoDB\GridFS\Bucket". Got "stdClass"', + ]; + + yield 'service-not-bucket' => [ + ['driver' => 'gridfs', 'bucket' => 'bucket'], + 'Unexpected value for GridFS "bucket" configuration. Expecting "MongoDB\GridFS\Bucket". Got "stdClass"', + ]; + } + + #[DataProvider('provideInvalidOptions')] + public function testInvalidOptions(array $options, string $message) + { + // Service used by "service-not-bucket" + $this->app->singleton('bucket', static fn () => new stdClass()); + + $this->app['config']->set('filesystems.disks.' . $this->dataName(), $options); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($message); + + Storage::disk($this->dataName()); + } + + public function testReadOnlyAndThrowOptions() + { + $this->app['config']->set('filesystems.disks.gridfs-readonly', [ + 'driver' => 'gridfs', + 'connection' => 'mongodb', + // Use ReadOnlyAdapter + 'read-only' => true, + // Throw exceptions + 'throw' => true, + ]); + + $disk = Storage::disk('gridfs-readonly'); + + $this->expectException(UnableToWriteFile::class); + $this->expectExceptionMessage('This is a readonly adapter.'); + + $disk->put('file.txt', ''); + } + + public function testPrefix() + { + $this->app['config']->set('filesystems.disks.gridfs-prefix', [ + 'driver' => 'gridfs', + 'connection' => 'mongodb', + 'prefix' => 'foo/bar/', + ]); + + $disk = Storage::disk('gridfs-prefix'); + $disk->put('hello/world.txt', 'Hello World!'); + $this->assertSame('Hello World!', $disk->get('hello/world.txt')); + $this->assertSame('Hello World!', stream_get_contents($this->getBucket()->openDownloadStreamByName('foo/bar/hello/world.txt')), 'File name is prefixed in the bucket'); + } + + private function getBucket(): Bucket + { + return DB::connection('mongodb')->getMongoDB()->selectGridFSBucket(); + } +}