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).
+
+
+addStreamFilter
is deprecated since version 9.21.0
+appendStreamFilter
is available since 9.21.0
and replace addStreamFilter
+prependStreamFilter
is available since 9.21.0
+
+
+- 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']);