Skip to content
Open
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
16 changes: 14 additions & 2 deletions src/Migration/Sources/CSV.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,21 @@ private function exportRows(int $batchSize): void

$buffer = [];

$headerCount = \count($headers);

while (($row = \fgetcsv($stream, 0, $delimiter, '"', '"')) !== false) {
if (\count($row) !== \count($headers)) {
throw new \Exception('CSV row does not match the number of header columns.', Exception::CODE_VALIDATION);
$rowCount = \count($row);

// Skip empty rows (e.g. trailing blank lines parsed as [''])
if ($rowCount === 1 && \trim($row[0]) === '') {
continue;
}

// Pad short rows with empty strings
if ($rowCount < $headerCount) {
$row = \array_pad($row, $headerCount, '');
} elseif ($rowCount > $headerCount) {
$row = \array_slice($row, 0, $headerCount);
}

$data = \array_combine($headers, $row);
Expand Down
68 changes: 68 additions & 0 deletions tests/Migration/Unit/General/CSVTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,74 @@ public function testCSVExportImportCompatibility()
}
}

/**
* Test that CSV parsing handles trailing empty lines gracefully.
* Trailing empty lines in CSV files produce rows like [''] which have
* a different count than headers, previously causing:
* "CSV row does not match the number of header columns."
*/
public function testCSVParsingHandlesTrailingEmptyLines(): void
{
$filepath = self::RESOURCES_DIR . 'trailing_empty_lines.csv';
$stream = fopen($filepath, 'r');
$this->assertNotFalse($stream);

$headers = fgetcsv($stream, 0, ',', '"', '"');
$this->assertSame(['id', 'name', 'age'], $headers);

$rows = [];
while (($row = fgetcsv($stream, 0, ',', '"', '"')) !== false) {
// Simulate the fixed behavior: skip empty rows
if (\count($row) === 1 && \trim($row[0]) === '') {
continue;
}
$rows[] = $row;
}
fclose($stream);

// Should have exactly 2 data rows, trailing empty line should be skipped
$this->assertCount(2, $rows);
$this->assertSame(['1', 'Alice', '23'], $rows[0]);
$this->assertSame(['2', 'Bob', '30'], $rows[1]);
}

/**
* Test that CSV parsing handles rows with fewer columns than headers.
* Short rows should be padded with empty strings rather than throwing.
*/
public function testCSVParsingHandlesShortRows(): void
{
$filepath = self::RESOURCES_DIR . 'short_rows.csv';
$stream = fopen($filepath, 'r');
$this->assertNotFalse($stream);

$headers = fgetcsv($stream, 0, ',', '"', '"');
$this->assertSame(['id', 'name', 'age'], $headers);
$headerCount = \count($headers);

$rows = [];
while (($row = fgetcsv($stream, 0, ',', '"', '"')) !== false) {
if (\count($row) === 1 && \trim($row[0]) === '') {
continue;
}
// Simulate the fixed behavior: pad short rows
if (\count($row) < $headerCount) {
$row = \array_pad($row, $headerCount, '');
}
$rows[] = \array_combine($headers, $row);
}
fclose($stream);

$this->assertCount(3, $rows);
$this->assertSame('Alice', $rows[0]['name']);
$this->assertSame('23', $rows[0]['age']);
// Short row should have been padded
$this->assertSame('Bob', $rows[1]['name']);
$this->assertSame('', $rows[1]['age']); // Padded with empty string
$this->assertSame('Charlie', $rows[2]['name']);
$this->assertSame('25', $rows[2]['age']);
}

private function recursiveDelete(string $dir): void
{
if (is_dir($dir)) {
Expand Down
4 changes: 4 additions & 0 deletions tests/Migration/resources/csv/short_rows.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id,name,age
1,Alice,23
2,Bob
3,Charlie,25
4 changes: 4 additions & 0 deletions tests/Migration/resources/csv/trailing_empty_lines.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id,name,age
1,Alice,23
2,Bob,30

Loading