From 87bc1aab2cced42710435dffaf4b1b9c4cffa053 Mon Sep 17 00:00:00 2001 From: ignace nyamagana butera Date: Mon, 27 Feb 2017 09:34:39 +0100 Subject: [PATCH] Implementing 9.0 Public API The goals are - to make the package more SOLID - to improve stream filter usage ### Added #### Classes - `League\Csv\Statement` : a Constraint builder class - `League\Csv\RecordSet` : a class to manipulare CSV document records - `League\Csv\Exception\CsvException` : an interface implemented by all package exceptions - `League\Csv\Exception\InvalidArgumentException` - `League\Csv\Exception\RuntimeException` - `League\Csv\Exception\InsertionException` ### Methods - `AbstractCsv::isStream` tell whether Stream filtering is supported - `AbstractCsv::addStreamFilter` append a new Stream filter to the CSV document - `Reader::getHeaderOffset` returns the CSV document header record offset - `Reader::getHeader` returns the CSV document header record - `Reader::select` returns a RecordSet object - `Reader::setHeaderOffset` sets the CSV document header record offset - `Writer::getFlushThreshold` returns the flushing mechanism threshold - `Writer::setFlushThreshold` sets the flushing mechanism threshold ### Fixed - Stream filtering is simplified and is supported for every object except when created from `SplFileObject`. - `Writer::insertOne` returns the numbers of bytes added - `Writer::insertOne` throws an exception on error instead of failing silently - `Writer::insertAll` returns the numbers of bytes added - `AbstractCsv::output` now HTTP/2 compliant - `AbstractCsv` are no longer clonable. ### Deprecated - None ### Removed - `AbstractCsv::newReader` - `AbstractCsv::newWriter` - `AbstractCsv::isActiveStreamFilter` replaced by `AbstractCsv::isStream` - `AbstractCsv::appendStreamFilter` replaced by `Reader::addStreamFilter` - `AbstractCsv::prependStreamFilter` replaced by `Reader::addStreamFilter` - `InvalidRowException` replaced by `InsertionException` - `Writer::fetchDelimitersOccurence` - `Writer::getIterator` - `Writer::jsonSerialize` - `Writer::toXML` - `Writer::toHTML` - `Reader::getNewLine` - `Reader::setNewLine` - `Reader::fetchAll` replaced by `RecordSet::fetchAll` - `Reader::fetchOne` replaced by `RecordSet::fetchOne` - `Reader::fetchColumn` replaced by `RecordSet::fetchColumn` - `Reader::fetchPairs` replaced by `RecordSet::fetchPairs` - `Reader::toXML` replaced by `RecordSet::toXML` - `Reader::toHTML` replaced by `RecordSet::toHTML` ### Internal changes - Improved `StreamIterator` - Removed the `League\Csv\Config` namespace - `AbstractCsv::createFromString` now uses `StreamIterator` instead of `SplFileObject` - `AbstractCsv::createFromPath` now uses `StreamIterator` instead of `SplFileObject` --- .gitattributes | 2 +- .scrutinizer.yml | 5 +- .travis.yml | 8 +- composer.json | 5 +- phpunit.xml | 6 +- src/AbstractCsv.php | 328 ++++++++-- src/Config/ControlsTrait.php | 416 ------------- src/Config/StatementTrait.php | 320 ---------- src/Config/StreamTrait.php | 296 --------- src/Exception/CsvException.php | 27 + src/Exception/InsertionException.php | 92 +++ src/Exception/InvalidArgumentException.php | 27 + src/Exception/RuntimeException.php | 27 + src/InvalidRowException.php | 73 --- src/MapIterator.php | 5 +- src/Plugin/ColumnConsistencyValidator.php | 7 +- src/Plugin/ForbiddenNullValuesValidator.php | 3 +- src/Plugin/SkipNullValuesFormatter.php | 3 +- src/Reader.php | 310 +++++----- src/RecordSet.php | 414 +++++++++++++ src/Statement.php | 302 +++++++++ src/StreamIterator.php | 98 ++- src/{Config => }/ValidatorTrait.php | 34 +- src/Writer.php | 211 ++++--- tests/ControlsTest.php | 194 ------ tests/CsvTest.php | 209 +++++-- tests/{ => Lib}/FilterReplace.php | 2 +- .../Plugin/ColumnConsistencyValidatorTest.php | 18 +- tests/Plugin/NullValidatorTest.php | 8 +- tests/Plugin/SkipNullValuesFormatterTest.php | 4 +- tests/ReaderTest.php | 418 +------------ tests/RecordSetTest.php | 580 ++++++++++++++++++ tests/StreamFilterTest.php | 152 ----- tests/StreamIteratorTest.php | 89 ++- tests/WriterTest.php | 62 +- 35 files changed, 2406 insertions(+), 2349 deletions(-) delete mode 100644 src/Config/ControlsTrait.php delete mode 100644 src/Config/StatementTrait.php delete mode 100644 src/Config/StreamTrait.php create mode 100644 src/Exception/CsvException.php create mode 100644 src/Exception/InsertionException.php create mode 100644 src/Exception/InvalidArgumentException.php create mode 100644 src/Exception/RuntimeException.php delete mode 100644 src/InvalidRowException.php create mode 100644 src/RecordSet.php create mode 100644 src/Statement.php rename src/{Config => }/ValidatorTrait.php (67%) delete mode 100644 tests/ControlsTest.php rename tests/{ => Lib}/FilterReplace.php (97%) create mode 100644 tests/RecordSetTest.php delete mode 100644 tests/StreamFilterTest.php diff --git a/.gitattributes b/.gitattributes index 462eea5c..b8c23c0b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,8 +8,8 @@ /.scrutinizer.yml export-ignore /.travis.yml export-ignore /docs export-ignore -/README.md export-ignore /CHANGELOG.md export-ignore /CONDUCT.md export-ignore +/README.md export-ignore /phpunit.xml export-ignore /tests export-ignore diff --git a/.scrutinizer.yml b/.scrutinizer.yml index 24597c45..01162324 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -4,15 +4,14 @@ filter: checks: php: code_rating: true - fix_doc_comments: true tools: external_code_coverage: timeout: 600 - runs: 1 + runs: 2 php_code_coverage: false php_loc: enabled: true excluded_dirs: [tests, vendor] php_cpd: enabled: true - excluded_dirs: [tests, vendor] \ No newline at end of file + excluded_dirs: [tests, vendor] diff --git a/.travis.yml b/.travis.yml index f9e608a3..1a5f9deb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,10 +5,14 @@ sudo: false matrix: include: - php: 7.0 - env: COLLECT_COVERAGE=true VALIDATE_CODING_STYLE=true - - php: 7.1 env: COLLECT_COVERAGE=true VALIDATE_CODING_STYLE=false + - php: 7.1 + env: COLLECT_COVERAGE=true VALIDATE_CODING_STYLE=true + - php: hhvm + env: COLLECT_COVERAGE=false VALIDATE_CODING_STYLE=false fast_finish: true + allow_failures: + - php: hhvm cache: directories: diff --git a/composer.json b/composer.json index 2ac10b0e..23d60a1d 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "ext-mbstring" : "*" }, "require-dev": { - "phpunit/phpunit" : "^5.2", + "phpunit/phpunit" : "^6.0", "friendsofphp/php-cs-fixer": "^1.9" }, "autoload": { @@ -40,6 +40,9 @@ "phpunit": "phpunit --coverage-text", "phpcs": "php-cs-fixer fix -v --diff --dry-run;" }, + "suggest": { + "ext-iconv" : "Needed to ease transcoding CSV using iconv stream filters" + }, "extra": { "branch-alias": { "dev-master": "9.x-dev" diff --git a/phpunit.xml b/phpunit.xml index 17527e1c..f073863c 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,8 +18,8 @@ - - src/ + + src @@ -27,7 +27,7 @@ - + diff --git a/src/AbstractCsv.php b/src/AbstractCsv.php index 314f3b58..b3905455 100644 --- a/src/AbstractCsv.php +++ b/src/AbstractCsv.php @@ -14,8 +14,8 @@ namespace League\Csv; -use League\Csv\Config\ControlsTrait; -use League\Csv\Config\StreamTrait; +use League\Csv\Exception\RuntimeException; +use LogicException; use SplFileObject; /** @@ -23,12 +23,12 @@ * * @package League.csv * @since 4.0.0 + * @author Ignace Nyamagana Butera * */ abstract class AbstractCsv { - use ControlsTrait; - use StreamTrait; + use ValidatorTrait; /** * UTF-8 BOM sequence @@ -56,29 +56,68 @@ abstract class AbstractCsv const BOM_UTF32_LE = "\xFF\xFE\x00\x00"; /** - * The file open mode flag + * The CSV document + * + * @var StreamIterator|SplFileObject + */ + protected $document; + + /** + * the field delimiter (one character only) * * @var string */ - protected $open_mode; + protected $delimiter = ','; /** - * Creates a new instance + * the field enclosure character (one character only) * - * The file path can be: + * @var string + */ + protected $enclosure = '"'; + + /** + * the field escape character (one character only) * - * - a string - * - a SplFileObject - * - a StreamIterator + * @var string + */ + protected $escape = '\\'; + + /** + * The Output file BOM character + * @var string + */ + protected $output_bom = ''; + + /** + * collection of stream filters * - * @param mixed $path The file path - * @param string $open_mode The file open mode flag + * @var array */ - protected function __construct($path, string $open_mode = 'r+') + protected $stream_filters = []; + + /** + * The stream filter mode (read or write) + * + * @var int + */ + protected $stream_filter_mode; + + /** + * The CSV document BOM sequence + * + * @var string|null + */ + protected $input_bom = null; + + /** + * New instance + * + * @param SplFileObject|StreamIterator $document The CSV Object instance + */ + protected function __construct($document) { - $this->open_mode = strtolower($open_mode); - $this->path = $path; - $this->initStreamFilter(); + $this->document = $document; } /** @@ -86,7 +125,16 @@ protected function __construct($path, string $open_mode = 'r+') */ public function __destruct() { - $this->path = null; + $this->clearStreamFilter(); + $this->document = null; + } + + /** + * @inheritdoc + */ + public function __clone() + { + throw new LogicException('An object of class '.get_class($this).' cannot be cloned'); } /** @@ -100,10 +148,10 @@ public static function createFromFileObject(SplFileObject $file): self { $csv = new static($file); $controls = $file->getCsvControl(); - $csv->setDelimiter($controls[0]); - $csv->setEnclosure($controls[1]); + $csv->delimiter = $controls[0]; + $csv->enclosure = $controls[1]; if (isset($controls[2])) { - $csv->setEscape($controls[2]); + $csv->escape = $controls[2]; } return $csv; @@ -124,9 +172,6 @@ public static function createFromStream($stream): self /** * Return a new {@link AbstractCsv} from a string * - * The string must be an object that implements the `__toString` method, - * or a string - * * @param string $str the string * * @return static @@ -149,66 +194,112 @@ public static function createFromString(string $str): self */ public static function createFromPath(string $path, string $open_mode = 'r+'): self { - return new static($path, $open_mode); + if (!$stream = @fopen($path, $open_mode)) { + throw new RuntimeException(error_get_last()['message']); + } + + return new static(new StreamIterator($stream)); } /** - * Return a new {@link AbstractCsv} instance from another {@link AbstractCsv} object + * Returns the current field delimiter * - * @param string $class the class to be instantiated - * @param string $open_mode the file open mode flag + * @return string + */ + public function getDelimiter(): string + { + return $this->delimiter; + } + + /** + * Returns the current field enclosure * - * @return static + * @return string */ - protected function newInstance(string $class, string $open_mode): self + public function getEnclosure(): string { - $csv = new $class($this->path, $open_mode); - $csv->delimiter = $this->delimiter; - $csv->enclosure = $this->enclosure; - $csv->escape = $this->escape; - $csv->input_bom = $this->input_bom; - $csv->output_bom = $this->output_bom; - $csv->newline = $this->newline; + return $this->enclosure; + } - return $csv; + /** + * Returns the current field escape character + * + * @return string + */ + public function getEscape(): string + { + return $this->escape; } /** - * Return a new {@link Writer} instance from a {@link AbstractCsv} object + * Returns the BOM sequence in use on Output methods * - * @param string $open_mode the file open mode flag + * @return string + */ + public function getOutputBOM(): string + { + return $this->output_bom; + } + + /** + * Returns the BOM sequence of the given CSV * - * @return Writer + * @return string */ - public function newWriter(string $open_mode = 'r+'): self + public function getInputBOM(): string { - return $this->newInstance(Writer::class, $open_mode); + if (null !== $this->input_bom) { + return $this->input_bom; + } + + $bom = [ + self::BOM_UTF32_BE, self::BOM_UTF32_LE, + self::BOM_UTF16_BE, self::BOM_UTF16_LE, self::BOM_UTF8, + ]; + + $this->document->setFlags(SplFileObject::READ_CSV); + $this->document->rewind(); + $line = $this->document->fgets(); + $res = array_filter($bom, function ($sequence) use ($line) { + return strpos($line, $sequence) === 0; + }); + + $this->input_bom = (string) array_shift($res); + + return $this->input_bom; } /** - * Return a new {@link Reader} instance from a {@link AbstractCsv} object + * Tells whether the stream filter capabilities can be used * - * @param string $open_mode the file open mode flag + * @return bool + */ + public function isStream(): bool + { + return $this->document instanceof StreamIterator; + } + + /** + * Tell whether the specify stream filter is attach to the current stream * - * @return Reader + * @return bool */ - public function newReader(string $open_mode = 'r+'): self + public function hasStreamFilter(string $filtername): bool { - return $this->newInstance(Reader::class, $open_mode); + return isset($this->stream_filters[$filtername]); } /** - * Set the Inner Iterator + * Retrieves the CSV content * - * @return StreamIterator|SplFileObject + * @return string */ - protected function getCsvDocument() + public function __toString(): string { - if ($this->path instanceof StreamIterator || $this->path instanceof SplFileObject) { - return $this->path; - } + ob_start(); + $this->fpassthru(); - return new SplFileObject($this->getStreamFilterPath(), $this->open_mode); + return ob_get_clean(); } /** @@ -223,9 +314,9 @@ public function output(string $filename = null): int { if (null !== $filename) { $filename = filter_var($filename, FILTER_SANITIZE_STRING, FILTER_FLAG_STRIP_LOW); - header('Content-Type: text/csv'); - header('Content-Transfer-Encoding: binary'); - header("Content-Disposition: attachment; filename=\"$filename\""); + header('content-type: text/csv'); + header('content-transfer-encoding: binary'); + header('content-disposition: attachment; filename="'.rawurlencode($filename).'"'); } return $this->fpassthru(); @@ -244,27 +335,128 @@ protected function fpassthru(): int if ($this->output_bom && $input_bom != $this->output_bom) { $bom = $this->output_bom; } - $csv = $this->getCsvDocument(); - $csv->rewind(); + + $this->document->rewind(); if ('' !== $bom) { - $csv->fseek(mb_strlen($input_bom)); + $this->document->fseek(mb_strlen($input_bom)); } echo $bom; - $res = $csv->fpassthru(); + $res = $this->document->fpassthru(); return $res + strlen($bom); } /** - * Retrieves the CSV content + * Sets the field delimiter * - * @return string + * @param string $delimiter + * + * @return static */ - public function __toString(): string + public function setDelimiter(string $delimiter): self { - ob_start(); - $this->fpassthru(); + $this->delimiter = $this->filterControl($delimiter, 'delimiter'); + $this->resetDynamicProperties(); - return ob_get_clean(); + return $this; + } + + /** + * Reset dynamic CSV document properties to improve performance + */ + protected function resetDynamicProperties() + { + } + + /** + * Sets the field enclosure + * + * @param string $enclosure + * + * @return static + */ + public function setEnclosure(string $enclosure): self + { + $this->enclosure = $this->filterControl($enclosure, 'enclosure'); + $this->resetDynamicProperties(); + + return $this; + } + + /** + * Sets the field escape character + * + * @param string $escape + * + * @return static + */ + public function setEscape(string $escape): self + { + $this->escape = $this->filterControl($escape, 'escape'); + $this->resetDynamicProperties(); + + return $this; + } + + /** + * Sets the BOM sequence to prepend the CSV on output + * + * @param string $str The BOM sequence + * + * @return static + */ + public function setOutputBOM(string $str): self + { + $this->output_bom = $str; + + return $this; + } + + /** + * append a stream filter + * + * @param string $filtername a string or an object that implements the '__toString' method + * + * @throws LogicException If the stream filter API can not be used + * + * @return static + */ + public function addStreamFilter(string $filtername): self + { + if (!$this->document instanceof StreamIterator) { + throw new LogicException('The stream filter API can not be used'); + } + + $this->stream_filters[$filtername][] = $this->document->appendFilter($filtername, $this->stream_filter_mode); + $this->resetDynamicProperties(); + $this->input_bom = null; + + return $this; + } + + /** + * Remove all registered stream filter + */ + protected function clearStreamFilter() + { + foreach (array_keys($this->stream_filters) as $filtername) { + $this->removeStreamFilter($filtername); + } + + $this->stream_filters = []; + } + + /** + * Remove all the stream filter with the same name + * + * @param string $filtername the stream filter name + */ + protected function removeStreamFilter(string $filtername) + { + foreach ($this->stream_filters[$filtername] as $filter) { + $this->document->removeFilter($filter); + } + + unset($this->stream_filters[$filtername]); } } diff --git a/src/Config/ControlsTrait.php b/src/Config/ControlsTrait.php deleted file mode 100644 index 96253dd0..00000000 --- a/src/Config/ControlsTrait.php +++ /dev/null @@ -1,416 +0,0 @@ -delimiter; - } - - /** - * Returns the current field enclosure - * - * @return string - */ - public function getEnclosure(): string - { - return $this->enclosure; - } - - /** - * Returns the current field escape character - * - * @return string - */ - public function getEscape(): string - { - return $this->escape; - } - - /** - * Returns the current newline sequence characters - * - * @return string - */ - public function getNewline(): string - { - return $this->newline; - } - - /** - * Returns the BOM sequence in use on Output methods - * - * @return string - */ - public function getOutputBOM(): string - { - return $this->output_bom; - } - - /** - * Returns the BOM sequence of the given CSV - * - * @return string - */ - public function getInputBOM(): string - { - if (null === $this->input_bom) { - $bom = [ - AbstractCsv::BOM_UTF32_BE, AbstractCsv::BOM_UTF32_LE, - AbstractCsv::BOM_UTF16_BE, AbstractCsv::BOM_UTF16_LE, AbstractCsv::BOM_UTF8, - ]; - $csv = $this->getCsvDocument(); - $csv->setFlags(SplFileObject::READ_CSV); - $csv->rewind(); - $line = $csv->fgets(); - $res = array_filter($bom, function ($sequence) use ($line) { - return strpos($line, $sequence) === 0; - }); - - $this->input_bom = (string) array_shift($res); - } - - return $this->input_bom; - } - - /** - * Sets the field delimiter - * - * @param string $delimiter - * - * @throws InvalidArgumentException If $delimiter is not a single character - * - * @return $this - */ - public function setDelimiter(string $delimiter): self - { - $this->delimiter = $this->filterControl($delimiter, 'delimiter'); - - return $this; - } - - /** - * Detect Delimiters occurences in the CSV - * - * Returns a associative array where each key represents - * a valid delimiter and each value the number of occurences - * - * @param string[] $delimiters the delimiters to consider - * @param int $nb_rows Detection is made using $nb_rows of the CSV - * - * @return array - */ - public function fetchDelimitersOccurrence(array $delimiters, int $nb_rows = 1): array - { - $nb_rows = $this->filterInteger($nb_rows, 1, 'The number of rows to consider must be a valid positive integer'); - $filter_row = function ($row) { - return is_array($row) && count($row) > 1; - }; - $delimiters = array_unique(array_filter($delimiters, function ($value) { - return 1 == strlen($value); - })); - $csv = $this->getCsvDocument(); - $csv->setFlags(SplFileObject::READ_CSV); - $res = []; - foreach ($delimiters as $delim) { - $csv->setCsvControl($delim, $this->enclosure, $this->escape); - $iterator = new CallbackFilterIterator(new LimitIterator($csv, 0, $nb_rows), $filter_row); - $res[$delim] = count(iterator_to_array($iterator, false), COUNT_RECURSIVE); - } - arsort($res, SORT_NUMERIC); - - return $res; - } - - /** - * Sets the field enclosure - * - * @param string $enclosure - * - * @throws InvalidArgumentException If $enclosure is not a single character - * - * @return $this - */ - public function setEnclosure(string $enclosure): self - { - $this->enclosure = $this->filterControl($enclosure, 'enclosure'); - - return $this; - } - - /** - * Sets the field escape character - * - * @param string $escape - * - * @throws InvalidArgumentException If $escape is not a single character - * - * @return $this - */ - public function setEscape(string $escape): self - { - $this->escape = $this->filterControl($escape, 'escape'); - - return $this; - } - - /** - * Sets the newline sequence characters - * - * @param string $newline - * - * @return static - */ - public function setNewline(string $newline): self - { - $this->newline = (string) $newline; - - return $this; - } - - /** - * Sets the BOM sequence to prepend the CSV on output - * - * @param string $str The BOM sequence - * - * @return static - */ - public function setOutputBOM(string $str): self - { - if (empty($str)) { - $this->output_bom = ''; - - return $this; - } - - $this->output_bom = (string) $str; - - return $this; - } - - /** - * Returns the record offset used as header - * - * If no CSV record is used this method MUST return null - * - * @return int|null - */ - public function getHeaderOffset() - { - return $this->header_offset; - } - - /** - * Returns the header - * - * If no CSV record is used this method MUST return an empty array - * - * @return string[] - */ - public function getHeader(): array - { - if (null !== $this->header_offset) { - $this->header = $this->filterHeader($this->getRow($this->header_offset)); - } - - return $this->header; - } - - /** - * Returns a single row from the CSV without filtering - * - * @param int $offset - * - * @throws InvalidArgumentException If the $offset is not valid or the row does not exist - * - * @return array - */ - protected function getRow(int $offset): array - { - $csv = $this->getCsvDocument(); - $csv->setFlags(SplFileObject::READ_CSV); - $csv->setCsvControl($this->delimiter, $this->enclosure, $this->escape); - $csv->seek($offset); - $row = $csv->current(); - if (empty($row) || [null] === $row) { - throw new InvalidArgumentException('the specified row does not exist or is empty'); - } - - if (0 != $offset) { - return $row; - } - - return $this->removeBOM($row, mb_strlen($this->getInputBOM()), $this->enclosure); - } - - /** - * Validates the array to be used by the fetchAssoc method - * - * @param array $keys - * - * @throws InvalidArgumentException If the submitted array fails the assertion - * - * @return array - */ - protected function filterHeader(array $keys): array - { - if (empty($keys)) { - return $keys; - } - - if ($keys !== array_unique(array_filter($keys, [$this, 'isValidKey']))) { - throw new InvalidArgumentException('Use a flat array with unique string values'); - } - - return $keys; - } - - /** - * Selects the array to be used as key for the fetchAssoc method - * - * Because of the header is represented as an array, to be valid - * a header MUST contain only unique string value. - * - *
    - *
  • If a array is given it will be used as the header
  • - *
  • If a integer is given it will represent the offset of the record to be used as header
  • - *
  • If an empty array or null is given it will mean that no header is used
  • - *
- * - * @param int|null|string[] $offset_or_keys the assoc key OR the row Index to be used - * as the key index - * - * @return $this - */ - public function setHeader($offset_or_keys): self - { - if (is_array($offset_or_keys)) { - $this->header = $this->filterHeader($offset_or_keys); - $this->header_offset = null; - - return $this; - } - - if (null === $offset_or_keys) { - $this->header = []; - $this->header_offset = null; - - return $this; - } - - $this->header_offset = $this->filterInteger( - $offset_or_keys, - 0, - 'the row index must be a positive integer, 0 or a non empty array' - ); - - return $this; - } - - /** - * Returns whether the submitted value can be used as string - * - * @param mixed $value - * - * @return bool - */ - protected function isValidKey($value): bool - { - return is_scalar($value) || (is_object($value) && method_exists($value, '__toString')); - } -} diff --git a/src/Config/StatementTrait.php b/src/Config/StatementTrait.php deleted file mode 100644 index 7ea16215..00000000 --- a/src/Config/StatementTrait.php +++ /dev/null @@ -1,320 +0,0 @@ -iterator_offset = $this->filterInteger($offset, 0, 'the offset must be a positive integer or 0'); - - return $this; - } - - /** - * Set LimitIterator Count - * - * @param int $limit - * - * @return $this - */ - public function setLimit(int $limit = -1): self - { - $this->iterator_limit = $this->filterInteger($limit, -1, 'the limit must an integer greater or equals to -1'); - - return $this; - } - - /** - * Set an Iterator sorting callable function - * - * @param callable $callable - * - * @return $this - */ - public function addSortBy(callable $callable): self - { - $this->iterator_sort_by[] = $callable; - - return $this; - } - - /** - * Set the Iterator filter method - * - * @param callable $callable - * - * @return $this - */ - public function addFilter(callable $callable): self - { - $this->iterator_filters[] = $callable; - - return $this; - } - - /** - * Returns the inner CSV Document Iterator object - * - * @return Iterator - */ - public function getIterator() - { - $iterator = $this->getCsvDocument(); - $iterator->setCsvControl($this->getDelimiter(), $this->getEnclosure(), $this->getEscape()); - $iterator->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY); - $iterator = $this->applyBomStripping($iterator); - $iterator = $this->applyHeader($iterator); - $iterator = $this->applyFilter($iterator); - $iterator = $this->applySortBy($iterator); - - return $this->applyIteratorInterval($iterator); - } - - /** - * Returns the current field delimiter - * - * @return string - */ - abstract public function getDelimiter(): string; - - /** - * Returns the current field enclosure - * - * @return string - */ - abstract public function getEnclosure(): string; - - /** - * Returns the current field escape character - * - * @return string - */ - abstract public function getEscape(): string; - - /** - * Returns the inner CSV Document Iterator object - * - * @return StreamIterator|SplFileObject - */ - abstract public function getCsvDocument(); - - /** - * Returns the BOM sequence of the given CSV - * - * @return string - */ - abstract public function getInputBOM(): string; - - /** - * Remove the BOM sequence from the CSV - * - * @param Iterator $iterator - * - * @return Iterator - */ - protected function applyBomStripping(Iterator $iterator): Iterator - { - $bom = $this->getInputBOM(); - if ('' == $bom) { - return $iterator; - } - - $bom_length = mb_strlen($bom); - $enclosure = $this->getEnclosure(); - $strip_bom = function ($row, $index) use ($bom_length, $enclosure) { - if (0 != $index || !is_array($row)) { - return $row; - } - - return $this->removeBOM($row, $bom_length, $enclosure); - }; - - return new MapIterator($iterator, $strip_bom); - } - - /** - * Returns the record offset used as header - * - * If no CSV record is used this method MUST return null - * - * @return int|null - */ - abstract public function getHeaderOffset(); - - /** - * Returns the header - * - * If no CSV record is used this method MUST return an empty array - * - * @return string[] - */ - abstract public function getHeader(): array; - - /** - * Add the CSV header if present - * - * @param Iterator $iterator - * - * @return Iterator - */ - public function applyHeader(Iterator $iterator): Iterator - { - $header = $this->getHeader(); - if (empty($header)) { - return $iterator; - } - - $header_count = count($header); - $combine = function (array $row) use ($header, $header_count) { - if ($header_count != count($row)) { - $row = array_slice(array_pad($row, $header_count, null), 0, $header_count); - } - - return array_combine($header, $row); - }; - - return new MapIterator($iterator, $combine); - } - - /** - * Filter the Iterator - * - * @param Iterator $iterator - * - * @return Iterator - */ - protected function applyFilter(Iterator $iterator): Iterator - { - $header_offset = $this->getHeaderOffset(); - if (null !== $header_offset) { - $strip_header = function ($row, $index) use ($header_offset) { - return $index !== $header_offset; - }; - array_unshift($this->iterator_filters, $strip_header); - } - - $normalized_csv = function ($row) { - return is_array($row) && $row != [null]; - }; - array_unshift($this->iterator_filters, $normalized_csv); - - $reducer = function ($iterator, $callable) { - return new CallbackFilterIterator($iterator, $callable); - }; - $iterator = array_reduce($this->iterator_filters, $reducer, $iterator); - $this->iterator_filters = []; - - return $iterator; - } - - /** - * Sort the Iterator - * - * @param Iterator $iterator - * - * @return Iterator - */ - protected function applySortBy(Iterator $iterator): Iterator - { - if (empty($this->iterator_sort_by)) { - return $iterator; - } - - $obj = new ArrayIterator(iterator_to_array($iterator)); - $obj->uasort(function ($row_a, $row_b) { - $res = 0; - foreach ($this->iterator_sort_by as $compare) { - if (0 !== ($res = ($compare)($row_a, $row_b))) { - break; - } - } - - return $res; - }); - $this->iterator_sort_by = []; - - return $obj; - } - - /** - * Sort the Iterator - * - * @param Iterator $iterator - * - * @return Iterator - */ - protected function applyIteratorInterval(Iterator $iterator): Iterator - { - $offset = $this->iterator_offset; - $limit = $this->iterator_limit; - $this->iterator_limit = -1; - $this->iterator_offset = 0; - - return new LimitIterator($iterator, $offset, $limit); - } -} diff --git a/src/Config/StreamTrait.php b/src/Config/StreamTrait.php deleted file mode 100644 index d333f633..00000000 --- a/src/Config/StreamTrait.php +++ /dev/null @@ -1,296 +0,0 @@ -:?read=|write=)? # The resource open mode - (?P.*?) # The resource registered filters - /resource=(?P.*) # The resource path - $,ix'; - - /** - * Internal path setter - */ - protected function initStreamFilter() - { - if (!is_string($this->path)) { - return; - } - - if (!preg_match($this->stream_regex, $this->path, $matches)) { - $this->stream_uri = $this->path; - - return; - } - - $this->stream_uri = $matches['resource']; - $this->stream_filters = array_map('urldecode', explode('|', $matches['filters'])); - $this->stream_filter_mode = $this->fetchStreamModeAsInt($matches['mode']); - } - - /** - * Get the stream mode - * - * @param string $mode - * - * @return int - */ - protected function fetchStreamModeAsInt(string $mode): int - { - $mode = strtolower($mode); - $mode = rtrim($mode, '='); - if ('write' == $mode) { - return STREAM_FILTER_WRITE; - } - - if ('read' == $mode) { - return STREAM_FILTER_READ; - } - - return STREAM_FILTER_ALL; - } - - /** - * Check if the trait methods can be used - * - * @throws LogicException If the API can not be use - */ - protected function assertStreamable() - { - if (!is_string($this->stream_uri)) { - throw new LogicException('The stream filter API can not be used'); - } - } - - /** - * Tells whether the stream filter capabilities can be used - * - * @return bool - */ - public function isActiveStreamFilter(): bool - { - return is_string($this->stream_uri); - } - - /** - * stream filter mode Setter - * - * Set the new Stream Filter mode and remove all - * previously attached stream filters - * - * @param int $mode - * - * @throws OutOfBoundsException If the mode is invalid - * - * @return $this - */ - public function setStreamFilterMode(int $mode): self - { - $this->assertStreamable(); - if (!in_array($mode, [STREAM_FILTER_ALL, STREAM_FILTER_READ, STREAM_FILTER_WRITE])) { - throw new OutOfBoundsException('the $mode should be a valid `STREAM_FILTER_*` constant'); - } - - $this->stream_filter_mode = $mode; - $this->stream_filters = []; - - return $this; - } - - /** - * stream filter mode getter - * - * @return int - */ - public function getStreamFilterMode(): int - { - $this->assertStreamable(); - - return $this->stream_filter_mode; - } - - /** - * append a stream filter - * - * @param string $filter_name a string or an object that implements the '__toString' method - * - * @return $this - */ - public function appendStreamFilter(string $filter_name): self - { - $this->assertStreamable(); - $this->stream_filters[] = $this->sanitizeStreamFilter($filter_name); - - return $this; - } - - /** - * prepend a stream filter - * - * @param string $filter_name a string or an object that implements the '__toString' method - * - * @return $this - */ - public function prependStreamFilter(string $filter_name): self - { - $this->assertStreamable(); - array_unshift($this->stream_filters, $this->sanitizeStreamFilter($filter_name)); - - return $this; - } - - /** - * Sanitize the stream filter name - * - * @param string $filter_name the stream filter name - * - * @return string - */ - protected function sanitizeStreamFilter(string $filter_name): string - { - return urldecode($filter_name); - } - - /** - * Detect if the stream filter is already present - * - * @param string $filter_name - * - * @return bool - */ - public function hasStreamFilter(string $filter_name): bool - { - $this->assertStreamable(); - - return false !== array_search(urldecode($filter_name), $this->stream_filters, true); - } - - /** - * Remove a filter from the collection - * - * @param string $filter_name - * - * @return $this - */ - public function removeStreamFilter(string $filter_name): self - { - $this->assertStreamable(); - $res = array_search(urldecode($filter_name), $this->stream_filters, true); - if (false !== $res) { - unset($this->stream_filters[$res]); - } - - return $this; - } - - /** - * Remove all registered stream filter - * - * @return $this - */ - public function clearStreamFilter(): self - { - $this->assertStreamable(); - $this->stream_filters = []; - - return $this; - } - - /** - * Return the filter path - * - * @return string - */ - protected function getStreamFilterPath(): string - { - $this->assertStreamable(); - if (!$this->stream_filters) { - return $this->stream_uri; - } - - return 'php://filter/' - .$this->getStreamFilterPrefix() - .implode('|', array_map('urlencode', $this->stream_filters)) - .'/resource='.$this->stream_uri; - } - - /** - * Return PHP stream filter prefix - * - * @return string - */ - protected function getStreamFilterPrefix(): string - { - if (STREAM_FILTER_READ == $this->stream_filter_mode) { - return 'read='; - } - - if (STREAM_FILTER_WRITE == $this->stream_filter_mode) { - return 'write='; - } - - return ''; - } -} diff --git a/src/Exception/CsvException.php b/src/Exception/CsvException.php new file mode 100644 index 00000000..c6890a6d --- /dev/null +++ b/src/Exception/CsvException.php @@ -0,0 +1,27 @@ + + * + */ +interface CsvException +{ +} diff --git a/src/Exception/InsertionException.php b/src/Exception/InsertionException.php new file mode 100644 index 00000000..dcfc97af --- /dev/null +++ b/src/Exception/InsertionException.php @@ -0,0 +1,92 @@ + + * + */ +class InsertionException extends RuntimeException +{ + /** + * The record submitted for insertion + * + * @var array + */ + protected $data; + + /** + * Validator which did not validated the data + * + * @var string + */ + protected $name = ''; + + /** + * Create an Exception from a Record row + * + * @param string[] $record + * + * @return self + */ + public static function createFromCsv(array $record): self + { + $exception = new static('Unable to write data to the CSV document'); + $exception->data = $record; + + return $exception; + } + + /** + * Create an Exception from a Record row + * + * @param string $name validator name + * @param string[] $data invalid data + * + * @return self + */ + public static function createFromValidator(string $name, array $data): self + { + $exception = new static('row validation failed'); + $exception->name = $name; + $exception->data = $data; + + return $exception; + } + + /** + * return the validator name + * + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * return the invalid data submitted + * + * @return array + */ + public function getData(): array + { + return $this->data; + } +} diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php new file mode 100644 index 00000000..d395ad30 --- /dev/null +++ b/src/Exception/InvalidArgumentException.php @@ -0,0 +1,27 @@ + + * + */ +class InvalidArgumentException extends \InvalidArgumentException implements CsvException +{ +} diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php new file mode 100644 index 00000000..476a2a2c --- /dev/null +++ b/src/Exception/RuntimeException.php @@ -0,0 +1,27 @@ + + * + */ +class RuntimeException extends \RuntimeException implements CsvException +{ +} diff --git a/src/InvalidRowException.php b/src/InvalidRowException.php deleted file mode 100644 index c66a8c33..00000000 --- a/src/InvalidRowException.php +++ /dev/null @@ -1,73 +0,0 @@ -name = $name; - $this->data = $data; - } - - /** - * return the validator name - * - * @return string - */ - public function getName(): string - { - return $this->name; - } - - /** - * return the invalid data submitted - * - * @return array - */ - public function getData(): array - { - return $this->data; - } -} diff --git a/src/MapIterator.php b/src/MapIterator.php index 7dd8a6d7..d6e96fb0 100644 --- a/src/MapIterator.php +++ b/src/MapIterator.php @@ -20,8 +20,9 @@ /** * A simple MapIterator * - * @package League.csv - * @since 3.3.0 + * @package League.csv + * @since 3.3.0 + * @author Ignace Nyamagana Butera * @internal used internally to modify CSV content * */ diff --git a/src/Plugin/ColumnConsistencyValidator.php b/src/Plugin/ColumnConsistencyValidator.php index 6f1d1799..30d4d4d8 100644 --- a/src/Plugin/ColumnConsistencyValidator.php +++ b/src/Plugin/ColumnConsistencyValidator.php @@ -20,7 +20,8 @@ * A class to manage column consistency on data insertion into a CSV * * @package League.csv - * @since 7.0.0 + * @since 7.0.0 + * @author Ignace Nyamagana Butera * */ class ColumnConsistencyValidator @@ -30,14 +31,14 @@ class ColumnConsistencyValidator * * @var int */ - private $columns_count = -1; + protected $columns_count = -1; /** * should the class detect the column count based the inserted row * * @var bool */ - private $detect_columns_count = false; + protected $detect_columns_count = false; /** * Set Inserted row column count diff --git a/src/Plugin/ForbiddenNullValuesValidator.php b/src/Plugin/ForbiddenNullValuesValidator.php index c3c5a2b5..12a89c24 100644 --- a/src/Plugin/ForbiddenNullValuesValidator.php +++ b/src/Plugin/ForbiddenNullValuesValidator.php @@ -18,7 +18,8 @@ * A class to validate null value handling on data insertion into a CSV * * @package League.csv - * @since 7.0.0 + * @since 7.0.0 + * @author Ignace Nyamagana Butera * */ class ForbiddenNullValuesValidator diff --git a/src/Plugin/SkipNullValuesFormatter.php b/src/Plugin/SkipNullValuesFormatter.php index 7a44df44..9a71e339 100644 --- a/src/Plugin/SkipNullValuesFormatter.php +++ b/src/Plugin/SkipNullValuesFormatter.php @@ -18,7 +18,8 @@ * A class to remove null value from data before insertion into a CSV * * @package League.csv - * @since 7.0.0 + * @since 7.0.0 + * @author Ignace Nyamagana Butera * */ class SkipNullValuesFormatter diff --git a/src/Reader.php b/src/Reader.php index a6b76978..b137d0fa 100644 --- a/src/Reader.php +++ b/src/Reader.php @@ -14,13 +14,12 @@ namespace League\Csv; -use DomDocument; -use Generator; -use InvalidArgumentException; +use CallbackFilterIterator; use Iterator; use IteratorAggregate; -use JsonSerializable; -use League\Csv\Config\StatementTrait; +use League\Csv\Exception\InvalidArgumentException; +use LimitIterator; +use SplFileObject; /** * A class to manage extracting and filtering a CSV @@ -29,240 +28,255 @@ * @since 3.0.0 * */ -class Reader extends AbstractCsv implements JsonSerializable, IteratorAggregate +class Reader extends AbstractCsv implements IteratorAggregate { - use StatementTrait; + /** + * @inheritdoc + */ + protected $stream_filter_mode = STREAM_FILTER_READ; /** - * Charset Encoding for the CSV + * CSV Document header offset * - * @var string + * @var int|null */ - protected $input_encoding = 'UTF-8'; + protected $header_offset; /** - * @inheritdoc + * CSV Document Header record + * + * @var string[] */ - protected $stream_filter_mode = STREAM_FILTER_READ; + protected $header = []; /** - * Gets the source CSV encoding charset + * Tell whether the header needs to be re-generated * - * @return string + * @var bool */ - public function getInputEncoding(): string + protected $is_header_loaded = false; + + /** + * Returns the record offset used as header + * + * If no CSV record is used this method MUST return null + * + * @return int|null + */ + public function getHeaderOffset() { - return $this->input_encoding; + return $this->header_offset; } /** - * Sets the CSV encoding charset + * Selects the record to be used as the CSV header + * + * Because of the header is represented as an array, to be valid + * a header MUST contain only unique string value. * - * @param string $str + * @param int|null $offset the header row offset * * @return static */ - public function setInputEncoding(string $str): self + public function setHeaderOffset($offset): self { - $str = str_replace('_', '-', $str); - $str = filter_var($str, FILTER_SANITIZE_STRING, ['flags' => FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH]); - if (empty($str)) { - throw new InvalidArgumentException('you should use a valid charset'); + $this->header_offset = null; + if (null !== $offset) { + $this->header_offset = $this->filterInteger( + $offset, + 0, + 'the header offset index must be a positive integer or 0' + ); } - $this->input_encoding = strtoupper($str); + $this->resetDynamicProperties(); return $this; } /** - * Returns a HTML table representation of the CSV Table - * - * @param string $class_attr optional classname - * - * @return string + * @inheritdoc */ - public function toHTML(string $class_attr = 'table-csv-data'): string + protected function resetDynamicProperties() { - $doc = $this->toXML('table', 'tr', 'td'); - $doc->documentElement->setAttribute('class', $class_attr); - - return $doc->saveHTML($doc->documentElement); + return $this->is_header_loaded = false; } /** - * Transforms a CSV into a XML + * Detect Delimiters occurences in the CSV * - * @param string $root_name XML root node name - * @param string $row_name XML row node name - * @param string $cell_name XML cell node name + * Returns a associative array where each key represents + * a valid delimiter and each value the number of occurences * - * @return DomDocument + * @param string[] $delimiters the delimiters to consider + * @param int $nb_rows Detection is made using $nb_rows of the CSV + * + * @return array */ - public function toXML(string $root_name = 'csv', string $row_name = 'row', string $cell_name = 'cell'): DomDocument + public function fetchDelimitersOccurrence(array $delimiters, int $nb_rows = 1): array { - $doc = new DomDocument('1.0', 'UTF-8'); - $root = $doc->createElement($root_name); - foreach ($this->convertToUtf8($this->getIterator()) as $row) { - $rowElement = $doc->createElement($row_name); - array_walk($row, function ($value) use (&$rowElement, $doc, $cell_name) { - $content = $doc->createTextNode($value); - $cell = $doc->createElement($cell_name); - $cell->appendChild($content); - $rowElement->appendChild($cell); - }); - $root->appendChild($rowElement); + $nb_rows = $this->filterInteger($nb_rows, 1, 'The number of rows to consider must be a valid positive integer'); + $filter_row = function ($row) { + return is_array($row) && count($row) > 1; + }; + $delimiters = array_unique(array_filter($delimiters, function ($value) { + return 1 == strlen($value); + })); + $this->document->setFlags(SplFileObject::READ_CSV); + $res = []; + foreach ($delimiters as $delim) { + $this->document->setCsvControl($delim, $this->enclosure, $this->escape); + $iterator = new CallbackFilterIterator(new LimitIterator($this->document, 0, $nb_rows), $filter_row); + $res[$delim] = count(iterator_to_array($iterator, false), COUNT_RECURSIVE); } - $doc->appendChild($root); + arsort($res, SORT_NUMERIC); - return $doc; + return $res; } /** - * Convert Csv file into UTF-8 + * Returns a collection of selected records * - * @param Iterator $iterator + * @param Statement|null $stmt * - * @return Iterator + * @return RecordSet */ - protected function convertToUtf8(Iterator $iterator): Iterator + public function select(Statement $stmt = null): RecordSet { - if (stripos($this->input_encoding, 'UTF-8') !== false) { - return $iterator; - } - - $convert_cell = function ($value) { - return mb_convert_encoding($value, 'UTF-8', $this->input_encoding); - }; - - $convert_row = function (array $row) use ($convert_cell) { - return array_map($convert_cell, $row); - }; + $stmt = $stmt ?? new Statement(); - return new MapIterator($iterator, $convert_row); + return $stmt->process($this); } /** * @inheritdoc */ - public function jsonSerialize() + public function getIterator(): Iterator { - return iterator_to_array($this->convertToUtf8($this->getIterator()), false); - } + $bom = $this->getInputBOM(); + $header = $this->getHeader(); + $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY); + $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape); + $normalized = function ($row) { + return is_array($row) && $row != [null]; + }; + $iterator = new CallbackFilterIterator($this->document, $normalized); + $iterator = $this->combineHeader($iterator, $header); - /** - * Returns a sequential array of all CSV lines - * - * The callable function will be applied to each Iterator item - * - * @return array - */ - public function fetchAll(): array - { - return iterator_to_array($this->getIterator(), false); + return $this->stripBOM($iterator, $bom); } /** - * Returns a single row from the CSV + * Add the CSV header if present and valid * - * By default if no offset is provided the first row of the CSV is selected - * - * @param int $offset the CSV row offset + * @param Iterator $iterator + * @param string[] $header * - * @return array + * @return Iterator */ - public function fetchOne(int $offset = 0): array + protected function combineHeader(Iterator $iterator, array $header): Iterator { - $this->setOffset($offset); - $this->setLimit(1); - $iterator = $this->getIterator(); - $iterator->rewind(); + if (null === $this->header_offset) { + return $iterator; + } - return (array) $iterator->current(); + $header = $this->filterColumnNames($header); + $header_count = count($header); + $iterator = new CallbackFilterIterator($iterator, function (array $row, int $offset) { + return $offset != $this->header_offset; + }); + + return new MapIterator($iterator, function (array $row) use ($header_count, $header) { + if ($header_count != count($row)) { + $row = array_slice(array_pad($row, $header_count, null), 0, $header_count); + } + + return array_combine($header, $row); + }); } /** - * Returns the next value from a single CSV column - * - * The callable function will be applied to each value to be return - * - * By default if no column index is provided the first column of the CSV is selected + * Strip the BOM sequence if present * - * @param string|int $column_index CSV column index + * @param Iterator $iterator + * @param string $bom * * @return Iterator */ - public function fetchColumn($column_index = 0): Iterator + protected function stripBOM(Iterator $iterator, string $bom): Iterator { - $column_index = $this->getFieldIndex($column_index, 'the column index value is invalid'); - $filter = function (array $row) use ($column_index) { - return isset($row[$column_index]); - }; - - $select = function ($row) use ($column_index) { - return $row[$column_index]; - }; + if ('' === $bom) { + return $iterator; + } - $this->addFilter($filter); + $bom_length = mb_strlen($bom); + return new MapIterator($iterator, function (array $row, $index) use ($bom_length) { + if (0 != $index) { + return $row; + } - return new MapIterator($this->getIterator(), $select); + return $this->removeBOM($row, $bom_length, $this->enclosure); + }); } /** - * Filter a field name against the CSV header if any + * Strip the BOM sequence from a record * - * @param string|int $field the field name or the field index - * @param string $error_message the associated error message + * @param string[] $row + * @param int $bom_length + * @param string $enclosure * - * @throws InvalidArgumentException if the field is invalid - * - * @return string|int + * @return string[] */ - protected function getFieldIndex($field, $error_message) + protected function removeBOM(array $row, int $bom_length, string $enclosure): array { - if (false !== array_search($field, $this->header, true)) { - return $field; - } - - $index = $this->filterInteger($field, 0, $error_message); - if (empty($this->header)) { - return $index; + if (0 == $bom_length) { + return $row; } - if (false !== ($index = array_search($index, array_flip($this->header), true))) { - return $index; + $row[0] = mb_substr($row[0], $bom_length); + if ($enclosure == mb_substr($row[0], 0, 1) && $enclosure == mb_substr($row[0], -1, 1)) { + $row[0] = mb_substr($row[0], 1, -1); } - throw new InvalidArgumentException($error_message); + return $row; } /** - * Fetches the next key-value pairs from a result set (first - * column is the key, second column is the value). - * - * By default if no column index is provided: - * - the first CSV column is used to provide the keys - * - the second CSV column is used to provide the value + * Returns the column header associate with the RecordSet * - * @param string|int $offset_index The column index to serve as offset - * @param string|int $value_index The column index to serve as value + * @throws InvalidArgumentException If no header is found * - * @return Generator + * @return string[] */ - public function fetchPairs($offset_index = 0, $value_index = 1): Generator + public function getHeader(): array { - $offset = $this->getFieldIndex($offset_index, 'the offset index value is invalid'); - $value = $this->getFieldIndex($value_index, 'the value index value is invalid'); - $filter = function ($row) use ($offset) { - return isset($row[$offset]); - }; + if ($this->is_header_loaded) { + return $this->header; + } - $select = function ($row) use ($offset, $value) { - return [$row[$offset], isset($row[$value]) ? $row[$value] : null]; - }; + $this->is_header_loaded = true; + if (null === $this->header_offset) { + $this->header = []; + + return $this->header; + } + + $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY); + $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape); + $this->document->seek($this->header_offset); + $header = $this->document->current(); + if (empty($header)) { + throw new InvalidArgumentException('The header record specified by `Reader::setHeaderOffset` does not exist or is empty'); + } + + if (0 !== $this->header_offset) { + $this->header = $header; - $this->addFilter($filter); - foreach (new MapIterator($this->getIterator(), $select) as $row) { - yield $row[0] => $row[1]; + return $this->header; } + + $this->header = $this->removeBOM($header, mb_strlen($this->getInputBOM()), $this->enclosure); + + return $this->header; } } diff --git a/src/RecordSet.php b/src/RecordSet.php new file mode 100644 index 00000000..75678f63 --- /dev/null +++ b/src/RecordSet.php @@ -0,0 +1,414 @@ + + * + */ +class RecordSet implements JsonSerializable, IteratorAggregate, Countable +{ + use ValidatorTrait; + + /** + * The CSV iterator result + * + * @var Iterator + */ + protected $iterator; + + /** + * The CSV header + * + * @var array + */ + protected $column_names = []; + + /** + * Charset Encoding for the CSV + * + * This information is used when converting the CSV to XML or JSON + * + * @var string + */ + protected $conversion_input_encoding = 'UTF-8'; + + /** + * Tell whether the CSV document offset + * must be kept on output + * + * @var bool + */ + protected $preserve_offset = false; + + /** + * New instance + * + * @param Iterator $iterator a CSV iterator + * @param array $column_names the CSV header + */ + public function __construct(Iterator $iterator, array $column_names = []) + { + $this->iterator = $iterator; + $this->column_names = $column_names; + } + + /** + * @inheritdoc + */ + public function __destruct() + { + $this->iterator = null; + } + + /** + * Returns the field names associate with the RecordSet + * + * @return string[] + */ + public function getColumnNames(): array + { + return $this->column_names; + } + + /** + * Returns a specific field names according to its offset + * + * If no field name is found or associated to the submitted + * offset an empty string is returned + * + * @param int $offset + * + * @return string + */ + public function getColumnName(int $offset): string + { + return $this->column_names[$offset] ?? ''; + } + + /** + * @inheritdoc + */ + public function getIterator(): Iterator + { + foreach ($this->iterator as $key => $value) { + $this->preserve_offset ? yield $key => $value : yield $value; + } + } + + /** + * @inheritdoc + */ + public function count(): int + { + return iterator_count($this->iterator); + } + + /** + * @inheritdoc + */ + public function jsonSerialize() + { + return iterator_to_array($this->convertToUtf8($this->iterator), $this->preserve_offset); + } + + /** + * Convert Csv file into UTF-8 + * + * @param Iterator $iterator + * + * @return Iterator + */ + protected function convertToUtf8(Iterator $iterator): Iterator + { + if (stripos($this->conversion_input_encoding, 'UTF-8') !== false) { + return $iterator; + } + + $convert_cell = function ($value) { + return mb_convert_encoding((string) $value, 'UTF-8', $this->conversion_input_encoding); + }; + + $convert_row = function (array $row) use ($convert_cell) { + $res = []; + foreach ($row as $key => $value) { + $res[$convert_cell($key)] = $convert_cell($value); + } + + return $res; + }; + + return new MapIterator($iterator, $convert_row); + } + + /** + * Returns a HTML table representation of the CSV Table + * + * @param string $class_attr optional classname + * + * @return string + */ + public function toHTML(string $class_attr = 'table-csv-data', string $offset_attr = 'data-record-offset'): string + { + $doc = $this->toXML('table', 'tr', 'td', 'title', $offset_attr); + $doc->documentElement->setAttribute('class', $class_attr); + + return $doc->saveHTML($doc->documentElement); + } + + /** + * Transforms a CSV into a XML + * + * @param string $root_name XML root node name + * @param string $row_name XML row node name + * @param string $cell_name XML cell node name + * @param string $column_attr XML column attribute name + * @param string $offset_attr XML offset attribute name + * + * @return DOMDocument + */ + public function toXML( + string $root_name = 'csv', + string $row_name = 'row', + string $cell_name = 'cell', + string $column_attr = 'name', + string $offset_attr = 'offset' + ): DOMDocument { + $doc = new DOMDocument('1.0', 'UTF-8'); + $root = $doc->createElement($root_name); + foreach ($this->convertToUtf8($this->iterator) as $offset => $row) { + $root->appendChild($this->toDOMNode( + $doc, + $row, + $offset, + $row_name, + $cell_name, + $column_attr, + $offset_attr + )); + } + $doc->appendChild($root); + + return $doc; + } + + /** + * convert a Record into a DOMNode + * + * @param DOMDocument $doc The DOMDocument + * @param array $row The CSV record + * @param int $offset The CSV record offset + * @param string $row_name XML row node name + * @param string $cell_name XML cell node name + * @param string $column_attr XML header attribute name + * @param string $offset_attr XML offset attribute name + * + * @return DOMElement + */ + protected function toDOMNode( + DOMDocument $doc, + array $row, + int $offset, + string $row_name, + string $cell_name, + string $column_attr, + string $offset_attr + ): DOMElement { + $rowElement = $doc->createElement($row_name); + if ($this->preserve_offset) { + $rowElement->setAttribute($offset_attr, (string) $offset); + } + foreach ($row as $name => $value) { + $content = $doc->createTextNode($value); + $cell = $doc->createElement($cell_name); + if (!empty($this->column_names)) { + $cell->setAttribute($column_attr, $name); + } + $cell->appendChild($content); + $rowElement->appendChild($cell); + } + + return $rowElement; + } + + /** + * Returns a sequential array of all CSV lines + * + * @return array + */ + public function fetchAll(): array + { + return iterator_to_array($this->iterator, $this->preserve_offset); + } + + /** + * Returns a single row from the CSV + * + * By default if no offset is provided the first row of the CSV is selected + * + * @param int $offset the CSV row offset + * + * @return array + */ + public function fetchOne(int $offset = 0): array + { + $offset = $this->filterInteger($offset, 0, 'the submitted offset is invalid'); + $it = new LimitIterator($this->iterator, $offset, 1); + $it->rewind(); + + return (array) $it->current(); + } + + /** + * Returns the next value from a single CSV column + * + * By default if no column index is provided the first column of the CSV is selected + * + * @param string|int $index CSV column index + * + * @return Generator + */ + public function fetchColumn($index = 0): Generator + { + $offset = $this->getColumnIndex($index, 'the column index value is invalid'); + $filter = function (array $row) use ($offset) { + return isset($row[$offset]); + }; + + $select = function ($row) use ($offset) { + return $row[$offset]; + }; + + $iterator = new MapIterator(new CallbackFilterIterator($this->iterator, $filter), $select); + foreach ($iterator as $key => $value) { + $this->preserve_offset ? yield $key => $value : yield $value; + } + } + + /** + * Filter a column name against the CSV header if any + * + * @param string|int $field the field name or the field index + * @param string $error_message the associated error message + * + * @throws InvalidArgumentException if the field is invalid + * + * @return string|int + */ + protected function getColumnIndex($field, string $error_message) + { + if (false !== array_search($field, $this->column_names, true) || is_string($field)) { + return $field; + } + + $index = $this->filterInteger($field, 0, $error_message); + if (empty($this->column_names)) { + return $index; + } + + if (false !== ($index = array_search($index, array_flip($this->column_names), true))) { + return $index; + } + + throw new InvalidArgumentException($error_message); + } + + /** + * Fetches the next key-value pairs from a result set (first + * column is the key, second column is the value). + * + * By default if no column index is provided: + * - the first CSV column is used to provide the keys + * - the second CSV column is used to provide the value + * + * @param string|int $offset_index The column index to serve as offset + * @param string|int $value_index The column index to serve as value + * + * @return Generator + */ + public function fetchPairs($offset_index = 0, $value_index = 1): Generator + { + $offset = $this->getColumnIndex($offset_index, 'the offset index value is invalid'); + $value = $this->getColumnIndex($value_index, 'the value index value is invalid'); + + $filter = function ($row) use ($offset) { + return isset($row[$offset]); + }; + + $select = function ($row) use ($offset, $value) { + return [$row[$offset], $row[$value] ?? null]; + }; + + $it = new MapIterator(new CallbackFilterIterator($this->iterator, $filter), $select); + + foreach ($it as $row) { + yield $row[0] => $row[1]; + } + } + + /** + * Sets the CSV encoding charset + * + * @param string $str + * + * @throws InvalidArgumentException if the charset is empty + * + * @return static + */ + public function setConversionInputEncoding(string $str): self + { + $str = str_replace('_', '-', $str); + $str = filter_var($str, FILTER_SANITIZE_STRING, ['flags' => FILTER_FLAG_STRIP_LOW | FILTER_FLAG_STRIP_HIGH]); + $str = trim($str); + if ('' === $str) { + throw new InvalidArgumentException('you should use a valid charset'); + } + $this->conversion_input_encoding = strtoupper($str); + + return $this; + } + + /** + * Whether we should preserve the CSV document record offset. + * + * If set to true CSV document record offset will added to + * method output where it makes sense. + * + * @param bool $status + * + * @return static + */ + public function preserveOffset(bool $status) + { + $this->preserve_offset = $status; + + return $this; + } +} diff --git a/src/Statement.php b/src/Statement.php new file mode 100644 index 00000000..a95f3538 --- /dev/null +++ b/src/Statement.php @@ -0,0 +1,302 @@ + + * + */ +class Statement +{ + use ValidatorTrait; + + /** + * CSV columns name + * + * @var array + */ + protected $columns = []; + + /** + * Callables to filter the iterator + * + * @var callable[] + */ + protected $where = []; + + /** + * Callables to sort the iterator + * + * @var callable[] + */ + protected $order_by = []; + + /** + * iterator Offset + * + * @var int + */ + protected $offset = 0; + + /** + * iterator maximum length + * + * @var int + */ + protected $limit = -1; + + /** + * Set and selected columns to be used by the RecordSet object + * + * The array offset represents the CSV document header value + * The array value represents the Alias named to be used by the RecordSet object + * + * @param array $columns + * + * @return self + */ + public function columns(array $columns): self + { + $columns = $this->filterColumnNames($columns); + if ($columns === $this->columns) { + return $this; + } + + $clone = clone $this; + $clone->columns = $columns; + + return $clone; + } + + /** + * Set the Iterator filter method + * + * @param callable $callable + * + * @return self + */ + public function where(callable $callable): self + { + $clone = clone $this; + $clone->where[] = $callable; + + return $clone; + } + + /** + * Set an Iterator sorting callable function + * + * @param callable $callable + * + * @return self + */ + public function orderBy(callable $callable): self + { + $clone = clone $this; + $clone->order_by[] = $callable; + + return $clone; + } + + /** + * Set LimitIterator Offset + * + * @param $offset + * + * @return self + */ + public function offset(int $offset): self + { + $offset = $this->filterInteger($offset, 0, 'the offset must be a positive integer or 0'); + if ($offset === $this->offset) { + return $this; + } + + $clone = clone $this; + $clone->offset = $offset; + + return $clone; + } + + /** + * Set LimitIterator Count + * + * @param int $limit + * + * @return self + */ + public function limit(int $limit): self + { + $limit = $this->filterInteger($limit, -1, 'the limit must an integer greater or equals to -1'); + if ($limit === $this->limit) { + return $this; + } + + $clone = clone $this; + $clone->limit = $limit; + + return $clone; + } + + /** + * Returns the inner CSV Document Iterator object + * + * @param Reader $reader + * + * @return RecordSet + */ + public function process(Reader $reader): RecordSet + { + list($columns, $combine) = $this->buildColumns($reader->getHeader()); + $iterator = $this->buildWhere($reader->getIterator()); + $iterator = $this->buildOrderBy($iterator); + $iterator = new LimitIterator($iterator, $this->offset, $this->limit); + if (null !== $combine) { + $iterator = new MapIterator($iterator, $combine); + } + + return new RecordSet($iterator, $columns); + } + + /** + * Add the CSV column if present + * + * @param string[] $columns + * + * @return array + */ + protected function buildColumns(array $columns): array + { + $combine = null; + if (!empty($this->columns)) { + $columns_alias = $this->filterColumnAgainstCsvHeader($columns); + $columns = array_values($columns_alias); + $combine = function (array $row) use ($columns_alias): array { + $record = []; + foreach ($columns_alias as $key => $alias) { + $record[$alias] = $row[$key] ?? null; + } + + return $record; + }; + } + + return [$columns, $combine]; + } + + /** + * Validate the column against the processed CSV header + * + * @param string[] $headers Reader CSV header + * + * @throws RuntimeException If a column is not found + */ + protected function filterColumnAgainstCsvHeader(array $headers) + { + if (empty($headers)) { + $filter = function ($key) { + return !is_int($key) || $key < 0; + }; + + if (empty(array_filter($this->columns, $filter, ARRAY_FILTER_USE_KEY))) { + return $this->columns; + } + + throw new RuntimeException('If no header is specified the columns keys must contain only integer'); + } + + $columns = $this->formatColumns($this->columns); + foreach ($columns as $key => $alias) { + if (false === array_search($key, $headers, true)) { + throw new RuntimeException(sprintf('The `%s` column does not exist in the Csv document', $key)); + } + } + + return $columns; + } + + /** + * Format the column array + * + * @param array $columns + * + * @return array + */ + private function formatColumns(array $columns): array + { + $res = []; + foreach ($columns as $key => $alias) { + $res[!is_string($key) ? $alias : $key] = $alias; + } + + return $res; + } + + /** + * Filter the Iterator + * + * @param Iterator $iterator + * + * @return Iterator + */ + protected function buildWhere(Iterator $iterator): Iterator + { + $reducer = function (Iterator $iterator, callable $callable): Iterator { + return new CallbackFilterIterator($iterator, $callable); + }; + + return array_reduce($this->where, $reducer, $iterator); + } + + /** + * Sort the Iterator + * + * @param Iterator $iterator + * + * @return Iterator + */ + protected function buildOrderBy(Iterator $iterator): Iterator + { + if (empty($this->order_by)) { + return $iterator; + } + + $compare = function (array $record_a, array $record_b): int { + $res = 0; + foreach ($this->order_by as $callable) { + if (0 !== ($res = $callable($record_a, $record_b))) { + break; + } + } + + return $res; + }; + + $iterator = new ArrayIterator(iterator_to_array($iterator, true)); + $iterator->uasort($compare); + + return $iterator; + } +} diff --git a/src/StreamIterator.php b/src/StreamIterator.php index 926c3780..7769d1af 100644 --- a/src/StreamIterator.php +++ b/src/StreamIterator.php @@ -14,21 +14,29 @@ namespace League\Csv; -use InvalidArgumentException; use Iterator; +use League\Csv\Exception\InvalidArgumentException; use LogicException; use SplFileObject; /** - * A Stream Iterator + * an object oriented interface for a stream resource. * - * @package League.csv - * @since 8.2.0 + * @package League.csv + * @since 8.2.0 + * @author Ignace Nyamagana Butera * @internal used internally to iterate over a stream resource * */ class StreamIterator implements Iterator { + /** + * Attached filters + * + * @var resource[] + */ + protected $filters; + /** * Stream pointer * @@ -100,6 +108,14 @@ public function __construct($stream) $this->stream = $stream; } + /** + * close the file pointer + */ + public function __destruct() + { + $this->stream = null; + } + /** * Set CSV control * @@ -157,16 +173,16 @@ public function setFlags(int $flags) * @param string $enclosure * @param string $escape * - * @return int + * @return int|false */ - public function fputcsv(array $fields, string $delimiter = null, string $enclosure = null, string $escape = null) + public function fputcsv(array $fields, string $delimiter = ',', string $enclosure = '"', string $escape = '\\') { return fputcsv( $this->stream, $fields, - null !== $delimiter ? $this->filterControl($delimiter, 'delimiter') : $this->delimiter, - null !== $enclosure ? $this->filterControl($enclosure, 'enclosure') : $this->enclosure, - null !== $escape ? $this->filterControl($escape, 'escape') : $this->escape + $this->filterControl($delimiter, 'delimiter'), + $this->filterControl($enclosure, 'enclosure'), + $this->filterControl($escape, 'escape') ); } @@ -195,7 +211,7 @@ public function current() /** * Retrieves the current line as a CSV Record * - * @return array + * @return array|bool */ protected function getCurrentRecord() { @@ -211,7 +227,7 @@ protected function getCurrentRecord() * * @return string */ - protected function getCurrentLine() + protected function getCurrentLine(): string { do { $line = fgets($this->stream); @@ -225,7 +241,7 @@ protected function getCurrentLine() * * @return int */ - public function key() + public function key(): int { return $this->current_line_number; } @@ -257,7 +273,7 @@ public function rewind() * * @return bool */ - public function valid() + public function valid(): bool { if ($this->flags & SplFileObject::READ_AHEAD) { return $this->current() !== false; @@ -273,7 +289,7 @@ public function valid() * * @return string */ - public function fgets() + public function fgets(): string { if (false !== $this->current_line) { $this->next(); @@ -288,7 +304,7 @@ public function fgets() * * @return int */ - public function fpassthru() + public function fpassthru(): int { return fpassthru($this->stream); } @@ -303,7 +319,7 @@ public function fpassthru() * * @return int */ - public function fseek($offset, $whence = SEEK_SET) + public function fseek(int $offset, int $whence = SEEK_SET): int { return fseek($this->stream, $offset, $whence); } @@ -341,16 +357,58 @@ public function seek(int $line_pos) * * @return int */ - public function fwrite($str, $length = 0) + public function fwrite(string $str, int $length = 0): int { return fwrite($this->stream, $str, $length); } /** - * close the file pointer + * append a filter + * + * @param string $filter_name + * + * @return resource */ - public function __destruct() + public function appendFilter(string $filter_name, int $read_write) { - $this->stream = null; + return stream_filter_append($this->stream, $filter_name, $read_write); + } + + /** + * prepend a filter + * + * @param string $filter_name + * + * @return resource + */ + public function prependFilter(string $filter_name, int $read_write) + { + return stream_filter_prepend($this->stream, $filter_name, $read_write); + } + + /** + * remove all attached filters + */ + public function removeFilter($resource) + { + return stream_filter_remove($resource); + } + + /** + * Flushes the output to a file + * + * @return bool + */ + public function fflush(): bool + { + return fflush($this->stream); + } + + /** + * @inheritdoc + */ + public function __clone() + { + throw new LogicException('An object of class '.StreamIterator::class.' cannot be cloned'); } } diff --git a/src/Config/ValidatorTrait.php b/src/ValidatorTrait.php similarity index 67% rename from src/Config/ValidatorTrait.php rename to src/ValidatorTrait.php index 5ace2079..6d3e03f1 100644 --- a/src/Config/ValidatorTrait.php +++ b/src/ValidatorTrait.php @@ -12,16 +12,17 @@ */ declare(strict_types=1); -namespace League\Csv\Config; +namespace League\Csv; -use InvalidArgumentException; +use League\Csv\Exception\InvalidArgumentException; /** * An abstract class to enable basic CSV manipulation * - * @package League.csv - * @since 9.0.0 - * @internal + * @package League.csv + * @since 9.0.0 + * @author Ignace Nyamagana Butera + * @internal Use to validate incoming data */ trait ValidatorTrait { @@ -65,25 +66,24 @@ protected function filterInteger(int $value, int $min_value, string $error_messa } /** - * Strip the BOM sequence from a record + * Validates the array to be used by the fetchAssoc method * - * @param string[] $row - * @param int $bom_length - * @param string $enclosure + * @param array $keys * - * @return string[] + * @throws InvalidArgumentException If the submitted array fails the assertion + * + * @return array */ - protected function removeBOM(array $row, int $bom_length, string $enclosure): array + protected function filterColumnNames(array $keys): array { - if (0 == $bom_length) { - return $row; + if (empty($keys)) { + return $keys; } - $row[0] = mb_substr($row[0], $bom_length); - if ($enclosure == mb_substr($row[0], 0, 1) && $enclosure == mb_substr($row[0], -1, 1)) { - $row[0] = mb_substr($row[0], 1, -1); + if ($keys !== array_unique(array_filter($keys, 'is_string'))) { + throw new InvalidArgumentException('Use a flat array with unique string values'); } - return $row; + return $keys; } } diff --git a/src/Writer.php b/src/Writer.php index 079a73ee..0f049d88 100644 --- a/src/Writer.php +++ b/src/Writer.php @@ -14,87 +14,78 @@ namespace League\Csv; -use InvalidArgumentException; -use ReflectionMethod; -use SplFileObject; +use League\Csv\Exception\InsertionException; +use League\Csv\Exception\InvalidArgumentException; use Traversable; /** - * A class to manage data insertion into a CSV + * A class to manage data insertion into a CSV * * @package League.csv - * @since 4.0.0 + * @since 4.0.0 + * @author Ignace Nyamagana Butera * */ class Writer extends AbstractCsv { /** - * Callables to validate the row before insertion + * @inheritdoc + */ + protected $stream_filter_mode = STREAM_FILTER_WRITE; + + /** + * callable collection to validate the record before insertion * * @var callable[] */ protected $validators = []; /** - * Callables to format the row before insertion + * callable collection to format the record before insertion * * @var callable[] */ protected $formatters = []; /** - * @inheritdoc - */ - protected $stream_filter_mode = STREAM_FILTER_WRITE; - - /** - * The CSV object holder + * Insert Rows count * - * @var SplFileObject|StreamIterator + * @var int */ - protected $csv; + protected $insert_count = 0; /** - * fputcsv method from SplFileObject or StreamIterator + * newline character * - * @var ReflectionMethod + * @var string */ - protected $fputcsv; + protected $newline = "\n"; /** - * Nb parameters for SplFileObject::fputcsv method + * Buffer flush threshold * - * @var integer + * @var int */ - protected $fputcsv_param_count; + protected $flush_threshold = 500; /** - * add a formatter to the collection + * Returns the current newline sequence characters * - * @param callable $callable - * - * @return $this + * @return string */ - public function addFormatter(callable $callable): self + public function getNewline(): string { - $this->formatters[] = $callable; - - return $this; + return $this->newline; } /** - * add a Validator to the collection - * - * @param callable $callable - * @param string $name the rule name + * Get the flush threshold * - * @return $this + * @return int|null */ - public function addValidator(callable $callable, string $name): self + public function getFlushThreshold() { - $this->validators[$name] = $callable; - - return $this; + return $this->flush_threshold; } /** @@ -106,131 +97,151 @@ public function addValidator(callable $callable, string $name): self * * @throws InvalidArgumentException If the given rows format is invalid * - * @return static + * @return int */ - public function insertAll($rows): self + public function insertAll($rows): int { if (!is_array($rows) && !$rows instanceof Traversable) { - throw new InvalidArgumentException( - 'the provided data must be an array OR a `Traversable` object' - ); + throw new InvalidArgumentException('the provided data must be an array OR a `Traversable` object'); } + $bytes = 0; foreach ($rows as $row) { - $this->insertOne($row); + $bytes += $this->insertOne($row); } - return $this; + $this->document->fflush(); + + return $bytes; } /** * Adds a single line to a CSV document * - * @param string[]|string $row a string, an array or an object implementing to '__toString' method + * @param string[] $row an array * - * @return static + * @throws InsertionException If the row can not be inserted + * + * @return int */ - public function insertOne(array $row): self + public function insertOne(array $row): int { - $row = $this->formatRow($row); - $this->validateRow($row); - $this->addRow($row); + $record = array_reduce($this->formatters, [$this, 'formatRecord'], $row); + $this->validateRecord($record); + $bytes = $this->document->fputcsv($record, $this->delimiter, $this->enclosure, $this->escape); + if (!$bytes) { + throw InsertionException::createFromCsv($record); + } - return $this; + return $bytes + $this->consolidate(); } /** * Format the given row * - * @param array $row + * @param string[] $record + * @param callable $formatter * - * @return array + * @return string[] */ - protected function formatRow(array $row): array + protected function formatRecord(array $record, callable $formatter): array { - foreach ($this->formatters as $formatter) { - $row = ($formatter)($row); - } - - return $row; + return $formatter($record); } /** - * Validate a row - * - * @param array $row - * - * @throws InvalidRowException If the validation failed - */ - protected function validateRow(array $row) + * Validate a row + * + * @param string[] $record + * + * @throws InsertionException If the validation failed + */ + protected function validateRecord(array $record) { foreach ($this->validators as $name => $validator) { - if (true !== ($validator)($row)) { - throw new InvalidRowException($name, $row, 'row validation failed'); + if (true !== ($validator)($record)) { + throw InsertionException::createFromValidator($name, $record); } } } /** - * Add new record to the CSV document + * Apply post insertion actions * - * @param array $row record to add + * @return int */ - protected function addRow(array $row) + protected function consolidate(): int { - $this->initCsv(); - $this->fputcsv->invokeArgs($this->csv, $this->getFputcsvParameters($row)); + $bytes = 0; if ("\n" !== $this->newline) { - $this->csv->fseek(-1, SEEK_CUR); - $this->csv->fwrite($this->newline, strlen($this->newline)); + $this->document->fseek(-1, SEEK_CUR); + $bytes = $this->document->fwrite($this->newline, strlen($this->newline)) - 1; } + + $this->insert_count++; + if (null !== $this->flush_threshold && 0 === $this->insert_count % $this->flush_threshold) { + $this->document->fflush(); + } + + return $bytes; } /** - * Initialize the CSV object and settings + * add a formatter to the collection + * + * @param callable $formatter + * + * @return static */ - protected function initCsv() + public function addFormatter(callable $formatter): self { - if (null !== $this->csv) { - return; - } + $this->formatters[] = $formatter; - $this->csv = $this->getCsvDocument(); - $this->fputcsv = new ReflectionMethod(get_class($this->csv), 'fputcsv'); - $this->fputcsv_param_count = $this->fputcsv->getNumberOfParameters(); + return $this; } /** - * returns the parameters for SplFileObject::fputcsv + * add a Validator to the collection * - * @param array $fields The fields to be add + * @param callable $validator + * @param string $name the validator name * - * @return array + * @return static */ - protected function getFputcsvParameters(array $fields): array + public function addValidator(callable $validator, string $name): self { - $parameters = [$fields, $this->delimiter, $this->enclosure]; - if (4 == $this->fputcsv_param_count) { - $parameters[] = $this->escape; - } + $this->validators[$name] = $validator; - return $parameters; + return $this; } /** - * {@inheritdoc} + * Sets the newline sequence characters + * + * @param string $newline + * + * @return static */ - public function isActiveStreamFilter(): bool + public function setNewline(string $newline): self { - return parent::isActiveStreamFilter() && null === $this->csv; + $this->newline = (string) $newline; + + return $this; } /** - * {@inheritdoc} + * Set the automatic flush threshold on write + * + * @param int|null $val */ - public function __destruct() + public function setFlushThreshold($val): self { - $this->csv = null; - parent::__destruct(); + if (null !== $val) { + $val = $this->filterInteger($val, 1, 'The flush threshold must be a valid positive integer or null'); + } + + $this->flush_threshold = $val; + + return $this; } } diff --git a/tests/ControlsTest.php b/tests/ControlsTest.php deleted file mode 100644 index 1293bc81..00000000 --- a/tests/ControlsTest.php +++ /dev/null @@ -1,194 +0,0 @@ -expected as $row) { - $csv->fputcsv($row); - } - - $this->csv = Reader::createFromFileObject($csv, "\n"); - } - - public function tearDown() - { - $this->csv = null; - } - - /** - * @expectedException InvalidArgumentException - * @expectedExceptionMessage The delimiter must be a single character - */ - public function testDelimeter() - { - $this->csv->setDelimiter('o'); - $this->assertSame('o', $this->csv->getDelimiter()); - - $this->csv->setDelimiter('foo'); - } - - public function testBOMSettings() - { - $this->assertSame('', $this->csv->getOutputBOM()); - $this->csv->setOutputBOM(Reader::BOM_UTF8); - $this->assertSame(Reader::BOM_UTF8, $this->csv->getOutputBOM()); - $this->csv->setOutputBOM(''); - $this->assertSame('', $this->csv->getOutputBOM()); - } - - public function testAddBOMSequences() - { - $this->csv->setOutputBOM(Reader::BOM_UTF8); - $expected = chr(239).chr(187).chr(191).'john,doe,john.doe@example.com'.PHP_EOL - .'jane,doe,jane.doe@example.com'.PHP_EOL; - $this->assertSame($expected, $this->csv->__toString()); - } - - public function testGetBomOnInputWithNoBOM() - { - $expected = 'john,doe,john.doe@example.com'.PHP_EOL - .'jane,doe,jane.doe@example.com'.PHP_EOL; - $reader = Reader::createFromString($expected); - $this->assertNotContains(Reader::BOM_UTF8, (string) $reader); - } - - public function testChangingBOMOnOutput() - { - $text = 'john,doe,john.doe@example.com'.PHP_EOL - .'jane,doe,jane.doe@example.com'.PHP_EOL; - $reader = Reader::createFromString(Reader::BOM_UTF32_BE.$text); - $reader->setOutputBOM(Reader::BOM_UTF8); - $this->assertSame(Reader::BOM_UTF8.$text, (string) $reader); - } - - public function testDetectDelimiterList() - { - $this->assertSame([',' => 4], $this->csv->fetchDelimitersOccurrence([','])); - } - - /** - * @expectedException InvalidArgumentException - * @expectedExceptionMessage The number of rows to consider must be a valid positive integer - */ - public function testDetectDelimiterListWithInvalidRowLimit() - { - $this->csv->fetchDelimitersOccurrence([','], -4); - } - - public function testDetectDelimiterListWithNoCSV() - { - $file = new SplTempFileObject(); - $file->fwrite("How are you today ?\nI'm doing fine thanks!"); - $csv = Writer::createFromFileObject($file); - $this->assertSame(['|' => 0], $csv->fetchDelimitersOccurrence(['toto', '|'], 5)); - } - - public function testDetectDelimiterListWithInconsistentCSV() - { - $data = new SplTempFileObject(); - $data->setCsvControl(';'); - $data->fputcsv(['toto', 'tata', 'tutu']); - $data->setCsvControl('|'); - $data->fputcsv(['toto', 'tata', 'tutu']); - $data->fputcsv(['toto', 'tata', 'tutu']); - $data->fputcsv(['toto', 'tata', 'tutu']); - - $csv = Writer::createFromFileObject($data); - $this->assertSame(['|' => 12, ';' => 4], $csv->fetchDelimitersOccurrence(['|', ';'], 5)); - } - - /** - * @expectedException InvalidArgumentException - * @expectedExceptionMessage The escape must be a single character - */ - public function testEscape() - { - $this->csv->setEscape('o'); - $this->assertSame('o', $this->csv->getEscape()); - - $this->csv->setEscape('foo'); - } - - /** - * @expectedException InvalidArgumentException - * @expectedExceptionMessage The enclosure must be a single character - */ - public function testEnclosure() - { - $this->csv->setEnclosure('o'); - $this->assertSame('o', $this->csv->getEnclosure()); - - $this->csv->setEnclosure('foo'); - } - - /** - * @expectedException InvalidArgumentException - * @expectedExceptionMessage you should use a valid charset - */ - public function testEncoding() - { - $expected = 'iso-8859-15'; - $this->csv->setInputEncoding($expected); - $this->assertSame(strtoupper($expected), $this->csv->getInputEncoding()); - - $this->csv->setInputEncoding(''); - } - - public function testCustomNewline() - { - $csv = Writer::createFromFileObject(new SplTempFileObject()); - $this->assertSame("\n", $csv->getNewline()); - $csv->setNewline("\r\n"); - $this->assertSame("\r\n", $csv->getNewline()); - } - - /** - * @dataProvider appliedFlagsProvider - */ - public function testAppliedFlags($flag, $fetch_count) - { - $path = __DIR__.'/data/tmp.txt'; - $obj = new SplFileObject($path, 'w+'); - $obj->fwrite("1st\n2nd\n"); - $obj->setFlags($flag); - $reader = Reader::createFromFileObject($obj); - $this->assertCount($fetch_count, $reader->fetchAll()); - $reader = null; - $obj = null; - unlink($path); - } - - public function appliedFlagsProvider() - { - return [ - 'NONE' => [0, 2], - 'DROP_NEW_LINE' => [SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE, 2], - 'READ_AHEAD' => [SplFileObject::READ_AHEAD, 2], - 'SKIP_EMPTY' => [SplFileObject::SKIP_EMPTY, 2], - 'READ_AHEAD|DROP_NEW_LINE' => [SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE, 2], - 'READ_AHEAD|SKIP_EMPTY' => [SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY, 2], - 'DROP_NEW_LINE|SKIP_EMPTY' => [SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY, 2], - 'READ_AHEAD|DROP_NEW_LINE|SKIP_EMPTY' => [SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY, 2], - ]; - } -} diff --git a/tests/CsvTest.php b/tests/CsvTest.php index 26363878..5393a7fe 100644 --- a/tests/CsvTest.php +++ b/tests/CsvTest.php @@ -2,17 +2,20 @@ namespace LeagueTest\Csv; -use DOMDocument; -use IteratorAggregate; -use JsonSerializable; +use League\Csv\Exception\InvalidArgumentException; +use League\Csv\Exception\RuntimeException; use League\Csv\Reader; -use PHPUnit_Framework_TestCase; +use League\Csv\Writer; +use LeagueTest\Csv\Lib\FilterReplace; +use LogicException; +use PHPUnit\Framework\TestCase; +use SplFileObject; use SplTempFileObject; /** * @group csv */ -class CsvTest extends PHPUnit_Framework_TestCase +class CsvTest extends TestCase { private $csv; @@ -36,45 +39,23 @@ public function tearDown() $this->csv = null; } - public function testInterface() + public function testCreateFromPathThrowsRuntimeException() { - $this->assertInstanceOf(IteratorAggregate::class, $this->csv); - $this->assertInstanceOf(JsonSerializable::class, $this->csv); + $this->expectException(RuntimeException::class); + Reader::createFromPath(__DIR__.'/foo/bar', 'r'); } - public function testToHTML() + public function testCreateFromStreamWithInvalidParameter() { - $this->assertContains('csv->toHTML()); + $this->expectException(InvalidArgumentException::class); + $path = __DIR__.'/data/foo.csv'; + Reader::createFromStream($path); } - public function testToXML() + public function testCloningIsForbidden() { - $this->assertInstanceOf(DOMDocument::class, $this->csv->toXML()); - } - - public function testJsonSerialize() - { - $this->assertSame($this->expected, json_decode(json_encode($this->csv), true)); - } - - /** - * @param $rawCsv - * - * @dataProvider getIso8859Csv - */ - public function testJsonSerializeAffectedByReaderOptions($rawCsv) - { - $csv = Reader::createFromString($rawCsv); - $csv->setInputEncoding('iso-8859-15'); - $csv->setOffset(799); - $csv->setLimit(50); - json_encode($csv); - $this->assertEquals(JSON_ERROR_NONE, json_last_error()); - } - - public static function getIso8859Csv() - { - return [[file_get_contents(__DIR__.'/data/prenoms.csv')]]; + $this->expectException(LogicException::class); + clone $this->csv; } /** @@ -99,13 +80,161 @@ public function testOutputHeaders() // Due to the variety of ways the xdebug expresses Content-Type of text files, // we cannot count on complete string matching. $this->assertContains('content-type: text/csv', strtolower($headers[0])); - $this->assertSame($headers[1], 'Content-Transfer-Encoding: binary'); - $this->assertSame($headers[2], 'Content-Disposition: attachment; filename="test.csv"'); + $this->assertSame($headers[1], 'content-transfer-encoding: binary'); + $this->assertSame($headers[2], 'content-disposition: attachment; filename="test.csv"'); } public function testToString() { $expected = "john,doe,john.doe@example.com\njane,doe,jane.doe@example.com\n"; - $this->assertSame($expected, $this->csv->__toString()); + $this->assertSame($expected, (string) $this->csv); + } + + public function testDelimeter() + { + $this->expectException(InvalidArgumentException::class); + $this->csv->setDelimiter('o'); + $this->assertSame('o', $this->csv->getDelimiter()); + $this->csv->setDelimiter('foo'); + } + + public function testBOMSettings() + { + $this->assertSame('', $this->csv->getOutputBOM()); + $this->csv->setOutputBOM(Reader::BOM_UTF8); + $this->assertSame(Reader::BOM_UTF8, $this->csv->getOutputBOM()); + $this->csv->setOutputBOM(''); + $this->assertSame('', $this->csv->getOutputBOM()); + } + + public function testAddBOMSequences() + { + $this->csv->setOutputBOM(Reader::BOM_UTF8); + $expected = chr(239).chr(187).chr(191).'john,doe,john.doe@example.com'.PHP_EOL + .'jane,doe,jane.doe@example.com'.PHP_EOL; + $this->assertSame($expected, (string) $this->csv); + } + + public function testGetBomOnInputWithNoBOM() + { + $expected = 'john,doe,john.doe@example.com'.PHP_EOL + .'jane,doe,jane.doe@example.com'.PHP_EOL; + $reader = Reader::createFromString($expected); + $this->assertNotContains(Reader::BOM_UTF8, (string) $reader); + } + + public function testChangingBOMOnOutput() + { + $text = 'john,doe,john.doe@example.com'.PHP_EOL + .'jane,doe,jane.doe@example.com'.PHP_EOL; + $reader = Reader::createFromString(Reader::BOM_UTF32_BE.$text); + $reader->setOutputBOM(Reader::BOM_UTF8); + $this->assertSame(Reader::BOM_UTF8.$text, (string) $reader); + } + + public function testDetectDelimiterList() + { + $this->assertSame([',' => 4], $this->csv->fetchDelimitersOccurrence([','])); + } + + public function testEscape() + { + $this->expectException(InvalidArgumentException::class); + $this->csv->setEscape('o'); + $this->assertSame('o', $this->csv->getEscape()); + + $this->csv->setEscape('foo'); + } + + public function testEnclosure() + { + $this->expectException(InvalidArgumentException::class); + $this->csv->setEnclosure('o'); + $this->assertSame('o', $this->csv->getEnclosure()); + + $this->csv->setEnclosure('foo'); + } + + /** + * @dataProvider appliedFlagsProvider + */ + public function testAppliedFlags($flag, $fetch_count) + { + $path = __DIR__.'/data/tmp.txt'; + $obj = new SplFileObject($path, 'w+'); + $obj->fwrite("1st\n2nd\n"); + $obj->setFlags($flag); + $reader = Reader::createFromFileObject($obj); + $this->assertCount($fetch_count, $reader->select()->fetchAll()); + $reader = null; + $obj = null; + unlink($path); + } + + public function appliedFlagsProvider() + { + return [ + 'NONE' => [0, 2], + 'DROP_NEW_LINE' => [SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE, 2], + 'READ_AHEAD' => [SplFileObject::READ_AHEAD, 2], + 'SKIP_EMPTY' => [SplFileObject::SKIP_EMPTY, 2], + 'READ_AHEAD|DROP_NEW_LINE' => [SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE, 2], + 'READ_AHEAD|SKIP_EMPTY' => [SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY, 2], + 'DROP_NEW_LINE|SKIP_EMPTY' => [SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY, 2], + 'READ_AHEAD|DROP_NEW_LINE|SKIP_EMPTY' => [SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE | SplFileObject::SKIP_EMPTY, 2], + ]; + } + + public function testAddStreamFilter() + { + $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); + $csv->addStreamFilter('string.rot13'); + $csv->addStreamFilter('string.tolower'); + $csv->addStreamFilter('string.toupper'); + foreach ($csv as $row) { + $this->assertSame($row, ['WBUA', 'QBR', 'WBUA.QBR@RKNZCYR.PBZ']); + } + } + + public function testFailedAddStreamFilter() + { + $this->expectException(LogicException::class); + $csv = Writer::createFromFileObject(new SplTempFileObject()); + $this->assertFalse($csv->isStream()); + $csv->addStreamFilter('string.toupper'); + } + + public function testStreamFilterDetection() + { + $filtername = 'string.toupper'; + $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); + $this->assertFalse($csv->hasStreamFilter($filtername)); + $csv->addStreamFilter($filtername); + $this->assertTrue($csv->hasStreamFilter($filtername)); + } + + public function testClearAttachedStreamFilters() + { + $path = __DIR__.'/data/foo.csv'; + $csv = Reader::createFromPath($path); + $csv->addStreamFilter('string.toupper'); + $this->assertContains('JOHN', (string) $csv); + $csv = Reader::createFromPath($path); + $this->assertNotContains('JOHN', (string) $csv); + } + + public function testRemoveStreamFilters() + { + $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); + $this->assertFalse($csv->hasStreamFilter('string.tolower')); + } + + public function testSetStreamFilterWriterNewLine() + { + stream_filter_register(FilterReplace::FILTER_NAME.'*', FilterReplace::class); + $csv = Writer::createFromPath(__DIR__.'/data/newline.csv'); + $csv->addStreamFilter(FilterReplace::FILTER_NAME."\r\n:\n"); + $csv->insertOne([1, 'two', 3, "new\r\nline"]); + $this->assertContains("1,two,3,\"new\nline\"", (string) $csv); } } diff --git a/tests/FilterReplace.php b/tests/Lib/FilterReplace.php similarity index 97% rename from tests/FilterReplace.php rename to tests/Lib/FilterReplace.php index 1b198f1e..81a011e5 100644 --- a/tests/FilterReplace.php +++ b/tests/Lib/FilterReplace.php @@ -1,6 +1,6 @@ csv = null; } - /** - * @expectedException InvalidArgumentException - */ public function testColumsCountSetterGetter() { + $this->expectException(InvalidArgumentException::class); $consistency = new ColumnConsistencyValidator(); $this->assertSame(-1, $consistency->getColumnsCount()); $consistency->setColumnsCount(3); @@ -40,11 +40,9 @@ public function testColumsCountSetterGetter() $consistency->setColumnsCount(-3); } - /** - * @expectedException InvalidArgumentException - */ public function testColumsCountConsistency() { + $this->expectException(InsertionException::class); $consistency = new ColumnConsistencyValidator(); $this->csv->addValidator($consistency, 'consistency'); $this->csv->insertOne(['john', 'doe', 'john.doe@example.com']); @@ -54,11 +52,9 @@ public function testColumsCountConsistency() $this->csv->insertOne(['jane', 'jane.doe@example.com']); } - /** - * @expectedException InvalidArgumentException - */ public function testAutoDetectColumnsCount() { + $this->expectException(InsertionException::class); $consistency = new ColumnConsistencyValidator(); $this->csv->addValidator($consistency, 'consistency'); $consistency->autodetectColumnsCount(); diff --git a/tests/Plugin/NullValidatorTest.php b/tests/Plugin/NullValidatorTest.php index c6a14555..68e2c86a 100644 --- a/tests/Plugin/NullValidatorTest.php +++ b/tests/Plugin/NullValidatorTest.php @@ -2,17 +2,17 @@ namespace LeagueTest\Csv\Plugin; -use League\Csv\InvalidRowException; +use League\Csv\Exception\InsertionException; use League\Csv\Plugin\ForbiddenNullValuesValidator; use League\Csv\Writer; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; use SplFileObject; use SplTempFileObject; /** * @group validators */ -class NullValidatorTest extends PHPUnit_Framework_TestCase +class NullValidatorTest extends TestCase { private $csv; @@ -37,7 +37,7 @@ public function testInsertNullThrowsException() $this->csv->addValidator($validator, $validator_name); try { $this->csv->insertOne($expected); - } catch (InvalidRowException $e) { + } catch (InsertionException $e) { $this->assertSame($validator_name, $e->getName()); $this->assertSame($expected, $e->getData()); $this->assertSame('row validation failed', $e->getMessage()); diff --git a/tests/Plugin/SkipNullValuesFormatterTest.php b/tests/Plugin/SkipNullValuesFormatterTest.php index b94d6bca..464aa09c 100644 --- a/tests/Plugin/SkipNullValuesFormatterTest.php +++ b/tests/Plugin/SkipNullValuesFormatterTest.php @@ -4,14 +4,14 @@ use League\Csv\Plugin\SkipNullValuesFormatter; use League\Csv\Writer; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; use SplFileObject; use SplTempFileObject; /** * @group formatter */ -class SkipNullValuesFormatterTest extends PHPUnit_Framework_TestCase +class SkipNullValuesFormatterTest extends TestCase { private $csv; diff --git a/tests/ReaderTest.php b/tests/ReaderTest.php index 91feb088..276b18d6 100644 --- a/tests/ReaderTest.php +++ b/tests/ReaderTest.php @@ -2,15 +2,15 @@ namespace LeagueTest\Csv; +use League\Csv\Exception\InvalidArgumentException; use League\Csv\Reader; -use League\Csv\Writer; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; use SplTempFileObject; /** * @group reader */ -class ReaderTest extends PHPUnit_Framework_TestCase +class ReaderTest extends TestCase { private $csv; @@ -34,6 +34,16 @@ public function tearDown() $this->csv = null; } + public function testGetHeader() + { + $this->csv->setHeaderOffset(1); + $this->assertSame(1, $this->csv->getHeaderOffset()); + $this->assertSame($this->expected[1], $this->csv->getHeader()); + $this->csv->setHeaderOffset(null); + $this->assertNull($this->csv->getHeaderOffset()); + $this->assertSame([], $this->csv->getHeader()); + } + public function testCreateFromFileObjectPreserveFileObjectCsvControls() { $delimiter = "\t"; @@ -49,401 +59,31 @@ public function testCreateFromFileObjectPreserveFileObjectCsvControls() } } - public function testSetLimit() - { - $this->assertCount(1, $this->csv->setLimit(1)->fetchAll()); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testSetLimitThrowException() - { - $this->csv->setLimit(-4); - } - - public function testSetOffset() - { - $this->assertContains( - ['jane', 'doe', 'jane.doe@example.com'], - $this->csv->setOffset(1)->fetchAll() - ); - } - - /** - * @dataProvider intervalTest - */ - public function testInterval($offset, $limit, $expected) - { - $this->csv->setOffset($offset); - $this->csv->setLimit($limit); - $this->assertContains( - ['jane', 'doe', 'jane.doe@example.com'], - $this->csv->setOffset(1)->fetchAll() - ); - } - - public function intervalTest() - { - return [ - 'tooHigh' => [1, 10, 1], - 'normal' => [1, 1, 1], - ]; - } - - /** - * @expectedException \OutOfBoundsException - */ - public function testIntervalThrowException() - { - $this->csv->setOffset(1); - $this->csv->setLimit(0); - $this->csv->fetchAll(); - } - - - public function testFilter() - { - $func = function ($row) { - return !in_array('jane', $row); - }; - $this->csv->addFilter($func); - $this->assertNotContains(['jane', 'doe', 'jane.doe@example.com'], $this->csv->fetchAll()); - } - - public function testSortBy() - { - $func = function ($rowA, $rowB) { - return strcmp($rowA[0], $rowB[0]); - }; - $this->csv->addSortBy($func); - $this->assertSame(array_reverse($this->expected), $this->csv->fetchAll()); - } - - public function testFetchAssoc() - { - $keys = ['firstname', 'lastname', 'email']; - $this->csv->setHeader($keys); - $res = $this->csv->fetchAll(); - foreach ($res as $offset => $row) { - $this->assertSame($keys, array_keys($row)); - } - } - - public function testFetchColumnWithFieldName() - { - $keys = ['firstname', 'lastname', 'email']; - $this->csv->setHeader($keys); - $res = $this->csv->fetchColumn('firstname'); - $this->assertSame(['john', 'jane'], iterator_to_array($res, false)); - } - - public function testFetchColumnWithColumnIndex() - { - $keys = ['firstname', 'lastname', 'email']; - $this->csv->setHeader($keys); - $res = $this->csv->fetchColumn(0); - $this->assertSame(['john', 'jane'], iterator_to_array($res, false)); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testFetchColumnTriggersException() - { - $keys = ['firstname', 'lastname', 'email']; - $this->csv->setHeader($keys); - $res = $this->csv->fetchColumn(24); - $this->assertSame(['john', 'jane'], iterator_to_array($res, false)); - } - - public function testFetchAssocLessKeys() - { - $keys = ['firstname']; - $this->csv->setHeader($keys); - $res = $this->csv->fetchAll(); - $this->assertContains(['firstname' => 'john'], $res); - } - - public function testFetchAssocMoreKeys() - { - $keys = ['firstname', 'lastname', 'email', 'age']; - $this->csv->setHeader($keys); - $res = $this->csv->fetchAll(); - - $this->assertContains([ - 'firstname' => 'jane', - 'lastname' => 'doe', - 'email' => 'jane.doe@example.com', - 'age' => null, - ], $res); - } - - public function testFetchAssocWithRowIndex() - { - $arr = [ - ['A', 'B', 'C'], - [1, 2, 3], - ['D', 'E', 'F'], - [6, 7, 8], - ]; - - $tmp = new SplTempFileObject(); - foreach ($arr as $row) { - $tmp->fputcsv($row); - } - - $csv = Reader::createFromFileObject($tmp); - $csv->setHeader(2); - $this->assertContains(['D' => '6', 'E' => '7', 'F' => '8'], $csv->fetchAll()); - } - - /** - * @param $expected - * @dataProvider validBOMSequences - */ - public function testStripBOM($expected, $res) - { - $tmpFile = new SplTempFileObject(); - foreach ($expected as $row) { - $tmpFile->fputcsv($row); - } - $csv = Reader::createFromFileObject($tmpFile); - $this->assertSame($res, $csv->fetchAll()[0][0]); - } - - public function validBOMSequences() + public function testDetectDelimiterListWithInvalidRowLimit() { - return [ - 'withBOM' => [[ - [Reader::BOM_UTF16_LE.'john', 'doe', 'john.doe@example.com'], - ['jane', 'doe', 'jane.doe@example.com'], - ], 'john'], - 'withDoubleBOM' => [[ - [Reader::BOM_UTF16_LE.Reader::BOM_UTF16_LE.'john', 'doe', 'john.doe@example.com'], - ['jane', 'doe', 'jane.doe@example.com'], - ], Reader::BOM_UTF16_LE.'john'], - 'withoutBOM' => [[ - ['john', 'doe', 'john.doe@example.com'], - ['jane', 'doe', 'jane.doe@example.com'], - ], 'john'], - ]; + $this->expectException(InvalidArgumentException::class); + $this->csv->fetchDelimitersOccurrence([','], -4); } - public function testStripBOMWithFetchAssoc() + public function testDetectDelimiterListWithNoCSV() { - $source = [ - [Reader::BOM_UTF16_LE.'john', 'doe', 'john.doe@example.com'], - ['jane', 'doe', 'jane.doe@example.com'], - ]; - - $tmp = new SplTempFileObject(); - foreach ($source as $row) { - $tmp->fputcsv($row); - } - $csv = Reader::createFromFileObject($tmp); - $csv->setHeader(0); - $res = array_keys($csv->fetchAll()[0]); - - $this->assertSame('john', $res[0]); - } - - public function testFetchAssocWithoutBOM() - { - $source = [ - ['john', 'doe', 'john.doe@example.com'], - ['jane', 'doe', 'jane.doe@example.com'], - ]; - - $tmp = new SplTempFileObject(); - foreach ($source as $row) { - $tmp->fputcsv($row); - } - $csv = Reader::createFromFileObject($tmp); - $csv->setHeader(0); - $res = array_keys($csv->fetchAll()[0]); - - $this->assertSame('john', $res[0]); - } - - - public function testStripBOMWithEnclosureFetchAssoc() - { - $expected = ['parent name', 'parentA']; - $source = Reader::BOM_UTF8.'"parent name","child name","title" - "parentA","childA","titleA"'; - $csv = Reader::createFromString($source); - $csv->setHeader(0); - $expected = [ - ['parent name' => 'parentA', 'child name' => 'childA', 'title' => 'titleA'], - ]; - $this->assertSame($expected, $csv->fetchAll()); - } - - public function testStripBOMWithEnclosureFetchColumn() - { - $source = Reader::BOM_UTF8.'"parent name","child name","title" - "parentA","childA","titleA"'; - $csv = Reader::createFromString($source); - $this->assertContains('parent name', $csv->fetchColumn()); - } - - public function testStripBOMWithEnclosureFetchAll() - { - $source = Reader::BOM_UTF8.'"parent name","child name","title" - "parentA","childA","titleA"'; - $csv = Reader::createFromString($source); - $csv->setHeader(null); - $this->assertContains(['parent name', 'child name', 'title'], $csv->fetchAll()); - } - - public function testStripBOMWithEnclosureFetchOne() - { - $source = Reader::BOM_UTF8.'"parent name","child name","title" - "parentA","childA","titleA"'; - $csv = Reader::createFromString($source); - $csv->setHeader([]); - $expected = ['parent name', 'child name', 'title']; - $this->assertEquals($expected, $csv->fetchOne()); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testFetchAssocKeyFailure() - { - $this->csv->setHeader([['firstname', 'lastname', 'email', 'age']]); - } - - /** - * @param $offset - * @dataProvider invalidOffsetWithFetchAssoc - * @expectedException \InvalidArgumentException - */ - public function testFetchAssocWithInvalidOffset($offset) - { - $arr = [ - ['A', 'B', 'C'], - [1, 2, 3], - ['D', 'E', 'F'], - [6, 7, 8], - ]; - - $tmp = new SplTempFileObject(); - foreach ($arr as $row) { - $tmp->fputcsv($row); - } - - Reader::createFromFileObject($tmp)->setHeader($offset)->fetchAll(); - } - - public function invalidOffsetWithFetchAssoc() - { - return [ - 'negative' => [-23], - 'tooHigh' => [23], - ]; - } - - public function testFetchColumn() - { - $this->assertContains('john', $this->csv->fetchColumn(0)); - $this->assertContains('jane', $this->csv->fetchColumn()); - } - - public function testFetchColumnInconsistentColumnCSV() - { - $raw = [ - ['john', 'doe'], - ['lara', 'croft', 'lara.croft@example.com'], - ]; - $file = new SplTempFileObject(); - foreach ($raw as $row) { - $file->fputcsv($row); - } - $csv = Reader::createFromFileObject($file); - $res = $csv->fetchColumn(2); - $this->assertCount(1, $res); - } - - public function testFetchColumnEmptyCol() - { - $raw = [ - ['john', 'doe'], - ['lara', 'croft'], - ]; - - $file = new SplTempFileObject(); - foreach ($raw as $row) { - $file->fputcsv($row); - } + $file->fwrite("How are you today ?\nI'm doing fine thanks!"); $csv = Reader::createFromFileObject($file); - $res = $csv->fetchColumn(2); - $this->assertCount(0, $res); + $this->assertSame(['|' => 0], $csv->fetchDelimitersOccurrence(['toto', '|'], 5)); } - /** - * @expectedException \InvalidArgumentException - */ - public function testfetchOne() + public function testDetectDelimiterListWithInconsistentCSV() { - $this->assertSame($this->expected[0], $this->csv->fetchOne(0)); - $this->assertSame($this->expected[1], $this->csv->fetchOne(1)); - $this->assertSame([], $this->csv->fetchOne(35)); - $this->csv->fetchOne(-5); - } - - public function testGetWriter() - { - $this->assertInstanceOf(Writer::class, $this->csv->newWriter()); - } - - /** - * @dataProvider fetchPairsDataProvider - */ - public function testFetchPairsIteratorMode($key, $value, $expected) - { - $iterator = $this->csv->fetchPairs($key, $value); - foreach ($iterator as $key => $value) { - $res = current($expected); - $this->assertSame($value, $res[$key]); - next($expected); - } - } + $data = new SplTempFileObject(); + $data->setCsvControl(';'); + $data->fputcsv(['toto', 'tata', 'tutu']); + $data->setCsvControl('|'); + $data->fputcsv(['toto', 'tata', 'tutu']); + $data->fputcsv(['toto', 'tata', 'tutu']); + $data->fputcsv(['toto', 'tata', 'tutu']); - public function fetchPairsDataProvider() - { - return [ - 'default values' => [ - 'key' => 0, - 'value' => 1, - 'expected' => [ - ['john' => 'doe'], - ['jane' => 'doe'], - ], - ], - 'changed key order' => [ - 'key' => 1, - 'value' => 0, - 'expected' => [ - ['doe' => 'john'], - ['doe' => 'jane'], - ], - ], - ]; - } - - public function testFetchPairsWithInvalidOffset() - { - $this->assertCount(0, iterator_to_array($this->csv->fetchPairs(10, 1), true)); - } - - public function testFetchPairsWithInvalidValue() - { - $res = $this->csv->fetchPairs(0, 15); - foreach ($res as $value) { - $this->assertNull($value); - } + $csv = Reader::createFromFileObject($data); + $this->assertSame(['|' => 12, ';' => 4], $csv->fetchDelimitersOccurrence(['|', ';'], 5)); } } diff --git a/tests/RecordSetTest.php b/tests/RecordSetTest.php new file mode 100644 index 00000000..4af20bc4 --- /dev/null +++ b/tests/RecordSetTest.php @@ -0,0 +1,580 @@ +expected as $row) { + $tmp->fputcsv($row); + } + + $this->csv = Reader::createFromFileObject($tmp); + } + + public function tearDown() + { + $this->csv = null; + } + + public function testSetLimit() + { + $stmt = (new Statement())->limit(1); + + $this->assertCount(1, $stmt->process($this->csv)->fetchAll()); + } + + public function testCountable() + { + $stmt = (new Statement())->limit(1); + $res = $stmt->process($this->csv); + $this->assertCount(1, $res); + $this->assertSame(iterator_to_array($res, false), $res->fetchAll()); + } + + public function testToHTML() + { + $this->assertContains('csv->select()->toHTML()); + } + + public function testAddHeaderToHTMLExport() + { + $this->csv->setHeaderOffset(0); + $res = $this->csv->select(); + $this->assertContains('jane', $res->toHTML()); + $this->csv->setHeaderOffset(null); + $this->assertContains('jane', $this->csv->select()->toHTML()); + $res->preserveOffset(true); + $this->assertContains('setHeaderOffset(0); + $expected = [ + 0 => ['parent name' => 'parentA', 'child name' => 'childA', 'title' => 'titleA'], + ]; + $this->assertSame($expected, $csv->select()->fetchAll()); + } + + + public function testPreserveOffset() + { + $expected = ['parent name', 'parentA']; + $source = Reader::BOM_UTF8.'"parent name","child name","title" + "parentA","childA","titleA"'; + $csv = Reader::createFromString($source); + $csv->setHeaderOffset(0); + $expectedNoOffset = [ + 0 => ['parent name' => 'parentA', 'child name' => 'childA', 'title' => 'titleA'], + ]; + $expectedWithOffset = [ + 1 => ['parent name' => 'parentA', 'child name' => 'childA', 'title' => 'titleA'], + ]; + $res = $csv->select(); + $res->preserveOffset(false); + $this->assertSame($expectedNoOffset, $res->fetchAll()); + $res->preserveOffset(true); + $this->assertSame($expectedWithOffset, $res->fetchAll()); + } + + + + public function testStripBOMWithEnclosureFetchColumn() + { + $source = Reader::BOM_UTF8.'"parent name","child name","title" + "parentA","childA","titleA"'; + $csv = Reader::createFromString($source); + $this->assertContains('parent name', $csv->select()->fetchColumn()); + } + + public function testStripBOMWithEnclosureFetchAll() + { + $source = Reader::BOM_UTF8.'"parent name","child name","title" + "parentA","childA","titleA"'; + $csv = Reader::createFromString($source); + $csv->setHeaderOffset(null); + $this->assertContains(['parent name', 'child name', 'title'], $csv->select()->fetchAll()); + } + + public function testStripBOMWithEnclosureFetchOne() + { + $source = Reader::BOM_UTF8.'"parent name","child name","title" + "parentA","childA","titleA"'; + $csv = Reader::createFromString($source); + $csv->setHeaderOffset(null); + $expected = ['parent name', 'child name', 'title']; + $this->assertEquals($expected, $csv->select()->fetchOne()); + } + + public function testFetchAssocKeyFailure() + { + $this->expectException(InvalidArgumentException::class); + (new Statement())->columns(['firstname', 'firstname', 'lastname', 'email', 'age']); + } + + /** + * @param $offset + * @dataProvider invalidOffsetWithFetchAssoc + */ + public function testFetchAssocWithInvalidOffset($offset) + { + $arr = [ + ['A', 'B', 'C'], + [1, 2, 3], + ['D', 'E', 'F'], + [6, 7, 8], + ]; + + $tmp = new SplTempFileObject(); + foreach ($arr as $row) { + $tmp->fputcsv($row); + } + + $this->expectException(InvalidArgumentException::class); + Reader::createFromFileObject($tmp)->setHeaderOffset($offset)->select()->fetchAll(); + } + + public function invalidOffsetWithFetchAssoc() + { + return [ + 'negative' => [-23], + 'tooHigh' => [23], + ]; + } + + public function testFetchColumn() + { + $this->assertContains('john', $this->csv->select()->fetchColumn(0)); + $this->assertContains('jane', $this->csv->select()->fetchColumn()); + } + + public function testFetchColumnInconsistentColumnCSV() + { + $raw = [ + ['john', 'doe'], + ['lara', 'croft', 'lara.croft@example.com'], + ]; + + $file = new SplTempFileObject(); + foreach ($raw as $row) { + $file->fputcsv($row); + } + $csv = Reader::createFromFileObject($file); + $res = $csv->select()->fetchColumn(2); + $this->assertCount(1, iterator_to_array($res)); + } + + public function testFetchColumnEmptyCol() + { + $raw = [ + ['john', 'doe'], + ['lara', 'croft'], + ]; + + $file = new SplTempFileObject(); + foreach ($raw as $row) { + $file->fputcsv($row); + } + $csv = Reader::createFromFileObject($file); + $res = $csv->select()->fetchColumn(2); + $this->assertCount(0, iterator_to_array($res)); + } + + public function testfetchOne() + { + $this->assertSame($this->expected[0], $this->csv->select()->fetchOne(0)); + $this->assertSame($this->expected[1], $this->csv->select()->fetchOne(1)); + $this->assertSame([], $this->csv->select()->fetchOne(35)); + } + + public function testFetchOneTriggersException() + { + $this->expectException(InvalidArgumentException::class); + $this->csv->select()->fetchOne(-5); + } + + /** + * @dataProvider fetchPairsDataProvider + */ + public function testFetchPairsIteratorMode($key, $value, $expected) + { + $iterator = $this->csv->select()->fetchPairs($key, $value); + foreach ($iterator as $key => $value) { + $res = current($expected); + $this->assertSame($value, $res[$key]); + next($expected); + } + } + + public function fetchPairsDataProvider() + { + return [ + 'default values' => [ + 'key' => 0, + 'value' => 1, + 'expected' => [ + ['john' => 'doe'], + ['jane' => 'doe'], + ], + ], + 'changed key order' => [ + 'key' => 1, + 'value' => 0, + 'expected' => [ + ['doe' => 'john'], + ['doe' => 'jane'], + ], + ], + ]; + } + + public function testFetchPairsWithInvalidOffset() + { + $this->assertCount(0, iterator_to_array($this->csv->select()->fetchPairs(10, 1), true)); + } + + public function testFetchPairsWithInvalidValue() + { + $res = $this->csv->select()->fetchPairs(0, 15); + foreach ($res as $value) { + $this->assertNull($value); + } + } + + /** + * @param $rawCsv + * + * @dataProvider getIso8859Csv + */ + public function testJsonSerializeAffectedByReaderOptions($rawCsv) + { + $csv = Reader::createFromString($rawCsv); + $res = (new Statement())->offset(799)->limit(50)->process($csv); + $res->setConversionInputEncoding('iso-8859-15'); + + json_encode($res); + $this->assertEquals(JSON_ERROR_NONE, json_last_error()); + } + + public static function getIso8859Csv() + { + return [[file_get_contents(__DIR__.'/data/prenoms.csv')]]; + } + + public function testEncodingTriggersException() + { + $this->expectException(InvalidArgumentException::class); + $this->csv->select()->setConversionInputEncoding(''); + } + + public function testGetHeader() + { + $stmt = new Statement(); + $result = $this->csv->select($stmt); + $this->assertSame([], $result->getColumnNames()); + $this->assertSame('', $result->getColumnName(3)); + } + + public function testGetComputedHeader() + { + $this->csv->setHeaderOffset(0); + $stmt = new Statement(); + $result = $this->csv->select($stmt); + $this->assertSame($this->expected[0], $result->getColumnNames()); + $this->assertSame($this->expected[0][0], $result->getColumnName(0)); + } + + public function testGetComputedHeaderWithSpecifiedHeader() + { + $expected = ['john' => 'prenom', 'doe' => 'lastname', 'john.doe@example.com' => 'email']; + $this->csv->setHeaderOffset(0); + $stmt = new Statement(); + $result = $this->csv->select($stmt->columns($expected)); + $this->assertSame(array_values($expected), $result->getColumnNames()); + } + + public function testColumnsThrowException() + { + $this->expectException(RuntimeException::class); + $stmt = (new Statement()) + ->columns(['john' => 'prenom', 'doe' => 'lastname', 'john.doe@example.com' => 'email']); + $stmt->process($this->csv); + } +} diff --git a/tests/StreamFilterTest.php b/tests/StreamFilterTest.php deleted file mode 100644 index d1d48a35..00000000 --- a/tests/StreamFilterTest.php +++ /dev/null @@ -1,152 +0,0 @@ -assertTrue($csv->hasStreamFilter('string.rot13')); - $this->assertSame(STREAM_FILTER_WRITE, $csv->getStreamFilterMode()); - } - - public function testInitStreamFilterWithReaderStream() - { - $filter = 'php://filter/read=string.toupper/resource='.__DIR__.'/data/foo.csv'; - $csv = Reader::createFromPath($filter); - $this->assertTrue($csv->hasStreamFilter('string.toupper')); - $this->assertSame(STREAM_FILTER_READ, $csv->getStreamFilterMode()); - } - - public function testInitStreamFilterWithBothStream() - { - $filter = 'php://filter/string.toupper/resource='.__DIR__.'/data/foo.csv'; - $csv = Reader::createFromPath($filter); - $this->assertTrue($csv->hasStreamFilter('string.toupper')); - $this->assertSame(STREAM_FILTER_ALL, $csv->getStreamFilterMode()); - } - - /** - * @expectedException LogicException - */ - public function testInitStreamFilterWithSplFileObject() - { - Reader::createFromFileObject(new SplFileObject(__DIR__.'/data/foo.csv'))->getStreamFilterMode(); - } - - public function testappendStreamFilter() - { - $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); - $csv->appendStreamFilter('string.toupper'); - foreach ($csv->getIterator() as $row) { - $this->assertSame($row, ['JOHN', 'DOE', 'JOHN.DOE@EXAMPLE.COM']); - } - } - - /** - * @expectedException LogicException - */ - public function testFailPrependStreamFilter() - { - $csv = Reader::createFromFileObject(new SplTempFileObject()); - $this->assertFalse($csv->isActiveStreamFilter()); - $csv->prependStreamFilter('string.toupper'); - } - - /** - * @expectedException LogicException - */ - public function testFailedapppendStreamFilter() - { - $csv = Writer::createFromFileObject(new SplTempFileObject()); - $this->assertFalse($csv->isActiveStreamFilter()); - $csv->appendStreamFilter('string.toupper'); - } - - /** - * @expectedException OutOfBoundsException - */ - public function testSetInvalidStreamFilterMode() - { - $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); - $csv->setStreamFilterMode(34); - } - - public function testClearAttachedStreamFilters() - { - $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); - $csv->appendStreamFilter('string.tolower'); - $csv->appendStreamFilter('string.rot13'); - $csv->appendStreamFilter('string.toupper'); - $csv->clearStreamFilter(); - $this->assertFalse($csv->hasStreamFilter('string.rot13')); - } - - public function testAddMultipleStreamFilter() - { - $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); - $csv->appendStreamFilter('string.tolower'); - $csv->prependStreamFilter('string.rot13'); - $csv->appendStreamFilter('string.toupper'); - $this->assertTrue($csv->hasStreamFilter('string.tolower')); - $csv->removeStreamFilter('string.tolower'); - $this->assertFalse($csv->hasStreamFilter('string.tolower')); - foreach ($csv as $row) { - $this->assertSame($row, ['WBUA', 'QBR', 'WBUA.QBR@RKNZCYR.PBZ']); - } - } - - public function testSwithingStreamFilterMode() - { - $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); - $csv->appendStreamFilter('string.toupper'); - $this->assertSame(STREAM_FILTER_READ, $csv->getStreamFilterMode()); - $csv->setStreamFilterMode(STREAM_FILTER_WRITE); - $this->assertSame(STREAM_FILTER_WRITE, $csv->getStreamFilterMode()); - foreach ($csv as $row) { - $this->assertSame($row, ['john', 'doe', 'john.doe@example.com']); - } - } - - public function testGetFilterPath() - { - $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); - $csv->appendStreamFilter('string.rot13'); - $csv->prependStreamFilter('string.toupper'); - $this->assertFalse($csv->getIterator()->getRealPath()); - } - - public function testGetFilterPathWithAllStream() - { - $filter = 'php://filter/string.toupper/resource='.__DIR__.'/data/foo.csv'; - $csv = Reader::createFromPath($filter); - $this->assertFalse($csv->getIterator()->getRealPath()); - } - - public function testSetStreamFilterWriterNewLine() - { - stream_filter_register(FilterReplace::FILTER_NAME.'*', FilterReplace::class); - $csv = Writer::createFromPath(__DIR__.'/data/newline.csv'); - $csv->appendStreamFilter(FilterReplace::FILTER_NAME."\r\n:\n"); - $this->assertTrue($csv->hasStreamFilter(FilterReplace::FILTER_NAME."\r\n:\n")); - $csv->insertOne([1, 'two', 3, "new\r\nline"]); - } - - public function testUrlEncodeFilterParameters() - { - $csv = Reader::createFromPath(__DIR__.'/data/foo.csv'); - $csv->appendStreamFilter('convert.iconv.UTF-8/ASCII//TRANSLIT'); - $this->assertCount(1, $csv->fetchAll()); - } -} diff --git a/tests/StreamIteratorTest.php b/tests/StreamIteratorTest.php index da03d101..2def5854 100644 --- a/tests/StreamIteratorTest.php +++ b/tests/StreamIteratorTest.php @@ -2,69 +2,60 @@ namespace LeagueTest\Csv; +use League\Csv\Exception\InvalidArgumentException; use League\Csv\Reader; use League\Csv\StreamIterator; use League\Csv\Writer; -use PHPUnit_Framework_TestCase; +use LogicException; +use PHPUnit\Framework\TestCase; use SplFileObject; /** * @group stream */ -class StreamIteratorTest extends PHPUnit_Framework_TestCase +class StreamIteratorTest extends TestCase { - protected $csv; - - public function setUp() - { - $this->csv = Reader::createFromStream(fopen(__DIR__.'/data/prenoms.csv', 'r')); - $this->csv->setDelimiter(';'); - } - - public function tearDown() - { - $this->csv = null; - } - - /** - * @expectedException InvalidArgumentException - */ - public function testCreateFromStreamWithInvalidParameter() + public function testCloningIsForbidden() { - $path = __DIR__.'/data/foo.csv'; - Reader::createFromStream($path); + $this->expectException(LogicException::class); + $toto = clone new StreamIterator(fopen('php://temp', 'r+')); } - public function testStreamIteratorIterator() - { - $this->assertCount(1, $this->csv->setLimit(1)->fetchAll()); - } - - /** - * @expectedException InvalidArgumentException - */ public function testCreateStreamWithInvalidParameter() { + $this->expectException(InvalidArgumentException::class); $path = __DIR__.'/data/foo.csv'; new StreamIterator($path); } - /** - * @expectedException InvalidArgumentException - */ public function testCreateStreamWithNonSeekableStream() { + $this->expectException(InvalidArgumentException::class); new StreamIterator(fopen('php://stdin', 'r')); } - /** - * @expectedException InvalidArgumentException - */ public function testSetCsvControlTriggersException() { + $this->expectException(InvalidArgumentException::class); (new StreamIterator(fopen('php://temp', 'r+')))->setCsvControl('toto'); } + public function testIterator() + { + $expected = [ + ['john', 'doe', 'john.doe@example.com'], + ['jane', 'doe', 'jane.doe@example.com'], + ]; + $fp = fopen('php://temp', 'r+'); + foreach ($expected as $row) { + fputcsv($fp, $row); + } + $stream = new StreamIterator($fp); + $stream->setFlags(SplFileObject::READ_CSV); + $this->assertCount(3, iterator_to_array($stream)); + } + + /** * @param $expected * @dataProvider validBOMSequences @@ -77,7 +68,7 @@ public function testStripBOM($expected, $res) } $csv = Reader::createFromStream($fp); - $this->assertSame($res, $csv->fetchAll()[0][0]); + $this->assertSame($res, $csv->select()->fetchAll()[0][0]); } public function validBOMSequences() @@ -98,40 +89,42 @@ public function validBOMSequences() ]; } - public function testGetReader() + public function testToString() { $fp = fopen('php://temp', 'r+'); $csv = Writer::createFromStream($fp); $expected = [ ['john', 'doe', 'john.doe@example.com'], - ['john', 'doe', 'john.doe@example.com'], + ['jane', 'doe', 'jane.doe@example.com'], ]; foreach ($expected as $row) { $csv->insertOne($row); } - $reader = $csv->newReader(); - $this->assertSame(['john', 'doe', 'john.doe@example.com'], $reader->fetchOne(0)); + $expected = "john,doe,john.doe@example.com\njane,doe,jane.doe@example.com\n"; + $this->assertSame($expected, $csv->__toString()); } - public function testToString() + public function testPrependFilter() { $fp = fopen('php://temp', 'r+'); - $csv = Writer::createFromStream($fp); + $csv = new StreamIterator($fp); $expected = [ ['john', 'doe', 'john.doe@example.com'], ['jane', 'doe', 'jane.doe@example.com'], ]; + $csv->prependFilter('string.toupper', STREAM_FILTER_WRITE); foreach ($expected as $row) { - $csv->insertOne($row); + $csv->fputcsv($row); + } + $csv->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY); + foreach ($csv as $key => $row) { + $this->assertSame(array_map('strtoupper', $expected[$key]), $row); } - - $expected = "john,doe,john.doe@example.com\njane,doe,jane.doe@example.com\n"; - $this->assertSame($expected, $csv->__toString()); } public function testIteratorWithLines() @@ -164,11 +157,9 @@ public function testCustomNewline() $this->assertSame("jane,doe\r\n", (string) $csv); } - /** - * @expectedException \LogicException - */ public function testStreamIteratorSeekThrowException() { + $this->expectException(LogicException::class); $fp = fopen('php://temp', 'r+'); $expected = [ ['john', 'doe', 'john.doe@example.com'], diff --git a/tests/WriterTest.php b/tests/WriterTest.php index d8a9b2ce..ed508148 100644 --- a/tests/WriterTest.php +++ b/tests/WriterTest.php @@ -3,8 +3,10 @@ namespace LeagueTest\Csv; use ArrayIterator; +use League\Csv\Exception\InsertionException; +use League\Csv\Exception\InvalidArgumentException; use League\Csv\Writer; -use PHPUnit_Framework_TestCase; +use PHPUnit\Framework\TestCase; use SplFileObject; use SplTempFileObject; use stdClass; @@ -12,7 +14,7 @@ /** * @group writer */ -class WriterTest extends PHPUnit_Framework_TestCase +class WriterTest extends TestCase { private $csv; @@ -29,13 +31,21 @@ public function tearDown() $this->csv = null; } + public function testflushThreshold() + { + $this->expectException(InvalidArgumentException::class); + $this->csv->setFlushThreshold(12); + $this->assertSame(12, $this->csv->getFlushThreshold()); + $this->csv->setFlushThreshold(0); + } + public function testSupportsStreamFilter() { $csv = Writer::createFromPath(__DIR__.'/data/foo.csv'); - $this->assertTrue($csv->isActiveStreamFilter()); - $csv->appendStreamFilter('string.toupper'); + $this->assertTrue($csv->isStream()); + $csv->setFlushThreshold(1); + $csv->addStreamFilter('string.toupper'); $csv->insertOne(['jane', 'doe', 'jane@example.com']); - $this->assertFalse($csv->isActiveStreamFilter()); $this->assertContains('JANE,DOE,JANE@EXAMPLE.COM', (string) $csv); } @@ -57,11 +67,20 @@ public function testInsertNormalFile() $this->assertContains('jane,doe,jane.doe@example.com', (string) $csv); } - /** - * @expectedException InvalidArgumentException - */ + public function testInsertThrowsExceptionOnError() + { + try { + $expected = ['jane', 'doe', 'jane.doe@example.com']; + $csv = Writer::createFromPath(__DIR__.'/data/foo.csv', 'r'); + $csv->insertOne($expected); + } catch (InsertionException $e) { + $this->assertSame($e->getData(), $expected); + } + } + public function testFailedSaveWithWrongType() { + $this->expectException(InvalidArgumentException::class); $this->csv->insertAll(new stdClass()); } @@ -88,19 +107,6 @@ public function dataToSave() ]; } - public function testGetReader() - { - $expected = [ - ['john', 'doe', 'john.doe@example.com'], - ]; - foreach ($expected as $row) { - $this->csv->insertOne($row); - } - - $reader = $this->csv->newReader(); - $this->assertSame(['john', 'doe', 'john.doe@example.com'], $reader->fetchOne(0)); - } - public function testCustomNewline() { $this->assertSame("\n", $this->csv->getNewline()); @@ -112,10 +118,12 @@ public function testCustomNewline() public function testAddValidationRules() { $func = function (array $row) { - return true; + return false; }; + $this->expectException(InsertionException::class); $this->csv->addValidator($func, 'func1'); + $this->csv->insertOne(['jane', 'doe']); } public function testFormatterRules() @@ -128,14 +136,4 @@ public function testFormatterRules() $this->csv->insertOne(['jane', 'doe']); $this->assertSame("JANE,DOE\n", (string) $this->csv); } - - public function testConversionWithWriter() - { - $this->csv->insertAll([ - ['john', 'doe', 'john.doe@example.com'], - ['jane', 'doe', 'jane.doe@example.com'], - ['toto', 'le', 'herisson'], - ]); - $this->assertStringStartsWith('csv->newReader()->toHTML()); - } }