diff --git a/docs/reference/class/MongoDBGridFSBucket.txt b/docs/reference/class/MongoDBGridFSBucket.txt index 0fdeb5cb8..7b4bb54cb 100644 --- a/docs/reference/class/MongoDBGridFSBucket.txt +++ b/docs/reference/class/MongoDBGridFSBucket.txt @@ -55,5 +55,6 @@ Methods /reference/method/MongoDBGridFSBucket-openDownloadStream /reference/method/MongoDBGridFSBucket-openDownloadStreamByName /reference/method/MongoDBGridFSBucket-openUploadStream + /reference/method/MongoDBGridFSBucket-registerGlobalStreamWrapperAlias /reference/method/MongoDBGridFSBucket-rename /reference/method/MongoDBGridFSBucket-uploadFromStream diff --git a/docs/reference/method/MongoDBGridFSBucket-registerGlobalStreamWrapperAlias.txt b/docs/reference/method/MongoDBGridFSBucket-registerGlobalStreamWrapperAlias.txt new file mode 100644 index 000000000..649915eea --- /dev/null +++ b/docs/reference/method/MongoDBGridFSBucket-registerGlobalStreamWrapperAlias.txt @@ -0,0 +1,132 @@ +=========================================================== +MongoDB\\GridFS\\Bucket::registerGlobalStreamWrapperAlias() +=========================================================== + +.. versionadded:: 1.18 + +.. default-domain:: mongodb + +.. contents:: On this page + :local: + :backlinks: none + :depth: 1 + :class: singlecol + +Definition +---------- + +.. phpmethod:: MongoDB\\GridFS\\Bucket::registerGlobalStreamWrapperAlias() + + Registers an alias for the bucket, which enables files within the bucket to + be accessed using a basic filename string (e.g. + `gridfs:///`). + + .. code-block:: php + + function registerGlobalStreamWrapperAlias(string $alias): void + +Parameters +---------- + +``$alias`` : array + A non-empty string used to identify the GridFS bucket when accessing files + using the ``gridfs://`` stream wrapper. + +Behavior +-------- + +After registering an alias for the bucket, the most recent revision of a file +can be accessed using a filename string in the form ``gridfs:///``. + +Supported stream functions: + +- :php:`copy() ` +- :php:`file_exists() ` +- :php:`file_get_contents() ` +- :php:`file_put_contents() ` +- :php:`filemtime() ` +- :php:`filesize() ` +- :php:`file() ` +- :php:`fopen() ` (with "r", "rb", "w", and "wb" modes) + +In read mode, the stream context can contain the option ``gridfs['revision']`` +to specify the revision number of the file to read. If omitted, the most recent +revision is read (revision ``-1``). + +In write mode, the stream context can contain the option ``gridfs['chunkSizeBytes']``. +If omitted, the defaults are inherited from the ``Bucket`` instance option. + +Example +------- + +Read and write to a GridFS bucket using the ``gridfs://`` stream wrapper +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The following example demonstrates how to register an alias for a GridFS bucket +and use the functions ``file_exists()``, ``file_get_contents()``, and +``file_put_contents()`` to read and write to the bucket. + +Each call to these functions makes a request to the server. + +.. code-block:: php + + selectDatabase('test'); + $bucket = $database->selectGridFSBucket(); + + $bucket->registerGlobalStreamWrapperAlias('mybucket'); + + var_dump(file_exists('gridfs://mybucket/hello.txt')); + + file_put_contents('gridfs://mybucket/hello.txt', 'Hello, GridFS!'); + + var_dump(file_exists('gridfs://mybucket/hello.txt')); + + echo file_get_contents('gridfs://mybucket/hello.txt'); + +The output would then resemble: + +.. code-block:: none + + bool(false) + bool(true) + Hello, GridFS! + +Read a specific revision of a file +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Using a stream context, you can specify the revision number of the file to +read. If omitted, the most recent revision is read. + +.. code-block:: php + + selectDatabase('test'); + $bucket = $database->selectGridFSBucket(); + + $bucket->registerGlobalStreamWrapperAlias('mybucket'); + + // Creating revision 0 + $handle = fopen('gridfs://mybucket/hello.txt', 'w'); + fwrite($handle, 'Hello, GridFS! (v0)'); + fclose($handle); + + // Creating revision 1 + $handle = fopen('gridfs://mybucket/hello.txt', 'w'); + fwrite($handle, 'Hello, GridFS! (v1)'); + fclose($handle); + + // Read revision 0 + $context = stream_context_create([ + 'gridfs' => ['revision' => 0], + ]); + $handle = fopen('gridfs://mybucket/hello.txt', 'r', false, $context); + echo fread($handle, 1024); + +The output would then resemble: + +.. code-block:: none + + Hello, GridFS! (v0) diff --git a/examples/gridfs-stream-wrapper.php b/examples/gridfs-stream-wrapper.php new file mode 100644 index 000000000..e07285fa0 --- /dev/null +++ b/examples/gridfs-stream-wrapper.php @@ -0,0 +1,59 @@ +/ + */ + +declare(strict_types=1); + +namespace MongoDB\Examples; + +use MongoDB\Client; + +use function file_exists; +use function file_get_contents; +use function file_put_contents; +use function getenv; +use function stream_context_create; + +use const PHP_EOL; + +require __DIR__ . '/../vendor/autoload.php'; + +$client = new Client(getenv('MONGODB_URI') ?: 'mongodb://127.0.0.1/'); +$bucket = $client->test->selectGridFSBucket(); +$bucket->drop(); + +// Register the alias "mybucket" for default bucket of the "test" database +$bucket->registerGlobalStreamWrapperAlias('mybucket'); + +echo 'File exists: '; +echo file_exists('gridfs://mybucket/hello.txt') ? 'yes' : 'no'; +echo PHP_EOL; + +echo 'Writing file'; +file_put_contents('gridfs://mybucket/hello.txt', 'Hello, GridFS!'); +echo PHP_EOL; + +echo 'File exists: '; +echo file_exists('gridfs://mybucket/hello.txt') ? 'yes' : 'no'; +echo PHP_EOL; + +echo 'Reading file: '; +echo file_get_contents('gridfs://mybucket/hello.txt'); +echo PHP_EOL; + +echo 'Writing new version of the file'; +file_put_contents('gridfs://mybucket/hello.txt', 'Hello, GridFS! (v2)'); +echo PHP_EOL; + +echo 'Reading new version of the file: '; +echo file_get_contents('gridfs://mybucket/hello.txt'); +echo PHP_EOL; + +echo 'Reading previous version of the file: '; +$context = stream_context_create(['gridfs' => ['revision' => -2]]); +echo file_get_contents('gridfs://mybucket/hello.txt', false, $context); +echo PHP_EOL; diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 50348b082..b5470ce18 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -81,6 +81,9 @@ + + $context + @@ -89,20 +92,13 @@ - - protocol]['collectionWrapper']]]> - protocol]['collectionWrapper']]]> - protocol]['file']]]> - protocol]['filename']]]> - protocol]['options']]]> - - - protocol]['collectionWrapper']]]> - protocol]['collectionWrapper']]]> - protocol]['file']]]> - protocol]['filename']]]> - protocol]['options']]]> - + + $context + $context + + + $context + diff --git a/src/GridFS/Bucket.php b/src/GridFS/Bucket.php index 9b04e12f7..763d8392f 100644 --- a/src/GridFS/Bucket.php +++ b/src/GridFS/Bucket.php @@ -31,6 +31,7 @@ use MongoDB\Exception\UnsupportedException; use MongoDB\GridFS\Exception\CorruptFileException; use MongoDB\GridFS\Exception\FileNotFoundException; +use MongoDB\GridFS\Exception\LogicException; use MongoDB\GridFS\Exception\StreamException; use MongoDB\Model\BSONArray; use MongoDB\Model\BSONDocument; @@ -39,6 +40,7 @@ use function array_intersect_key; use function array_key_exists; use function assert; +use function explode; use function fopen; use function get_resource_type; use function in_array; @@ -54,6 +56,7 @@ use function MongoDB\BSON\toJSON; use function property_exists; use function sprintf; +use function str_contains; use function stream_context_create; use function stream_copy_to_stream; use function stream_get_meta_data; @@ -587,6 +590,29 @@ public function openUploadStream(string $filename, array $options = []) return fopen($path, 'w', false, $context); } + /** + * Register an alias to enable basic filename access for this bucket. + * + * For applications that need to interact with GridFS using only a filename + * string, a bucket can be registered with an alias. Files can then be + * accessed using the following pattern: + * + * gridfs:/// + * + * Read operations will always target the most recent revision of a file. + * + * @param non-empty-string string $alias The alias to use for the bucket + */ + public function registerGlobalStreamWrapperAlias(string $alias): void + { + if ($alias === '' || str_contains($alias, '/')) { + throw new InvalidArgumentException(sprintf('The bucket alias must be a non-empty string without any slash, "%s" given', $alias)); + } + + // Use a closure to expose the private method into another class + StreamWrapper::setContextResolver($alias, fn (string $path, string $mode, array $context) => $this->resolveStreamContext($path, $mode, $context)); + } + /** * Renames the GridFS file with the specified ID. * @@ -756,4 +782,50 @@ private function registerStreamWrapper(): void StreamWrapper::register(self::STREAM_WRAPPER_PROTOCOL); } + + /** + * Create a stream context from the path and mode provided to fopen(). + * + * @see StreamWrapper::setContextResolver() + * + * @param string $path The full url provided to fopen(). It contains the filename. + * gridfs://database_name/collection_name.files/file_name + * @param array{revision?: int, chunkSizeBytes?: int, disableMD5?: bool} $context The options provided to fopen() + * + * @return array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array} + * + * @throws FileNotFoundException + * @throws LogicException + */ + private function resolveStreamContext(string $path, string $mode, array $context): array + { + // Fallback to an empty filename if the path does not contain one: "gridfs://alias" + $filename = explode('/', $path, 4)[3] ?? ''; + + if ($mode === 'r' || $mode === 'rb') { + $file = $this->collectionWrapper->findFileByFilenameAndRevision($filename, $context['revision'] ?? -1); + + if (! is_object($file)) { + throw FileNotFoundException::byFilenameAndRevision($filename, $context['revision'] ?? -1, $path); + } + + return [ + 'collectionWrapper' => $this->collectionWrapper, + 'file' => $file, + ]; + } + + if ($mode === 'w' || $mode === 'wb') { + return [ + 'collectionWrapper' => $this->collectionWrapper, + 'filename' => $filename, + 'options' => $context + [ + 'chunkSizeBytes' => $this->chunkSizeBytes, + 'disableMD5' => $this->disableMD5, + ], + ]; + } + + throw LogicException::openModeNotSupported($mode); + } } diff --git a/src/GridFS/Exception/LogicException.php b/src/GridFS/Exception/LogicException.php new file mode 100644 index 000000000..907e27f07 --- /dev/null +++ b/src/GridFS/Exception/LogicException.php @@ -0,0 +1,70 @@ + */ + private static array $contextResolvers = []; + public function __destruct() { /* Ensure the stream is closed so the last chunk is written. This is @@ -84,6 +90,20 @@ public static function register(string $protocol = 'gridfs'): void stream_wrapper_register($protocol, static::class, STREAM_IS_URL); } + /** + * @see Bucket::resolveStreamContext() + * + * @param Closure(string, string, array):ContextOptions|null $resolver + */ + public static function setContextResolver(string $name, ?Closure $resolver): void + { + if ($resolver === null) { + unset(self::$contextResolvers[$name]); + } else { + self::$contextResolvers[$name] = $resolver; + } + } + /** * Closes the stream. * @@ -123,17 +143,44 @@ public function stream_eof(): bool */ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { - $this->initProtocol($path); + $context = []; + + /** + * The Bucket methods { @see Bucket::openUploadStream() } and { @see Bucket::openDownloadStreamByFile() } + * always set an internal context. But the context can also be set by the user. + */ + if (is_resource($this->context)) { + $context = stream_context_get_options($this->context)['gridfs'] ?? []; + + if (! is_array($context)) { + throw LogicException::invalidContext($context); + } + } + + // When the stream is opened using fopen(), the context is not required, it can contain only options. + if (! isset($context['collectionWrapper'])) { + $bucketAlias = explode('/', $path, 4)[2] ?? ''; + + if (! isset(self::$contextResolvers[$bucketAlias])) { + throw LogicException::bucketAliasNotRegistered($bucketAlias); + } + + $context = self::$contextResolvers[$bucketAlias]($path, $mode, $context); + } + + if (! $context['collectionWrapper'] instanceof CollectionWrapper) { + throw LogicException::invalidContextCollectionWrapper($context['collectionWrapper']); + } - if ($mode === 'r') { - return $this->initReadableStream(); + if ($mode === 'r' || $mode === 'rb') { + return $this->initReadableStream($context); } - if ($mode === 'w') { - return $this->initWritableStream(); + if ($mode === 'w' || $mode === 'wb') { + return $this->initWritableStream($context); } - return false; + throw LogicException::openModeNotSupported($mode); } /** @@ -250,6 +297,20 @@ public function stream_write(string $data): int return $this->stream->writeBytes($data); } + /** @return false|array */ + public function url_stat(string $path, int $flags) + { + assert($this->stream === null); + + try { + $this->stream_open($path, 'r', 0, $openedPath); + } catch (FileNotFoundException $e) { + return false; + } + + return $this->stream_stat(); + } + /** * Returns a stat template with default values. */ @@ -274,31 +335,16 @@ private function getStatTemplate(): array ]; } - /** - * Initialize the protocol from the given path. - * - * @see StreamWrapper::stream_open() - */ - private function initProtocol(string $path): void - { - $parts = explode('://', $path, 2); - $this->protocol = $parts[0] ?: 'gridfs'; - } - /** * Initialize the internal stream for reading. * - * @see StreamWrapper::stream_open() + * @param array{collectionWrapper: CollectionWrapper, file: object} $contextOptions */ - private function initReadableStream(): bool + private function initReadableStream(array $contextOptions): bool { - assert(is_resource($this->context)); - $context = stream_context_get_options($this->context); - - assert($this->protocol !== null); $this->stream = new ReadableStream( - $context[$this->protocol]['collectionWrapper'], - $context[$this->protocol]['file'], + $contextOptions['collectionWrapper'], + $contextOptions['file'], ); return true; @@ -307,18 +353,14 @@ private function initReadableStream(): bool /** * Initialize the internal stream for writing. * - * @see StreamWrapper::stream_open() + * @param array{collectionWrapper: CollectionWrapper, filename: string, options: array} $contextOptions */ - private function initWritableStream(): bool + private function initWritableStream(array $contextOptions): bool { - assert(is_resource($this->context)); - $context = stream_context_get_options($this->context); - - assert($this->protocol !== null); $this->stream = new WritableStream( - $context[$this->protocol]['collectionWrapper'], - $context[$this->protocol]['filename'], - $context[$this->protocol]['options'], + $contextOptions['collectionWrapper'], + $contextOptions['filename'], + $contextOptions['options'], ); return true; diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index c00568f37..9079e04a6 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -99,6 +99,21 @@ public static function provideExamples(): Generator 'expectedOutput' => $expectedOutput, ]; + $expectedOutput = <<<'OUTPUT' +File exists: no +Writing file +File exists: yes +Reading file: Hello, GridFS! +Writing new version of the file +Reading new version of the file: Hello, GridFS! (v2) +Reading previous version of the file: Hello, GridFS! +OUTPUT; + + yield 'gridfs-stream-wrapper' => [ + 'file' => __DIR__ . '/../examples/gridfs-stream-wrapper.php', + 'expectedOutput' => $expectedOutput, + ]; + $expectedOutput = <<<'OUTPUT' MongoDB\Examples\Persistable\PersistableEntry Object ( diff --git a/tests/GridFS/BucketFunctionalTest.php b/tests/GridFS/BucketFunctionalTest.php index bef444310..46c0c9294 100644 --- a/tests/GridFS/BucketFunctionalTest.php +++ b/tests/GridFS/BucketFunctionalTest.php @@ -3,12 +3,14 @@ namespace MongoDB\Tests\GridFS; use MongoDB\BSON\Binary; +use MongoDB\BSON\ObjectId; use MongoDB\Collection; use MongoDB\Driver\ReadConcern; use MongoDB\Driver\ReadPreference; use MongoDB\Driver\WriteConcern; use MongoDB\Exception\InvalidArgumentException; use MongoDB\GridFS\Bucket; +use MongoDB\GridFS\CollectionWrapper; use MongoDB\GridFS\Exception\CorruptFileException; use MongoDB\GridFS\Exception\FileNotFoundException; use MongoDB\GridFS\Exception\StreamException; @@ -18,6 +20,7 @@ use MongoDB\Tests\Fixtures\Codec\TestDocumentCodec; use MongoDB\Tests\Fixtures\Codec\TestFileCodec; use MongoDB\Tests\Fixtures\Document\TestFile; +use ReflectionMethod; use stdClass; use function array_merge; @@ -891,6 +894,85 @@ public function testDanglingOpenWritableStream(): void $this->assertSame(14, $fileDocument->length); } + public function testDanglingOpenWritableStreamWithGlobalStreamWrapperAlias(): void + { + if (! strncasecmp(PHP_OS, 'WIN', 3)) { + $this->markTestSkipped('Test does not apply to Windows'); + } + + $code = <<<'PHP' + require '%s'; + require '%s'; + $client = MongoDB\Tests\FunctionalTestCase::createTestClient(); + $database = $client->selectDatabase(getenv('MONGODB_DATABASE') ?: 'phplib_test'); + $database->selectGridFSBucket()->registerGlobalStreamWrapperAlias('alias'); + $stream = fopen('gridfs://alias/hello.txt', 'w'); + fwrite($stream, 'Hello MongoDB!'); + PHP; + + @exec( + implode(' ', [ + PHP_BINARY, + '-r', + escapeshellarg( + sprintf( + $code, + __DIR__ . '/../../vendor/autoload.php', + // Include the PHPUnit autoload file to ensure PHPUnit classes can be loaded + __DIR__ . '/../../vendor/bin/.phpunit/phpunit/vendor/autoload.php', + ), + ), + '2>&1', + ]), + $output, + $return, + ); + + $this->assertSame([], $output); + $this->assertSame(0, $return); + + $fileDocument = $this->filesCollection->findOne(['filename' => 'hello.txt']); + + $this->assertNotNull($fileDocument); + $this->assertSame(14, $fileDocument->length); + } + + public function testResolveStreamContextForRead(): void + { + $stream = $this->bucket->openUploadStream('filename'); + fwrite($stream, 'foobar'); + fclose($stream); + + $method = new ReflectionMethod($this->bucket, 'resolveStreamContext'); + $method->setAccessible(true); + + $context = $method->invokeArgs($this->bucket, ['gridfs://bucket/filename', 'rb', []]); + + $this->assertIsArray($context); + $this->assertArrayHasKey('collectionWrapper', $context); + $this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']); + $this->assertArrayHasKey('file', $context); + $this->assertIsObject($context['file']); + $this->assertInstanceOf(ObjectId::class, $context['file']->_id); + $this->assertSame('filename', $context['file']->filename); + } + + public function testResolveStreamContextForWrite(): void + { + $method = new ReflectionMethod($this->bucket, 'resolveStreamContext'); + $method->setAccessible(true); + + $context = $method->invokeArgs($this->bucket, ['gridfs://bucket/filename', 'wb', []]); + + $this->assertIsArray($context); + $this->assertArrayHasKey('collectionWrapper', $context); + $this->assertInstanceOf(CollectionWrapper::class, $context['collectionWrapper']); + $this->assertArrayHasKey('filename', $context); + $this->assertSame('filename', $context['filename']); + $this->assertArrayHasKey('options', $context); + $this->assertSame(['chunkSizeBytes' => 261120, 'disableMD5' => false], $context['options']); + } + /** * Asserts that an index with the given name exists for the collection. * diff --git a/tests/GridFS/StreamWrapperFunctionalTest.php b/tests/GridFS/StreamWrapperFunctionalTest.php index ebe6eb9ba..a2f1fe701 100644 --- a/tests/GridFS/StreamWrapperFunctionalTest.php +++ b/tests/GridFS/StreamWrapperFunctionalTest.php @@ -4,13 +4,31 @@ use MongoDB\BSON\Binary; use MongoDB\BSON\UTCDateTime; +use MongoDB\GridFS\Exception\FileNotFoundException; +use MongoDB\GridFS\Exception\LogicException; +use MongoDB\GridFS\StreamWrapper; +use function copy; use function fclose; use function feof; +use function file_exists; +use function file_get_contents; +use function file_put_contents; +use function filemtime; +use function filesize; +use function filetype; +use function fopen; use function fread; use function fseek; use function fstat; use function fwrite; +use function is_dir; +use function is_file; +use function is_link; +use function stream_context_create; +use function stream_get_contents; +use function time; +use function usleep; use const SEEK_CUR; use const SEEK_END; @@ -36,6 +54,13 @@ public function setUp(): void ]); } + public function tearDown(): void + { + StreamWrapper::setContextResolver('bucket', null); + + parent::tearDown(); + } + public function testReadableStreamClose(): void { $stream = $this->bucket->openDownloadStream('length-10'); @@ -204,4 +229,138 @@ public function testWritableStreamWrite(): void $this->assertSame(6, fwrite($stream, 'foobar')); } + + /** @dataProvider provideUrl */ + public function testStreamWithContextResolver(string $url, string $expectedFilename): void + { + $this->bucket->registerGlobalStreamWrapperAlias('bucket'); + + $stream = fopen($url, 'wb'); + + $this->assertSame(6, fwrite($stream, 'foobar')); + $this->assertTrue(fclose($stream)); + + $file = $this->filesCollection->findOne(['filename' => $expectedFilename]); + $this->assertNotNull($file); + + $stream = fopen($url, 'rb'); + + $this->assertSame('foobar', fread($stream, 10)); + $this->assertTrue(fclose($stream)); + } + + public static function provideUrl() + { + yield 'simple file' => ['gridfs://bucket/filename', 'filename']; + yield 'subdirectory file' => ['gridfs://bucket/path/to/filename.txt', 'path/to/filename.txt']; + yield 'question mark can be used in file name' => ['gridfs://bucket/file%20name?foo=bar', 'file%20name?foo=bar']; + } + + public function testFilePutAndGetContents(): void + { + $this->bucket->registerGlobalStreamWrapperAlias('bucket'); + + $filename = 'gridfs://bucket/path/to/filename'; + + $this->assertSame(6, file_put_contents($filename, 'foobar')); + + $file = $this->filesCollection->findOne(['filename' => 'path/to/filename']); + $this->assertNotNull($file); + + $this->assertSame('foobar', file_get_contents($filename)); + } + + public function testEmptyFilename(): void + { + $this->bucket->registerGlobalStreamWrapperAlias('bucket'); + + $filename = 'gridfs://bucket'; + + $this->assertSame(6, file_put_contents($filename, 'foobar')); + + $file = $this->filesCollection->findOne(['filename' => '']); + $this->assertNotNull($file); + + $this->assertSame('foobar', file_get_contents($filename)); + } + + public function testOpenSpecificRevision(): void + { + $this->bucket->registerGlobalStreamWrapperAlias('bucket'); + + $filename = 'gridfs://bucket/path/to/filename'; + + // Insert 3 revisions, wait 1ms between each to ensure they have different uploadDate + file_put_contents($filename, 'version 0'); + usleep(1000); + file_put_contents($filename, 'version 1'); + usleep(1000); + file_put_contents($filename, 'version 2'); + + $context = stream_context_create([ + 'gridfs' => ['revision' => -2], + ]); + $stream = fopen($filename, 'r', false, $context); + $this->assertSame('version 1', stream_get_contents($stream)); + fclose($stream); + + // Revision not existing + $this->expectException(FileNotFoundException::class); + $this->expectExceptionMessage('File with name "path/to/filename" and revision "10" not found in "gridfs://bucket/path/to/filename"'); + $context = stream_context_create([ + 'gridfs' => ['revision' => 10], + ]); + fopen($filename, 'r', false, $context); + } + + public function testFileNoFoundWithContextResolver(): void + { + $this->bucket->registerGlobalStreamWrapperAlias('bucket'); + + $this->expectException(FileNotFoundException::class); + $this->expectExceptionMessage('File with name "filename" and revision "-1" not found in "gridfs://bucket/filename"'); + + fopen('gridfs://bucket/filename', 'r'); + } + + public function testFileNoFoundWithoutDefaultResolver(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('GridFS stream wrapper has no bucket alias: "bucket"'); + + fopen('gridfs://bucket/filename', 'w'); + } + + public function testFileStats(): void + { + $this->bucket->registerGlobalStreamWrapperAlias('bucket'); + $path = 'gridfs://bucket/filename'; + + $this->assertFalse(file_exists($path)); + $this->assertFalse(is_file($path)); + + $time = time(); + $this->assertSame(6, file_put_contents($path, 'foobar')); + + $this->assertTrue(file_exists($path)); + $this->assertSame('file', filetype($path)); + $this->assertTrue(is_file($path)); + $this->assertFalse(is_dir($path)); + $this->assertFalse(is_link($path)); + $this->assertSame(6, filesize($path)); + $this->assertGreaterThanOrEqual($time, filemtime($path)); + $this->assertLessThanOrEqual(time(), filemtime($path)); + } + + public function testCopy(): void + { + $this->bucket->registerGlobalStreamWrapperAlias('bucket'); + $path = 'gridfs://bucket/filename'; + + $this->assertSame(6, file_put_contents($path, 'foobar')); + + copy($path, $path . '.copy'); + $this->assertSame('foobar', file_get_contents($path . '.copy')); + $this->assertSame('foobar', file_get_contents($path)); + } }