From d9841192c51178a962d61de08244f9060f262def Mon Sep 17 00:00:00 2001 From: Lukas Heller Date: Tue, 14 Nov 2023 09:49:30 +0100 Subject: [PATCH 1/3] wip Writer --- composer.json | 3 +- src/Csv.php | 26 ++++++ src/CsvProcessor.php | 44 +++++++++- src/CsvWriter.php | 149 ++++++++++++++++++++++++++++++++++ src/Support/FileHandler.php | 2 +- tests/Fixtures/data_write.csv | 3 + tests/Unit/CsvTest.php | 20 ++++- tests/Unit/CsvWriteTest.php | 139 +++++++++++++++++++++++++++++++ 8 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 src/CsvWriter.php create mode 100644 tests/Fixtures/data_write.csv create mode 100644 tests/Unit/CsvWriteTest.php diff --git a/composer.json b/composer.json index d42fd9f..01a7872 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "laravel/pint": "^1.13", "pestphp/pest": "^2.19", "spatie/ray": "^1.39", - "rector/rector": "^0.18.5" + "rector/rector": "^0.18.5", + "mockery/mockery": "^1.6" }, "config": { "allow-plugins": { diff --git a/src/Csv.php b/src/Csv.php index 1cbb063..13cd5bb 100644 --- a/src/Csv.php +++ b/src/Csv.php @@ -26,6 +26,14 @@ public static function read(string $filePath) return new self($filePath); } + /** + * Create a new instance of the CsvWriter class. + */ + public static function make(array $data, $options = []) + { + return new CsvWriter($data, $options); + } + /** * Set the delimiter for the CSV file. * @@ -227,4 +235,22 @@ public function toJson() { return json_encode($this->toArray(), JSON_THROW_ON_ERROR); } + + /** + * Insert a row with data at the specified index. + */ + public function insertAt(int $index, array $row) + { + + if ($this->processor->skipRows != []) { + $index = max($index + count($this->processor->skipRows), 2); + // dump($index + count($this->processor->skipRows)); + // dd($index); + } + (new CsvWriter([])) + ->setFileHandler($this->processor->fileHandler) + ->insertRow($index, $row); + + return $this; + } } diff --git a/src/CsvProcessor.php b/src/CsvProcessor.php index f66b1d6..75c81e3 100644 --- a/src/CsvProcessor.php +++ b/src/CsvProcessor.php @@ -3,6 +3,7 @@ namespace Heller\SimpleCsv; use Closure; +use Generator; use Heller\SimpleCsv\Support\FileHandler; class CsvProcessor @@ -31,7 +32,7 @@ public function __construct(public FileHandler $fileHandler) { } - public function process() + public function process(): Generator { if (($handle = $this->fileHandler->openFile()) === false) { return; @@ -65,10 +66,8 @@ public function process() /** * Precessing a single row of CSV data - * - * @return array|object */ - public function prepareRow(array $row) + public function prepareRow(array $row): array|object { $row = $this->skipColumnsByIndex($row); $row = $this->skipColumnsByHeaderName($row); @@ -102,6 +101,12 @@ public function prepareRow(array $row) return $this->createObjectInstance($row); } + /** + * Get the header row from the CSV file. + * + * If headers were set manually, return those. + * Otherwise read the header row from the CSV based on headerRow index. + */ public function getHeaderRow() { if ($this->headers) { @@ -206,4 +211,35 @@ public function combineWithHeader($header, $row) return $row; } + + /** + * Insert a row with data at the specified index in the CSV file. + * + * @param int $position The index to insert the row at + * @param array $rowData The data for the new row + */ + public function insertAt($position = 0, $rowData = []) + { + // if rows are skipped, we need to add the number of skipped rows to the position + // this is mainly when the rows are mapped to the headers + if ($this->skipRows != []) { + $position = $position + count($this->skipRows); + } + + $writer = new CsvWriter([]); + $writer->setFileHandler($this->fileHandler); + $writer->insertRow($position, $rowData); + } + + /** + * Appending data to the end of the CSV file. + */ + public function append($rowData = []) + { + $writer = new CsvWriter($rowData); + $writer->setFileHandler($this->fileHandler); + $writer->write( + append: true + ); + } } diff --git a/src/CsvWriter.php b/src/CsvWriter.php new file mode 100644 index 0000000..c505878 --- /dev/null +++ b/src/CsvWriter.php @@ -0,0 +1,149 @@ +data = $data; + } + + public function write($append = false) + { + $fileMode = $append ? 'a+' : 'w'; + $handle = fopen($this->filePath, $fileMode); + + if ($handle === false) { + throw new \Exception("Could not open file: $this->filePath"); + } + + if ($append === true) { + $headers = fgetcsv($handle); + $this->headers = $headers; + } + + if ($this->headers !== [] && $append === false) { + fputcsv($handle, $this->headers); + } + + foreach ($this->data as $row) { + if ($this->headers !== []) { + $row = $this->normalizeRowWithHeaders($row); + } + + fputcsv($handle, $row); + + } + + fclose($handle); + } + + public function toFile(string $filePath) + { + $this->filePath = $filePath; + + return $this; + } + + public function append() + { + return $this->write(true); + } + + public function withHeaders(array $headers) + { + $this->headers = $headers; + + return $this; + } + + protected function normalizeRowWithHeaders($row) + { + // if the keys of the row are strings, then we need to merge the headers with the row + // so that the keys are in the same order as the headers + if (array_keys($row) !== range(0, count($row) - 1)) { + // dd($this->headers); + + return array_merge(array_flip($this->headers), $row); + } + + return $row; + } + + protected function getFileHandler() + { + if ($this->fileHandler === null) { + $this->fileHandler = new FileHandler($this->filePath); + } + + return $this->fileHandler; + } + + public function setFileHandler(FileHandler $fileHandler) + { + $this->fileHandler = $fileHandler; + + return $this; + } + + public function insertRow(int $position, array $rowData) + { + $handle = $this->getFileHandler()->openFile(); + + if ($handle === false) { + return; + } + + $tempFile = tmpfile(); + + $this->copyDataUpToPosition($handle, $tempFile, $position); + $this->writeNewRow($tempFile, $rowData); + $this->copyRemainingData($handle, $tempFile); + + $this->rewindAndCopyBack($handle, $tempFile); + + fclose($handle); + fclose($tempFile); + } + + private function copyDataUpToPosition($handle, $tempFile, $position) + { + for ($i = 1; $i < $position; $i++) { + $line = fgets($handle); + fwrite($tempFile, $line); + } + } + + private function writeNewRow($tempFile, $rowData) + { + fputcsv($tempFile, $rowData); + } + + private function copyRemainingData($handle, $tempFile) + { + while (($line = fgets($handle)) !== false) { + fwrite($tempFile, $line); + } + } + + private function rewindAndCopyBack($handle, $tempFile) + { + rewind($handle); + rewind($tempFile); + + while (($line = fgets($tempFile)) !== false) { + fwrite($handle, $line); + } + } +} diff --git a/src/Support/FileHandler.php b/src/Support/FileHandler.php index e51c11a..4794f83 100644 --- a/src/Support/FileHandler.php +++ b/src/Support/FileHandler.php @@ -17,7 +17,7 @@ public function __construct(protected string $filePath) */ public function openFile() { - return str_starts_with($this->filePath, 'http') ? $this->handleFromUrl() : fopen($this->filePath, 'r'); + return str_starts_with($this->filePath, 'http') ? $this->handleFromUrl() : fopen($this->filePath, 'r+'); } protected function handleFromUrl() diff --git a/tests/Fixtures/data_write.csv b/tests/Fixtures/data_write.csv new file mode 100644 index 0000000..3e38485 --- /dev/null +++ b/tests/Fixtures/data_write.csv @@ -0,0 +1,3 @@ +Col1,Col2,Col3 +Foo,Bar,Baz +Foo2,Bar2,Baz2 diff --git a/tests/Unit/CsvTest.php b/tests/Unit/CsvTest.php index e2f3e74..aef7fbe 100644 --- a/tests/Unit/CsvTest.php +++ b/tests/Unit/CsvTest.php @@ -148,15 +148,11 @@ }); test('It works with a csv from an url', function () { - - // $file = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vQbCA0PYQtwEDF2g4rv3-22vUpoBaNaYWFNW3wR0s0a904D-9vRfmIkNzA7VmKDArfGY81whg9tWhWp/pub?gid=0&single=true&output=csv'; $file = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vQbCA0PYQtwEDF2g4rv3-22vUpoBaNaYWFNW3wR0s0a904D-9vRfmIkNzA7VmKDArfGY81whg9tWhWp/pub?gid=103922319&single=true&output=csv'; - // $file = __DIR__.'/../Fixtures/data_10krows.csv'; $csv = Csv::read($file)->mapToHeaders(); expect(count($csv->toArray()))->toBeGreaterThan(0); - // ray()->measure(); }); @@ -379,3 +375,19 @@ function makeTestFile($rows = 1000) expect($csv->count())->toBe(3); }); + +test('it inserts a row at a certain position', function () { + $file = makeTestFile(2); + + $csv = Csv::read($file) + ->mapToHeaders() + ->insertAt(1, ['Foo3', 'Bar3', 'Baz3']); + + expect($csv->toArray()[0])->toBe([ + 'Foo' => 'Foo3', + 'Bar' => 'Bar3', + 'Baz' => 'Baz3', + ]); + + unlink($file); +}); diff --git a/tests/Unit/CsvWriteTest.php b/tests/Unit/CsvWriteTest.php new file mode 100644 index 0000000..1a572c9 --- /dev/null +++ b/tests/Unit/CsvWriteTest.php @@ -0,0 +1,139 @@ +toFile(__DIR__.'/../Fixtures/data_write.csv') + ->write(); + + expect(file_get_contents(__DIR__.'/../Fixtures/data_write.csv')) + ->toBe( + 'Foo,Bar,Baz'.PHP_EOL + ); +}); +test('It writes headers to a file', function () { + $data = [[ + 'Foo', + 'Bar', + 'Baz', + ], + ]; + + $file = Csv::make($data) + ->withHeaders(['Col1', 'Col2', 'Col3']) + ->toFile(__DIR__.'/../Fixtures/data_write.csv') + ->write(); + + $foo = file_get_contents(__DIR__.'/../Fixtures/data_write.csv'); + + expect($foo) + ->toBe( + 'Col1,Col2,Col3'.PHP_EOL. + 'Foo,Bar,Baz'.PHP_EOL + ); +}); + +test('It writes assoc array to the matching header columns', function () { + $data = [ + [ + 'Col2' => 'Bar', + 'Col1' => 'Foo', + 'Col3' => 'Baz', + ], + ]; + + $file = Csv::make($data) + ->withHeaders(['Col1', 'Col2', 'Col3']) + ->toFile(__DIR__.'/../Fixtures/data_write.csv'); + + $foo = file_get_contents(__DIR__.'/../Fixtures/data_write.csv'); + + expect($foo) + ->toBe( + 'Col1,Col2,Col3'.PHP_EOL. + 'Foo,Bar,Baz'.PHP_EOL + ); +}); + +test('It appends data to a file', function () { + $data = [[ + 'Foo', + 'Bar', + 'Baz', + ], + ]; + + $file = Csv::make($data) + ->withHeaders(['Col1', 'Col2', 'Col3']) + ->toFile(__DIR__.'/../Fixtures/data_write.csv') + ->write(); + + expect(file_get_contents(__DIR__.'/../Fixtures/data_write.csv')) + ->toBe( + 'Col1,Col2,Col3'.PHP_EOL. + 'Foo,Bar,Baz'.PHP_EOL + ); + $data = [[ + 'Foo', + 'Bar', + 'Baz', + ]]; + $file = Csv::make($data) + ->toFile(__DIR__.'/../Fixtures/data_write.csv') + ->append(); + + expect(file_get_contents(__DIR__.'/../Fixtures/data_write.csv')) + ->toBe( + 'Col1,Col2,Col3'.PHP_EOL. + 'Foo,Bar,Baz'.PHP_EOL. + 'Foo,Bar,Baz'.PHP_EOL + ); +}); + +test('It appends assoc data to a file in the right order', function () { + $data = [ + [ + 'Col2' => 'Bar', + 'Col1' => 'Foo', + 'Col3' => 'Baz', + ], + ]; + + unlink(__DIR__.'/../Fixtures/data_write.csv'); + + $file = Csv::make($data) + ->withHeaders(['Col1', 'Col2', 'Col3']) + ->toFile(__DIR__.'/../Fixtures/data_write.csv') + ->write(); + + expect(file_get_contents(__DIR__.'/../Fixtures/data_write.csv')) + ->toBe( + 'Col1,Col2,Col3'.PHP_EOL. + 'Foo,Bar,Baz'.PHP_EOL + ); + $data = [ + [ + 'Col2' => 'Bar2', + 'Col1' => 'Foo2', + 'Col3' => 'Baz2', + ], + ]; + $file = Csv::make($data) + ->withHeaders(['Col1', 'Col2', 'Col3']) + ->toFile(__DIR__.'/../Fixtures/data_write.csv') + ->append(); + + expect(file_get_contents(__DIR__.'/../Fixtures/data_write.csv')) + ->toBe( + 'Col1,Col2,Col3'.PHP_EOL. + 'Foo,Bar,Baz'.PHP_EOL. + 'Foo2,Bar2,Baz2'.PHP_EOL + ); +}); From b6e29f61668761567f872a8e5a51f8761023be97 Mon Sep 17 00:00:00 2001 From: Lukas Heller Date: Thu, 18 Jul 2024 12:02:50 +0200 Subject: [PATCH 2/3] tests: switch default csv url to gist --- tests/Unit/CsvTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Unit/CsvTest.php b/tests/Unit/CsvTest.php index aef7fbe..593a945 100644 --- a/tests/Unit/CsvTest.php +++ b/tests/Unit/CsvTest.php @@ -148,7 +148,7 @@ }); test('It works with a csv from an url', function () { - $file = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vQbCA0PYQtwEDF2g4rv3-22vUpoBaNaYWFNW3wR0s0a904D-9vRfmIkNzA7VmKDArfGY81whg9tWhWp/pub?gid=103922319&single=true&output=csv'; + $file = 'https://gist.githubusercontent.com/lpheller/de4ff8f79da8f640478bc8f2a0070cdb/raw/test.csv'; $csv = Csv::read($file)->mapToHeaders(); From 32f901f7748938531a26d5e8f4537ff91b50ef39 Mon Sep 17 00:00:00 2001 From: lpheller Date: Thu, 18 Jul 2024 10:03:15 +0000 Subject: [PATCH 3/3] Fix styling --- src/Csv.php | 22 +++++++++++----------- src/CsvProcessor.php | 8 +++----- src/Support/FileHandler.php | 4 +--- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/Csv.php b/src/Csv.php index 13cd5bb..6d49b57 100644 --- a/src/Csv.php +++ b/src/Csv.php @@ -18,7 +18,7 @@ public function __construct(string $filePath) /** * Create a new instance of the Csv class. * - * @param string $filePath The path or url to the CSV file + * @param string $filePath The path or url to the CSV file * @return $this */ public static function read(string $filePath) @@ -37,7 +37,7 @@ public static function make(array $data, $options = []) /** * Set the delimiter for the CSV file. * - * @param string $delimiter The delimiter for the CSV file + * @param string $delimiter The delimiter for the CSV file * @return $this */ public function delimiter(string $delimiter) @@ -51,7 +51,7 @@ public function delimiter(string $delimiter) * Map the CSV data to header keys. * The header row will be used as the keys for the data rows. * - * @param int|array $headerRow The header row number or an array of header names + * @param int|array $headerRow The header row number or an array of header names * @return $this */ public function mapToHeaders(array|int|bool $headerRow = 1) @@ -74,7 +74,7 @@ public function mapToHeaders(array|int|bool $headerRow = 1) * Set the header row number. * This is only used when mapping the CSV data to header keys. * - * @param int $row The header row number + * @param int $row The header row number * @return $this */ public function setHeaderRow(int $row) @@ -105,7 +105,7 @@ public function getHeaderRow() /** * Skip rows by index. * - * @param int|array $rows The row numbers to skip + * @param int|array $rows The row numbers to skip * @return $this */ public function skipRows(int|array $rows) @@ -118,7 +118,7 @@ public function skipRows(int|array $rows) /** * Skip columns by index or header name. * - * @param int|array $columns The column numbers or header names to skip + * @param int|array $columns The column numbers or header names to skip * @return $this */ public function skipColumns(int|array $columns) @@ -131,7 +131,7 @@ public function skipColumns(int|array $columns) /** * Filter the rows with a callback function. * - * @param Closure $callback The callback function to filter the rows + * @param Closure $callback The callback function to filter the rows * @return $this */ public function filter(Closure $callback) @@ -146,10 +146,10 @@ public function filter(Closure $callback) * object will be an instance of that class. Otherwise, it will be an * instance of stdClass. * - * @param string|null $customClass The custom class to map the data to + * @param string|null $customClass The custom class to map the data to * @return $this */ - public function mapToObject(string $customClass = null) + public function mapToObject(?string $customClass = null) { $this->mapToHeaders(); $this->processor->mapToObject = true; @@ -192,7 +192,7 @@ public function first() * used for huge files. Use the process() method instead to process the * CSV line by line. * - * @return array + * @return array */ public function toArray() { @@ -207,7 +207,7 @@ public function toArray() /** * Process the CSV data line by line. * - * @param callable $callback The callback function to process each row + * @param callable $callback The callback function to process each row */ public function each($callback) { diff --git a/src/CsvProcessor.php b/src/CsvProcessor.php index 75c81e3..a404dbc 100644 --- a/src/CsvProcessor.php +++ b/src/CsvProcessor.php @@ -28,9 +28,7 @@ class CsvProcessor public array $headers = []; - public function __construct(public FileHandler $fileHandler) - { - } + public function __construct(public FileHandler $fileHandler) {} public function process(): Generator { @@ -215,8 +213,8 @@ public function combineWithHeader($header, $row) /** * Insert a row with data at the specified index in the CSV file. * - * @param int $position The index to insert the row at - * @param array $rowData The data for the new row + * @param int $position The index to insert the row at + * @param array $rowData The data for the new row */ public function insertAt($position = 0, $rowData = []) { diff --git a/src/Support/FileHandler.php b/src/Support/FileHandler.php index 4794f83..d3920d8 100644 --- a/src/Support/FileHandler.php +++ b/src/Support/FileHandler.php @@ -6,9 +6,7 @@ class FileHandler { protected $cachedContent; - public function __construct(protected string $filePath) - { - } + public function __construct(protected string $filePath) {} /** * Open the file and return a stream resource