Skip to content

Commit b700b10

Browse files
authored
PHPLIB-1323 Implement unlink for GridFS stream wrapper (#1206)
* Delete all file revisions by bulk * Thrown a FileNotFoundException in rename and unlink * Fix rename with same file name
1 parent ed33572 commit b700b10

7 files changed

+175
-64
lines changed

docs/reference/method/MongoDBGridFSBucket-registerGlobalStreamWrapperAlias.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ Supported stream functions:
4848
- :php:`filesize() <filesize>`
4949
- :php:`file() <file>`
5050
- :php:`fopen() <fopen>` with "r", "rb", "w", and "wb" modes
51-
- :php:`rename() <rename>` rename all revisions of a file in the same bucket
51+
- :php:`rename() <rename>`
52+
- :php:`unlink() <unlink>`
5253

5354
In read mode, the stream context can contain the option ``gridfs['revision']``
5455
to specify the revision number of the file to read. If omitted, the most recent
@@ -57,6 +58,9 @@ revision is read (revision ``-1``).
5758
In write mode, the stream context can contain the option ``gridfs['chunkSizeBytes']``.
5859
If omitted, the defaults are inherited from the ``Bucket`` instance option.
5960

61+
The functions `rename` and `unlink` will rename or remove all revisions of a
62+
filename. If the filename does not exist, these functions throw a ``FileNotFoundException``.
63+
6064
Example
6165
-------
6266

@@ -86,6 +90,8 @@ Each call to these functions makes a request to the server.
8690

8791
echo file_get_contents('gridfs://mybucket/hello.txt');
8892

93+
unlink('gridfs://mybucket/hello.txt');
94+
8995
The output would then resemble:
9096

9197
.. code-block:: none

psalm-baseline.xml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,20 +85,34 @@
8585
<code>$context</code>
8686
</MixedArgumentTypeCoercion>
8787
</file>
88+
<file src="src/GridFS/CollectionWrapper.php">
89+
<MixedAssignment>
90+
<code>$ids[]</code>
91+
</MixedAssignment>
92+
</file>
8893
<file src="src/GridFS/ReadableStream.php">
8994
<MixedArgument>
9095
<code><![CDATA[$currentChunk->n]]></code>
9196
<code><![CDATA[$this->file->length]]></code>
9297
</MixedArgument>
9398
</file>
9499
<file src="src/GridFS/StreamWrapper.php">
95-
<InvalidArgument>
96-
<code>$context</code>
97-
<code>$context</code>
98-
</InvalidArgument>
100+
<InvalidDocblock>
101+
<code>private function getContext(string $path, string $mode): array</code>
102+
</InvalidDocblock>
103+
<MixedArgumentTypeCoercion>
104+
<code><![CDATA[$this->getContext($path, $mode)]]></code>
105+
<code><![CDATA[$this->getContext($path, $mode)]]></code>
106+
</MixedArgumentTypeCoercion>
99107
<MixedAssignment>
100108
<code>$context</code>
109+
<code>$count</code>
110+
<code>$count</code>
101111
</MixedAssignment>
112+
<MixedMethodCall>
113+
<code>deleteFileAndChunksByFilename</code>
114+
<code>updateFilenameForFilename</code>
115+
</MixedMethodCall>
102116
</file>
103117
<file src="src/Model/BSONArray.php">
104118
<MixedAssignment>

src/GridFS/CollectionWrapper.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,29 @@ public function deleteChunksByFilesId($id): void
8080
$this->chunksCollection->deleteMany(['files_id' => $id]);
8181
}
8282

83+
/**
84+
* Delete all GridFS files and chunks for a given filename.
85+
*/
86+
public function deleteFileAndChunksByFilename(string $filename): ?int
87+
{
88+
/** @var iterable<array{_id: mixed}> $files */
89+
$files = $this->findFiles(['filename' => $filename], [
90+
'typeMap' => ['root' => 'array'],
91+
'projection' => ['_id' => 1],
92+
]);
93+
94+
/** @var list<mixed> $ids */
95+
$ids = [];
96+
foreach ($files as $file) {
97+
$ids[] = $file['_id'];
98+
}
99+
100+
$count = $this->filesCollection->deleteMany(['_id' => ['$in' => $ids]])->getDeletedCount();
101+
$this->chunksCollection->deleteMany(['files_id' => ['$in' => $ids]]);
102+
103+
return $count;
104+
}
105+
83106
/**
84107
* Deletes a GridFS file and related chunks by ID.
85108
*
@@ -256,12 +279,12 @@ public function insertFile($file): void
256279
/**
257280
* Updates the filename field in the file document for all the files with a given filename.
258281
*/
259-
public function updateFilenameForFilename(string $filename, string $newFilename): UpdateResult
282+
public function updateFilenameForFilename(string $filename, string $newFilename): ?int
260283
{
261284
return $this->filesCollection->updateMany(
262285
['filename' => $filename],
263286
['$set' => ['filename' => $newFilename]],
264-
);
287+
)->getMatchedCount();
265288
}
266289

267290
/**

src/GridFS/Exception/FileNotFoundException.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@
2525

2626
class FileNotFoundException extends RuntimeException
2727
{
28+
/**
29+
* Thrown when a file cannot be found by its filename.
30+
*
31+
* @param string $filename Filename
32+
* @return self
33+
*/
34+
public static function byFilename(string $filename)
35+
{
36+
return new self(sprintf('File with name "%s" not found', $filename));
37+
}
38+
2839
/**
2940
* Thrown when a file cannot be found by its filename and revision.
3041
*

src/GridFS/ReadableStream.php

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,12 @@
2222
use MongoDB\Driver\CursorInterface;
2323
use MongoDB\Exception\InvalidArgumentException;
2424
use MongoDB\GridFS\Exception\CorruptFileException;
25-
use MongoDB\GridFS\Exception\LogicException;
2625

2726
use function assert;
2827
use function ceil;
2928
use function floor;
3029
use function is_integer;
3130
use function is_object;
32-
use function is_string;
3331
use function property_exists;
3432
use function sprintf;
3533
use function strlen;
@@ -178,20 +176,6 @@ public function readBytes(int $length): string
178176
return $data;
179177
}
180178

181-
/**
182-
* Rename all revisions of the file.
183-
*/
184-
public function rename(string $newFilename): bool
185-
{
186-
if (! isset($this->file->filename) || ! is_string($this->file->filename)) {
187-
throw new LogicException('Cannot rename file without a filename');
188-
}
189-
190-
$this->collectionWrapper->updateFilenameForFilename($this->file->filename, $newFilename);
191-
192-
return true;
193-
}
194-
195179
/**
196180
* Seeks the chunk and buffer offsets for the next read operation.
197181
*

src/GridFS/StreamWrapper.php

Lines changed: 71 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ public static function register(string $protocol = 'gridfs'): void
9696
/**
9797
* Rename all revisions of a filename.
9898
*
99-
* @return bool True on success or false on failure.
99+
* @return true
100+
* @throws FileNotFoundException
100101
*/
101102
public function rename(string $fromPath, string $toPath): bool
102103
{
@@ -105,16 +106,17 @@ public function rename(string $fromPath, string $toPath): bool
105106
throw LogicException::renamePathMismatch($fromPath, $toPath);
106107
}
107108

108-
try {
109-
$this->stream_open($fromPath, 'r', 0, $openedPath);
110-
} catch (FileNotFoundException $e) {
111-
return false;
112-
}
109+
$context = $this->getContext($fromPath, 'w');
110+
111+
$newFilename = explode('/', $toPath, 4)[3] ?? '';
112+
$count = $context['collectionWrapper']->updateFilenameForFilename($context['filename'], $newFilename);
113113

114-
$newName = explode('/', $toPath, 4)[3] ?? '';
115-
assert($this->stream instanceof ReadableStream);
114+
if ($count === 0) {
115+
throw FileNotFoundException::byFilename($fromPath);
116+
}
116117

117-
return $this->stream->rename($newName);
118+
// If $count is null, the update is unacknowledged, the operation is considered successful.
119+
return true;
118120
}
119121

120122
/**
@@ -170,41 +172,12 @@ public function stream_eof(): bool
170172
*/
171173
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
172174
{
173-
$context = [];
174-
175-
/**
176-
* The Bucket methods { @see Bucket::openUploadStream() } and { @see Bucket::openDownloadStreamByFile() }
177-
* always set an internal context. But the context can also be set by the user.
178-
*/
179-
if (is_resource($this->context)) {
180-
$context = stream_context_get_options($this->context)['gridfs'] ?? [];
181-
182-
if (! is_array($context)) {
183-
throw LogicException::invalidContext($context);
184-
}
185-
}
186-
187-
// When the stream is opened using fopen(), the context is not required, it can contain only options.
188-
if (! isset($context['collectionWrapper'])) {
189-
$bucketAlias = explode('/', $path, 4)[2] ?? '';
190-
191-
if (! isset(self::$contextResolvers[$bucketAlias])) {
192-
throw LogicException::bucketAliasNotRegistered($bucketAlias);
193-
}
194-
195-
$context = self::$contextResolvers[$bucketAlias]($path, $mode, $context);
196-
}
197-
198-
if (! $context['collectionWrapper'] instanceof CollectionWrapper) {
199-
throw LogicException::invalidContextCollectionWrapper($context['collectionWrapper']);
200-
}
201-
202175
if ($mode === 'r' || $mode === 'rb') {
203-
return $this->initReadableStream($context);
176+
return $this->initReadableStream($this->getContext($path, $mode));
204177
}
205178

206179
if ($mode === 'w' || $mode === 'wb') {
207-
return $this->initWritableStream($context);
180+
return $this->initWritableStream($this->getContext($path, $mode));
208181
}
209182

210183
throw LogicException::openModeNotSupported($mode);
@@ -324,6 +297,25 @@ public function stream_write(string $data): int
324297
return $this->stream->writeBytes($data);
325298
}
326299

300+
/**
301+
* Remove all revisions of a filename.
302+
*
303+
* @return true
304+
* @throws FileNotFoundException
305+
*/
306+
public function unlink(string $path): bool
307+
{
308+
$context = $this->getContext($path, 'w');
309+
$count = $context['collectionWrapper']->deleteFileAndChunksByFilename($context['filename']);
310+
311+
if ($count === 0) {
312+
throw FileNotFoundException::byFilename($path);
313+
}
314+
315+
// If $count is null, the update is unacknowledged, the operation is considered successful.
316+
return true;
317+
}
318+
327319
/** @return false|array */
328320
public function url_stat(string $path, int $flags)
329321
{
@@ -338,6 +330,45 @@ public function url_stat(string $path, int $flags)
338330
return $this->stream_stat();
339331
}
340332

333+
/**
334+
* @return array{collectionWrapper: CollectionWrapper, file: object}|array{collectionWrapper: CollectionWrapper, filename: string, options: array}
335+
* @psalm-return ($mode == 'r' or $mode == 'rb' ? array{collectionWrapper: CollectionWrapper, file: object} : array{collectionWrapper: CollectionWrapper, filename: string, options: array})
336+
*/
337+
private function getContext(string $path, string $mode): array
338+
{
339+
$context = [];
340+
341+
/**
342+
* The Bucket methods { @see Bucket::openUploadStream() } and { @see Bucket::openDownloadStreamByFile() }
343+
* always set an internal context. But the context can also be set by the user.
344+
*/
345+
if (is_resource($this->context)) {
346+
$context = stream_context_get_options($this->context)['gridfs'] ?? [];
347+
348+
if (! is_array($context)) {
349+
throw LogicException::invalidContext($context);
350+
}
351+
}
352+
353+
// When the stream is opened using fopen(), the context is not required, it can contain only options.
354+
if (! isset($context['collectionWrapper'])) {
355+
$bucketAlias = explode('/', $path, 4)[2] ?? '';
356+
357+
if (! isset(self::$contextResolvers[$bucketAlias])) {
358+
throw LogicException::bucketAliasNotRegistered($bucketAlias);
359+
}
360+
361+
/** @see Bucket::resolveStreamContext() */
362+
$context = self::$contextResolvers[$bucketAlias]($path, $mode, $context);
363+
}
364+
365+
if (! $context['collectionWrapper'] instanceof CollectionWrapper) {
366+
throw LogicException::invalidContextCollectionWrapper($context['collectionWrapper']);
367+
}
368+
369+
return $context;
370+
}
371+
341372
/**
342373
* Returns a stat template with default values.
343374
*/

tests/GridFS/StreamWrapperFunctionalTest.php

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
use function stream_context_create;
3030
use function stream_get_contents;
3131
use function time;
32+
use function unlink;
3233
use function usleep;
3334

3435
use const SEEK_CUR;
@@ -374,10 +375,33 @@ public function testRenameAllRevisions(): void
374375
$this->assertSame(6, file_put_contents($path, 'foobar'));
375376
$this->assertSame(6, file_put_contents($path, 'foobar'));
376377

377-
$this->assertTrue(rename($path, $path . '.renamed'));
378+
$result = rename($path, $path . '.renamed');
379+
$this->assertTrue($result);
378380
$this->assertTrue(file_exists($path . '.renamed'));
379381
$this->assertFalse(file_exists($path));
380382
$this->assertSame('foobar', file_get_contents($path . '.renamed'));
383+
384+
$this->expectException(FileNotFoundException::class);
385+
$this->expectExceptionMessage('File with name "gridfs://bucket/filename" not found');
386+
rename($path, $path . '.renamed');
387+
}
388+
389+
public function testRenameSameFilename(): void
390+
{
391+
$this->bucket->registerGlobalStreamWrapperAlias('bucket');
392+
$path = 'gridfs://bucket/filename';
393+
394+
$this->assertSame(6, file_put_contents($path, 'foobar'));
395+
396+
$result = rename($path, $path);
397+
$this->assertTrue($result);
398+
$this->assertTrue(file_exists($path));
399+
$this->assertSame('foobar', file_get_contents($path));
400+
401+
$path = 'gridfs://bucket/missing';
402+
$this->expectException(FileNotFoundException::class);
403+
$this->expectExceptionMessage('File with name "gridfs://bucket/missing" not found');
404+
rename($path, $path);
381405
}
382406

383407
public function testRenamePathMismatch(): void
@@ -387,4 +411,22 @@ public function testRenamePathMismatch(): void
387411

388412
rename('gridfs://bucket/filename', 'gridfs://other/newname');
389413
}
414+
415+
public function testUnlinkAllRevisions(): void
416+
{
417+
$this->bucket->registerGlobalStreamWrapperAlias('bucket');
418+
$path = 'gridfs://bucket/path/to/filename';
419+
420+
file_put_contents($path, 'version 0');
421+
file_put_contents($path, 'version 1');
422+
423+
$result = unlink($path);
424+
425+
$this->assertTrue($result);
426+
$this->assertFalse(file_exists($path));
427+
428+
$this->expectException(FileNotFoundException::class);
429+
$this->expectExceptionMessage('File with name "gridfs://bucket/path/to/filename" not found');
430+
unlink($path);
431+
}
390432
}

0 commit comments

Comments
 (0)