Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
122 changes: 117 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ result, so a typo in a filename cannot look like an empty import.
Csv::read('data.csv')->delimiter(';')->toArray();
```

### Escaping

Fields are parsed the RFC 4180 way: a quote inside a quoted field is doubled,
and a backslash is just a character. Some producers, notably MySQL's
`SELECT ... INTO OUTFILE`, escape with a backslash instead:

```php
Csv::read('dump.csv')->escape('\\')->toArray();
```

### Encoding

Files that are not UTF-8 are converted while reading. Anything `iconv` knows
works as a name, `Windows-1252` covers most Excel exports:

```php
Csv::read('export.csv')->encoding('Windows-1252')->toArray();
```

The conversion runs as a stream filter, so it costs nothing per row. Without
it, non-ASCII characters come back as invalid UTF-8 and anything downstream
that expects valid UTF-8 — `toJson()`, a database write — fails on them.

## Header mapping

`mapToHeaders()` uses a row of the CSV as the keys for every data row. The
Expand Down Expand Up @@ -170,13 +193,102 @@ Csv::read('data.csv')
});
```

## Writing

```php
Csv::make($rows)->toFile('out.csv')->write();
```

`write()` replaces the file. Pass header names to get them as the first row:

```php
Csv::make([['Ada', 'Berlin']])
->withHeaders(['name', 'city'])
->toFile('out.csv')
->write();

// name,city
// Ada,Berlin
```

Associative rows are put into header order regardless of the order of their
keys, and a column a row does not carry is written empty:

```php
Csv::make([['city' => 'Berlin', 'name' => 'Ada'], ['name' => 'Bob']])
->withHeaders(['name', 'city'])
->toFile('out.csv')
->write();

// name,city
// Ada,Berlin
// Bob,
```

Objects are written by their public properties, so anything read with
`mapToObject()` can be written straight back out.

### Appending

`append()` keeps the existing contents and does not repeat the header row. When
no headers are set, the header already in the file defines the column order:

```php
Csv::make([['city' => 'Hamburg', 'name' => 'Bob']])
->toFile('out.csv')
->append();
```

If the file is missing or empty, `append()` writes it like `write()` would,
header included.

### Inserting at a position

`insertAt()` puts rows in front of an existing record. Records are counted from
1 and the header is record 1, so the first data row is position 2:

```php
// Col1,Col2
// A,A
// B,B

Csv::make([['NEW', 'NEW']])->toFile('data.csv')->insertAt(3);

// Col1,Col2
// A,A
// NEW,NEW
// B,B
```

Everything after the insert is copied byte for byte, so quoting and spacing of
untouched records survive. The file is rebuilt next to itself and moved into
place in one step, which means a crash mid-write cannot leave a half-written
file behind. Memory stays constant regardless of file size — inserting into a
1M row file costs about 2 MB.

A position past the end appends. A missing or empty file is written from
scratch, like `write()`.

### Delimiter and line endings

```php
Csv::make($rows)->delimiter(';')->toFile('out.csv')->write();
```

Rows end with `\n`. Excel on Windows expects `\r\n`, and needs a UTF-8 BOM to
read anything outside ASCII correctly:

```php
Csv::make($rows)->delimiter(';')->crlf()->bom()->toFile('export.csv')->write();
```

`bom()` only applies to `write()`. `append()` and `insertAt()` leave a file
that already has content alone.

## Known limitations

- Backslash still acts as an escape character inside quoted fields, matching
PHP's current `fgetcsv` default. A field ending in `\` can swallow its
closing quote.
- Reading only. There is no CSV writer.
- No encoding conversion. Input is expected to be UTF-8.
- Output is always UTF-8. Only reading converts between encodings.
- Existing records can be inserted in front of, but not changed or removed.

## Development

Expand Down
37 changes: 37 additions & 0 deletions src/Csv.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ public static function read(string $filePath)
return new self($filePath);
}

/**
* Start writing rows to a CSV file.
*
* @param array $data Rows as arrays or objects
*/
public static function make(array $data): CsvWriter
{
return new CsvWriter($data);
}

/**
* Set the delimiter for the CSV file.
*
Expand All @@ -39,6 +49,33 @@ public function delimiter(string $delimiter)
return $this;
}

/**
* Treat this character as an escape character inside quoted fields. Off by
* default, which is RFC 4180. Pass a backslash for files that use one, such
* as a MySQL SELECT ... INTO OUTFILE dump.
*
* @return $this
*/
public function escape(string $escape)
{
$this->processor->escape = $escape;

return $this;
}

/**
* Convert the file from this encoding to UTF-8 while reading.
*
* @param string $encoding Any iconv name, for example 'Windows-1252'
* @return $this
*/
public function encoding(string $encoding)
{
$this->processor->fileHandler->encoding($encoding);

return $this;
}

/**
* Map the CSV data to header keys.
* The header row will be used as the keys for the data rows.
Expand Down
13 changes: 10 additions & 3 deletions src/CsvProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ class CsvProcessor

public string $delimiter = ',';

/**
* RFC 4180 has no escape character, a quote is doubled instead. PHP's
* historic default is a backslash, which makes a field ending in one
* swallow its closing quote.
*/
public string $escape = '';

public bool $skipEmptyRows = false;

public array $headers = [];
Expand All @@ -39,7 +46,7 @@ public function process()

$rowNumber = 0;

while (($row = fgetcsv($handle, null, $this->delimiter, escape: '\\')) !== false) {
while (($row = fgetcsv($handle, null, $this->delimiter, escape: $this->escape)) !== false) {
$rowNumber++;

if (in_array($rowNumber, $this->skipRows)) {
Expand Down Expand Up @@ -148,7 +155,7 @@ protected function getHeaderRowFromCsv()

// Skip rows until the header row
for ($i = 1; $i < $this->headerRow; $i++) {
if (fgetcsv($handle, escape: '\\') === false) {
if (fgetcsv($handle, escape: $this->escape) === false) {
throw new \RuntimeException('Header row not found in CSV.');
}
}
Expand All @@ -157,7 +164,7 @@ protected function getHeaderRowFromCsv()
$handle,
null,
$this->delimiter,
escape: '\\'
escape: $this->escape
);
fclose($handle);

Expand Down
Loading
Loading