Files
dualfroz aa4f462b2e fix: make Buffer::nth() positional so it stays correct after deletions
Buffer::nth() resolved the requested position by adding it to the first row key
and indexing the rows array by that computed key. That assumes contiguous keys,
but Buffer::delete() removes rows with unset() without reindexing, leaving gaps.
After a deletion nth() therefore returned the wrong record (or an empty array for
a valid position, or a record for an out-of-range position). Resolve the position
against the record order with array_values() so nth() matches getRecords() and
ResultSet::nth().
2026-09-04 09:31:11 +02:00

3.2 KiB

fix: make Buffer::nth() positional so it stays correct after deletions

Problem

Buffer::nth() (and Buffer::nthAsObject()) return the wrong record once a record has been deleted from the buffer. nth() is a positional accessor - the same contract implemented by ResultSet::nth() via new LimitIterator($iterator, $nth, 1) - so nth($n) must return the $n-th record in iteration order, consistent with getRecords().

$buffer = new League\Csv\Buffer(['id', 'name']);
$buffer->insert(['1', 'a'], ['2', 'b'], ['3', 'c'], ['4', 'd'], ['5', 'e']);
$buffer->delete(fn (array $row) => $row['id'] === '2');

// getRecords() correctly yields a, c, d, e
// but:
$buffer->nth(1); // []            (expected ['id' => '3', 'name' => 'c'])
$buffer->nth(4); // ['5', 'e']    (expected [] - only 4 records remain)

Root cause

src/Buffer.php, nthRow():

$offset = $first + $nth;
if (!array_key_exists($offset, $this->rows)) {
    return [];
}

return $this->rows[$nth + $first];

The lookup adds $nth to the first row key and indexes $this->rows by that computed key. That only works while the row keys are contiguous. Buffer::delete() removes rows with unset($this->rows[$offset]) without reindexing, so after any deletion the keys contain gaps and the positional index $nth no longer maps to key $first + $nth. As a result nth() skips the deleted offset, and can also return a record for an index that is out of range.

The fix

Resolve $nth against the record order instead of the row keys:

private function nthRow(int $nth, string $method): array
{
    -1 < $nth || throw InvalidArgument::dueToInvalidRecordOffset($nth, $method);

    return array_values($this->rows)[$nth] ?? [];
}

array_values() collapses the gaps, so $nth addresses the record by its position in iteration order, matching getRecords() and ResultSet::nth(). array_values is already imported by the class.

Test

Added BufferTest::it_returns_records_by_positional_offset_after_a_middle_record_is_deleted, which inserts five records, deletes a middle one, and asserts that nth(0..3) return the four remaining records in order and that nth(4) returns [].

Gate

composer install --ignore-platform-req=ext-xdebug
vendor/bin/phpunit --no-coverage

The full run reports 6 pre-existing errors, all PDOException: could not find driver from the SQLite3/PDO-backed tests (RdbmsResultTest and the sqlite3/pdo cases in BufferTest); these are environmental (the sqlite3 / pdo_sqlite extensions are not installed) and are present unchanged on a clean checkout. Excluding those environment-dependent cases the suite is green:

vendor/bin/phpunit --no-coverage --filter '/^(?!.*(sqlite3|pdo)).*$/i'

Result: exit code 0 - OK, but some tests were skipped! Tests: 1006, Assertions: 1990, Skipped: 3 (the 3 skips are pre-existing).

Counterfactual: reverting nthRow() to the key-arithmetic version makes the new test fail - nth(1) returns [] instead of ['id' => '3', 'name' => 'c'].

Flags for the account owner

The SQLite3/PDO tests are not annotated with a required-extension attribute, so they error (rather than skip) when the drivers are missing. That is unrelated to this change and was left untouched.