2026-09-01 19:34:41 +02:00
|
|
|
<?php
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Serializes tabular data as RFC 4180-compatible CSV.
|
|
|
|
|
*/
|
|
|
|
|
final class CsvExporter
|
|
|
|
|
{
|
|
|
|
|
private const UTF8_BOM = "\xEF\xBB\xBF";
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param list<mixed> $headers
|
|
|
|
|
* @param list<list<mixed>> $rows
|
|
|
|
|
*/
|
|
|
|
|
public function export(array $headers, array $rows, bool $withBom = false): string
|
|
|
|
|
{
|
|
|
|
|
$records = [];
|
|
|
|
|
$records[] = $this->formatRecord($headers);
|
|
|
|
|
|
|
|
|
|
$headerCount = count($headers);
|
|
|
|
|
$rowNumber = 0;
|
|
|
|
|
foreach ($rows as $row) {
|
|
|
|
|
$rowNumber++;
|
|
|
|
|
if (!is_array($row)) {
|
|
|
|
|
throw new InvalidArgumentException(sprintf('CSV row %d must be an array.', $rowNumber));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$fieldCount = count($row);
|
|
|
|
|
if ($fieldCount !== $headerCount) {
|
|
|
|
|
throw new InvalidArgumentException(sprintf(
|
|
|
|
|
'CSV row %d has %d fields; expected %d fields for %d headers.',
|
|
|
|
|
$rowNumber,
|
|
|
|
|
$fieldCount,
|
|
|
|
|
$headerCount,
|
|
|
|
|
$headerCount,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$records[] = $this->formatRecord($row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$csv = implode("\r\n", $records) . "\r\n";
|
|
|
|
|
return $withBom ? self::UTF8_BOM . $csv : $csv;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 21:03:38 +02:00
|
|
|
/** Serialize an allow-listed report projection without exposing associative keys. */
|
|
|
|
|
public function exportRecords(array $records, bool $withBom = false): string
|
|
|
|
|
{
|
|
|
|
|
if ($records === []) return $withBom ? self::UTF8_BOM : '';
|
|
|
|
|
$headers = array_keys($records[0]);
|
|
|
|
|
$rows = [];
|
|
|
|
|
foreach ($records as $record) {
|
|
|
|
|
if (!is_array($record) || array_keys($record) !== $headers) {
|
|
|
|
|
throw new InvalidArgumentException('CSV report records must share the same ordered fields.');
|
|
|
|
|
}
|
|
|
|
|
$rows[] = array_values($record);
|
|
|
|
|
}
|
|
|
|
|
return $this->export($headers, $rows, $withBom);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-01 19:34:41 +02:00
|
|
|
/**
|
|
|
|
|
* @param list<mixed> $fields
|
|
|
|
|
*/
|
|
|
|
|
private function formatRecord(array $fields): string
|
|
|
|
|
{
|
|
|
|
|
return implode(',', array_map(
|
|
|
|
|
fn (mixed $field): string => $this->escapeField($field),
|
|
|
|
|
$fields,
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function escapeField(mixed $field): string
|
|
|
|
|
{
|
|
|
|
|
if ($field === null) {
|
|
|
|
|
$value = '';
|
|
|
|
|
} elseif (is_scalar($field)) {
|
|
|
|
|
$value = (string)$field;
|
|
|
|
|
} else {
|
|
|
|
|
throw new InvalidArgumentException('CSV fields must be scalar values or null.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prefix formula-like values so spreadsheet programs treat them as text.
|
|
|
|
|
if ($value !== '' && preg_match('/^[=+\-@]/', $value) === 1) {
|
|
|
|
|
$value = "'" . $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (strpbrk($value, ",\"\r\n") === false) {
|
|
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return '"' . str_replace('"', '""', $value) . '"';
|
|
|
|
|
}
|
|
|
|
|
}
|