diff --git a/.gitignore b/.gitignore index 6ee903d..c7aed3c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ .phpunit.cache /phpunit.xml +.php-cs-fixer.cache + composer.lock diff --git a/README.md b/README.md index 24f39f0..0e055ba 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Latest Version](http://poser.pugx.org/alexandre-daubois/lazy-stream/v/stable)](https://packagist.org/packages/alexandre-daubois/lazy-stream) [![License](http://poser.pugx.org/alexandre-daubois/lazy-stream/license)](https://packagist.org/packages/alexandre-daubois/lazy-stream) -LazyStream is a library that provides a convenient way to write lazily to streams using generators. It allows you to write data incrementally to a stream, reducing memory usage and improving performance when dealing with large amounts of data. +LazyStream is a **pure PHP**, **zero-dependencies** library that provides a convenient way to write lazily to streams using generators. It allows you to write data incrementally to a stream, reducing memory usage and improving performance when dealing with large amounts of data. ## Features @@ -108,6 +108,28 @@ $stream = new MultiLazyStreamWriter([ $stream->trigger(); ``` +## The `LazyStreamChunkWriter` class + +The `LazyStreamChunkWriter` class is a specialized class that allows you to write data to a stream in chunks. The mechanism is pretty different from other writers. Instead of writing data from a generator, you can write data by calling the `send()` method. This allows to write data in a more controlled and "natural" way without having to worry about generators and iterators. + +**The LazyStreamChunkWriter ALWAYS append data to the stream, thus the opening mode can not be controlled.** This is done to ensure a proper autoclosing behavior. If you need to write data in an empty stream, you should use the `LazyStreamWriter` class or ensure your stream is empty before sending data. + +Here is an example of how to use the `LazyStreamChunkWriter` class: + +```php +use LazyStream\LazyStreamChunkWriter; + +$stream = new LazyStreamChunkWriter('https://user:pass@example.com/my-file.json'); + +$data = /** fetch data from somewhere */; +$stream->send($data); + +// normal flow of the application + +$data = /** fetch data from somewhere else */; +$stream->send($data); +``` + ## Reading lazily a stream with `LazyStreamReader` Files are already read lazily by default: when you call `fread()`, you only fetch the number of bytes you asked for, not more. diff --git a/composer.json b/composer.json index d2ae7b5..72193a7 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "phpunit/phpunit": "^10.5.10" + "friendsofphp/php-cs-fixer": "^3.52", + "phpunit/phpunit": "^10.5.16" } } diff --git a/src/LazyStreamChunkWriter.php b/src/LazyStreamChunkWriter.php new file mode 100644 index 0000000..d8dba53 --- /dev/null +++ b/src/LazyStreamChunkWriter.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LazyStream; + +use LazyStream\Exception\LazyStreamOpenException; +use LazyStream\Exception\LazyStreamWriterTriggerException; + +/** + * Implementation of the LazyStreamWriterInterface that allows writing data + * to a stream by sending data when needed. The advantage of this class is that + * data can be sent to the stream in a more "natural" order, where + * {@see LazyStreamWriter} requires to provide a data provider iterator. + */ +class LazyStreamChunkWriter extends LazyStreamWriter +{ + private \Generator $dataProvider; + protected string $openingMode = 'a'; + + /** + * @param string $uri A valid stream URI. + * @param bool $autoClose Whether the stream should be closed once the `trigger` method is done. + */ + public function __construct( + protected string $uri, + private bool $autoClose = false, + ) { + // no parent constructor call because we don't want to provide + // an iterator as data provider + + $this->dataProvider = (function (): \Generator { + while (true) { + $data = yield; + + if (null === $this->handle) { + $this->openStream(); + } + + if (false === \fwrite($this->handle, $data)) { + throw new LazyStreamWriterTriggerException(sprintf('Unable to write to stream with URI "%s".', $this->metadata['uri'])); + } + + if ($this->autoClose) { + $this->closeStream(); + } + } + })(); + } + + /** + * Sends data to the stream. If the stream is not open, it will be opened. + */ + public function send(mixed $data): void + { + try { + $this->dataProvider->send($data); + } catch (LazyStreamOpenException $lazyStreamOpenException) { + throw $lazyStreamOpenException; + } catch (\Throwable $throwable) { + throw new LazyStreamWriterTriggerException(previous: $throwable); + } finally { + if ($this->autoClose) { + $this->closeStream(); + } + } + } + + /** + * This method is not allowed to be called on this class. Use {@see self::send()} instead. + */ + public function trigger(): never + { + throw new \LogicException(sprintf('You must provide data to write to the stream by calling "%s::send()".', __CLASS__)); + } + + public function equals(LazyStreamWriter $other): bool + { + return $this->uri === $other->uri; + } +} diff --git a/tests/LazyStreamChunkWriterTest.php b/tests/LazyStreamChunkWriterTest.php new file mode 100644 index 0000000..4f795c7 --- /dev/null +++ b/tests/LazyStreamChunkWriterTest.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + + +use LazyStream\Exception\LazyStreamOpenException; +use LazyStream\Exception\LazyStreamWriterTriggerException; +use LazyStream\LazyStreamChunkWriter; +use LazyStream\LazyStreamWriter; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; + +#[CoversClass(LazyStreamChunkWriter::class)] +class LazyStreamChunkWriterTest extends TestCase +{ + public function testEqualsDifferentStreams(): void + { + $lazyStream = new LazyStreamChunkWriter('php://memory'); + $other = new LazyStreamChunkWriter('php://input'); + + $this->assertFalse($lazyStream->equals($other)); + } + + public function testEqualsSameUri(): void + { + $lazyStream = new LazyStreamChunkWriter('php://memory'); + $other = new LazyStreamChunkWriter('php://memory'); + + $this->assertTrue($lazyStream->equals($other)); + } + + public function testStreamIsLazilyOpened(): void + { + $lazyStream = new LazyStreamChunkWriter('php://memory'); + + $this->assertNull($lazyStream->getStreamHandle()); + } + + public function testTriggerStreamsThrows(): void + { + $lazyStream = new LazyStreamChunkWriter('php://memory'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('You must provide data to write to the stream by calling "LazyStream\LazyStreamChunkWriter::send()".'); + $lazyStream->trigger(); + } + + public function testInvalidStreamThrowsAtSend(): void + { + $lazyStream = new LazyStreamChunkWriter('php://invalid'); + + $this->expectException(LazyStreamOpenException::class); + $this->expectExceptionMessage('Unable to open "php://invalid" with mode "a".'); + $lazyStream->send('test'); + } + + public function testGetType(): void + { + $lazyStream = new LazyStreamChunkWriter('php://memory'); + + $this->assertSame('MEMORY', $lazyStream->getMetadata()['stream_type']); + $this->assertNull($lazyStream->getStreamHandle()); + } + + public function testGetTypeOnTriggeredStreamWithAutoclose(): void + { + $lazyStream = new LazyStreamChunkWriter('php://memory', autoClose: true); + + $this->assertSame('MEMORY', $lazyStream->getMetadata()['stream_type']); + $this->assertNull($lazyStream->getStreamHandle()); + } + + public function testGetTypeOnTriggeredStreamWithoutAutoclose(): void + { + $lazyStream = new LazyStreamChunkWriter('php://memory'); + + $lazyStream->send('test'); + $this->assertNotNull($lazyStream->getStreamHandle()); + + $this->assertSame('MEMORY', $lazyStream->getMetadata()['stream_type']); + $this->assertNotNull($lazyStream->getStreamHandle()); + } + + public function testSend(): void + { + $lazyStream = new LazyStreamChunkWriter(__DIR__.\DIRECTORY_SEPARATOR.__METHOD__); + + try { + $lazyStream->send('test'); + $lazyStream->send('test2'); + $this->assertSame('testtest2', file_get_contents(__DIR__.\DIRECTORY_SEPARATOR.__METHOD__)); + } finally { + \unlink(__DIR__.\DIRECTORY_SEPARATOR.__METHOD__); + } + } + + public function testSendWithAutoclose(): void + { + $lazyStream = new LazyStreamChunkWriter(__DIR__.\DIRECTORY_SEPARATOR.__METHOD__, true); + + try { + $lazyStream->send('test'); + $lazyStream->send('test2'); + $this->assertSame('testtest2', file_get_contents(__DIR__.\DIRECTORY_SEPARATOR.__METHOD__)); + } finally { + \unlink(__DIR__.\DIRECTORY_SEPARATOR.__METHOD__); + } + } +}