diff --git a/.gitattributes b/.gitattributes index 516c22d..b179359 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,8 +3,6 @@ tests/ export-ignore .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore -.scrutinizer.yml export-ignore -.travis.yml export-ignore CONTRIBUTING.md export-ignore phpspec.yml.dist export-ignore phpunit.xml.dist export-ignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f7b8104 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: composer + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + versioning-strategy: widen diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f1ea42..8a84560 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,48 +6,52 @@ on: pull_request: branches: [ master ] +permissions: + contents: read + jobs: test: strategy: + fail-fast: false matrix: - php-versions: [ '7.4', '8.0', '8.1' ] - include: - - php-versions: '7.4' - coverage: pcov - composer-prefer: '--prefer-lowest --prefer-stable' - phpunit-flags: '--coverage-clover coverage.xml' + php-versions: [ '8.2', '8.3', '8.4', '8.5' ] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 - name: Set up PHP uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - coverage: ${{ matrix.coverage }} + coverage: none + tools: composer:v2 + - - name: Validate composer.json and composer.lock - run: composer validate --strict + - name: Validate Composer metadata + run: | + if [ -f composer.lock ]; then + composer validate --strict --no-interaction + else + composer validate --strict --no-check-lock --no-interaction + fi - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v2 + uses: actions/cache@v6 with: path: vendor - key: ${{ runner.os }}-composer-${{ matrix.composer-prefer }}$-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.json', '**/composer.lock') }} restore-keys: | - ${{ runner.os }}-composer-${{ matrix.composer-prefer }}- + ${{ runner.os }}-composer-${{ matrix.php-versions }}- - name: Install dependencies - run: composer update --prefer-dist --no-progress ${{ matrix.composer-prefer }} + run: | + if [ -f composer.lock ]; then + composer install --prefer-dist --no-progress --no-interaction + else + composer update --prefer-dist --no-progress --no-interaction + fi - name: Run test suite - run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} - - - name: Upload coverage - if: matrix.coverage - run: | - wget https://scrutinizer-ci.com/ocular.phar - php ocular.phar code-coverage:upload --format=php-clover coverage.xml --revision=${{ github.event.pull_request.head.sha || github.sha }} + run: vendor/bin/phpunit diff --git a/.scrutinizer.yml b/.scrutinizer.yml deleted file mode 100644 index acbda73..0000000 --- a/.scrutinizer.yml +++ /dev/null @@ -1,7 +0,0 @@ -checks: - php: - code_rating: true - duplication: true - -tools: - external_code_coverage: true diff --git a/README.md b/README.md index a6f48fe..e8ed089 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@ # portphp/csv [![Latest Version](https://img.shields.io/github/release/portphp/csv.svg?style=flat-square)](https://github.com/portphp/csv/releases) -[![Build Status](https://travis-ci.org/portphp/csv.svg)](https://travis-ci.org/portphp/csv) -[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/portphp/csv/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/portphp/csv/?branch=master) -[![Code Coverage](https://scrutinizer-ci.com/g/portphp/csv/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/portphp/csv/?branch=master) +[![CI](https://github.com/portphp/csv/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/portphp/csv/actions/workflows/test.yml) +[![PHP Version](https://img.shields.io/packagist/php-v/portphp/csv.svg?style=flat-square)](https://packagist.org/packages/portphp/csv) -CVS reader and writer for [Port](https://github.com/portphp). +**Requirements:** PHP ^8.2 (tested on 8.2–8.5). + + +CSV reader and writer for [Port](https://github.com/portphp). ## Installation @@ -22,7 +24,7 @@ of the Composer documentation. ## Documentation -Documentation is available at https://portphp.readthedocs.org. +Documentation is available at https://portphp.readthedocs.io. ## Issues and feature requests diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md new file mode 100644 index 0000000..ff43082 --- /dev/null +++ b/UPGRADE-3.0.md @@ -0,0 +1,4 @@ +# Upgrade from 2.x to 3.0 + +- Minimum PHP is **8.2** (`^8.2`). +- Requires `portphp/portphp` **^2.0**. diff --git a/composer.json b/composer.json index a98b0f6..c2702b1 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "support": { "issues": "https://github.com/portphp/portphp/issues", "source": "https://github.com/portphp/csv", - "docs": "https://portphp.readthedocs.org" + "docs": "https://portphp.readthedocs.io" }, "require": { "portphp/portphp": "dev-master" @@ -31,7 +31,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.6" }, "autoload-dev": { "psr-4": { @@ -40,7 +40,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.0.x-dev" } } } diff --git a/src/CsvReader.php b/src/CsvReader.php index df86afb..e015d94 100644 --- a/src/CsvReader.php +++ b/src/CsvReader.php @@ -118,8 +118,11 @@ public function current(): ?array return $this->file->current(); } - // Since the CSV has column headers use them to construct an associative array for the columns in this line - do { + // Since the CSV has column headers use them to construct an associative array for the columns in this line. + // Check valid() before current(): SplFileObject::current() returns false at EOF, and a do-while would + // still enter the body once when the iterator is already invalid (e.g. OneToManyReader calls current() + // after next() past the last detail row), causing count(false) TypeError on PHP 8+. + while ($this->valid()) { $line = $this->file->current(); $columnHeaders = $this->columnHeaders; @@ -155,7 +158,7 @@ public function current(): ?array $this->errors[$this->key()] = $line; $this->next(); } - } while($this->valid()); + } return null; } @@ -377,15 +380,17 @@ protected function readHeaderRow($rowNumber) */ protected function incrementHeaders(array $headers) { + $counts = []; $incrementedHeaders = []; - foreach (array_count_values($headers) as $header => $count) { - if ($count > 1) { + + foreach ($headers as $header) { + + if (!isset($counts[$header])) { + $counts[$header] = 0; $incrementedHeaders[] = $header; - for ($i = 1; $i < $count; $i++) { - $incrementedHeaders[] = $header . $i; - } } else { - $incrementedHeaders[] = $header; + $counts[$header]++; + $incrementedHeaders[] = $header . $counts[$header]; } } diff --git a/src/CsvReaderFactory.php b/src/CsvReaderFactory.php index f985c30..abc56ca 100644 --- a/src/CsvReaderFactory.php +++ b/src/CsvReaderFactory.php @@ -2,6 +2,7 @@ namespace Port\Csv; +use Port\Reader; use Port\Reader\ReaderFactory; /** @@ -62,7 +63,7 @@ public function __construct( * * @return CsvReader */ - public function getReader(\SplFileObject $file) + public function getReader(\SplFileObject $file): Reader { $reader = new CsvReader($file, $this->delimiter, $this->enclosure, $this->escape); diff --git a/src/CsvWriter.php b/src/CsvWriter.php index 31fa52d..90b1a6c 100644 --- a/src/CsvWriter.php +++ b/src/CsvWriter.php @@ -33,14 +33,20 @@ class CsvWriter extends AbstractStreamWriter */ protected $prependHeaderRow; + /** + * @var string + */ + private $escape; + /** * @param string $delimiter The delimiter * @param string $enclosure The enclosure * @param resource $stream * @param boolean $utf8Encoding * @param boolean $prependHeaderRow + * @param string $escape The escape character (pass '' for PHP 8.4+ preferred "no escape" behavior) */ - public function __construct($delimiter = ',', $enclosure = '"', $stream = null, $utf8Encoding = false, $prependHeaderRow = false) + public function __construct($delimiter = ',', $enclosure = '"', $stream = null, $utf8Encoding = false, $prependHeaderRow = false, string $escape = '\\') { parent::__construct($stream); @@ -48,12 +54,13 @@ public function __construct($delimiter = ',', $enclosure = '"', $stream = null, $this->enclosure = $enclosure; $this->utf8Encoding = $utf8Encoding; $this->prependHeaderRow = $prependHeaderRow; + $this->escape = $escape; } /** * {@inheritdoc} */ - public function prepare() + public function prepare(): void { if ($this->utf8Encoding) { fprintf($this->getStream(), chr(0xEF) . chr(0xBB) . chr(0xBF)); @@ -67,9 +74,9 @@ public function writeItem(mixed $item) { if ($this->prependHeaderRow && 1 == $this->row++) { $headers = array_keys($item); - fputcsv($this->getStream(), $headers, $this->delimiter, $this->enclosure); + fputcsv($this->getStream(), $headers, $this->delimiter, $this->enclosure, $this->escape); } - fputcsv($this->getStream(), $item, $this->delimiter, $this->enclosure); + fputcsv($this->getStream(), $item, $this->delimiter, $this->enclosure, $this->escape); } } diff --git a/tests/CsvReaderTest.php b/tests/CsvReaderTest.php index acf04ae..156f56a 100644 --- a/tests/CsvReaderTest.php +++ b/tests/CsvReaderTest.php @@ -265,6 +265,97 @@ public function testMaximumNesting() } } + /** + * When the iterator is already at EOF, current() must return null rather than + * calling count() on SplFileObject's false (do-while entered once while invalid). + * + * @see https://github.com/portphp/csv/pull/3 + */ + public function testCurrentAtEndOfFileWithHeadersReturnsNull() + { + $file = new \SplTempFileObject(); + $file->fwrite("id,name\n1,Alice\n2,Bob\n"); + $file->rewind(); + + $reader = new CsvReader($file); + $reader->setHeaderRowNumber(0); + + // Exhaust the iterator + iterator_to_array($reader); + + $this->assertFalse($reader->valid()); + $this->assertNull($reader->current()); + } + + /** + * getRow() past the last line should not TypeError on count(false). + * + * @see https://github.com/portphp/csv/pull/3 + */ + public function testGetRowPastEndWithHeadersReturnsNull() + { + $file = new \SplTempFileObject(); + $file->fwrite("id,name\n1,Alice\n"); + $file->rewind(); + + $reader = new CsvReader($file); + $reader->setHeaderRowNumber(0); + + $this->assertNull($reader->getRow(99)); + } + + /** + * OneToManyReader calls rightReader->current() after next() past the last + * detail row. Without checking valid() first, CsvReader::current() hit + * count(false) and broke joins on the last master row. + * + * This is the real-world failure reported against PR #3. + * + * @see https://github.com/portphp/csv/pull/3 + * @see https://github.com/portphp/csv/pull/3#issuecomment-769855101 + */ + public function testOneToManyReaderConsumesLastDetailRowWithoutError() + { + $masterFile = new \SplTempFileObject(); + $masterFile->fwrite("id,name\n1,Alice\n2,Bob\n"); + $masterFile->rewind(); + $masterReader = new CsvReader($masterFile); + $masterReader->setHeaderRowNumber(0); + + $detailFile = new \SplTempFileObject(); + $detailFile->fwrite("id,item\n1,apple\n1,banana\n2,carrot\n"); + $detailFile->rewind(); + $detailReader = new CsvReader($detailFile); + $detailReader->setHeaderRowNumber(0); + + $reader = new \Port\Reader\OneToManyReader( + $masterReader, + $detailReader, + 'items', + 'id', + 'id' + ); + + $rows = iterator_to_array($reader); + + $this->assertCount(2, $rows); + $this->assertEquals('Alice', $rows[1]['name']); + $this->assertEquals( + array( + array('id' => '1', 'item' => 'apple'), + array('id' => '1', 'item' => 'banana'), + ), + $rows[1]['items'] + ); + $this->assertEquals('Bob', $rows[2]['name']); + $this->assertEquals( + array( + array('id' => '2', 'item' => 'carrot'), + ), + $rows[2]['items'] + ); + } + protected function getReader($filename) { $file = new \SplFileObject(__DIR__.'/fixtures/'.$filename); diff --git a/tests/CsvWriterTest.php b/tests/CsvWriterTest.php index 6556ba9..287109d 100644 --- a/tests/CsvWriterTest.php +++ b/tests/CsvWriterTest.php @@ -97,4 +97,79 @@ public function testHeaderPrependedWhenOptionSetToTrue() ); $writer->finish(); } + + /** + * Proves escape is applied: default '\\' and empty-string escape produce different CSV + * for a field that contains a backslash before a quote. + * + * Also exercises that fputcsv receives an explicit $escape argument, which is required + * on PHP 8.4+ (omitting it is deprecated; phpunit.xml converts deprecations to exceptions). + */ + public function testEscapeParameterAffectsOutput() + { + $item = array('a\\"b'); + + $defaultWriter = new CsvWriter(',', '"', fopen('php://temp', 'r+')); + $defaultWriter->setCloseStreamOnFinish(false); + $defaultWriter->prepare(); + $defaultWriter->writeItem($item); + $defaultOutput = $this->readWriterContents($defaultWriter); + + $emptyEscapeWriter = new CsvWriter(',', '"', fopen('php://temp', 'r+'), false, false, ''); + $emptyEscapeWriter->setCloseStreamOnFinish(false); + $emptyEscapeWriter->prepare(); + $emptyEscapeWriter->writeItem($item); + $emptyOutput = $this->readWriterContents($emptyEscapeWriter); + + $this->assertNotSame( + $defaultOutput, + $emptyOutput, + 'Custom escape should change CSV encoding of fields containing backslash/quote' + ); + + $this->assertSame($this->fputcsvString($item, '\\'), $defaultOutput); + $this->assertSame($this->fputcsvString($item, ''), $emptyOutput); + + fclose($defaultWriter->getStream()); + fclose($emptyEscapeWriter->getStream()); + } + + /** + * Empty escape is usable for modern "no escape" CSV and does not trigger PHP 8.4+ + * fputcsv deprecation (which phpunit.xml converts to exceptions). + */ + public function testEmptyEscapeWritesWithoutDeprecation() + { + $writer = new CsvWriter(',', '"', $this->getStream(), false, false, ''); + $writer->prepare(); + $writer->writeItem(array('hello', 'world')); + $writer->writeItem(array('say "hi"', 'path\\to')); + + $this->assertContentsEquals( + $this->fputcsvString(array('hello', 'world'), '') . + $this->fputcsvString(array('say "hi"', 'path\\to'), ''), + $writer + ); + + $writer->finish(); + } + + private function readWriterContents(CsvWriter $writer) + { + $stream = $writer->getStream(); + rewind($stream); + + return stream_get_contents($stream); + } + + private function fputcsvString(array $fields, $escape) + { + $stream = fopen('php://temp', 'r+'); + fputcsv($stream, $fields, ',', '"', $escape); + rewind($stream); + $contents = stream_get_contents($stream); + fclose($stream); + + return $contents; + } }