From 70cd9bd320cb419b6d79f73073b51fc19094e51f Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Wed, 22 Jul 2026 12:58:36 +0200 Subject: [PATCH 01/12] =?UTF-8?q?CI:=20modernize=20GitHub=20Actions=20(PHP?= =?UTF-8?q?=208.2=E2=80=938.5)=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0 housekeeping: - Upgrade actions/checkout and actions/cache to v4 - Test on PHP 8.2–8.5 - Remove Scrutinizer coverage upload - Fail the suite on test errors (no continue-on-error) Co-authored-by: Janez Urevc --- .github/workflows/test.yml | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f1ea42..81e599c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,48 +6,42 @@ 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@v4 - 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.json + run: composer validate --strict --no-check-lock - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: vendor - key: ${{ runner.os }}-composer-${{ matrix.composer-prefer }}$-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.json') }} 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: composer update --prefer-dist --no-progress - 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 From db4e37025eb6a22dbcf900641d57c3f44139d299 Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Wed, 22 Jul 2026 13:29:25 +0200 Subject: [PATCH 02/12] Address Copilot CI feedback from #11 (#12) Declare php ^8.2, lock-aware validate/install, cache key includes lock, --no-interaction. --- .github/workflows/test.yml | 18 ++++++++++++++---- composer.json | 1 + 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81e599c..7784970 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,19 +29,29 @@ jobs: tools: composer:v2 - - name: Validate composer.json - run: composer validate --strict --no-check-lock + - 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 uses: actions/cache@v4 with: path: vendor - key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.json') }} + key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.json', '**/composer.lock') }} restore-keys: | ${{ runner.os }}-composer-${{ matrix.php-versions }}- - name: Install dependencies - run: composer update --prefer-dist --no-progress + 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 diff --git a/composer.json b/composer.json index 0b7f00f..3b64ae3 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "docs": "https://portphp.readthedocs.org" }, "require": { + "php": "^8.2", "portphp/portphp": "^1.6.0" }, "autoload": { From 7ec53d95f62ac76962bbccb121ee99eb60b2c45c Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Wed, 22 Jul 2026 13:47:27 +0200 Subject: [PATCH 03/12] Update README CI badges and drop Scrutinizer - Replace dead Travis/Scrutinizer badges with GitHub Actions - Note supported PHP versions - Point docs links at readthedocs.io - Remove .scrutinizer.yml (coverage no longer uploaded) --- .scrutinizer.yml | 7 ------- README.md | 12 +++++++----- 2 files changed, 7 insertions(+), 12 deletions(-) delete mode 100644 .scrutinizer.yml 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..bc17757 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) +[![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 From 57508b09b6a085d94e3be9ed7ec0a388dee60255 Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Wed, 22 Jul 2026 13:50:44 +0200 Subject: [PATCH 04/12] Address Copilot feedback on README hygiene PR - Align composer.json support.docs with readthedocs.io - Drop stale .scrutinizer.yml / .travis.yml export-ignore entries - Clarify PHP badge wording on core (tested in CI) --- .gitattributes | 2 -- composer.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) 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/composer.json b/composer.json index 3b64ae3..7719973 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": { "php": "^8.2", From 0d9bf7d4c0cd0fd58c970244454dda5d2f9b0143 Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Wed, 22 Jul 2026 13:53:45 +0200 Subject: [PATCH 05/12] Point CI badge link at the workflow runs page --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bc17757..e8ed089 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # portphp/csv [![Latest Version](https://img.shields.io/github/release/portphp/csv.svg?style=flat-square)](https://github.com/portphp/csv/releases) -[![CI](https://github.com/portphp/csv/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/portphp/csv/actions) +[![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) **Requirements:** PHP ^8.2 (tested on 8.2–8.5). From 402d7952547502e91f03e3a2b51c7a02a30fef65 Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Wed, 22 Jul 2026 14:09:57 +0200 Subject: [PATCH 06/12] Prepare 3.0.0: PHP ^8.2 and portphp/portphp ^2.0 Breaking major: drop PHP < 8.2 and portphp 1.x. Add Dependabot and UPGRADE-3.0.md. --- .github/dependabot.yml | 13 +++++++++++++ UPGRADE-3.0.md | 4 ++++ composer.json | 6 +++--- 3 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 UPGRADE-3.0.md 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/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 7719973..1f99543 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,7 @@ }, "require": { "php": "^8.2", - "portphp/portphp": "^1.6.0" + "portphp/portphp": "^2.0" }, "autoload": { "psr-4": { @@ -32,7 +32,7 @@ } }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^9.6" }, "autoload-dev": { "psr-4": { @@ -41,7 +41,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.0.x-dev" + "dev-master": "3.0.x-dev" } } } From a902bd9623fb94f91dff9a3021fa842d2e011b1b Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Wed, 22 Jul 2026 14:21:35 +0200 Subject: [PATCH 07/12] =?UTF-8?q?Fix=20compatibility=20with=20portphp=202.?= =?UTF-8?q?0=20/=20Symfony=207=E2=80=938=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CsvReaderFactory.php | 3 ++- src/CsvWriter.php | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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 2766b69..fcdb8f5 100644 --- a/src/CsvWriter.php +++ b/src/CsvWriter.php @@ -53,7 +53,7 @@ public function __construct($delimiter = ',', $enclosure = '"', $stream = null, /** * {@inheritdoc} */ - public function prepare() + public function prepare(): void { if ($this->utf8Encoding) { fprintf($this->getStream(), chr(0xEF) . chr(0xBB) . chr(0xBF)); @@ -63,7 +63,7 @@ public function prepare() /** * {@inheritdoc} */ - public function writeItem(array $item) + public function writeItem(array $item): void { if ($this->prependHeaderRow && 1 == $this->row++) { $headers = array_keys($item); From 7386da87ae42e2f4d46e00b0c6b96e4d481b09b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:28:02 +0000 Subject: [PATCH 08/12] Bump actions/cache from 4 to 6 Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7784970..e4ce32d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: fi - name: Cache Composer packages - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: vendor key: ${{ runner.os }}-composer-${{ matrix.php-versions }}-${{ hashFiles('**/composer.json', '**/composer.lock') }} From 133c4819a169d1c2e5fde5c2a3c78f2d60027637 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:28:04 +0000 Subject: [PATCH 09/12] Bump actions/checkout from 4 to 7 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7784970..9f468df 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up PHP uses: shivammathur/setup-php@v2 From e3f743d3577d81956d4d6e3af7f148754dfadc51 Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Thu, 23 Jul 2026 10:37:40 +0200 Subject: [PATCH 10/12] Allow configuring escape for CsvWriter (#17) PHP 8.4 deprecates fputcsv() calls that omit the $escape argument. Add an optional constructor parameter (default '\\' for BC) and pass it through to fputcsv so callers can silence the deprecation and control escape behavior (including empty string for modern "no escape" CSV). Adds PHPUnit coverage that proves escape is applied and that writing works without deprecation exceptions under convertDeprecationsToExceptions. Supersedes #10. --- src/CsvWriter.php | 13 +++++-- tests/CsvWriterTest.php | 75 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/CsvWriter.php b/src/CsvWriter.php index fcdb8f5..1eb53a6 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,6 +54,7 @@ public function __construct($delimiter = ',', $enclosure = '"', $stream = null, $this->enclosure = $enclosure; $this->utf8Encoding = $utf8Encoding; $this->prependHeaderRow = $prependHeaderRow; + $this->escape = $escape; } /** @@ -67,9 +74,9 @@ public function writeItem(array $item): void { 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/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; + } } From a7c68d5bd06ffd89192a18bb5f5b24a4a1a80439 Mon Sep 17 00:00:00 2001 From: Janez Urevc Date: Thu, 23 Jul 2026 11:17:25 +0200 Subject: [PATCH 11/12] Fix CsvReader::current() TypeError at EOF (#18) Supersedes #3 with regression tests. --- src/CsvReader.php | 9 ++-- tests/CsvReaderTest.php | 91 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/CsvReader.php b/src/CsvReader.php index 7423c30..72abfff 100644 --- a/src/CsvReader.php +++ b/src/CsvReader.php @@ -107,8 +107,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(); // In non-strict mode pad/slice the line to match the column headers @@ -135,7 +138,7 @@ public function current(): ?array $this->errors[$this->key()] = $line; $this->next(); } - } while($this->valid()); + } return null; } 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); From f3b6ac42f1617f247d92a9ba71fee0235fa8460a Mon Sep 17 00:00:00 2001 From: Gomcodoctor Date: Fri, 18 Sep 2026 13:38:50 +0530 Subject: [PATCH 12/12] Fix incrementHeaders() to preserve column position for duplicate headers array_count_values() groups every occurrence of a repeated header together in the output, based on that header's first-appearance order in the input - not the position it actually appeared at. For headers like ['a', 'b', 'a', 'c'] this produces ['a', 'a1', 'b', 'c'], which no longer lines up column-for-column with the CSV row's actual values at those same indexes. Walk $headers in original order instead, incrementing a per-header counter as each duplicate is encountered, so the incremented headers array stays positionally aligned with the row data: ['a', 'b', 'a1', 'c']. --- src/CsvReader.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/CsvReader.php b/src/CsvReader.php index df86afb..4839e94 100644 --- a/src/CsvReader.php +++ b/src/CsvReader.php @@ -377,15 +377,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]; } }