Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
70cd9bd
CI: modernize GitHub Actions (PHP 8.2–8.5) (#11)
slashrsm Jul 22, 2026
db4e370
Address Copilot CI feedback from #11 (#12)
slashrsm Jul 22, 2026
7ec53d9
Update README CI badges and drop Scrutinizer
slashrsm Jul 22, 2026
57508b0
Address Copilot feedback on README hygiene PR
slashrsm Jul 22, 2026
0d9bf7d
Point CI badge link at the workflow runs page
slashrsm Jul 22, 2026
a6fb7ef
Merge pull request #13 from slashrsm/docs/readme-ci-badges
slashrsm Jul 22, 2026
402d795
Prepare 3.0.0: PHP ^8.2 and portphp/portphp ^2.0
slashrsm Jul 22, 2026
a902bd9
Fix compatibility with portphp 2.0 / Symfony 7–8 APIs
slashrsm Jul 22, 2026
808fdff
Merge pull request #14 from slashrsm/release/3.0.0
slashrsm Jul 22, 2026
7386da8
Bump actions/cache from 4 to 6
dependabot[bot] Jul 22, 2026
133c481
Bump actions/checkout from 4 to 7
dependabot[bot] Jul 22, 2026
f60b2ac
Merge pull request #16 from portphp/dependabot/github_actions/actions…
slashrsm Jul 22, 2026
a83dbfe
Merge branch 'master' into dependabot/github_actions/actions/cache-6
slashrsm Jul 22, 2026
e270cd7
Merge pull request #15 from portphp/dependabot/github_actions/actions…
slashrsm Jul 22, 2026
e3f743d
Allow configuring escape for CsvWriter (#17)
slashrsm Jul 23, 2026
a7c68d5
Fix CsvReader::current() TypeError at EOF (#18)
slashrsm Jul 23, 2026
f3b6ac4
Fix incrementHeaders() to preserve column position for duplicate headers
gomcodoctor Sep 18, 2026
23763af
Merge branch 'master' into fix/increment-headers-sequential-suffix
gomcodoctor Sep 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 26 additions & 22 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 0 additions & 7 deletions .scrutinizer.yml

This file was deleted.

12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down
4 changes: 4 additions & 0 deletions UPGRADE-3.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Upgrade from 2.x to 3.0

- Minimum PHP is **8.2** (`^8.2`).
- Requires `portphp/portphp` **^2.0**.
6 changes: 3 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -31,7 +31,7 @@
}
},
"require-dev": {
"phpunit/phpunit": "^9.5"
"phpunit/phpunit": "^9.6"
},
"autoload-dev": {
"psr-4": {
Expand All @@ -40,7 +40,7 @@
},
"extra": {
"branch-alias": {
"dev-master": "2.0.x-dev"
"dev-master": "3.0.x-dev"
}
}
}
23 changes: 14 additions & 9 deletions src/CsvReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -155,7 +158,7 @@ public function current(): ?array
$this->errors[$this->key()] = $line;
$this->next();
}
} while($this->valid());
}

return null;
}
Expand Down Expand Up @@ -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];
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/CsvReaderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Port\Csv;

use Port\Reader;
use Port\Reader\ReaderFactory;

/**
Expand Down Expand Up @@ -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);

Expand Down
15 changes: 11 additions & 4 deletions src/CsvWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,27 +33,34 @@ 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);

$this->delimiter = $delimiter;
$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));
Expand All @@ -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);
}
}
91 changes: 91 additions & 0 deletions tests/CsvReaderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading