From f0f04b36d3f855f55fc0cc8e682ef9b175b6a1bf Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Fri, 3 Jan 2025 23:38:38 +0100 Subject: [PATCH] Improve CallbackStreamFilter implementation --- CHANGELOG.md | 4 +- .../9.0/connections/callback-stream-filter.md | 125 ++++++++-- docs/9.0/connections/filters.md | 29 ++- docs/9.0/interoperability/encoding.md | 2 +- src/AbstractCsv.php | 37 ++- src/AbstractCsvTest.php | 22 +- src/CallbackStreamFilter.php | 228 +++++++++++++----- src/CallbackStreamFilterTest.php | 75 +++++- src/CharsetConverter.php | 4 +- src/CharsetConverterTest.php | 6 +- src/ReaderTest.php | 2 +- src/Stream.php | 17 ++ src/SwapDelimiter.php | 2 +- src/WriterTest.php | 2 +- 14 files changed, 439 insertions(+), 116 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb4ce3c..4f1990cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ All Notable changes to `Csv` will be documented in this file - Adding the `TabularDataReader::map` method. - Adding `CallbackStreamFilter` class +- `AbstractCsv::appendStreamFilter` +- `AbstractCsv::prependStreamFilter` ### Deprecated -- None +- `AbstractCsv::addStreamFilter` use `AbstractCsv::appendStreamFilter` instead. ### Fixed diff --git a/docs/9.0/connections/callback-stream-filter.md b/docs/9.0/connections/callback-stream-filter.md index fbc1f3e7..5616cd0e 100644 --- a/docs/9.0/connections/callback-stream-filter.md +++ b/docs/9.0/connections/callback-stream-filter.md @@ -5,7 +5,7 @@ title: Dynamic Stream Filter # Callback Stream Filter -

Available since version 9.22.0

+

Available since version 9.21.0

Sometimes you may encounter a scenario where you need to create a specific stream filter to resolve a specific issue. Instead of having to put up with the hassle of creating a @@ -13,38 +13,129 @@ fully fledge stream filter, we are introducing a `CallbackStreamFilter`. This fi is a PHP stream filter which enables applying a callable onto the stream prior to it being actively consumed by the CSV process. -## Usage with CSV objects +## Registering the callbacks + +Out of the box, to work, the feature requires a callback and an associated unique filter name. + +```php +use League\Csv\CallbackStreamFilter; + +CallbackStreamFilter::register('string.to.upper', strtoupper(...)); +``` -Out of the box, the filter can not work, it requires a unique name and a callback to be usable. Once registered you can re-use the filter with CSV documents or with a resource. -let's imagine we have a CSV document with the return carrier character as the end of line character. -This type of document is parsable by the package but only if you enable the deprecated `auto_detect_line_endings`. +

CallbackStreanFilter::register register your callback +globally. So you only need to register it once. Preferably in your container definition if you +are using a framework.

+ +You can always check for the registered filter by calling the `CallbackStreamFilter::isRegistered` + +```php +CallbackStreamFilter::isRegisterd('string.to.upper'); //returns true +CallbackStreamFilter::isRegisterd('string.to.lower'); //returns false +``` + +Last but not least you can always list all the registered filter names by calling the + +```php +CallbackStreamFilter::registeredFilters(); // returns a list +``` + +## Usage with CSV objects + +Let's imagine we have a CSV document using the return carrier character (`\r`) as the end of line character. +This type of document is parsable by the package but only if you enable the deprecated `auto_detect_line_endings` ini setting. -If you no longer want to rely on that feature since it emits a deprecation warning you can use the new -`CallbackStreamFilter` instead by swaping the offending character with a modern alternative. +If you no longer want to rely on that feature since it has been deprecated since PHP 8.1 and will be +removed from PHP once PHP9.0 is release, you can use the `CallbackStreamFilter` instead by +swaping the offending character with a supported alternative. ```php use League\Csv\CallbackStreamFilter; use League\Csv\Reader; -$csv = "title1,title2,title3\rcontent11,content12,content13\rcontent21,content22,content23\r"; +$csv = "title1,title2,title3\r". + . "content11,content12,content13\r" + . "content21,content22,content23\r"; $document = Reader::createFromString($csv); -CallbackStreamFilter::addTo( - $document, - 'swap.carrier.return', +$document->setHeaderOffset(0); + +CallbackStreamFilter::register( + 'swap.carrier.return', fn (string $bucket): string => str_replace("\r", "\n", $bucket) ); -$document->setHeaderOffset(0); +CallbackStreamFilter::appendTo($document, 'swap.carrier.return'); + return $document->first(); -// returns ['title1' => 'content11', 'title2' => 'content12', 'title3' => 'content13'] +// returns [ +// 'title1' => 'content11', +// 'title2' => 'content12', +// 'title3' => 'content13', +// ] ``` -The `addTo` method register the filter with the unique `swap.carrier.return` name and then attach -it to the CSV document object on read. +The `appendTo` method will check for the availability of the filter via its +name `swap.carrier.return`. If it is not present a `LogicException` will be +thrown, otherwise it will attach the filter to the CSV document object at the +bottom of the stream filter queue. Since we are using the `Reader` class, the +filter is attached using the reader mode. If we were to use the `Writer` class, +the filter would be attached using the write mode only.

On read, the CSV document content is never changed or replaced. -Conversely, the changes are persisted during writing.

+However, on write, the changes are persisted into the created document.

+ +## Usage with streams + +Of course the `CallbackStreamFilter` can be use in other scenario or with PHP stream resources. + +With PHP streams you can also use: + +- `CallbackStreamFilter::appendTo` +- `CallbackStreamFilter::appendOnReadTo` +- `CallbackStreamFilter::appendOnWriteTo` +- `CallbackStreamFilter::prependTo` +- `CallbackStreamFilter::prependOnReadTo` +- `CallbackStreamFilter::prependOnWriteTo` + +to add the stream filter at the bottom or on the top of the stream filter queue on +a read or write mode. + +

Those methods can also be used by the CSV classes, but +the read or write mode will be superseeded by the CSV class mode.

+ +```php +use League\Csv\CallbackStreamFilter; + +$csv = "title1,title2,title3\r". + . "content11,content12,content13\r" + . "content21,content22,content23\r"; +$stream = tmpfile(); +fwrite($stream, $csv); + +// We first check to see if the callback is not already registered +// without the check a LogicException would be thrown on +// usage or on callback registration +if (!CallbackStreamFilter::isRegistered('swap.carrier.return')) { + CallbackStreamFilter::register( + 'swap.carrier.return', + fn (string $bucket): string => str_replace("\r", "\n", $bucket) + ); +} +CallbackStreamFilter::apppendOnReadTo($stream, 'swap.carrier.return'); +$data = []; + +rewind($stream); +while (($record = fgetcsv($stream, 1000, ',')) !== false) { + $data[] = $record; +} +fclose($stream); + +return $data[0] +//returns ['title1', 'title2', 'title3'] +``` -Of course the `CallbackStreamFilter` can be use in other different scenario or with PHP stream resources. +

If you use appendTo or prependTo on a stream +which can be read and written to, the filter will be registered on both mode which +MAY lead to unexpected behaviour depending on your callback logic.

diff --git a/docs/9.0/connections/filters.md b/docs/9.0/connections/filters.md index c60d2d06..c672ccd4 100644 --- a/docs/9.0/connections/filters.md +++ b/docs/9.0/connections/filters.md @@ -79,16 +79,29 @@ Here's a table to quickly determine if PHP stream filters works depending on how ```php public AbstractCsv::addStreamFilter(string $filtername, mixed $params = null): self +public AbstractCsv::appendStreamFilter(string $filtername, mixed $params = null): self +public AbstractCsv::prependStreamFilter(string $filtername, mixed $params = null): self public AbstractCsv::hasStreamFilter(string $filtername): bool ``` The `AbstractCsv::addStreamFilter` method adds a stream filter to the connection. -- The `$filtername` parameter is a string that represents the filter as registered using php `stream_filter_register` function or one of [PHP internal stream filter](http://php.net/manual/en/filters.php). +
+ +
+- The `$filtername` parameter is a string that represents the filter as registered using php `stream_filter_register` function or one of [PHP internal stream filter](http://php.net/manual/en/filters.php). - The `$params` : This filter will be added with the specified parameters to the end of the list. -

Each time your call addStreamFilter with the same argument the corresponding filter is registered again.

+The `appendStreamFilter` adds the stream filter at the bottom of the stream filter queue whereas +`prependStreamFilter` adds the stream filter on top of the queue. Both methods share the same +arguments and the same return type. + +

Each time your call appendStreamFilter with the same argument the corresponding filter is registered again.

The `AbstractCsv::hasStreamFilter` method tells whether a specific stream filter is already attached to the connection. @@ -101,8 +114,8 @@ stream_filter_register('convert.utf8decode', Transcode::class); $reader = Reader::createFromPath('/path/to/my/chinese.csv', 'r'); if ($reader->supportsStreamFilterOnRead()) { - $reader->addStreamFilter('convert.utf8decode'); - $reader->addStreamFilter('string.toupper'); + $reader->appendStreamFilter('convert.utf8decode'); + $reader->appendStreamFilter('string.toupper'); } $reader->hasStreamFilter('string.toupper'); //returns true @@ -116,7 +129,7 @@ foreach ($reader as $row) { ## Stream filters removal -Stream filters attached **with** `addStreamFilter` are: +Stream filters attached **with** `addStreamFilter`, `appendStreamFilter`, `prependStreamFilter` are: - removed on the CSV object destruction. @@ -133,8 +146,8 @@ stream_filter_register('convert.utf8decode', Transcode::class); $fp = fopen('/path/to/my/chines.csv', 'r'); stream_filter_append($fp, 'string.rot13'); //stream filter attached outside of League\Csv $reader = Reader::createFromStream($fp); -$reader->addStreamFilter('convert.utf8decode'); -$reader->addStreamFilter('string.toupper'); +$reader->prependStreamFilter('convert.utf8decode'); +$reader->prependStreamFilter('string.toupper'); $reader->hasStreamFilter('string.rot13'); //returns false $reader = null; // 'string.rot13' is still attached to `$fp` @@ -148,4 +161,4 @@ The library comes bundled with the following stream filters: - [RFC4180Field](/9.0/interoperability/rfc4180-field/) stream filter to read or write RFC4180 compliant CSV field; - [CharsetConverter](/9.0/converter/charset/) stream filter to convert your CSV document content using the `mbstring` extension; - [SkipBOMSequence](/9.0/connections/bom/) stream filter to skip your CSV document BOM sequence if present; -- [CallbackStramFilter](/9.0/connections/callback-strean-filter/) apply a callback via a stream filter. +- [CallbackStreamFilter](/9.0/connections/callback-stream-filter/) apply a callback via a stream filter. diff --git a/docs/9.0/interoperability/encoding.md b/docs/9.0/interoperability/encoding.md index 4e53caf3..122729b6 100644 --- a/docs/9.0/interoperability/encoding.md +++ b/docs/9.0/interoperability/encoding.md @@ -21,7 +21,7 @@ $reader = Reader::createFromPath('/path/to/my/file.csv', 'r'); //let's set the output BOM $reader->setOutputBOM(Bom::Utf8); //let's convert the incoming data from iso-88959-15 to utf-8 -$reader->addStreamFilter('convert.iconv.ISO-8859-15/UTF-8'); +$reader->appendStreamFilter('convert.iconv.ISO-8859-15/UTF-8'); //BOM detected and adjusted for the output echo $reader->getContent(); ``` diff --git a/src/AbstractCsv.php b/src/AbstractCsv.php index 48976890..3aa61a02 100644 --- a/src/AbstractCsv.php +++ b/src/AbstractCsv.php @@ -378,7 +378,7 @@ public function setOutputBOM(Bom|string|null $str): static * @throws InvalidArgument If the stream filter API can not be appended * @throws UnavailableFeature If the stream filter API can not be used */ - public function addStreamFilter(string $filtername, null|array $params = null): static + public function appendStreamFilter(string $filtername, null|array $params = null): static { $this->document instanceof Stream || throw UnavailableFeature::dueToUnsupportedStreamFilterApi(get_class($this->document)); @@ -390,6 +390,24 @@ public function addStreamFilter(string $filtername, null|array $params = null): return $this; } + /** + * Prepend a stream filter. + * + * @throws InvalidArgument If the stream filter API can not be appended + * @throws UnavailableFeature If the stream filter API can not be used + */ + public function prependStreamFilter(string $filtername, null|array $params = null): static + { + $this->document instanceof Stream || throw UnavailableFeature::dueToUnsupportedStreamFilterApi(get_class($this->document)); + + $this->document->prependFilter($filtername, static::STREAM_FILTER_MODE, $params); + $this->stream_filters[$filtername] = true; + $this->resetProperties(); + $this->input_bom = null; + + return $this; + } + /** * DEPRECATION WARNING! This method will be removed in the next major point release. * @@ -516,4 +534,21 @@ public function output(?string $filename = null): int throw new InvalidArgument($exception->getMessage()); } } + + /** + * DEPRECATION WARNING! This method will be removed in the next major point release. + * @codeCoverageIgnore + * @deprecated since version 9.22.0 + * @see AbstractCsv::appendStreamFilter() + * + * Append a stream filter. + * + * @throws InvalidArgument If the stream filter API can not be appended + * @throws UnavailableFeature If the stream filter API can not be used + */ + #[Deprecated(message:'use League\Csv\AbstractCsv::appendStreamFilter() instead', since:'league/csv:9.18.0')] + public function addStreamFilter(string $filtername, null|array $params = null): static + { + return $this->appendStreamFilter($filtername, $params); + } } diff --git a/src/AbstractCsvTest.php b/src/AbstractCsvTest.php index a119f057..e054b94c 100644 --- a/src/AbstractCsvTest.php +++ b/src/AbstractCsvTest.php @@ -324,28 +324,28 @@ public function testEnclosure(): void $this->csv->setEnclosure('foo'); } - public function testAddStreamFilter(): void + public function testappendStreamFilter(): void { $csv = Reader::createFromPath(__DIR__.'/../test_files/foo.csv'); - $csv->addStreamFilter('string.rot13'); - $csv->addStreamFilter('string.tolower'); - $csv->addStreamFilter('string.toupper'); + $csv->appendStreamFilter('string.rot13'); + $csv->appendStreamFilter('string.tolower'); + $csv->appendStreamFilter('string.toupper'); foreach ($csv as $row) { self::assertSame($row, ['WBUA', 'QBR', 'WBUA.QBR@RKNZCYR.PBZ']); } } - public function testFailedAddStreamFilter(): void + public function testFailedappendStreamFilter(): void { $csv = Writer::createFromFileObject(new SplTempFileObject()); self::assertFalse($csv->supportsStreamFilterOnWrite()); $this->expectException(UnavailableFeature::class); - $csv->addStreamFilter('string.toupper'); + $csv->appendStreamFilter('string.toupper'); } - public function testFailedAddStreamFilterWithWrongFilter(): void + public function testFailedappendStreamFilterWithWrongFilter(): void { $this->expectException(InvalidArgument::class); @@ -353,7 +353,7 @@ public function testFailedAddStreamFilterWithWrongFilter(): void $tmpfile = tmpfile(); Writer::createFromStream($tmpfile) - ->addStreamFilter('foobar.toupper'); + ->appendStreamFilter('foobar.toupper'); } public function testStreamFilterDetection(): void @@ -363,7 +363,7 @@ public function testStreamFilterDetection(): void self::assertFalse($csv->hasStreamFilter($filtername)); - $csv->addStreamFilter($filtername); + $csv->appendStreamFilter($filtername); self::assertTrue($csv->hasStreamFilter($filtername)); } @@ -372,7 +372,7 @@ public function testClearAttachedStreamFilters(): void { $path = __DIR__.'/../test_files/foo.csv'; $csv = Reader::createFromPath($path); - $csv->addStreamFilter('string.toupper'); + $csv->appendStreamFilter('string.toupper'); self::assertStringContainsString('JOHN', $csv->toString()); @@ -384,7 +384,7 @@ public function testClearAttachedStreamFilters(): void public function testSetStreamFilterOnWriter(): void { $csv = Writer::createFromPath(__DIR__.'/../test_files/newline.csv', 'w+'); - $csv->addStreamFilter('string.toupper'); + $csv->appendStreamFilter('string.toupper'); $csv->insertOne([1, 'two', 3, "new\r\nline"]); self::assertStringContainsString("1,TWO,3,\"NEW\r\nLINE\"", $csv->toString()); diff --git a/src/CallbackStreamFilter.php b/src/CallbackStreamFilter.php index af8750d0..c9c2d2d1 100644 --- a/src/CallbackStreamFilter.php +++ b/src/CallbackStreamFilter.php @@ -14,127 +14,237 @@ namespace League\Csv; use Closure; +use LogicException; use php_user_filter; use RuntimeException; use TypeError; use function array_key_exists; +use function array_keys; +use function get_resource_type; +use function gettype; +use function in_array; +use function is_array; use function is_resource; +use function restore_error_handler; +use function set_error_handler; +use function stream_bucket_append; +use function stream_bucket_make_writeable; +use function stream_filter_append; +use function stream_filter_register; +use function stream_get_filters; + +use const PSFS_PASS_ON; +use const STREAM_FILTER_ALL; +use const STREAM_FILTER_READ; +use const STREAM_FILTER_WRITE; final class CallbackStreamFilter extends php_user_filter { private const FILTER_NAME = 'string.league.csv.stream.callback.filter'; - public static function getFiltername(string $name): string + /** @var array */ + private static array $filters = []; + + public function onCreate(): bool + { + return is_array($this->params) && + array_key_exists('name', $this->params) && + self::getFiltername($this->params['name']) === $this->filtername && + isset(self::$filters[$this->params['name']]) + ; + } + + public function filter($in, $out, &$consumed, bool $closing): int + { + if (!is_array($this->params) || !isset($this->params['name'], self::$filters[$this->params['name']])) { + return PSFS_PASS_ON; + } + + /** @var Closure(string, ?array): string $callback */ + $callback = self::$filters[$this->params['name']]; + while (null !== ($bucket = stream_bucket_make_writeable($in))) { + $bucket->data = ($callback)($bucket->data, $this->params); + $consumed += $bucket->datalen; + stream_bucket_append($out, $bucket); + } + + return PSFS_PASS_ON; + } + + private static function getFiltername(string $name): string { return self::FILTER_NAME.'.'.$name; } /** * Static method to register the class as a stream filter. + * + * @param callable(string, ?array): string $callback */ - public static function register(string $name): void + public static function register(string $name, callable $callback): void { $filtername = self::getFiltername($name); - if (!in_array($filtername, stream_get_filters(), true)) { - stream_filter_register($filtername, self::class); + if (isset(self::$filters[$name]) || in_array($filtername, stream_get_filters(), true)) { + throw new LogicException('The stream filter "'.$name.'" is already registered.'); + } + + stream_filter_register($filtername, self::class); + if (!$callback instanceof Closure) { + $callback = $callback(...); } + + self::$filters[$name] = $callback; } /** - * Static method to attach the stream filter to a CSV Reader or Writer instance. + * Tells whether a callback with the given name is already registered or not. */ - public static function addTo(AbstractCsv $csv, string $name, callable $callback): void + public static function isRegistered(string $name): bool { - self::register($name); + return isset(self::$filters[$name]) + && in_array(self::getFiltername($name), stream_get_filters(), true) ; + } - $csv->addStreamFilter(self::getFiltername($name), [ - 'name' => $name, - 'callback' => $callback instanceof Closure ? $callback : $callback(...), - ]); + /** + * Returns the list of registered filters. + * + * @return array + */ + public static function registeredFilters(): array + { + return array_keys(self::$filters); } /** - * @param resource $stream - * @param callable(string): string $callback + * @param resource|AbstractCsv $stream * * @throws TypeError * @throws RuntimeException * - * @return resource + * @return resource|AbstractCsv */ - public static function appendTo(mixed $stream, string $name, callable $callback): mixed + public static function appendTo(mixed $stream, string $name, array $params = []): mixed { - self::register($name); + return self::appendFilter(STREAM_FILTER_ALL, $stream, $name, $params); + } - is_resource($stream) || throw new TypeError('Argument passed must be a stream resource, '.gettype($stream).' given.'); - 'stream' === ($type = get_resource_type($stream)) || throw new TypeError('Argument passed must be a stream resource, '.$type.' resource given'); + /** + * @param resource|AbstractCsv $stream + * + * @throws TypeError + * @throws RuntimeException + * + * @return resource|AbstractCsv + */ + public static function apppendOnReadTo(mixed $stream, string $name, array $params = []): mixed + { + return self::appendFilter(STREAM_FILTER_READ, $stream, $name, $params); + } - set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); - $filter = stream_filter_append($stream, self::getFiltername($name), params: [ - 'name' => $name, - 'callback' => $callback instanceof Closure ? $callback : $callback(...), - ]); - restore_error_handler(); + /** + * @param resource|AbstractCsv $stream + * + * @throws TypeError + * @throws RuntimeException + * + * @return resource|AbstractCsv + */ + public static function apppendOnWriteTo(mixed $stream, string $name, array $params = []): mixed + { + return self::appendFilter(STREAM_FILTER_WRITE, $stream, $name, $params); + } - if (!is_resource($filter)) { - throw new RuntimeException('Could not append the registered stream filter: '.self::getFiltername($name)); - } + /** + * @param resource|AbstractCsv $stream + * + * @throws TypeError + * @throws RuntimeException + * + * @return resource|AbstractCsv + */ + public static function prependTo(mixed $stream, string $name, array $params = []): mixed + { + return self::prependFilter(STREAM_FILTER_ALL, $stream, $name, $params); + } - return $filter; + /** + * @param resource|AbstractCsv $stream + * + * @throws TypeError + * @throws RuntimeException + * + * @return resource|AbstractCsv + */ + public static function prependOnReadTo(mixed $stream, string $name, array $params = []): mixed + { + return self::prependFilter(STREAM_FILTER_READ, $stream, $name, $params); } /** - * @param resource $stream - * @param callable(string): string $callback + * @param resource|AbstractCsv $stream * * @throws TypeError * @throws RuntimeException * - * @return resource + * @return resource|AbstractCsv */ - public static function prependTo(mixed $stream, string $name, callable $callback): mixed + public static function prependOnWriteTo(mixed $stream, string $name, array $params = []): mixed { - self::register($name); + return self::prependFilter(STREAM_FILTER_WRITE, $stream, $name, $params); + } + + /** + * @param resource|AbstractCsv $stream + * + * @throws TypeError + * @throws RuntimeException + * + * @return resource|AbstractCsv + */ + private static function prependFilter(int $mode, mixed $stream, string $name, array $params = []): mixed + { + self::isRegistered($name) || throw new LogicException('The stream filter "'.$name.'" is not registered.'); + if ($stream instanceof AbstractCsv) { + return $stream->prependStreamFilter(self::getFiltername($name), [...$params, ...['name' => $name]]); + } is_resource($stream) || throw new TypeError('Argument passed must be a stream resource, '.gettype($stream).' given.'); 'stream' === ($type = get_resource_type($stream)) || throw new TypeError('Argument passed must be a stream resource, '.$type.' resource given'); - $filtername = self::getFiltername($name); set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); - $filter = stream_filter_append($stream, $filtername, params: [ - 'name' => $name, - 'callback' => $callback instanceof Closure ? $callback : $callback(...), - ]); + $filter = stream_filter_prepend($stream, self::getFiltername($name), $mode, [...$params, ...['name' => $name]]); restore_error_handler(); - if (!is_resource($filter)) { - throw new RuntimeException('Could not append the registered stream filter: '.self::getFiltername($name)); - } + is_resource($filter) || throw new RuntimeException('Could not append the registered stream filter: '.$name); return $filter; } - public function onCreate(): bool - { - return is_array($this->params) && - array_key_exists('name', $this->params) && - self::getFiltername($this->params['name']) === $this->filtername && - array_key_exists('callback', $this->params) && - $this->params['callback'] instanceof Closure - ; - } - - public function filter($in, $out, &$consumed, bool $closing): int + /** + * @param resource|AbstractCsv $stream + * + * @throws TypeError + * @throws RuntimeException + * + * @return resource|AbstractCsv + */ + private static function appendFilter(int $mode, mixed $stream, string $name, array $params = []): mixed { - /** @var Closure(string): string $callback */ - $callback = $this->params['callback']; /* @phpstan-ignore-line */ - while (null !== ($bucket = stream_bucket_make_writeable($in))) { - $bucket->data = ($callback)($bucket->data); - $consumed += $bucket->datalen; - stream_bucket_append($out, $bucket); + self::isRegistered($name) || throw new LogicException('The stream filter "'.$name.'" is not registered.'); + if ($stream instanceof AbstractCsv) { + return $stream->appendStreamFilter(self::getFiltername($name), [...$params, ...['name' => $name]]); } - return PSFS_PASS_ON; + is_resource($stream) || throw new TypeError('Argument passed must be a stream resource, '.gettype($stream).' given.'); + 'stream' === ($type = get_resource_type($stream)) || throw new TypeError('Argument passed must be a stream resource, '.$type.' resource given'); + + set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); + $filter = stream_filter_append($stream, self::getFiltername($name), $mode, [...$params, ...['name' => $name]]); + restore_error_handler(); + + is_resource($filter) || throw new RuntimeException('Could not append the registered stream filter: '.$name); + + return $filter; } } diff --git a/src/CallbackStreamFilterTest.php b/src/CallbackStreamFilterTest.php index c25b23f3..d41bdc4d 100644 --- a/src/CallbackStreamFilterTest.php +++ b/src/CallbackStreamFilterTest.php @@ -13,10 +13,16 @@ namespace League\Csv; +use LogicException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use function fclose; +use function fgetcsv; +use function fwrite; +use function rewind; use function str_replace; +use function tmpfile; final class CallbackStreamFilterTest extends TestCase { @@ -35,11 +41,8 @@ public function it_can_swap_the_delimiter_on_read(): void $reader = Reader::createFromString($document); $reader->setDelimiter("\x02"); - CallbackStreamFilter::addTo( - $reader, - 'swap.delemiter.in', - fn (string $bucket): string => str_replace('💩', "\x02", $bucket) - ); + CallbackStreamFilter::register('swap.delemiter.in', fn (string $bucket): string => str_replace('💩', "\x02", $bucket)); + CallbackStreamFilter::appendTo($reader, 'swap.delemiter.in'); $reader->setHeaderOffset(0); self::assertSame( @@ -53,13 +56,65 @@ public function it_can_swap_the_delimiter_on_write(): void { $writer = Writer::createFromString(); $writer->setDelimiter("\x02"); - CallbackStreamFilter::addTo( - $writer, - 'swap.delemiter.out', - fn (string $bucket): string => str_replace("\x02", '💩', $bucket) - ); + CallbackStreamFilter::register('swap.delemiter.out', fn (string $bucket): string => str_replace("\x02", '💩', $bucket)); + CallbackStreamFilter::appendTo($writer, 'swap.delemiter.out'); $writer->insertOne(['observeedOn' => '2023-10-01', 'temperature' => '18', 'place' => 'Yamoussokro']); self::assertSame('2023-10-01💩18💩Yamoussokro'."\n", $writer->toString()); + self:; + self::assertContains('swap.delemiter.out', CallbackStreamFilter::registeredFilters()); + } + + + #[Test] + public function it_can_add_stream_callbacks_as_stream_filters(): void + { + CallbackStreamFilter::register('string.to.upper', strtoupper(...)); + self::assertTrue(CallbackStreamFilter::isRegistered('string.to.upper')); + self::assertFalse(CallbackStreamFilter::isRegistered('string.to.lower')); + } + + #[Test] + public function it_can_not_add_twice_the_same_callback_with_the_same_name(): void + { + CallbackStreamFilter::register('string.to.lower', strtolower(...)); + + $this->expectExceptionObject(new LogicException('The stream filter "string.to.lower" is already registered.')); + CallbackStreamFilter::register('string.to.lower', strtolower(...)); + } + + #[Test] + public function it_can_be_added_to_a_csv_document(): void + { + $csv = "title1,title2,title3\rcontent11,content12,content13\rcontent21,content22,content23\r"; + $document = Reader::createFromString($csv); + $document->setHeaderOffset(0); + + CallbackStreamFilter::register('swap.carrier.return', fn (string $bucket): string => str_replace("\r", "\n", $bucket)); + CallbackStreamFilter::appendTo($document, 'swap.carrier.return'); + self::assertSame([ + 'title1' => 'content11', + 'title2' => 'content12', + 'title3' => 'content13', + ], $document->first()); + } + + #[Test] + public function it_can_be_added_to_a_stream(): void + { + $csv = "title1,title2,title3\rcontent11,content12,content13\rcontent21,content22,content23\r"; + + $stream = tmpfile(); + fwrite($stream, $csv); + rewind($stream); + + CallbackStreamFilter::appendTo($stream, 'swap.carrier.return'); + $data = []; + while (($record = fgetcsv($stream, 1000, ',')) !== false) { + $data[] = $record; + } + fclose($stream); + + self::assertSame(['title1', 'title2', 'title3'], $data[0]); } } diff --git a/src/CharsetConverter.php b/src/CharsetConverter.php index 5c17cc57..54b90b38 100644 --- a/src/CharsetConverter.php +++ b/src/CharsetConverter.php @@ -53,7 +53,7 @@ public static function addBOMSkippingTo(Reader $document, string $output_encodin { self::register(); - $document->addStreamFilter( + $document->appendStreamFilter( self::getFiltername((Bom::tryFrom($document->getInputBOM()) ?? Bom::Utf8)->encoding(), $output_encoding), [self::BOM_SEQUENCE => self::SKIP_BOM_SEQUENCE] ); @@ -68,7 +68,7 @@ public static function addTo(AbstractCsv $csv, string $input_encoding, string $o { self::register(); - return $csv->addStreamFilter(self::getFiltername($input_encoding, $output_encoding), $params); + return $csv->appendStreamFilter(self::getFiltername($input_encoding, $output_encoding), $params); } /** diff --git a/src/CharsetConverterTest.php b/src/CharsetConverterTest.php index 28ed67f7..28ae7298 100644 --- a/src/CharsetConverterTest.php +++ b/src/CharsetConverterTest.php @@ -84,7 +84,7 @@ public function testCharsetConverterAsStreamFilter(): void $expected = 'Batman,Superman,Anaïs'; $raw = mb_convert_encoding($expected, 'iso-8859-15', 'utf-8'); $csv = Reader::createFromString($raw) - ->addStreamFilter('string.toupper'); + ->appendStreamFilter('string.toupper'); CharsetConverter::addTo($csv, 'iso-8859-15', 'utf-8'); self::assertContains(CharsetConverter::FILTERNAME.'.*', stream_get_filters()); @@ -98,8 +98,8 @@ public function testCharsetConverterAsStreamFilterFailed(): void $expected = 'Batman,Superman,Anaïs'; $raw = mb_convert_encoding($expected, 'iso-8859-15', 'utf-8'); $csv = Reader::createFromString($raw) - ->addStreamFilter('string.toupper') - ->addStreamFilter('convert.league.csv.iso-8859-15:utf-8') + ->appendStreamFilter('string.toupper') + ->appendStreamFilter('convert.league.csv.iso-8859-15:utf-8') ; } diff --git a/src/ReaderTest.php b/src/ReaderTest.php index e539d82a..d983de36 100644 --- a/src/ReaderTest.php +++ b/src/ReaderTest.php @@ -647,7 +647,7 @@ public function testStreamWithFiltersDestructsGracefully(): void fputcsv($fp, ['abc', '123'], escape: ''); $csv = Reader::createFromStream($fp); - $csv->addStreamFilter('convert.iconv.UTF-8/UTF-16'); + $csv->appendStreamFilter('convert.iconv.UTF-8/UTF-16'); // An explicitly closed file handle makes the stream filter resources invalid fclose($fp); diff --git a/src/Stream.php b/src/Stream.php index 453a89fc..d667bf0f 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -195,6 +195,23 @@ public function appendFilter(string $filtername, int $read_write, ?array $params $this->filters[$filtername][] = $res; } + /** + * Appends a filter. + * + * @see http://php.net/manual/en/function.stream-filter-append.php + * + * @throws InvalidArgument if the filter can not be appended + */ + public function prependFilter(string $filtername, int $read_write, ?array $params = null): void + { + set_error_handler(fn (int $errno, string $errstr, string $errfile, int $errline) => true); + $res = stream_filter_prepend($this->stream, $filtername, $read_write, $params ?? []); + restore_error_handler(); + is_resource($res) || throw InvalidArgument::dueToStreamFilterNotFound($filtername); + + $this->filters[$filtername][] = $res; + } + /** * Sets CSV control. * diff --git a/src/SwapDelimiter.php b/src/SwapDelimiter.php index d27887e1..eba23593 100644 --- a/src/SwapDelimiter.php +++ b/src/SwapDelimiter.php @@ -54,7 +54,7 @@ public static function addTo(AbstractCsv $csv, string $inputDelimiter): void { self::register(); - $csv->addStreamFilter(self::getFiltername(), [ + $csv->appendStreamFilter(self::getFiltername(), [ 'mb_separator' => $inputDelimiter, 'separator' => $csv->getDelimiter(), 'mode' => $csv instanceof Writer ? self::MODE_WRITE : self::MODE_READ, diff --git a/src/WriterTest.php b/src/WriterTest.php index ead9ccca..7f3893f1 100644 --- a/src/WriterTest.php +++ b/src/WriterTest.php @@ -64,7 +64,7 @@ public function testSupportsStreamFilter(): void self::assertTrue($csv->supportsStreamFilterOnWrite()); $csv->setFlushThreshold(3); - $csv->addStreamFilter('string.toupper'); + $csv->appendStreamFilter('string.toupper'); $csv->insertOne(['jane', 'doe', 'jane@example.com']); $csv->insertOne(['jane', 'doe', 'jane@example.com']); $csv->insertOne(['jane', 'doe', 'jane@example.com']);