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().
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.