commit 7f690c67cdc6ca188d66e4215f04a9c40b6d7b6f Author: upstream Date: Fri Sep 4 09:31:11 2026 +0200 Upstream thephpleague/csv state of the touched files before 96e3608aa6 diff --git a/src/Buffer.php b/src/Buffer.php new file mode 100644 index 0000000..d4083c2 --- /dev/null +++ b/src/Buffer.php @@ -0,0 +1,534 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace League\Csv; + +use CallbackFilterIterator; +use Closure; +use Iterator; +use League\Csv\Query\Constraint\Criteria; +use League\Csv\Query\Predicate; +use League\Csv\Schema\Inspector; +use League\Csv\Schema\Schema; +use League\Csv\Serializer\Denormalizer; +use League\Csv\Serializer\MappingFailed; +use League\Csv\Serializer\TypeCastingFailed; +use mysqli_result; +use PDOStatement; +use PgSql\Result; +use ReflectionException; +use RuntimeException; +use SQLite3Result; + +use function array_combine; +use function array_diff; +use function array_fill_keys; +use function array_filter; +use function array_is_list; +use function array_key_exists; +use function array_key_first; +use function array_key_last; +use function array_keys; +use function array_map; +use function array_push; +use function array_reduce; +use function array_unique; +use function array_values; +use function count; +use function in_array; +use function is_int; +use function sort; + +use const ARRAY_FILTER_USE_KEY; + +final class Buffer implements TabularData +{ + public const INCLUDE_HEADER = 1; + public const EXCLUDE_HEADER = 2; + + /** @var array|array{} */ + private readonly array $header; + /** @var array|array{} */ + private readonly array $sortedHeader; + /** @var array */ + private readonly array $nullRecord; + /** @var array> */ + private array $rows = []; + /** @var array callable collection to validate the record before insertion. */ + private array $validators = []; + /** @var array collection of Closure to format the record before reading. */ + private array $formatters = []; + + /** + * @param list|array{} $header + * + * @throws SyntaxError + */ + public function __construct(array $header = []) + { + $this->header = match (true) { + !array_is_list($header) => throw new SyntaxError('The header must be a list of unique column names.'), + $header !== array_filter($header, is_string(...)) => throw SyntaxError::dueToInvalidHeaderColumnNames(), + $header !== array_unique($header) => throw SyntaxError::dueToDuplicateHeaderColumnNames($header), + default => $header, + }; + sort($header); + $this->sortedHeader = $header; + $this->nullRecord = array_fill_keys($this->header, null); + } + + /** + * Returns a new instance from a tabular data implementing object. + * + * @throws RuntimeException|SyntaxError If the column names can not be found + */ + public static function from(PDOStatement|Result|mysqli_result|SQLite3Result|TabularData|TabularDataProvider $dataStorage, int $options = self::INCLUDE_HEADER): self + { + if ($dataStorage instanceof TabularDataProvider) { + $dataStorage = $dataStorage->getTabularData(); + } + + /** @var Iterator $rows */ + $rows = $dataStorage instanceof TabularData ? $dataStorage->getRecords() : RdbmsResult::rows($dataStorage); + $instance = new self(array_values(match (true) { + self::EXCLUDE_HEADER === $options => [], + $dataStorage instanceof TabularData => $dataStorage->getHeader(), + default => RdbmsResult::columnNames($dataStorage), + })); + + /** + * @var int $offset + * @var list $row + */ + foreach (new MapIterator($rows, fn (array $record): array => array_values($record)) as $offset => $row) { + $instance->rows[$offset] = $row; + } + + return $instance; + } + + /** + * @throws CannotInsertRecord + * @throws Exception + */ + public function to(TabularDataWriter $dataStorage, int $options = self::INCLUDE_HEADER): int + { + $bytes = 0; + $header = $this->getHeader(); + if (self::INCLUDE_HEADER === $options && [] !== $header) { + $bytes += $dataStorage->insertOne($header); + } + + return $bytes + $dataStorage->insertAll($this->getRecords()); + } + + public function isEmpty(): bool + { + return [] === $this->rows; + } + + public function hasHeader(): bool + { + return [] !== $this->header; + } + + public function recordCount(): int + { + return count($this->rows); + } + + /** + * @return list|array{} + */ + public function getHeader(): array + { + return $this->header; + } + + /** + * @throws SyntaxError + * + * @return Iterator + */ + public function getRecords(array $header = []): Iterator + { + $header = match (true) { + [] === $header => $this->header, + $header !== array_filter($header, is_int(...), ARRAY_FILTER_USE_KEY) => throw new SyntaxError('The header must be a list of unique column names.'), + $header !== array_filter($header, is_string(...)) => throw SyntaxError::dueToInvalidHeaderColumnNames(), + $header !== array_unique($header) => throw SyntaxError::dueToDuplicateHeaderColumnNames($header), + default => $header, + }; + + return MapIterator::fromIterable($this->rows, fn (array $row): array => $this->rowToRecord($row, $header)); + } + + /** + * @template T of object + * @param class-string $className + * @param array $header + * + * @throws Exception + * @throws MappingFailed + * @throws TypeCastingFailed + * + * @return iterator + */ + public function getRecordsAsObject(string $className, array $header = []): Iterator + { + return Denormalizer::assignAll($className, $this->getRecords($header), [] === $header ? $this->header : $header); + } + + /** + * Run a map over each container members. + * + * @template TMap + * + * @param callable(array, int): TMap $callback + * + * @throws SyntaxError + * + * @return Iterator + */ + public function map(callable $callback): Iterator + { + return MapIterator::fromIterable($this->getRecords(), $callback); + } + + /** + * @param callable(TInitial|null, array, array-key=): TInitial $callback + * @param TInitial|null $initial + * + * @template TInitial + * + * @throws SyntaxError + * + * @return TInitial|null + */ + public function reduce(callable $callback, mixed $initial = null): mixed + { + foreach ($this->getRecords() as $offset => $record) { + $initial = $callback($initial, $record, $offset); + } + + return $initial; + } + + public function inferSchema(?Inspector $inspector = null, array $header = []): Schema + { + return ($inspector ?? Inspector::default())->schema($this, $header); + } + + public function inferRecords(?Inspector $inspector = null, array $header = []): Iterator + { + return $this->inferSchema($inspector, $header)->parse($this); + } + + /** + * @param non-negative-int $nth + * + * @throws InvalidArgument + */ + public function nth(int $nth): array + { + if ([] === ($row = $this->nthRow($nth, __METHOD__))) { + return []; + } + + return $this->rowToRecord($row, $this->header); + } + + /** + * @template T of object + * + * @param non-negative-int $nth + * @param class-string $className + * @param array $header + * + * @throws InvalidArgument + * @throws ReflectionException + */ + public function nthAsObject(int $nth, string $className, array $header = []): ?object + { + if ([] === ($row = $this->nthRow($nth, __METHOD__))) { + return null; + } + + return Denormalizer::assign($className, $this->rowToRecord($row, [] !== $header ? $header : $this->header)); + } + + public function firstOffset(): ?int + { + return array_key_first($this->rows); + } + + public function first(): array + { + return null === ($offset = $this->firstOffset()) ? [] : $this->rowToRecord($this->rows[$offset], $this->header); + } + + /** + * @param class-string $className + * @param array $header + * + * @throws ReflectionException + */ + public function firstAsObject(string $className, array $header = []): ?object + { + $offset = $this->firstOffset(); + if (null === $offset) { + return null; + } + + $row = $this->rows[$offset] ?? []; + if ([] === $row) { + return null; + } + + return Denormalizer::assign($className, $this->rowToRecord($row, [] !== $header ? $header : $this->header)); + } + + public function lastOffset(): ?int + { + return array_key_last($this->rows); + } + + public function last(): array + { + return null === ($offset = $this->lastOffset()) ? [] : $this->rowToRecord($this->rows[$offset], $this->header); + } + + /** + * @param class-string $className + * @param array $header + * + * @throws ReflectionException + */ + public function lastAsObject(string $className, array $header = []): ?object + { + $offset = $this->lastOffset(); + if (null === $offset) { + return null; + } + + $row = $this->rows[$offset] ?? []; + if ([] === $row) { + return null; + } + + return Denormalizer::assign($className, $this->rowToRecord($row, [] !== $header ? $header : $this->header)); + } + + /** + * @throws InvalidArgument + */ + private function nthRow(int $nth, string $method): array + { + -1 < $nth || throw InvalidArgument::dueToInvalidRecordOffset($nth, $method); + if (null === ($first = $this->firstOffset())) { + return []; + } + + $offset = $first + $nth; + if (!array_key_exists($offset, $this->rows)) { + return []; + } + + return $this->rows[$nth + $first]; + } + + public function fetchColumn(int|string $index = 0): Iterator + { + if (is_int($index)) { + $index > -1 || throw InvalidArgument::dueToInvalidColumnIndex($index, 'offset', __METHOD__); + [] === $this->header || array_key_exists($index, $this->header) || throw InvalidArgument::dueToInvalidColumnIndex($index, 'name', __METHOD__); + + $iterator = new MapIterator($this->getRecords(), fn (array $row) => array_values($row)); + $iterator = new CallbackFilterIterator($iterator, fn (array $row) => array_key_exists($index, $row)); + + return new MapIterator($iterator, fn (array $row) => $row[$index]); + } + + [] !== $this->header || throw InvalidArgument::dueToInvalidColumnIndex($index, 'name', __METHOD__); + in_array($index, $this->header, true) || throw InvalidArgument::dueToInvalidColumnIndex($index, 'name', __METHOD__); + + $iterator = new CallbackFilterIterator($this->getRecords(), fn (array $row) => array_key_exists($index, $row)); + + return new MapIterator($iterator, fn (array $row) => $row[$index]); + } + + /** + * Adds a record validator. + * + * @param callable(array): bool $validator + */ + public function addValidator(callable $validator, string $name): self + { + $this->validators[$name] = !$validator instanceof Closure ? $validator(...) : $validator; + + return $this; + } + + /** + * Adds a record formatter. + * + * @param callable(array): array $formatter + */ + public function addFormatter(callable $formatter): self + { + $this->formatters[] = !$formatter instanceof Closure ? $formatter(...) : $formatter; + + return $this; + } + + /** + * @throws CannotInsertRecord + */ + public function insert(array ...$records): int + { + [] !== $records || throw CannotInsertRecord::triggerOnValidation('@buffer_record_validation_on_insert', $records); + + array_push($this->rows, ...array_map($this->formatInsertRecord(...), $records)); + + return count($records); + } + + /** + * @throws CannotInsertRecord + * @throws SyntaxError + */ + public function update(Predicate|Closure|callable $where, array $record): int + { + $record = $this->filterUpdateRecord($record); + $updateRecord = function (array $row) use ($record): array { + foreach ($record as $index => $value) { + $row[$index] = $value; + } + + return $this->validateRecord($row); + }; + + $affectedRecords = 0; + /** @var Iterator $iterator */ + $iterator = new MapIterator(new CallbackFilterIterator($this->getRecords(), $this->filterPredicate($where)), $updateRecord); + foreach ($iterator as $offset => $row) { + $this->rows[$offset] = $row; + $affectedRecords++; + } + + return $affectedRecords; + } + + /** + * @throws SyntaxError + */ + public function delete(Predicate|Closure|callable $where): int + { + $affectedRecords = 0; + foreach (new CallbackFilterIterator($this->getRecords(), $this->filterPredicate($where)) as $offset => $row) { + unset($this->rows[$offset]); + $affectedRecords++; + } + + return $affectedRecords; + } + + public function truncate(): void + { + $this->rows = []; + } + + /** + * @throws CannotInsertRecord + */ + private function formatInsertRecord(array $record): array + { + $this->filterInsertRecord($record) || throw CannotInsertRecord::triggerOnValidation('@buffer_record_validation_on_insert', $record); + + return $this->validateRecord(match (true) { + [] === $this->header => !array_is_list($record) ? array_values($record) : $record, + array_is_list($record) => array_combine($this->header, $record), + default => [...$this->nullRecord, ...$record], + }); + } + + private function filterInsertRecord(array $record): bool + { + if ([] === $this->header) { + return true; + } + + if (array_is_list($record)) { + return count($record) === count($this->header); + } + + $keys = array_keys($record); + sort($keys); + + return $keys === $this->sortedHeader; + } + + /** + * @throws CannotInsertRecord + */ + private function filterUpdateRecord(array $record): array + { + [] !== $record || throw CannotInsertRecord::triggerOnValidation('@buffer_record_validation_on_update', $record); + if (array_is_list($record)) { + return $this->rowToRecord($record, $this->header); + } + + $keys = array_keys($record); + + return match (true) { + $keys === array_filter($keys, is_int(...)) => $record, + $keys !== array_filter($keys, is_string(...)), + [] !== array_diff($keys, $this->header) => throw CannotInsertRecord::triggerOnValidation('@buffer_record_validation_on_update', $record), + default => $record, + }; + } + + /** + * Validates a record. + * + * @throws CannotInsertRecord If the validation failed + */ + private function validateRecord(array $record): array + { + $record = array_reduce($this->formatters, fn (array $record, callable $formatter): array => $formatter($record), $record); + foreach ($this->validators as $name => $validator) { + true === $validator($record) || throw CannotInsertRecord::triggerOnValidation($name, $record); + } + + return !array_is_list($record) ? array_values($record) : $record; + } + + private function filterPredicate(Predicate|Closure|callable $predicate): Predicate + { + return !$predicate instanceof Predicate ? Criteria::all($predicate) : $predicate; + } + + private function rowToRecord(array $row, array $header): array + { + if ([] === $header) { + return $row; + } + + $record = []; + foreach ($header as $offset => $headerName) { + $record[$headerName] = $row[$offset] ?? null; + } + + return $record; + } +} diff --git a/src/BufferTest.php b/src/BufferTest.php new file mode 100644 index 0000000..e373f7e --- /dev/null +++ b/src/BufferTest.php @@ -0,0 +1,836 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use League\Csv\Buffer; +use League\Csv\CannotInsertRecord; +use League\Csv\InvalidArgument; +use League\Csv\Query\Constraint\Column; +use League\Csv\Query\Constraint\Offset; +use League\Csv\Reader; +use League\Csv\Writer; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Buffer::class)] +final class BufferTest extends TestCase +{ + #[Test] + public function it_will_create_a_datable_with_a_header(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + $buffer->insert( + ['2011-01-01', '1', 'Galway'], + ['2011-01-02', '-1', 'Galway'], + ['2011-01-03', '0', 'Galway'], + ['2011-01-01', '6', 'Berkeley'], + ['2011-01-02', '8', 'Berkeley'], + ['2011-01-03', '5', 'Berkeley'], + ); + + self::assertSame($header, $buffer->getHeader()); + self::assertSame( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Galway'], + $buffer->nth(0) + ); + self::assertSame(6, $buffer->recordCount()); + self::assertTrue($buffer->hasHeader()); + self::assertFalse($buffer->isEmpty()); + + $weather = new class (new DateTimeImmutable(), 6, 'Brussels') { + public function __construct( + public readonly DateTimeImmutable $date, + public readonly int $temperature, + public readonly string $place, + ) { + } + }; + + $objStart = $buffer->nthAsObject(0, $weather::class); + $objEnd = $buffer->nthAsObject($buffer->recordCount() - 1, $weather::class); + $objFirst = $buffer->firstAsObject($weather::class); + $objLast = $buffer->lastAsObject($weather::class); + + self::assertInstanceOf($weather::class, $objStart); + self::assertInstanceOf($weather::class, $objFirst); + self::assertInstanceOf($weather::class, $objLast); + self::assertSame('2011-01-01', $objStart->date->format('Y-m-d')); + self::assertEquals($objFirst, $objStart); + self::assertEquals($objLast, $objEnd); + } + + #[Test] + public function it_will_create_a_datable_without_a_header(): void + { + $buffer = new Buffer(); + self::assertSame([], $buffer->last()); + self::assertSame([], $buffer->first()); + self::assertNull($buffer->nthAsObject(23, stdClass::class)); + + $buffer->insert( + ['2011-01-01', '1', 'Galway'], + ['2011-01-02', '-1', 'Galway'], + ['2011-01-03', '0', 'Galway'], + ['2011-01-01', '6', 'Berkeley'], + ['2011-01-02', '8', 'Berkeley'], + ['2011-01-03', '5', 'Berkeley'], + ); + + self::assertSame([], $buffer->getHeader()); + self::assertFalse($buffer->hasHeader()); + self::assertFalse($buffer->isEmpty()); + self::assertSame(['2011-01-01', '1', 'Galway'], $buffer->nth(0)); + self::assertSame([], $buffer->nth(42)); + self::assertSame(6, $buffer->recordCount()); + + $weather = new class (new DateTimeImmutable(), 6, 'Brussels') { + public function __construct( + public readonly DateTimeImmutable $date, + public readonly int $temperature, + public readonly string $place, + ) { + } + }; + + $obj = $buffer->firstAsObject($weather::class, ['date', 'temperature', 'place']); + self::assertInstanceOf($weather::class, $obj); + self::assertSame('2011-01-01', $obj->date->format('Y-m-d')); + self::assertNull($buffer->nthAsObject(42, $weather::class, ['date', 'temperature', 'place'])); + + $collection = $buffer->getRecordsAsObject($weather::class, ['date', 'temperature', 'place']); + $collection = iterator_to_array($collection); + self::assertInstanceOf($weather::class, $collection[0]); + self::assertSame('2011-01-01', $collection[0]->date->format('Y-m-d')); + + $mappedCollection = $buffer->map( + fn (array $item) => new ($weather::class)(new DateTimeImmutable($item[0]), (int) $item[1], (string) $item[2]) + ); + + $mappedCollection = iterator_to_array($mappedCollection); + self::assertInstanceOf($weather::class, $mappedCollection[0]); + self::assertSame('2011-01-01', $mappedCollection[0]->date->format('Y-m-d')); + } + + #[Test] + public function it_will_only_consider_header_content_and_not_the_record_keys_and_values_1(): void + { + $header = ['date', 'temperature']; + $buffer = new Buffer($header); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1'], + ['date' => '2011-01-02', 'temperature' => '-1'], + ['date' => '2011-01-03', 'temperature' => '0'], + ['date' => '2011-01-01', 'temperature' => '6'], + ['date' => '2011-01-02', 'temperature' => '8'], + ['date' => '2011-01-03', 'temperature' => '5'], + ); + + self::assertSame(['date' => '2011-01-01', 'temperature' => '1'], $buffer->nth(0)); + self::assertSame($header, $buffer->getHeader()); + self::assertSame(6, $buffer->recordCount()); + self::assertSame(['date' => '2011-01-03', 'temperature' => '5'], $buffer->last()); + self::assertSame(['date' => '2011-01-01', 'temperature' => '1'], $buffer->first()); + } + + #[Test] + public function it_will_only_consider_header_content_and_not_the_record_keys_and_values_2(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Berkeley'], + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Berkeley'], + ); + + self::assertSame(['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley'], $buffer->nth(0)); + self::assertSame($header, $buffer->getHeader()); + self::assertSame(6, $buffer->recordCount()); + } + + #[Test] + public function it_will_return_no_rows_if_non_rows_are_supplied(): void + { + $emptyDataTable = new Buffer(); + self::assertFalse($emptyDataTable->hasHeader()); + self::assertTrue($emptyDataTable->isEmpty()); + self::assertSame([], $emptyDataTable->getHeader()); + self::assertSame(0, $emptyDataTable->recordCount()); + + $header = ['foo', 'bar', 'baz']; + $emptyDataTableWithHeader = new Buffer(header: $header); + + self::assertSame($header, $emptyDataTableWithHeader->getHeader()); + self::assertSame(0, $emptyDataTableWithHeader->recordCount()); + } + + #[Test] + public function it_can_be_filled_by_inserting_new_records(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + self::assertSame(2, $buffer->insert( + ['2011-01-01', '1', 'Galway'], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Berkeley'], + )); + + self::assertSame($header, $buffer->getHeader()); + self::assertSame(2, $buffer->recordCount()); + self::assertSame(['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Galway'], $buffer->nth(0)); + } + + #[Test] + public function it_can_not_be_filled_by_inserting_new_invalid_list_due_to_missing_fields(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + $buffer->insert(['2011-01-01', '1']); + } + + #[Test] + public function it_can_not_be_filled_by_inserting_new_invalid_record_with_missing_fields(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + $buffer->insert(['date' => '2011-01-01', 'temperature' => '1']); + } + + #[Test] + public function it_can_not_be_filled_by_inserting_new_invalid_record_with_unknown_fields(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + $buffer->insert(['date' => '2011-01-01', 'temperature' => '1', 'location' => 'Berkeley']); + } + + #[Test] + public function it_can_not_be_filled_by_inserting_new_invalid_record_with_extra_fields(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + $buffer->insert(['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley', 'origin' => 'station']); + } + + #[Test] + public function it_can_not_be_filled_by_inserting_new_invalid_record_with_mixed_fields(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + $buffer->insert(['date' => '2011-01-01', '1', 'place' => 'Berkeley', 'origin' => 'station']); + } + + #[Test] + public function it_can_not_be_filled_by_inserting_new_invalid_record_on_buffer_without_header(): void + { + $buffer = new Buffer(); + $buffer->insert(['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley', 'origin' => 'station']); + + self::assertSame(1, $buffer->recordCount()); + self::assertSame(['2011-01-01', '1', 'Berkeley', 'station'], $buffer->nth(0)); + } + + #[Test] + public function it_can_be_filled_by_inserting_new_record_on_buffer_without_header(): void + { + $buffer = new Buffer(); + $buffer->insert(['2011-01-01', '1', 'Berkeley', 'station']); + self::assertSame(1, $buffer->recordCount()); + } + + #[Test] + public function it_can_be_updated_by_replacing_or_removing_records_using_its_offset(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Berkeley'], + ); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Galway'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Galway'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Galway'], + ); + + self::assertSame(1, $buffer->update(fn (array $row, int $offset): bool => 0 === $offset, ['date' => '2025-02-08', 'temperature' => '3'])); + self::assertSame(2, $buffer->delete(fn (array $row, int $offset): bool => in_array($offset, [4, 5, 4], true))); + + self::assertSame($header, $buffer->getHeader()); + self::assertSame(4, $buffer->recordCount()); + self::assertSame(['date' => '2025-02-08', 'temperature' => '3', 'place' => 'Berkeley'], $buffer->nth(0)); + } + + #[Test] + public function it_can_not_insert_invalid_records(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + + $buffer->insert(['foo' => 'bar']); + } + + #[Test] + public function it_can_not_insert_without_records(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + $buffer->insert(); + } + + #[Test] + public function it_can_not_unshift_invalid_records(): void + { + $header = ['date', 'temperature', 'place']; + $buffer = new Buffer($header); + + $this->expectException(CannotInsertRecord::class); + + $buffer->insert(['foo' => 'bar']); + } + + #[Test] + public function it_can_be_used_with_sqlite3(): void + { + $db = new SQLite3(''); + $tableCreateQuery = <<exec($tableCreateQuery); + $seedData = [ + ['name' => 'Ronnie', 'email' => 'ronnie@example.com'], + ['name' => 'Bobby', 'email' => 'bobby@example.com'], + ['name' => 'Ricky', 'email' => 'ricky@example.com'], + ['name' => 'Mike', 'email' => 'mike@example.com'], + ['name' => 'Ralph', 'email' => 'ralph@example.com'], + ['name' => 'Johnny', 'email' => 'johnny@example.com'], + ]; + + $stmt = $db->prepare('INSERT INTO users (name, email) VALUES (:name, :email)'); + if (!$stmt instanceof SQLite3Stmt) { + throw new SQLite3Exception('Unable to prepare statement'); + } + + foreach ($seedData as $data) { + $stmt->bindValue(':name', $data['name']); + $stmt->bindValue(':email', $data['email']); + $stmt->execute(); + } + + /** @var SQLite3Stmt $stmt */ + $stmt = $db->prepare('SELECT * FROM users'); + /** @var SQLite3Result $result */ + $result = $stmt->execute(); + $tabularData = Buffer::from($result); + + self::assertSame(['id', 'name', 'email'], $tabularData->getHeader()); + self::assertSame(6, $tabularData->recordCount()); + self::assertSame( + [1, 'Ronnie', 'ronnie@example.com'], + Buffer::from($tabularData, Buffer::EXCLUDE_HEADER)->nth(0) + ); + } + + #[Test] + public function it_can_be_used_with_sqlite3_and_strip_header(): void + { + $db = new SQLite3(''); + $tableCreateQuery = <<exec($tableCreateQuery); + $seedData = [ + ['name' => 'Ronnie', 'email' => 'ronnie@example.com'], + ['name' => 'Bobby', 'email' => 'bobby@example.com'], + ['name' => 'Ricky', 'email' => 'ricky@example.com'], + ['name' => 'Mike', 'email' => 'mike@example.com'], + ['name' => 'Ralph', 'email' => 'ralph@example.com'], + ['name' => 'Johnny', 'email' => 'johnny@example.com'], + ]; + + $stmt = $db->prepare('INSERT INTO users (name, email) VALUES (:name, :email)'); + if (!$stmt instanceof SQLite3Stmt) { + throw new SQLite3Exception('Unable to prepare statement'); + } + + foreach ($seedData as $data) { + $stmt->bindValue(':name', $data['name']); + $stmt->bindValue(':email', $data['email']); + $stmt->execute(); + } + + /** @var SQLite3Stmt $stmt */ + $stmt = $db->prepare('SELECT * FROM users'); + /** @var SQLite3Result $result */ + $result = $stmt->execute(); + $tabularData = Buffer::from($result, Buffer::EXCLUDE_HEADER); + + self::assertSame([], $tabularData->getHeader()); + self::assertSame(6, $tabularData->recordCount()); + self::assertSame( + [1, 'Ronnie', 'ronnie@example.com'], + Buffer::from($tabularData)->nth(0) + ); + } + + #[Test] + public function it_can_be_used_with_pdo(): void + { + $connection = new PDO('sqlite::memory:'); + $tableCreateQuery = <<exec($tableCreateQuery); + $seedData = [ + ['name' => 'Ronnie', 'email' => 'ronnie@example.com'], + ['name' => 'Bobby', 'email' => 'bobby@example.com'], + ['name' => 'Ricky', 'email' => 'ricky@example.com'], + ['name' => 'Mike', 'email' => 'mike@example.com'], + ['name' => 'Ralph', 'email' => 'ralph@example.com'], + ['name' => 'Johnny', 'email' => 'johnny@example.com'], + ]; + + $stmt = $connection->prepare('INSERT INTO users (name, email) VALUES (:name, :email)'); + foreach ($seedData as $data) { + $stmt->bindValue(':name', $data['name']); + $stmt->bindValue(':email', $data['email']); + $stmt->execute(); + } + + $stmt = $connection->prepare('SELECT * FROM users'); + $stmt->execute(); + $tabularData = Buffer::from($stmt); + + self::assertSame(['id', 'name', 'email'], $tabularData->getHeader()); + self::assertSame(6, $tabularData->recordCount()); + self::assertSame(['id' => 1, 'name' => 'Ronnie', 'email' => 'ronnie@example.com'], $tabularData->nth(0)); + } + + #[Test] + public function it_can_be_used_with_pdo_without_storing_the_header(): void + { + $connection = new PDO('sqlite::memory:'); + $tableCreateQuery = <<exec($tableCreateQuery); + $seedData = [ + ['name' => 'Ronnie', 'email' => 'ronnie@example.com'], + ['name' => 'Bobby', 'email' => 'bobby@example.com'], + ['name' => 'Ricky', 'email' => 'ricky@example.com'], + ['name' => 'Mike', 'email' => 'mike@example.com'], + ['name' => 'Ralph', 'email' => 'ralph@example.com'], + ['name' => 'Johnny', 'email' => 'johnny@example.com'], + ]; + + $stmt = $connection->prepare('INSERT INTO users (name, email) VALUES (:name, :email)'); + foreach ($seedData as $data) { + $stmt->bindValue(':name', $data['name']); + $stmt->bindValue(':email', $data['email']); + $stmt->execute(); + } + + $stmt = $connection->prepare('SELECT * FROM users'); + $stmt->execute(); + $tabularData = Buffer::from($stmt, Buffer::EXCLUDE_HEADER); + + self::assertSame([], $tabularData->getHeader()); + self::assertSame(6, $tabularData->recordCount()); + self::assertSame([1, 'Ronnie', 'ronnie@example.com'], $tabularData->nth(0)); + } + + #[Test] + public function it_implements_array_access_getter_methods_with_header(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => null], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Berkeley'], + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Galway'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Galway'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Galway'], + ); + + self::assertSame(['date' => '2011-01-01', 'temperature' => '1', 'place' => null], $buffer->nth(0)); + self::assertNotEmpty($buffer->nth(5)); + self::assertEmpty($buffer->nth(42)); + } + + #[Test] + public function it_throws_an_exception_on_negative_offset(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + $this->expectException(InvalidArgument::class); + + $buffer->nth(-1); /* @phpstan-ignore-line */ + } + + #[Test] + public function it_returns_an_empty_array_if_no_record_is_present(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + + self::assertSame([], $buffer->nth(0)); + self::assertSame([], $buffer->nth(42)); + } + + #[Test] + public function it_implements_array_access_getter_methods_without_header(): void + { + $buffer = new Buffer(); + $buffer->insert( + ['2011-01-01', '1'], + ['2011-01-02', '-1'], + ['2011-01-03', '0'], + ['2011-01-01', '6'], + ['2011-01-02', '8'], + ['2011-01-03', '5'], + ); + + self::assertSame(['2011-01-01', '1'], $buffer->nth(0)); + self::assertNotEmpty($buffer->nth(5)); + self::assertEmpty($buffer->nth(42)); + } + + #[Test] + public function it_can_update_the_buffer_using_a_record_as_a_list(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Galway'], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Galway'], + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Galway'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Berkeley'], + ); + + self::assertSame(0, $buffer->update(Offset::filterOn('=', 42), ['2011-01-01', '1', 'bujumbura'])); + self::assertSame(1, $buffer->update(Offset::filterOn('=', 0), ['2011-01-01', '1', 'bujumbura'])); + self::assertSame(['date' => '2011-01-01', 'temperature' => '1', 'place' => 'bujumbura'], $buffer->nth(0)); + } + + #[Test] + public function it_can_update_the_buffer_using_a_record_as_a_list_on_a_buffer_without_header(): void + { + $buffer = new Buffer(); + $buffer->insert( + ['2011-01-01', '1', 'Galway'], + ['2011-01-02', '-1', 'Berkeley'], + ['2011-01-03', '0', 'Galway'], + ['2011-01-01', '6', 'Berkeley'], + ['2011-01-02', '8', 'Galway'], + ['2011-01-03', '5', 'Berkeley'], + ); + + self::assertSame(0, $buffer->update(Offset::filterOn('=', 42), ['2011-01-01', '1', 'bujumbura'])); + self::assertSame(1, $buffer->update(Offset::filterOn('=', 0), [2 => 'bujumbura'])); + self::assertSame(['2011-01-01', '1', 'bujumbura'], $buffer->nth(0)); + } + + #[Test] + public function it_can_delete_the_buffer_records_using_a_closure(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Galway'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Berkeley'], + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Galway'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Galway'], + ); + + self::assertSame(3, $buffer->delete(fn (array $record, int $offset): bool => 0 === $offset % 2)); + self::assertSame(3, $buffer->recordCount()); + } + + #[Test] + public function it_can_return_a_column_by_name(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Galway'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Galway'], + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Galway'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Berkeley'], + ); + + self::assertSame( + ['2011-01-01', '2011-01-02', '2011-01-03', '2011-01-01', '2011-01-02', '2011-01-03'], + iterator_to_array($buffer->fetchColumn('date')) + ); + } + + #[Test] + public function it_can_return_a_column_by_offset(): void + { + $buffer = new Buffer(); + $buffer->insert( + ['2011-01-01', '1'], + ['2011-01-02', '-1'], + ['2011-01-03', '0'], + ['2011-01-01', '6'], + ['2011-01-02', '8'], + ['2011-01-03', '5'], + ); + + self::assertSame( + ['2011-01-01', '2011-01-02', '2011-01-03', '2011-01-01', '2011-01-02', '2011-01-03'], + iterator_to_array($buffer->fetchColumn()) + ); + } + + #[Test] + public function it_can_return_a_column_by_offset_even_when_theres_a_header(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Galway'], + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Berkeley'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Galway'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Berkeley'], + ); + + self::assertSame( + ['2011-01-01', '2011-01-02', '2011-01-03', '2011-01-01', '2011-01-02', '2011-01-03'], + iterator_to_array($buffer->fetchColumn()) + ); + } + + #[Test] + public function it_will_store_the_buffer_with_its_header(): void + { + $buffer = new Buffer(['date', 'temperature']); + $buffer->insert( + ['2011-01-01', '1'], + ['2011-01-02', '-1'], + ); + + $writer = Writer::fromString(); + $res = $buffer->to($writer); + + self::assertSame(44, $res); + self::assertSame("date,temperature\n2011-01-01,1\n2011-01-02,-1\n", $writer->toString()); + } + + #[Test] + public function it_will_store_the_buffer_without_its_header(): void + { + $buffer = new Buffer(['date', 'temperature']); + $buffer->insert( + ['2011-01-01', '1'], + ['2011-01-02', '-1'], + ); + + $writer = Writer::fromString(); + $res = $buffer->to($writer, Buffer::EXCLUDE_HEADER); + + self::assertSame(27, $res); + self::assertSame("2011-01-01,1\n2011-01-02,-1\n", $writer->toString()); + } + + #[Test] + public function it_will_store_the_buffer_without_its_header_if_none_exists(): void + { + $buffer = new Buffer(); + $buffer->insert( + ['2011-01-01', '1'], + ['2011-01-02', '-1'], + ); + + $writer = Writer::fromString(); + $res = $buffer->to($writer); + + self::assertSame(27, $res); + self::assertSame("2011-01-01,1\n2011-01-02,-1\n", $writer->toString()); + } + + #[Test] + public function it_can_validate_a_record_on_insertion(): void + { + $buffer = new Buffer(); + $buffer->addValidator(fn (array $row): bool => $row[1] >= 0, 'func1'); + + $this->expectExceptionObject(CannotInsertRecord::triggerOnValidation('func1', ['column1', -1])); + + $buffer->insert(['column1', 1]); + $buffer->insert(['column1', -1]); + } + + #[Test] + public function it_can_validate_a_record_on_update(): void + { + $buffer = new Buffer(); + $buffer->addValidator(fn (array $row): bool => $row[1] >= 0, 'func1'); + + $this->expectExceptionObject(CannotInsertRecord::triggerOnValidation('func1', ['column1', -1])); + + $buffer->insert(['column1', 1]); + $buffer->update(fn (array $row, int $offset): bool => 0 === $offset, [1 => -1]); + } + + #[Test] + public function it_can_re_order_the_fields_using_the_header(): void + { + $buffer = new Buffer(); + $buffer->insert( + ['moko', 'mibalé', 'misató'], + ['un', 'deux', 'trois'], + ['one', 'two', 'three'], + ['unos', 'dos', 'tres'], + ); + + $res = iterator_to_array($buffer->getRecords([2 => 'column 1', 1 => 'column 2', 0 => 'column 3'])); + self::assertSame(['column 1' => 'misató', 'column 2' => 'mibalé', 'column 3' => 'moko'], $res[0]); + } + + #[Test] + public function it_fails_to_update_a_record_with_no_record(): void + { + $buffer = new Buffer(); + $buffer->insert( + ['moko', 'mibalé', 'misató'], + ['un', 'deux', 'trois'], + ['one', 'two', 'three'], + ['unos', 'dos', 'tres'], + ); + + $this->expectException(CannotInsertRecord::class); + $buffer->update(fn (array $row, int $offset): bool => 1 === $offset, []); + } + + #[Test] + public function it_can_format_the_data_inserted(): void + { + $buffer = new Buffer(); + $buffer->addFormatter(fn (array $row): array => array_map(strtoupper(...), $row)); + $buffer->insert(['jane', 'doe']); + self::assertSame(['JANE', 'DOE'], $buffer->nth(0)); + } + + #[Test] + public function it_formats_the_data_before_validation(): void + { + $buffer = new Buffer(); + $buffer->addFormatter(fn (array $row): array => array_map(strtoupper(...), $row)); + $buffer->addValidator(fn (array $row): bool => strtolower((string) $row[1]) === $row[1], 'func1'); + + $this->expectException(CannotInsertRecord::class); + $buffer->insert(['jane', 'doe']); + } + + #[Test] + public function it_makes_a_difference_between_record_offset_first_and_last(): void + { + $csv = <<setHeaderOffset(0); + + $buffer = Buffer::from($document->slice(2, 3)); + $bufferAsArray = iterator_to_array($buffer->getRecords()); + $firstRecord = ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Galway']; + $lastRecord = ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Berkeley']; + + self::assertSame($firstRecord, $buffer->first()); + //offset 3 based on the original CSV document + $index1 = $buffer->firstOffset(); + self::assertIsInt($index1); + self::assertSame($firstRecord, $bufferAsArray[$index1]); + self::assertSame($lastRecord, $buffer->last()); + //offset 5 based on the original CSV document + $index2 = $buffer->lastOffset(); + self::assertIsInt($index2); + self::assertSame($lastRecord, $bufferAsArray[$index2]); + + //delete all records except the first record! + $buffer->delete(Column::filterOn('temperature', '<>', '0')); + $index3 = $buffer->firstOffset(); + self::assertIsInt($index3); + self::assertSame($firstRecord, $bufferAsArray[$index3]); + + $index4 = $buffer->lastOffset(); + self::assertIsInt($index4); + self::assertSame($firstRecord, $bufferAsArray[$index4]); + } + + #[Test] + public function it_implements_the_truncate_method(): void + { + $buffer = new Buffer(['date', 'temperature', 'place']); + $buffer->insert( + ['date' => '2011-01-01', 'temperature' => '1', 'place' => null], + ['date' => '2011-01-02', 'temperature' => '-1', 'place' => 'Berkeley'], + ['date' => '2011-01-03', 'temperature' => '0', 'place' => 'Berkeley'], + ['date' => '2011-01-01', 'temperature' => '6', 'place' => 'Galway'], + ['date' => '2011-01-02', 'temperature' => '8', 'place' => 'Galway'], + ['date' => '2011-01-03', 'temperature' => '5', 'place' => 'Galway'], + ); + + self::assertFalse($buffer->isEmpty()); + self::assertTrue($buffer->hasHeader()); + + $buffer->truncate(); + + self::assertTrue($buffer->isEmpty()); + self::assertTrue($buffer->hasHeader()); + } +}