diff --git a/README.md b/README.md index 0554458..7cd35b3 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,37 @@ # Laravel CSV -PHP Laravel package to create CSV files in a memory-optimized way. +PHP Laravel package to export and import CSV files in a memory-optimized way. -# Description -Generate CSV files from any of these data sources: _PHP arrays_, _Laravel Collections_ or _Laravel Queries_. You can either prompt the user to download the file or store it in a Laravel disk. For larger datasets, you can generate the file in background as a Laravel Job. +## Description +Export CSV files from _PHP arrays_, _Laravel Collections_ or _Laravel Queries_ and choose to prompt the user to download the file, store it in a Laravel disk or create the file in background as a Laravel Job. -This project was inspired on https://github.com/maatwebsite/Laravel-Excel which is a great project and can handle many formats (Excel, PDF, OpenOffice and CSV). But since it uses PhpSpreadsheet, it is not optimized for exporting large CSV files (thousands of records) causing the PHP memory exhaustion. +Import CSV files from _Laravel Disks_, _local files_, _strings_ or _resources_ and choose to retrieve the full content or in small chunks. -The memory usage is optimized in this project by retrieving small chunks of results at a time and outputting the CSV content directly to the client browser or to a persistent file with the use of [PHP streams](https://www.php.net/manual/en/intro.stream.php). +The memory usage is optimized in this project by using [PHP streams](https://www.php.net/manual/en/intro.stream.php), which places the content in a temporary file (rather than PHP thread memory) and reads/writes content one line at a time. -This project is using some of Laravel-Excel design principles because it is both a solid work and a reference, and by doing that, it also reduces the learning curve and adoption to this library. +NOTE: This project was inspired on https://github.com/maatwebsite/Laravel-Excel which is a great project and can handle many formats (Excel, PDF, OpenOffice and CSV). But since it uses PhpSpreadsheet, it is not optimized for handling large CSV files (thousands of records) causing the PHP memory exhaustion. -# Requirements +## Upgrading from v1.0 to v2.0 +Version 2.0 adds the importing feature so the only required action is to change the importing namespace: +```php +// v1.0 (old) +use Vitorccs\LaravelCsv\Concerns\Exportable; +use Vitorccs\LaravelCsv\Concerns\FromArray; +use Vitorccs\LaravelCsv\Concerns\FromCollection; +use Vitorccs\LaravelCsv\Concerns\FromQuery; +``` +```php +// v2.0 (new) +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromCollection; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; +``` + +## Requirements * PHP >= 8.0 * Laravel >= 6.x -# Installation +## Installation Step 1) Add composer dependency ```bash composer require vitorccs/laravel-csv @@ -27,16 +44,17 @@ php artisan vendor:publish --provider="Vitorccs\LaravelCsv\ServiceProviders\CsvS Step 3) Edit your local `config\csv.php` file per your project preferences -Step 4) Create an Export class file as shown below +## How to Export +Step 1) Create an Export class file as shown below -Note: you may implement _FromArray_, _FromCollection_ or _FromQuery_ +Note: you may implement _FromArray_, _FromCollection_ or _FromQuery_ ```php namespace App\Exports; use App\User; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromQuery; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; class UsersExport implements FromQuery { @@ -44,12 +62,13 @@ class UsersExport implements FromQuery public function query() { - return User::query(); + return User::query() + ->where('created_at', '>=', '2024-01-01 00:00:00'); } } ``` -Step 5) The file can now be generated by using a single line: +Step 2) The file can now be generated by using a single line: ```php # prompt the client browser to download the file return (new UsersExport)->download('users.csv'); @@ -61,8 +80,16 @@ In case you want the file to be stored in the disk: return (new UsersExport)->store('users.csv', 's3'); ``` +You may also get the content as stream for better control over the output: +```php +# will get the content in a stream (content placed in a temporary file) +return (new UsersExport)->stream(); +``` + For larger files, you may want to generate the file in background as a Laravel Job ```php +use App\Jobs\NotifyCsvCreated; + # generate a {uuid-v4}.csv filename $filename = CsvHelper::filename(); @@ -72,20 +99,22 @@ $filename = CsvHelper::filename(); ->queue($filename, 's3') ->allOnQueue('default') ->chain([ + // You must create the Laravel Job below new NotifyCsvCreated($filename) ]); ``` -# Data sources -Note: only `FromQuery` can chunk results per `chunk_size` parameter from config file. +## Export - Data sources +Note: Only `FromQuery` can chunk results per `chunk_size` parameter from config file. + +### Laravel Eloquent Query Builder -## Laravel Eloquent Query Builder ```php namespace App\Exports; use App\User; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromQuery; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; class MyQueryExport implements FromQuery { @@ -98,14 +127,15 @@ class MyQueryExport implements FromQuery } ``` -## Laravel Database Query Builder +### Laravel Database Query Builder + ```php namespace App\Exports; use App\User; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromQuery; use Illuminate\Support\Facades\DB; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; class MyQueryExport implements FromQuery { @@ -118,12 +148,13 @@ class MyQueryExport implements FromQuery } ``` -## Laravel Collection +### Laravel Collection + ```php namespace App\Exports; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromCollection; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromCollection; class MyCollectionExport implements FromCollection { @@ -140,13 +171,14 @@ class MyCollectionExport implements FromCollection } ``` -## Laravel LazyCollection +### Laravel LazyCollection + ```php namespace App\Exports; use App\User; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromCollection; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromCollection; class MyQueryExport implements FromCollection { @@ -159,12 +191,13 @@ class MyQueryExport implements FromCollection } ``` -## PHP Arrays +### PHP Arrays + ```php namespace App\Exports; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromArray; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; class MyArrayExport implements FromArray { @@ -181,14 +214,145 @@ class MyArrayExport implements FromArray } ``` -# Implementations +## How to Import +Step 1) Create an Import class file as shown below -## Headings -`WithHeadings` adds a heading row +Note: you may implement _FromDisk_, _FromFile_, _FromResource_ or _FromContents_ ```php -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromArray; +namespace App\Exports; + +use Vitorccs\LaravelCsv\Concerns\Importables\Importable; +use Vitorccs\LaravelCsv\Concerns\Importables\FromDisk; + +class UsersImport implements FromDisk +{ + use Importable; + + public function disk(): ?string + { + return 's3'; + } + + public function filename(): string + { + return 'users.csv'; + } +} +``` + +Step 2) The content can now be retrieved by using a single line: +```php +# get the records in array format +return (new UsersImport)->getArray(); +``` + +```php +# in case the result is too large, you may receive small chunk of results +# at a time in your callback function, preventing memory exhaustion. +(new UsersImport)->chunkArray(function(array $rows, int $index) { + // do something with the rows + echo "Chunk $index has the following records:"; + print_r($rows); +}); +``` + +## Import - Data sources + +### From string + +```php +namespace App\Imports; + +use Vitorccs\LaravelCsv\Concerns\Importables\Importable; +use Vitorccs\LaravelCsv\Concerns\Importables\FromContents; + +class MyContents implements FromContents +{ + use Importable; + + public function contents(): string + { + return "A1,B1,C1\nA2,B2,C2\n,A3,B3,C3"; + } +} +``` + +### From local File + +```php +namespace App\Imports; + +use Vitorccs\LaravelCsv\Concerns\Importables\Importable; +use Vitorccs\LaravelCsv\Concerns\Importables\FromFile; + +class MyFileImport implements FromFile +{ + use Importable; + + public function filename(): string; + { + return storage_path() . '/users.csv'; + } +} +``` + +### From resource + +```php +namespace App\Imports; + +use Vitorccs\LaravelCsv\Concerns\Importables\Importable; +use Vitorccs\LaravelCsv\Concerns\Importables\FromResource; + +class MyResourceImport implements FromResource +{ + use Importable; + + public function resource() + { + $contents = "A1,B1,C1\nA2,B2,C2\n,A3,B3,C3"; + $resource = fopen('php://memory', 'w+'); + + fputs($resource, $contents); + + return $resource; + } +} +``` + +### From Laravel Disk +```php +namespace App\Exports; + +use Vitorccs\LaravelCsv\Concerns\Importables\Importable; +use Vitorccs\LaravelCsv\Concerns\Importables\FromDisk; + +class UsersImport implements FromDisk +{ + use Importable; + + public function disk(): ?string + { + return 'local'; + } + + public function filename(): string + { + return 'my_imports/users.csv'; + } +} +``` + +## Implementations +The implementations below work with both Export and Import mode. + +### Headings +Implement `WithHeadings` for setting a heading to the CSV file. + +```php +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; use Vitorccs\LaravelCsv\Concerns\WithHeadings; class UsersExport implements FromArray, WithHeadings @@ -202,12 +366,12 @@ class UsersExport implements FromArray, WithHeadings } ``` -## Mapping rows -Implement `WithMapping` if you either need to set the value of each column or apply some custom formatting. +### Mapping rows +Implement `WithMapping` if you either need to set the value of each column or apply some custom formatting. ```php -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromArray; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; use Vitorccs\LaravelCsv\Concerns\WithMapping; class UsersExport implements FromArray, WithMapping @@ -225,16 +389,21 @@ class UsersExport implements FromArray, WithMapping } ``` -## Formatting columns -Implement `WithColumnFormatting` to format Date and Number fields. -Note: The Date must be a Carbon or Datetime object, and the number must be numeric string, integer or float. The formatting preferences are set in the config file `csv.php`. +### Formatting columns +Implement `WithColumnFormatting` to format date and numeric fields. + +In export mode, the Date must be either a Carbon or a Datetime object, and the number must be any kind of numeric data (numeric string, integer or float). + +In import mode, the string content must match with the formatting set (e.g: yyyy-mm-dd for dates). + +The formatting preferences are set in the config file `csv.php`. ```php -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromArray; +use Carbon\Carbon; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; use Vitorccs\LaravelCsv\Concerns\WithColumnFormatting; use Vitorccs\LaravelCsv\Enum\CellFormat; -use Carbon\Carbon; class UsersExport implements FromArray, WithColumnFormatting { @@ -260,12 +429,12 @@ class UsersExport implements FromArray, WithColumnFormatting } ``` -## Limiting the results -Implement the method below if you need to limit the quantity of results. +### Limiting the results +Implement the method below if you need to limit the quantity of results to be exported/imported. ```php -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromQuery; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; class UsersExport implements FromQuery { diff --git a/src/Concerns/Exportable.php b/src/Concerns/Exportables/Exportable.php similarity index 78% rename from src/Concerns/Exportable.php rename to src/Concerns/Exportables/Exportable.php index cf635f5..296b489 100644 --- a/src/Concerns/Exportable.php +++ b/src/Concerns/Exportables/Exportable.php @@ -1,9 +1,10 @@ getFilename($filename), $disk, $diskOptions); } + /** + * @return resource + */ + public function stream() + { + return CsvExporter::stream($this); + } + /** * @param string|null $filename * @return string diff --git a/src/Concerns/FromArray.php b/src/Concerns/Exportables/FromArray.php similarity index 66% rename from src/Concerns/FromArray.php rename to src/Concerns/Exportables/FromArray.php index 15d7167..8b2403d 100644 --- a/src/Concerns/FromArray.php +++ b/src/Concerns/Exportables/FromArray.php @@ -1,6 +1,6 @@ service->queue($exportable, $filename, $disk, $diskOptions); } + + /** + * @param object $exportable + * @return resource + * @throws InvalidCellValueException + */ + public function stream(object $exportable) + { + return $this->service->getStream($exportable); + } } diff --git a/src/CsvImporter.php b/src/CsvImporter.php index 36f3c98..c025ca1 100644 --- a/src/CsvImporter.php +++ b/src/CsvImporter.php @@ -25,7 +25,7 @@ public function __construct(ImportableService $service) */ public function getConfig(): CsvConfig { - return $this->service->getConfig(); + return $this->service->getConfig(); } /** @@ -37,12 +37,33 @@ public function setConfig(CsvConfig $config): void } /** - * @param string $filename - * @param string|null $disk + * @param object $importable + * @return int + */ + public function count(object $importable): int + { + return $this->service->count($importable); + } + + /** + * @param object $importable * @return array */ - public function fromDisk(string $filename, ?string $disk = null): array + public function getArray(object $importable): array + { + return $this->service->getArray($importable); + } + + /** + * @param object $importable + * @param callable(array,int):void $callable + * @param int|null $size + * @return void + */ + public function chunkArray(object $importable, + callable $callable, + ?int $size): void { - return $this->service->fromDisk($filename, $disk); + $this->service->chunkArray($importable, $callable, $size); } } diff --git a/src/Facades/CsvExporter.php b/src/Facades/CsvExporter.php index 4e3acf9..5b689b7 100644 --- a/src/Facades/CsvExporter.php +++ b/src/Facades/CsvExporter.php @@ -9,12 +9,13 @@ /** * @method static CsvConfig getConfig() + * @method static void setConfig(CsvConfig $config) * @method static int count(object $exportable) * @method static array toArray(object $exportable) * @method static string store(object $exportable, string $filename = null, ?string $disk = null, array $diskOptions = []) * @method static StreamedResponse download(object $exportable, string $filename) + * @method static stream(object $exportable) * @method static PendingDispatch queue(object $exportable, string $filename, ?string $disk = null, array $diskOptions = []) - * @method static void setConfig(CsvConfig $config) */ class CsvExporter extends Facade { diff --git a/src/Facades/CsvImporter.php b/src/Facades/CsvImporter.php index f00c8d0..0b725e3 100644 --- a/src/Facades/CsvImporter.php +++ b/src/Facades/CsvImporter.php @@ -7,8 +7,10 @@ /** * @method static CsvConfig getConfig() - * @method static array fromDisk(string $filename, ?string $disk = null) * @method static void setConfig(CsvConfig $config) + * @method static int count(object $importable) + * @method static void chunkArray(object $importable, callable $callable, ?int $size = null) + * @method static array getArray(object $importable) */ class CsvImporter extends Facade { diff --git a/src/Handlers/Handler.php b/src/Handlers/Handler.php deleted file mode 100644 index 5e3919b..0000000 --- a/src/Handlers/Handler.php +++ /dev/null @@ -1,10 +0,0 @@ -stream = $resource; + $this->csvConfig = $csvConfig; + } + + /** + * @return resource + */ + public function getResource() + { + return $this->stream; + } + + /** + * @return int + */ + public function count(): int + { + $i = 0; + rewind($this->stream); + + while (!feof($this->stream)) { + $row = fgets($this->stream); + if (empty($row)) continue; + $i++; + } + + return $i; + } + + /** + * @param callable(array,int):void $callable + * @param int $size + * @param int|null $maxRecords + * @return void + */ + public function getChunk(callable $callable, + int $size, + ?int $maxRecords = null): void + { + $this->prepareForReading(); + $counter = 0; + $isMaxRecords = false; + + while (!feof($this->stream) && !$isMaxRecords) { + $remaining = $maxRecords + ? min($maxRecords - $counter, $size) + : $size; + $rows = $this->readStream($remaining); + $callable($rows); + $counter += count($rows); + $isMaxRecords = $maxRecords && $counter >= $maxRecords; + } + } + + /** + * @param int|null $maxRecords + * @return array + */ + public function getAll(?int $maxRecords = null): array + { + $this->prepareForReading(); + + return $this->readStream($maxRecords); + } + + private function readStream(?int $quantity = null): array + { + $rows = []; + $counter = 0; + $isMaxQuantity = false; + + while (!feof($this->stream) && !$isMaxQuantity) { + $row = fgetcsv( + $this->stream, + null, + $this->csvConfig->csv_delimiter, + $this->csvConfig->csv_enclosure, + $this->csvConfig->csv_escape + ); + if (!is_array($row)) continue; + $rows[] = $row; + $counter++; + $isMaxQuantity = $quantity && $counter >= $quantity; + } + + return $rows; + } + + /** + * @return void + */ + private function prepareForReading(): void + { + rewind($this->stream); + + // remove UTF-8 BOM character + if (fgets($this->stream, 4) !== CsvHelper::getBom()) { + rewind($this->stream); + } + } +} diff --git a/src/Handlers/ArrayHandler.php b/src/Handlers/Writers/ArrayHandler.php similarity index 80% rename from src/Handlers/ArrayHandler.php rename to src/Handlers/Writers/ArrayHandler.php index 8d0dafb..f3c896d 100644 --- a/src/Handlers/ArrayHandler.php +++ b/src/Handlers/Writers/ArrayHandler.php @@ -1,6 +1,6 @@ handler; } diff --git a/src/Handlers/Writers/Handler.php b/src/Handlers/Writers/Handler.php new file mode 100644 index 0000000..a8c86d7 --- /dev/null +++ b/src/Handlers/Writers/Handler.php @@ -0,0 +1,17 @@ +stream = fopen('php://temp', 'a+'); + $this->stream = fopen('php://temp', 'a+') or throw new RuntimeException('Cannot open stream');; $this->csvConfig = $csvConfig; $this->init(); } diff --git a/src/Helpers/FormatterHelper.php b/src/Helpers/FormatterHelper.php index 7192e64..e2ee499 100644 --- a/src/Helpers/FormatterHelper.php +++ b/src/Helpers/FormatterHelper.php @@ -9,7 +9,8 @@ class FormatterHelper * @param string $format * @return string */ - public static function date($date, string $format): string + public static function date(\DateTime|string $date, + string $format): string { if (!($date instanceof \DateTime)) { return (string)$date; @@ -25,10 +26,10 @@ public static function date($date, string $format): string * @param string $thousandsSep * @return string */ - public static function number($number, - int $decimals = 0, - string $decimalSep = '.', - string $thousandsSep = ','): string + public static function number(float|int|string $number, + int $decimals = 0, + string $decimalSep = '.', + string $thousandsSep = ','): string { if (!is_numeric($number)) { return (string)$number; diff --git a/src/Helpers/ParseHelper.php b/src/Helpers/ParseHelper.php new file mode 100644 index 0000000..c71ac2e --- /dev/null +++ b/src/Helpers/ParseHelper.php @@ -0,0 +1,45 @@ +query()->count(); } - return 0; + throw new \RuntimeException('Missing data source trait'); } /** @@ -97,7 +97,8 @@ public function store(object $exportable, * @return StreamedResponse * @throws InvalidCellValueException */ - public function download(object $exportable, string $filename): StreamedResponse + public function download(object $exportable, + string $filename): StreamedResponse { $headers = [ 'Content-Type' => CsvHelper::$contentType, @@ -156,7 +157,7 @@ public function array(object $exportable): array * @return resource * @throws InvalidCellValueException */ - private function getStream(object $exportable) + public function getStream(object $exportable) { $stream = App::make(Writer::class, [ 'formatter' => new FormatterService($this->config), diff --git a/src/Services/FormatterService.php b/src/Services/FormatterService.php index f79668d..5c598f9 100644 --- a/src/Services/FormatterService.php +++ b/src/Services/FormatterService.php @@ -24,7 +24,7 @@ public function __construct(CsvConfig $config) * @param \DateTime|string $date * @return string */ - public function date($date): string + public function date(\DateTime|string $date): string { return FormatterHelper::date($date, $this->config->format_date); } @@ -33,16 +33,16 @@ public function date($date): string * @param \DateTime|string $date * @return string */ - public function datetime($date): string + public function datetime(\DateTime|string $date): string { return FormatterHelper::date($date, $this->config->format_datetime); } /** - * @param int|float|string $number + * @param float|int|string $number * @return string */ - public function decimal($number): string + public function decimal(float|int|string $number): string { return FormatterHelper::number( $number, @@ -53,10 +53,10 @@ public function decimal($number): string } /** - * @param int|float|string $number + * @param float|int|string $number * @return string */ - public static function integer($number): string + public static function integer(float|int|string $number): string { return FormatterHelper::number($number); } diff --git a/src/Services/ImportableService.php b/src/Services/ImportableService.php index 8ea6ed7..8f0d714 100644 --- a/src/Services/ImportableService.php +++ b/src/Services/ImportableService.php @@ -2,8 +2,14 @@ namespace Vitorccs\LaravelCsv\Services; +use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Storage; +use Vitorccs\LaravelCsv\Concerns\Importables\FromContents; +use Vitorccs\LaravelCsv\Concerns\Importables\FromDisk; +use Vitorccs\LaravelCsv\Concerns\Importables\FromFile; +use Vitorccs\LaravelCsv\Concerns\Importables\FromResource; use Vitorccs\LaravelCsv\Entities\CsvConfig; +use Vitorccs\LaravelCsv\Handlers\Readers\StreamHandler; class ImportableService { @@ -37,25 +43,84 @@ public function getConfig(): CsvConfig } /** - * @param string $filename - * @param string|null $disk + * @param object $importable + * @return int + */ + public function count(object $importable): int + { + return $this->getReader($importable)->count($importable); + } + + /** + * @param object $importable * @return array */ - public function fromDisk(string $filename, ?string $disk = null): array + public function getArray(object $importable): array { - $filename = Storage::disk($disk ?: $this->config->disk) - ->path($filename); + return $this->getReader($importable)->getRows($importable); + } - return $this->getReader()->generate($filename); + /** + * @param object $importable + * @param callable(array,int):void $callable + * @param int|null $size + * @return void + */ + public function chunkArray(object $importable, + callable $callable, + ?int $size = null): void + { + $size = $size ?: $this->config->chunk_size; + + $this->getReader($importable)->chunkRows($importable, $callable, $size); } /** + * @param object $importable + * @return resource + */ + protected function stream(object $importable) + { + if ($importable instanceof FromResource) { + if (!is_resource($importable->resource())) { + throw new \RuntimeException('Not a valid resource'); + } + return $importable->resource(); + } + + if ($importable instanceof FromDisk) { + $disk = $importable->disk() ?: $this->config->disk; + return Storage::disk($disk)->readStream($importable->filename()); + } + + if ($importable instanceof FromContents) { + $stream = fopen('php://temp', 'w+') or throw new \RuntimeException('Cannot open temp stream'); + fwrite($stream, $importable->contents()); + return $stream; + } + + if ($importable instanceof FromFile) { + $stream = fopen($importable->filename(), 'a+') or throw new \RuntimeException('Cannot file as stream'); + return $stream; + } + + throw new \RuntimeException('Missing data source trait'); + } + + /** + * @param object $importable * @return Reader */ - public function getReader(): Reader + protected function getReader(object $importable): Reader { - return app(Reader::class, [ - 'csvConfig' => $this->config + $resource = $this->stream($importable); + + /** @var Reader $reader */ + $reader = App::make(Reader::class, [ + 'parser' => new ParserService($this->config), + 'handler' => new StreamHandler($this->config, $resource) ]); + + return $reader; } } diff --git a/src/Services/ParserService.php b/src/Services/ParserService.php new file mode 100644 index 0000000..691d3b5 --- /dev/null +++ b/src/Services/ParserService.php @@ -0,0 +1,72 @@ +config = $config; + } + + /** + * @param string $date + * @return Carbon|null + */ + public function toCarbonDate(string $date): ?Carbon + { + return ParseHelper::toCarbon($date, $this->config->format_date) ?: null; + } + + /** + * @param string $date + * @return Carbon|null + */ + public function toCarbonDatetime(string $date): ?Carbon + { + return ParseHelper::toCarbon($date, $this->config->format_datetime) ?: null; + } + + /** + * @param string $decimal + * @return float|null + */ + public function toFloat(string $decimal): ?float + { + $value = ParseHelper::toFloat( + $decimal, + $this->config->format_number_decimal_sep, + $this->config->format_number_thousand_sep + ); + + return $value ?: null; + } + + /** + * @param string $integer + * @return int|null + */ + public function toInteger(string $integer): ?int + { + $value = $this->toFloat($integer); + + return $value ? intval($value) : null; + } +} \ No newline at end of file diff --git a/src/Services/Reader.php b/src/Services/Reader.php index 9ff2468..1243d8b 100644 --- a/src/Services/Reader.php +++ b/src/Services/Reader.php @@ -2,38 +2,170 @@ namespace Vitorccs\LaravelCsv\Services; -use Vitorccs\LaravelCsv\Entities\CsvConfig; +use Vitorccs\LaravelCsv\Concerns\WithColumnFormatting; +use Vitorccs\LaravelCsv\Concerns\WithHeadings; +use Vitorccs\LaravelCsv\Concerns\WithMapping; +use Vitorccs\LaravelCsv\Enum\CellFormat; +use Vitorccs\LaravelCsv\Handlers\Readers\Handler; use Vitorccs\LaravelCsv\Helpers\CsvHelper; class Reader { /** - * @var CsvConfig + * @var ParserService */ - private CsvConfig $csvConfig; + protected ParserService $parser; /** - * @param CsvConfig $csvConfig + * @var Handler */ - public function __construct(CsvConfig $csvConfig) + protected Handler $handler; + + /** + * @param Handler $handler + * @param ParserService $parser + */ + public function __construct(ParserService $parser, + Handler $handler) { - $this->csvConfig = $csvConfig; + $this->parser = $parser; + $this->handler = $handler; } /** - * @param string $path + * @param object $importable + * @return int + */ + public function count(object $importable): int + { + $offset = $importable instanceof WithHeadings ? 1 : 0; + $count = $this->handler->count(); + + return $count - $offset; + } + + /** + * @param object $importable + * @param callable(array,int):void $callable + * @param int $size + * @return void + */ + public function chunkRows(object $importable, + callable $callable, + int $size): void + { + $counter = 0; + $hasHeadings = $importable instanceof WithHeadings; + $maxRows = $this->getMaxRows($importable, $hasHeadings); + + $wrapper = function (array $rows) use ($callable, $hasHeadings, $importable, &$counter) { + $hasHeadings = $counter === 0 && $hasHeadings; + $rows = $this->prepareRows($importable, $rows, $hasHeadings); + $callable($rows, $counter); + $counter++; + }; + + $this->handler->getChunk($wrapper, $size, $maxRows); + } + + /** + * @param object $importable + * @return array + */ + public function getRows(object $importable): array + { + $hasHeadings = $importable instanceof WithHeadings; + $maxRows = $this->getMaxRows($importable, $hasHeadings); + $rows = $this->handler->getAll($maxRows); + + return $this->prepareRows($importable, $rows, $hasHeadings); + } + + /** + * @param object $importable + * @param array $rows + * @param bool $hasHeadings * @return array */ - public function generate(string $path): array - { - return array_map(function (string $row) { - $row = str_replace(CsvHelper::getBom(), '', $row); - return str_getcsv( - $row, - $this->csvConfig->csv_delimiter, - $this->csvConfig->csv_enclosure, - $this->csvConfig->csv_escape - ); - }, file($path)); + protected function prepareRows(object $importable, + array $rows, + bool $hasHeadings): array + { + $formats = $importable instanceof WithColumnFormatting ? $importable->columnFormats() : []; + $withMapping = $importable instanceof WithMapping; + + foreach ($rows as $index => $row) { + if ($index === 0 && $hasHeadings) { + $formattedRow = $importable->headings(); + } else { + $mappedRow = $withMapping ? $importable->map($row) : $row; + $formattedRow = $this->applyFormatting($mappedRow, $formats); + } + + $rows[$index] = $formattedRow; + } + + return $rows; + } + + /** + * @param object $importable + * @param bool $hasHeadings + * @return int|null + */ + protected function getMaxRows(object $importable, bool $hasHeadings): ?int + { + return $importable->limit() + ? $importable->limit() + intval($hasHeadings) + : null; + } + + /** + * @param array $row + * @param array $formats + * @return array + */ + protected function applyFormatting(array $row, + array $formats): array + { + return array_map( + fn($value, int $columnIndex) => $this->formatCellValue($value, $formats, $columnIndex), + $row, + array_keys($row) + ); + } + + /** + * @param mixed $value + * @param array $formats + * @param int $columnIndex + * @return mixed + */ + protected function formatCellValue(mixed $value, + array $formats, + int $columnIndex): mixed + { + $columnLetter = CsvHelper::getColumnLetter($columnIndex + 1); + $format = $formats[$columnLetter] ?? null; + + if (!strlen(trim($value))) return $value; + + if ($format === CellFormat::DATE) { + return $this->parser->toCarbonDate($value) ?: $value; + } + + if ($format === CellFormat::DATETIME) { + return $this->parser->toCarbonDatetime($value) ?: $value; + } + + if ($format === CellFormat::DECIMAL) { + return $this->parser->toFloat($value) ?: $value; + } + + if ($format === CellFormat::INTEGER) { + return $this->parser->toInteger($value) ?: $value; + } + + return $value; } } diff --git a/src/Services/Writer.php b/src/Services/Writer.php index 987fc30..f258e71 100644 --- a/src/Services/Writer.php +++ b/src/Services/Writer.php @@ -3,15 +3,15 @@ namespace Vitorccs\LaravelCsv\Services; use Illuminate\Database\Eloquent\Model; -use Vitorccs\LaravelCsv\Concerns\FromArray; -use Vitorccs\LaravelCsv\Concerns\FromCollection; -use Vitorccs\LaravelCsv\Concerns\FromQuery; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromCollection; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; use Vitorccs\LaravelCsv\Concerns\WithColumnFormatting; use Vitorccs\LaravelCsv\Concerns\WithHeadings; use Vitorccs\LaravelCsv\Concerns\WithMapping; use Vitorccs\LaravelCsv\Enum\CellFormat; use Vitorccs\LaravelCsv\Exceptions\InvalidCellValueException; -use Vitorccs\LaravelCsv\Handlers\Handler; +use Vitorccs\LaravelCsv\Handlers\Writers\Handler; use Vitorccs\LaravelCsv\Helpers\CsvHelper; use Vitorccs\LaravelCsv\Helpers\ModelHelper; use Vitorccs\LaravelCsv\Helpers\QueryBuilderHelper; @@ -21,12 +21,12 @@ class Writer /** * @var FormatterService */ - private FormatterService $formatter; + protected FormatterService $formatter; /** * @var Handler */ - private Handler $handler; + protected Handler $handler; /** * @param Handler $handler @@ -84,16 +84,17 @@ public function generate(object $exportable) * @return void * @throws InvalidCellValueException */ - private function iterateRows(object $exportable, - iterable $rows): void + protected function iterateRows(object $exportable, + iterable $rows): void { $formats = $exportable instanceof WithColumnFormatting ? $exportable->columnFormats() : []; $withMapping = $exportable instanceof WithMapping; - $rowIndex = 1; - foreach ($rows as $row) { + + foreach ($rows as $index => $row) { $mappedRow = $withMapping ? $exportable->map($row) : $row; $normalizedRow = $this->normalizeRow($mappedRow); - $formattedRow = $this->applyFormatting($normalizedRow, $formats, $rowIndex++); + $formattedRow = $this->applyFormatting($normalizedRow, $formats, $index); + $this->writeRow($formattedRow); } } @@ -102,7 +103,7 @@ private function iterateRows(object $exportable, * @param mixed $row * @return array */ - private function normalizeRow(mixed $row): array + protected function normalizeRow(mixed $row): array { if ($row instanceof Model) { $row = ModelHelper::toArrayValues($row); @@ -123,9 +124,9 @@ private function normalizeRow(mixed $row): array * @return array * @throws InvalidCellValueException */ - private function applyFormatting(array $row, - array $formats, - int $rowIndex): array + protected function applyFormatting(array $row, + array $formats, + int $rowIndex): array { return array_map( fn($value, int $columnIndex) => $this->formatCellValue($value, $formats, $rowIndex, $columnIndex), @@ -137,14 +138,19 @@ private function applyFormatting(array $row, /** * @throws InvalidCellValueException */ - private function formatCellValue(mixed $value, - array $formats, - int $rowIndex, - int $columnIndex): string + protected function formatCellValue(mixed $value, + array $formats, + int $rowIndex, + int $columnIndex): string { $columnLetter = CsvHelper::getColumnLetter($columnIndex + 1); + $rowNumber = $rowIndex + 1; $format = $formats[$columnLetter] ?? null; + if (is_null($value)) { + return ''; + } + if ($format === CellFormat::DATE) { return $this->formatter->date($value); } @@ -166,7 +172,7 @@ private function formatCellValue(mixed $value, return (string)$value; } } catch (\Throwable $e) { - throw new InvalidCellValueException("{$columnLetter}{$rowIndex}"); + throw new InvalidCellValueException("{$columnLetter}{$rowNumber}"); } return $value; @@ -176,7 +182,7 @@ private function formatCellValue(mixed $value, * @param array $content * @return void */ - private function writeRow(array $content): void + protected function writeRow(array $content): void { $this->handler->addContent($content); } diff --git a/tests/Concerns/ExportableTest.php b/tests/Concerns/Exportables/ExportableTest.php similarity index 77% rename from tests/Concerns/ExportableTest.php rename to tests/Concerns/Exportables/ExportableTest.php index b637d08..c422afc 100644 --- a/tests/Concerns/ExportableTest.php +++ b/tests/Concerns/Exportables/ExportableTest.php @@ -1,11 +1,11 @@ once() - ->andReturns($filename); + ->andReturns($this->filename); - $this->assertEquals($filename, $this->export->store($filename)); + $this->assertEquals($this->filename, $this->export->store($this->filename)); } public function test_download() @@ -65,6 +63,18 @@ public function test_download() $this->assertEquals($mock, $this->export->download()); } + public function test_steam() + { + $mock = $this->getMockBuilder(\SplTempFileObject::class) + ->disableOriginalConstructor(); + + CsvExporter::shouldReceive('stream') + ->once() + ->andReturns($mock); + + $this->assertEquals($mock, $this->export->stream()); + } + public function test_queue() { $mock = \Mockery::mock(PendingDispatch::class); diff --git a/tests/Concerns/Exportables/FromArrayTest.php b/tests/Concerns/Exportables/FromArrayTest.php new file mode 100644 index 0000000..08bc652 --- /dev/null +++ b/tests/Concerns/Exportables/FromArrayTest.php @@ -0,0 +1,46 @@ +store($this->filename); + $actual = $this->getFromDisk($this->filename); + + $this->assertEquals($export->expected(), $actual); + } + } + + public function test_limit_from_array() + { + $limit = rand(1, 5); + + $exports = [ + new NoHeadingsExport($limit), + new WithHeadingsExport($limit), + ]; + + foreach ($exports as $export) { + $export->store($this->filename); + $actual = $this->getFromDiskArray($this->filename); + $expected = $export instanceof WithHeadings + ? $limit + 1 + : $limit; + + $this->assertCount($expected, $actual); + } + } +} diff --git a/tests/Concerns/Exportables/FromCollectionTest.php b/tests/Concerns/Exportables/FromCollectionTest.php new file mode 100644 index 0000000..2c2bc15 --- /dev/null +++ b/tests/Concerns/Exportables/FromCollectionTest.php @@ -0,0 +1,60 @@ +seed(TestCsvSeeder::class); + } + + public function test_from_collection() + { + $exports = [ + new CollectionNoHeadingsExport(), + new CollectionWithHeadingsExport(), + new CursorNoHeadingsExport(), + new CursorWithHeadingsExport(), + ]; + + foreach ($exports as $export) { + $export->store($this->filename); + $actual = $this->getFromDisk($this->filename); + + $this->assertEquals($export->expected(), $actual); + } + } + + public function test_limit_from_collection() + { + $limit = rand(1, 5); + + $exports = [ + new CollectionNoHeadingsExport($limit), + new CollectionWithHeadingsExport($limit), + new CursorNoHeadingsExport($limit), + new CursorWithHeadingsExport($limit), + ]; + + foreach ($exports as $export) { + $export->store($this->filename); + $actual = $this->getFromDiskArray($this->filename); + $expected = $export instanceof WithHeadings + ? $limit + 1 + : $limit; + + $this->assertCount($expected, $actual); + } + } +} diff --git a/tests/Concerns/Exportables/FromQueryTest.php b/tests/Concerns/Exportables/FromQueryTest.php new file mode 100644 index 0000000..365e207 --- /dev/null +++ b/tests/Concerns/Exportables/FromQueryTest.php @@ -0,0 +1,61 @@ +seed(TestCsvSeeder::class); + } + + public function test_from_builder() + { + $exports = [ + new EloquentNoHeadingsExport(), + new EloquentWithHeadingsExport(), + new QueryNoHeadingsExport(), + new QueryWithHeadingsExport(), + ]; + + foreach ($exports as $export) { + $export->store($this->filename); + $actual = $this->getFromDisk($this->filename); + + $this->assertEquals($export->expected(), $actual); + } + } + + + public function test_limit_from_builder() + { + $limit = rand(1, 5); + + $exports = [ + new EloquentNoHeadingsExport($limit), + new EloquentWithHeadingsExport($limit), + new QueryNoHeadingsExport($limit), + new QueryWithHeadingsExport($limit), + ]; + + foreach ($exports as $export) { + $export->store($this->filename); + $actual = $this->getFromDiskArray($this->filename); + $expected = $export instanceof WithHeadings + ? $limit + 1 + : $limit; + + $this->assertCount($expected, $actual); + } + } +} diff --git a/tests/Concerns/FromArrayTest.php b/tests/Concerns/FromArrayTest.php deleted file mode 100644 index 8142df9..0000000 --- a/tests/Concerns/FromArrayTest.php +++ /dev/null @@ -1,21 +0,0 @@ -store($this->filename); - $contents = $this->readFromDisk($this->filename); - - $this->assertEquals($export->toArray(), $contents); - } -} diff --git a/tests/Concerns/FromCollectionTest.php b/tests/Concerns/FromCollectionTest.php deleted file mode 100644 index 195e2a8..0000000 --- a/tests/Concerns/FromCollectionTest.php +++ /dev/null @@ -1,40 +0,0 @@ -seed(TestUsersSeeder::class); - } - - public function test_from_collection() - { - $export = new FromCollectionExport(); - - $export->store($this->filename); - $contents = $this->readFromDisk($this->filename); - - $this->assertEquals($export->toArray(), $contents); - } - - public function test_from_cursor() - { - $export = new FromCursorExport(); - - $export->store($this->filename); - $contents = $this->readFromDisk($this->filename); - - $this->assertEquals($export->toArray(), $contents); - } -} diff --git a/tests/Concerns/FromQueryTest.php b/tests/Concerns/FromQueryTest.php deleted file mode 100644 index bc38162..0000000 --- a/tests/Concerns/FromQueryTest.php +++ /dev/null @@ -1,40 +0,0 @@ -seed(TestUsersSeeder::class); - } - - public function test_from_eloquent_builder() - { - $export = new FromEloquentBuilderExport(); - - $export->store($this->filename); - $contents = $this->readFromDisk($this->filename); - - $this->assertEquals($export->toArray(), $contents); - } - - public function test_from_query_builder() - { - $export = new FromQueryBuilderExport(); - - $export->store($this->filename); - $contents = $this->readFromDisk($this->filename); - - $this->assertEquals($export->toArray(), $contents); - } -} diff --git a/tests/Concerns/Importables/AbstractTestCase.php b/tests/Concerns/Importables/AbstractTestCase.php new file mode 100644 index 0000000..3e299d4 --- /dev/null +++ b/tests/Concerns/Importables/AbstractTestCase.php @@ -0,0 +1,127 @@ +assertSame($actualCalls, $chunk); + $this->assertCount(count($expectedRows), $rows); + $this->assertSame($expectedRows, $rows); + + $actualCalls++; + }; + + $import->chunkArray($callable, $size); + + if (method_exists($import, 'delete')) { + $import->delete(); + }; + + $this->assertEquals($expectedCalls, $actualCalls); + } + + public static function noHeadingsChunkProvider(): array + { + $import = new class() { + use NoHeadingsTrait; + }; + + return [ + 'no limit' => [ + null, + 2, + 5, + $import->expected(), + false + ], + 'multiple of chunk size' => [ + 6, + 2, + 3, + array_slice($import->expected(), 0, 6), + false + ], + 'less than chunk size' => [ + 5, + 2, + 3, + array_slice($import->expected(), 0, 5), + false + ], + 'same of results quantity' => [ + 10, + 2, + 5, + $import->expected(), + false + ], + 'greater than results quantity' => [ + 100, + 2, + 5, + $import->expected(), + false + ], + ]; + } + + public static function withHeadingChunkProvider(): array + { + $import = new class() { + use WithHeadingsTrait; + }; + + return [ + 'no limit' => [ + null, + 2, + 6, + $import->expected(), + true + ], + 'multiple of chunk size' => [ + 6, + 2, + 4, + array_slice($import->expected(), 0, 7), + true + ], + 'less than chunk size' => [ + 5, + 2, + 3, + array_slice($import->expected(), 0, 6), + true + ], + 'same of results quantity' => [ + 10, + 2, + 6, + $import->expected(), + true + ], + 'greater than results quantity' => [ + 100, + 2, + 6, + $import->expected(), + true + ], + ]; + } +} \ No newline at end of file diff --git a/tests/Concerns/Importables/FromContentsTest.php b/tests/Concerns/Importables/FromContentsTest.php new file mode 100644 index 0000000..ef5da53 --- /dev/null +++ b/tests/Concerns/Importables/FromContentsTest.php @@ -0,0 +1,60 @@ +getArray(); + + $this->assertEquals($actual, $import->expected()); + } + } + + public function test_limit_from_contents() + { + $limit = rand(1, 9); + + $imports = [ + new NoHeadingsImport($limit), + new WithHeadingsImport($limit), + ]; + + foreach ($imports as $import) { + $actual = $import->getArray(); + $expected = $import instanceof WithHeadings + ? $limit + 1 + : $limit; + + $this->assertCount($expected, $actual); + } + } + + /** + * @dataProvider noHeadingsChunkProvider + * @dataProvider withHeadingChunkProvider + */ + public function test_chunk_contents(?int $limit, + int $size, + int $expectedCalls, + array $expectedRecords, + bool $withHeadings) + { + $import = $withHeadings + ? new WithHeadingsImport($limit) + : new NoHeadingsImport($limit); + + $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); + } +} \ No newline at end of file diff --git a/tests/Concerns/Importables/FromDiskTest.php b/tests/Concerns/Importables/FromDiskTest.php new file mode 100644 index 0000000..bccdd2e --- /dev/null +++ b/tests/Concerns/Importables/FromDiskTest.php @@ -0,0 +1,62 @@ +getArray(); + $import->delete(); + + $this->assertEquals($actual, $import->expected()); + } + } + + public function test_limit_from_disk() + { + $limit = rand(1, 9); + + $imports = [ + new NoHeadingsImport($limit), + new WithHeadingsImport($limit), + ]; + + foreach ($imports as $import) { + $actual = $import->getArray(); + $expected = $import instanceof WithHeadings + ? $limit + 1 + : $limit; + $import->delete(); + + $this->assertCount($expected, $actual); + } + } + + /** + * @dataProvider noHeadingsChunkProvider + * @dataProvider withHeadingChunkProvider + */ + public function test_chunk_contents(?int $limit, + int $size, + int $expectedCalls, + array $expectedRecords, + bool $withHeadings) + { + $import = $withHeadings + ? new WithHeadingsImport($limit) + : new NoHeadingsImport($limit); + + $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); + } +} \ No newline at end of file diff --git a/tests/Concerns/Importables/FromFileTest.php b/tests/Concerns/Importables/FromFileTest.php new file mode 100644 index 0000000..1c3f3c4 --- /dev/null +++ b/tests/Concerns/Importables/FromFileTest.php @@ -0,0 +1,62 @@ +getArray(); + $import->delete(); + + $this->assertEquals($actual, $import->expected()); + } + } + + public function test_limit_from_file() + { + $limit = rand(1, 9); + + $imports = [ + new NoHeadingsImport($limit), + new WithHeadingsImport($limit), + ]; + + foreach ($imports as $import) { + $actual = $import->getArray(); + $expected = $import instanceof WithHeadings + ? $limit + 1 + : $limit; + $import->delete(); + + $this->assertCount($expected, $actual); + } + } + + /** + * @dataProvider noHeadingsChunkProvider + * @dataProvider withHeadingChunkProvider + */ + public function test_chunk_from_file(?int $limit, + int $size, + int $expectedCalls, + array $expectedRecords, + bool $withHeadings) + { + $import = $withHeadings + ? new WithHeadingsImport($limit) + : new NoHeadingsImport($limit); + + $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); + } +} \ No newline at end of file diff --git a/tests/Concerns/Importables/FromResourceTest.php b/tests/Concerns/Importables/FromResourceTest.php new file mode 100644 index 0000000..03db97a --- /dev/null +++ b/tests/Concerns/Importables/FromResourceTest.php @@ -0,0 +1,78 @@ +getArray(); + + $this->assertEquals($actual, $import->expected()); + } + } + + /** + * @dataProvider diskDataProvider + */ + public function test_limit_from_resource(string $source) + { + $limit = rand(1, 9); + + $imports = [ + new NoHeadingsImport($source, $limit), + new WithHeadingsImport($source, $limit), + ]; + + foreach ($imports as $import) { + $actual = $import->getArray(); + $expected = $import instanceof WithHeadings + ? $limit + 1 + : $limit; + + $this->assertCount($expected, $actual); + } + } + + /** + * @dataProvider noHeadingsChunkProvider + * @dataProvider withHeadingChunkProvider + */ + public function test_chunk_from_file(?int $limit, + int $size, + int $expectedCalls, + array $expectedRecords, + bool $withHeadings) + { + $import = $withHeadings + ? new WithHeadingsImport(limit: $limit) + : new NoHeadingsImport(limit: $limit); + + $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); + } + + public static function diskDataProvider(): array + { + return [ + 'from temp' => [ + 'php://temp', + ], + 'from memory' => [ + 'php://memory', + ] + ]; + } +} \ No newline at end of file diff --git a/tests/Concerns/Importables/ImportableTest.php b/tests/Concerns/Importables/ImportableTest.php new file mode 100644 index 0000000..8a280f9 --- /dev/null +++ b/tests/Concerns/Importables/ImportableTest.php @@ -0,0 +1,55 @@ +import = new FromContentsImport(); + } + + public function test_count() + { + $count = 100; + + CsvImporter::shouldReceive('count') + ->once() + ->with($this->import) + ->andReturn($count); + + $this->assertEquals($count, $this->import->count()); + } + + public function test_get_array() + { + $array = [1, 2, 3]; + + CsvImporter::shouldReceive('getArray') + ->once() + ->with($this->import) + ->andReturn($array); + + $this->assertEquals($array, $this->import->getArray()); + } + + public function test_chunk_array() + { + $callable = function (array $rows) { + $this->assertCount(count($this->import->getArray()), $rows); + }; + $size = 100; + + CsvImporter::shouldReceive('chunkArray') + ->once() + ->with($this->import, $callable, $size); + + $this->import->chunkArray($callable, $size); + } +} \ No newline at end of file diff --git a/tests/Concerns/WithColumnFormattingTest.php b/tests/Concerns/WithColumnFormattingTest.php index 03f6553..0f47283 100644 --- a/tests/Concerns/WithColumnFormattingTest.php +++ b/tests/Concerns/WithColumnFormattingTest.php @@ -2,20 +2,56 @@ namespace Vitorccs\LaravelCsv\Tests\Concerns; +use Carbon\Carbon; use Vitorccs\LaravelCsv\Tests\Data\Exports\WithColumnFormattingExport; +use Vitorccs\LaravelCsv\Tests\Data\Imports\WithColumnFormattingImport; use Vitorccs\LaravelCsv\Tests\TestCase; class WithColumnFormattingTest extends TestCase { - protected string $filename = 'with_column_formatting.csv'; - - public function teste_with_column_formatting() + public function test_export_with_column_formatting() { $export = new WithColumnFormattingExport(); + $config = $export->getConfig(); + $config->format_date = $export->formatDate(); + $config->format_datetime = $export->formatDateTime(); + $config->format_number_thousand_sep = $export->thousandSeparator(); + $config->format_number_decimal_sep = $export->decimalSeparator(); + $export->setConfig($config); + $export->store($this->filename); - $contents = $this->readFromDisk($this->filename); + $actual = $this->getFromDisk($this->filename); + + $this->assertEquals($export->expected(), $actual); + } + + public function test_import_with_column_formatting() + { + $import = new WithColumnFormattingImport(); + + $config = $import->getConfig(); + $config->format_date = $import->formatDate(); + $config->format_datetime = $import->formatDateTime(); + $config->format_number_thousand_sep = $import->thousandSeparator(); + $config->format_number_decimal_sep = $import->decimalSeparator(); + $import->setConfig($config); + + $expected = $import->expected(); + $actualRows = $import->getArray(); - $this->assertEquals($export->toArray(), $contents); + foreach ($actualRows as $i => $actualRow) { + $this->assertEquals($expected[$i][0], $actualRow[0]); + $this->assertEquals($expected[$i][1], $actualRow[1]); + if ($i == 0) { + $this->assertInstanceOf(Carbon::class, $actualRow[2]); + $this->assertEquals($expected[$i][2]->toDateString(), $actualRow[2]->toDateString()); + $this->assertInstanceOf(Carbon::class, $actualRow[3]); + $this->assertEquals($expected[$i][3]->toDateTimeString(), $actualRow[3]->toDateTimeString()); + } else { + $this->assertSame($expected[$i][2], $actualRow[2]); + $this->assertSame($expected[$i][3], $actualRow[3]); + } + } } } diff --git a/tests/Concerns/WithHeadingsTest.php b/tests/Concerns/WithHeadingsTest.php deleted file mode 100644 index eaad149..0000000 --- a/tests/Concerns/WithHeadingsTest.php +++ /dev/null @@ -1,21 +0,0 @@ -store($this->filename); - $contents = $this->readFromDisk($this->filename); - - $this->assertEquals($export->toArray(), $contents); - } -} diff --git a/tests/Concerns/WithLimitTest.php b/tests/Concerns/WithLimitTest.php deleted file mode 100644 index f178607..0000000 --- a/tests/Concerns/WithLimitTest.php +++ /dev/null @@ -1,29 +0,0 @@ -seed(TestUsersSeeder::class); - } - - public function test_from_query() - { - $export = new WithLimitExport(); - - $export->store($this->filename); - $contents = $this->readFromDisk($this->filename); - - $this->assertCount($export->limit(), $contents); - } -} diff --git a/tests/Concerns/WithMappingTest.php b/tests/Concerns/WithMappingTest.php index 669cfc4..28eeace 100644 --- a/tests/Concerns/WithMappingTest.php +++ b/tests/Concerns/WithMappingTest.php @@ -2,28 +2,37 @@ namespace Vitorccs\LaravelCsv\Tests\Concerns; -use Vitorccs\LaravelCsv\Tests\Data\Database\Seeders\TestUsersSeeder; +use Vitorccs\LaravelCsv\Tests\Data\Database\Seeders\TestCsvSeeder; use Vitorccs\LaravelCsv\Tests\Data\Exports\WithMappingExport; +use Vitorccs\LaravelCsv\Tests\Data\Imports\WithMappingImport; use Vitorccs\LaravelCsv\Tests\TestCase; class WithMappingTest extends TestCase { - protected string $filename = 'with_mapping_test.csv'; - protected function setUp(): void { parent::setUp(); - $this->seed(TestUsersSeeder::class); + $this->seed(TestCsvSeeder::class); } - public function test_from_query() + public function test_export_mapping() { $export = new WithMappingExport(); $export->store($this->filename); - $contents = $this->readFromDisk($this->filename); + $actual = $this->getFromDisk($this->filename); + + $this->assertSame($export->expected(), $actual); + } + + public function test_import_mapping() + { + $import = new WithMappingImport(); + + $rows = $import->getArray(); + $expected = $import->expected(); - $this->assertEquals($export->toArray(), $contents); + $this->assertSame($rows, $expected); } } diff --git a/tests/Data/Database/Factories/TestUserFactory.php b/tests/Data/Database/Factories/TestUserFactory.php index 113b106..283c352 100644 --- a/tests/Data/Database/Factories/TestUserFactory.php +++ b/tests/Data/Database/Factories/TestUserFactory.php @@ -2,13 +2,13 @@ /** @var \Illuminate\Database\Eloquent\Factory $factory */ use Faker\Generator as Faker; -use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestUser; +use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestCsv; -$factory->define(TestUser::class, function (Faker $faker) { +$factory->define(TestCsv::class, function (Faker $faker) { return [ - 'name' => $faker->name(), - 'email' => $faker->unique()->safeEmail(), - 'email_verified_at' => now(), - 'active' => 1 + 'integer' => $faker->numberBetween(-1000, 1000), + 'decimal' => $faker->randomFloat(), + 'string' => $faker->word(), + 'timestamp' => $faker->dateTime() ]; }); diff --git a/tests/Data/Database/Migrations/0000_00_00_000000_create_test_users_table.php b/tests/Data/Database/Migrations/0000_00_00_000000_create_test_csv_table.php similarity index 57% rename from tests/Data/Database/Migrations/0000_00_00_000000_create_test_users_table.php rename to tests/Data/Database/Migrations/0000_00_00_000000_create_test_csv_table.php index e6ce92c..00526fc 100644 --- a/tests/Data/Database/Migrations/0000_00_00_000000_create_test_users_table.php +++ b/tests/Data/Database/Migrations/0000_00_00_000000_create_test_csv_table.php @@ -1,10 +1,10 @@ mediumIncrements('id'); - $table->string('name'); - $table->string('email'); - $table->timestamp('email_verified_at')->nullable(); - $table->boolean('active')->default(true); - $table->timestamps(); + $table->mediumInteger('integer')->nullable(); + $table->decimal('decimal', 8, 2)->nullable(); + $table->string('string')->nullable(); + $table->timestamp('timestamp')->nullable(); }); } diff --git a/tests/Data/Database/Seeders/TestCsvSeeder.php b/tests/Data/Database/Seeders/TestCsvSeeder.php new file mode 100644 index 0000000..e7398ec --- /dev/null +++ b/tests/Data/Database/Seeders/TestCsvSeeder.php @@ -0,0 +1,66 @@ + 1, + 'integer' => 1, + 'decimal' => 1.23, + 'string' => 'text_1', + 'timestamp' => '2025-01-01' + ], + [ + 'id' => 2, + 'integer' => -1, + 'decimal' => -1.23, + 'string' => 'text_2', + 'timestamp' => '2025-01-02' + ], + [ + 'id' => 3, + 'integer' => 1000, + 'decimal' => 1000.23, + 'string' => 'text_3', + 'timestamp' => '2025-01-03' + ], + [ + 'id' => 4, + 'integer' => -1000, + 'decimal' => -1000.23, + 'string' => 'text_4', + 'timestamp' => '2025-01-04' + ], + [ + 'id' => 5, + 'integer' => 1000000, + 'decimal' => 1000000.23, + 'string' => 'text_5', + 'timestamp' => '2025-01-05' + ], + [ + 'id' => 6, + 'integer' => -1000000, + 'decimal' => -1000000.23, + 'string' => 'text_6', + 'timestamp' => '2025-01-06' + ], + ]; + + /** + * Run the database seeds. + * + * @return void + */ + public function run() + { + foreach (self::USERS as $user) { + TestCsv::create($user); + } + } +} diff --git a/tests/Data/Database/Seeders/TestUsersSeeder.php b/tests/Data/Database/Seeders/TestUsersSeeder.php deleted file mode 100644 index b9db27e..0000000 --- a/tests/Data/Database/Seeders/TestUsersSeeder.php +++ /dev/null @@ -1,21 +0,0 @@ -create(); - } -} diff --git a/tests/Data/Exports/FromArrayExport.php b/tests/Data/Exports/FromArrayExport.php deleted file mode 100644 index 00faaf2..0000000 --- a/tests/Data/Exports/FromArrayExport.php +++ /dev/null @@ -1,27 +0,0 @@ -limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function array(): array + { + return $this->contents(); + } +} diff --git a/tests/Data/Exports/NoHeadings/FromArrayExportAlt.php b/tests/Data/Exports/NoHeadings/FromArrayExportAlt.php new file mode 100644 index 0000000..7cf4f20 --- /dev/null +++ b/tests/Data/Exports/NoHeadings/FromArrayExportAlt.php @@ -0,0 +1,46 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function expected(): string + { + return "'a 1'|'b 1'|'c 1'\n'a 2'|'b 2'|'c 2'"; + } + + public function csvDelimiter(): string + { + return '|'; + } + + public function csvEnclosure(): string + { + return "'"; + } + + public function array(): array + { + return [ + ['a 1', 'b 1', 'c 1'], + ['a 2', 'b 2', 'c 2'], + ]; + } +} diff --git a/tests/Data/Exports/NoHeadings/FromCollectionExport.php b/tests/Data/Exports/NoHeadings/FromCollectionExport.php new file mode 100644 index 0000000..65eac9e --- /dev/null +++ b/tests/Data/Exports/NoHeadings/FromCollectionExport.php @@ -0,0 +1,29 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function collection(): Collection + { + return collect($this->contents()); + } +} diff --git a/tests/Data/Exports/NoHeadings/FromCursorExport.php b/tests/Data/Exports/NoHeadings/FromCursorExport.php new file mode 100644 index 0000000..61b05f9 --- /dev/null +++ b/tests/Data/Exports/NoHeadings/FromCursorExport.php @@ -0,0 +1,30 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function collection(): LazyCollection + { + return TestCsv::cursor(); + } +} \ No newline at end of file diff --git a/tests/Data/Exports/NoHeadings/FromEloquentBuilderExport.php b/tests/Data/Exports/NoHeadings/FromEloquentBuilderExport.php new file mode 100644 index 0000000..39a953f --- /dev/null +++ b/tests/Data/Exports/NoHeadings/FromEloquentBuilderExport.php @@ -0,0 +1,29 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function query() + { + return TestCsv::query(); + } +} diff --git a/tests/Data/Exports/NoHeadings/FromExportTrait.php b/tests/Data/Exports/NoHeadings/FromExportTrait.php new file mode 100644 index 0000000..bb69472 --- /dev/null +++ b/tests/Data/Exports/NoHeadings/FromExportTrait.php @@ -0,0 +1,23 @@ + array_values($user), TestCsvSeeder::USERS); + } + + public function expected(): string + { + return '1,1,1.23,text_1,2025-01-01' . "\n" . + '2,-1,-1.23,text_2,2025-01-02' . "\n" . + '3,1000,1000.23,text_3,2025-01-03' . "\n" . + '4,-1000,-1000.23,text_4,2025-01-04' . "\n" . + '5,1000000,1000000.23,text_5,2025-01-05' . "\n" . + '6,-1000000,-1000000.23,text_6,2025-01-06'; + } +} \ No newline at end of file diff --git a/tests/Data/Exports/NoHeadings/FromQueryBuilderExport.php b/tests/Data/Exports/NoHeadings/FromQueryBuilderExport.php new file mode 100644 index 0000000..6257e04 --- /dev/null +++ b/tests/Data/Exports/NoHeadings/FromQueryBuilderExport.php @@ -0,0 +1,29 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function query() + { + return DB::table('test_csvs'); + } +} diff --git a/tests/Data/Exports/WithColumnFormattingExport.php b/tests/Data/Exports/WithColumnFormattingExport.php index bc5b4e8..67312d8 100644 --- a/tests/Data/Exports/WithColumnFormattingExport.php +++ b/tests/Data/Exports/WithColumnFormattingExport.php @@ -3,8 +3,8 @@ namespace Vitorccs\LaravelCsv\Tests\Data\Exports; use Carbon\Carbon; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromArray; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; use Vitorccs\LaravelCsv\Concerns\WithColumnFormatting; use Vitorccs\LaravelCsv\Enum\CellFormat; @@ -14,16 +14,33 @@ class WithColumnFormattingExport implements FromArray, WithColumnFormatting public function array(): array { - $format = 'Y-m-d H:i:s'; - $timestamp1 = '2021-02-03 4:05:06'; - $timestamp2 = '2021-12-31 23:15:46'; - + // 2nd line must ignore carbon parse since they are in an unexpected format return [ - [1, 2.3, Carbon::parse($timestamp1, 'UTC'), \DateTime::createFromFormat($format, $timestamp1)], - [2, 5.300, Carbon::parse($timestamp2, 'UTC'), \DateTime::createFromFormat($format, $timestamp2)], + [1, 2.30, Carbon::parse('2021-02-03'), Carbon::parse('2021-12-31 12:34:56')], + [2, 5300.91, Carbon::parse('2021-02-03')->toDateString(), Carbon::parse('2021-12-31 23:15:46')->toDateTimeString()] ]; } + public function formatDate(): string + { + return 'Y_m_d'; + } + + public function formatDateTime(): string + { + return 'd/m/Y H_i_s'; + } + + public function decimalSeparator(): string + { + return ':'; + } + + public function thousandSeparator(): string + { + return '#'; + } + public function columnFormats(): array { return [ @@ -33,4 +50,10 @@ public function columnFormats(): array 'D' => CellFormat::DATETIME ]; } + + public function expected(): string + { + return '1,2:30,2021_02_03,"31/12/2021 12_34_56"' . "\n" . + '2,5#300:91,2021-02-03,"2021-12-31 23:15:46"'; + } } diff --git a/tests/Data/Exports/WithHeadings/FromArrayExport.php b/tests/Data/Exports/WithHeadings/FromArrayExport.php new file mode 100644 index 0000000..4073f91 --- /dev/null +++ b/tests/Data/Exports/WithHeadings/FromArrayExport.php @@ -0,0 +1,29 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function array(): array + { + return $this->contents(); + } +} diff --git a/tests/Data/Exports/WithHeadings/FromCollectionExport.php b/tests/Data/Exports/WithHeadings/FromCollectionExport.php new file mode 100644 index 0000000..73193b4 --- /dev/null +++ b/tests/Data/Exports/WithHeadings/FromCollectionExport.php @@ -0,0 +1,30 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function collection(): Collection + { + return collect($this->contents()); + } +} diff --git a/tests/Data/Exports/WithHeadings/FromCursorExport.php b/tests/Data/Exports/WithHeadings/FromCursorExport.php new file mode 100644 index 0000000..839a4bd --- /dev/null +++ b/tests/Data/Exports/WithHeadings/FromCursorExport.php @@ -0,0 +1,31 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function collection(): LazyCollection + { + return TestCsv::cursor(); + } +} \ No newline at end of file diff --git a/tests/Data/Exports/WithHeadings/FromEloquentBuilderExport.php b/tests/Data/Exports/WithHeadings/FromEloquentBuilderExport.php new file mode 100644 index 0000000..36ca582 --- /dev/null +++ b/tests/Data/Exports/WithHeadings/FromEloquentBuilderExport.php @@ -0,0 +1,30 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function query() + { + return TestCsv::query(); + } +} diff --git a/tests/Data/Exports/WithHeadings/FromExportTrait.php b/tests/Data/Exports/WithHeadings/FromExportTrait.php new file mode 100644 index 0000000..80eaab0 --- /dev/null +++ b/tests/Data/Exports/WithHeadings/FromExportTrait.php @@ -0,0 +1,35 @@ + array_values($user), TestCsvSeeder::USERS); + } + + public function expected(): string + { + return 'id,integer,decimal,string,timestamp' . "\n" . + '1,1,1.23,text_1,2025-01-01' . "\n" . + '2,-1,-1.23,text_2,2025-01-02' . "\n" . + '3,1000,1000.23,text_3,2025-01-03' . "\n" . + '4,-1000,-1000.23,text_4,2025-01-04' . "\n" . + '5,1000000,1000000.23,text_5,2025-01-05' . "\n" . + '6,-1000000,-1000000.23,text_6,2025-01-06'; + } +} \ No newline at end of file diff --git a/tests/Data/Exports/WithHeadings/FromQueryBuilderExport.php b/tests/Data/Exports/WithHeadings/FromQueryBuilderExport.php new file mode 100644 index 0000000..76c41e8 --- /dev/null +++ b/tests/Data/Exports/WithHeadings/FromQueryBuilderExport.php @@ -0,0 +1,30 @@ +limit = $limit; + } + + public function limit(): ?int + { + return $this->limit; + } + + public function query() + { + return DB::table('test_csvs'); + } +} diff --git a/tests/Data/Exports/WithHeadingsExport.php b/tests/Data/Exports/WithHeadingsExport.php index 0f84ed6..caffed6 100644 --- a/tests/Data/Exports/WithHeadingsExport.php +++ b/tests/Data/Exports/WithHeadingsExport.php @@ -2,13 +2,22 @@ namespace Vitorccs\LaravelCsv\Tests\Data\Exports; -use Vitorccs\LaravelCsv\Concerns\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; use Vitorccs\LaravelCsv\Concerns\WithHeadings; -class WithHeadingsExport implements WithHeadings +class WithHeadingsExport implements WithHeadings, FromArray { use Exportable; + public function array(): array + { + return [ + ['a1', 'b1', 'c1'], + ['a2', 'b2', 'c2'], + ]; + } + public function headings(): array { return [ @@ -17,4 +26,9 @@ public function headings(): array 'C' ]; } + + public function expected(): string + { + return "A,B,C\na1,b1,c1\na2,b2,c2"; + } } diff --git a/tests/Data/Exports/WithLimitExport.php b/tests/Data/Exports/WithLimitExport.php index 591ba4e..d1e6e2c 100644 --- a/tests/Data/Exports/WithLimitExport.php +++ b/tests/Data/Exports/WithLimitExport.php @@ -2,9 +2,9 @@ namespace Vitorccs\LaravelCsv\Tests\Data\Exports; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromQuery; -use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestUser; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; +use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestCsv; class WithLimitExport implements FromQuery { @@ -17,6 +17,6 @@ public function limit(): ?int public function query() { - return TestUser::query(); + return TestCsv::query(); } } diff --git a/tests/Data/Exports/WithMappingExport.php b/tests/Data/Exports/WithMappingExport.php index 308a442..8e29eb3 100644 --- a/tests/Data/Exports/WithMappingExport.php +++ b/tests/Data/Exports/WithMappingExport.php @@ -2,10 +2,10 @@ namespace Vitorccs\LaravelCsv\Tests\Data\Exports; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromQuery; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; use Vitorccs\LaravelCsv\Concerns\WithMapping; -use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestUser; +use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestCsv; class WithMappingExport implements FromQuery, WithMapping { @@ -13,15 +13,25 @@ class WithMappingExport implements FromQuery, WithMapping public function query() { - return TestUser::query(); + return TestCsv::query(); } public function map($row): array { return [ - mb_strtoupper($row->name), - $row->created_at->format('Y-m'), - $row->active ? 'Active' : 'Inactive' + 'replace', + 'concatenate_' . $row->string, + $row->integer + 1 ]; } + + public function expected(): string + { + return 'replace,concatenate_text_1,2' . "\n" . + 'replace,concatenate_text_2,0' . "\n" . + 'replace,concatenate_text_3,1001' . "\n" . + 'replace,concatenate_text_4,-999' . "\n" . + 'replace,concatenate_text_5,1000001' . "\n" . + 'replace,concatenate_text_6,-999999'; + } } diff --git a/tests/Data/Exports/WithMappingExportSimple.php b/tests/Data/Exports/WithMappingExportSimple.php index 1552038..743ce01 100644 --- a/tests/Data/Exports/WithMappingExportSimple.php +++ b/tests/Data/Exports/WithMappingExportSimple.php @@ -2,10 +2,10 @@ namespace Vitorccs\LaravelCsv\Tests\Data\Exports; -use Vitorccs\LaravelCsv\Concerns\Exportable; -use Vitorccs\LaravelCsv\Concerns\FromQuery; +use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; +use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; use Vitorccs\LaravelCsv\Concerns\WithMapping; -use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestUser; +use Vitorccs\LaravelCsv\Tests\Data\Stubs\TestCsv; class WithMappingExportSimple implements FromQuery, WithMapping { @@ -13,7 +13,7 @@ class WithMappingExportSimple implements FromQuery, WithMapping public function query() { - return TestUser::query(); + return TestCsv::query(); } public function map($row): array diff --git a/tests/Data/Imports/NoHeadings/FromContentsImport.php b/tests/Data/Imports/NoHeadings/FromContentsImport.php new file mode 100644 index 0000000..51f4d3e --- /dev/null +++ b/tests/Data/Imports/NoHeadings/FromContentsImport.php @@ -0,0 +1,20 @@ +limit; + } +} \ No newline at end of file diff --git a/tests/Data/Imports/NoHeadings/FromContentsImportAlt.php b/tests/Data/Imports/NoHeadings/FromContentsImportAlt.php new file mode 100644 index 0000000..14e0dd5 --- /dev/null +++ b/tests/Data/Imports/NoHeadings/FromContentsImportAlt.php @@ -0,0 +1,35 @@ +limit; + } + + public function contents(): string + { + return "'a 1'|'b 1'|'c 1'\na2|b2|c2\na3|b3|c3\na4|b4|c4\na5|b5|c5\na6|b6|c6\na7|b7|c7\na8|b8|c8\na9|b9|c9\na10|b10|c10"; + } + + public function csvDelimiter(): string + { + return '|'; + } + + public function csvEnclosure(): string + { + return "'"; + } +} \ No newline at end of file diff --git a/tests/Data/Imports/NoHeadings/FromDiskImport.php b/tests/Data/Imports/NoHeadings/FromDiskImport.php new file mode 100644 index 0000000..bba62d7 --- /dev/null +++ b/tests/Data/Imports/NoHeadings/FromDiskImport.php @@ -0,0 +1,41 @@ +filename = uniqid(); + + Storage::put($this->filename, $this->contents()); + } + + public function delete(): void + { + Storage::delete($this->filename); + } + + public function limit(): ?int + { + return $this->limit; + } + + public function filename(): string + { + return $this->filename; + } + + public function disk(): ?string + { + return null; + } +} \ No newline at end of file diff --git a/tests/Data/Imports/NoHeadings/FromFileImport.php b/tests/Data/Imports/NoHeadings/FromFileImport.php new file mode 100644 index 0000000..9b0a096 --- /dev/null +++ b/tests/Data/Imports/NoHeadings/FromFileImport.php @@ -0,0 +1,35 @@ +uniqId = uniqid(); + + file_put_contents($this->filename(), $this->contents()) or throw new \RuntimeException('Unable to write file'); + } + + public function limit(): ?int + { + return $this->limit; + } + + public function delete(): void + { + unlink($this->filename()); + } + + public function filename(): string + { + return sprintf('%s/%s.csv', realpath(__DIR__ . '/../../Storage'), $this->uniqId); + } +} \ No newline at end of file diff --git a/tests/Data/Imports/NoHeadings/FromImportTrait.php b/tests/Data/Imports/NoHeadings/FromImportTrait.php new file mode 100644 index 0000000..4422b82 --- /dev/null +++ b/tests/Data/Imports/NoHeadings/FromImportTrait.php @@ -0,0 +1,27 @@ +resource = fopen($source, 'w+') or throw new \RuntimeException('Fail to create resource'); + fputs($this->resource, $this->contents()); + } + + public function limit(): ?int + { + return $this->limit; + } + + /** + * @return resource + */ + public function resource() + { + return $this->resource; + } +} \ No newline at end of file diff --git a/tests/Data/Imports/WithColumnFormattingImport.php b/tests/Data/Imports/WithColumnFormattingImport.php new file mode 100644 index 0000000..ab11e1d --- /dev/null +++ b/tests/Data/Imports/WithColumnFormattingImport.php @@ -0,0 +1,58 @@ + CellFormat::INTEGER, + 'B' => CellFormat::DECIMAL, + 'C' => CellFormat::DATE, + 'D' => CellFormat::DATETIME + ]; + } + + public function expected(): array + { + return [ + [1, 2.30, Carbon::parse('2021-02-03'), Carbon::parse('2021-12-31 12:34:56')], + [2, 5300.91, '2021-02-03', '2021-12-31 23:15'] + ]; + } +} diff --git a/tests/Data/Imports/WithHeadings/FromContentsImport.php b/tests/Data/Imports/WithHeadings/FromContentsImport.php new file mode 100644 index 0000000..3d906d5 --- /dev/null +++ b/tests/Data/Imports/WithHeadings/FromContentsImport.php @@ -0,0 +1,21 @@ +limit; + } +} \ No newline at end of file diff --git a/tests/Data/Imports/WithHeadings/FromDiskImport.php b/tests/Data/Imports/WithHeadings/FromDiskImport.php new file mode 100644 index 0000000..f46b82a --- /dev/null +++ b/tests/Data/Imports/WithHeadings/FromDiskImport.php @@ -0,0 +1,42 @@ +filename = uniqid(); + + Storage::put($this->filename, $this->contents()); + } + + public function delete(): void + { + Storage::delete($this->filename); + } + + public function limit(): ?int + { + return $this->limit; + } + + public function filename(): string + { + return $this->filename; + } + + public function disk(): ?string + { + return null; + } +} \ No newline at end of file diff --git a/tests/Data/Imports/WithHeadings/FromFileImport.php b/tests/Data/Imports/WithHeadings/FromFileImport.php new file mode 100644 index 0000000..a2a99b8 --- /dev/null +++ b/tests/Data/Imports/WithHeadings/FromFileImport.php @@ -0,0 +1,36 @@ +uniqId = uniqid(); + + file_put_contents($this->filename(), $this->contents()) or throw new \RuntimeException('Unable to write file'); + } + + public function limit(): ?int + { + return $this->limit; + } + + public function delete(): void + { + unlink($this->filename()); + } + + public function filename(): string + { + return sprintf('%s/%s.csv', realpath(__DIR__ . '/../../Storage'), $this->uniqId); + } +} \ No newline at end of file diff --git a/tests/Data/Imports/WithHeadings/FromImportTrait.php b/tests/Data/Imports/WithHeadings/FromImportTrait.php new file mode 100644 index 0000000..18788a5 --- /dev/null +++ b/tests/Data/Imports/WithHeadings/FromImportTrait.php @@ -0,0 +1,33 @@ +resource = fopen($source, 'w+') or throw new \RuntimeException('Fail to create resource'); + fputs($this->resource, $this->contents()); + } + + public function limit(): ?int + { + return $this->limit; + } + + /** + * @return resource + */ + public function resource() + { + return $this->resource; + } +} \ No newline at end of file diff --git a/tests/Data/Imports/WithHeadingsImport.php b/tests/Data/Imports/WithHeadingsImport.php new file mode 100644 index 0000000..48dd0ce --- /dev/null +++ b/tests/Data/Imports/WithHeadingsImport.php @@ -0,0 +1,35 @@ +seed(TestUsersSeeder::class); + $this->seed(TestCsvSeeder::class); } public function test_from_query() { - $builder = TestUser::query(); + $builder = TestCsv::query(); $chunks = []; $chunkSize = 9; $countResults = $builder->count(); diff --git a/tests/Services/ExportableServiceTest.php b/tests/Services/ExportableServiceTest.php index f2c4495..3b51047 100644 --- a/tests/Services/ExportableServiceTest.php +++ b/tests/Services/ExportableServiceTest.php @@ -9,12 +9,13 @@ use Vitorccs\LaravelCsv\Entities\CsvConfig; use Vitorccs\LaravelCsv\Jobs\CreateCsv; use Vitorccs\LaravelCsv\Services\ExportableService; -use Vitorccs\LaravelCsv\Tests\Data\Database\Seeders\TestUsersSeeder; -use Vitorccs\LaravelCsv\Tests\Data\Exports\FromArrayExport; -use Vitorccs\LaravelCsv\Tests\Data\Exports\FromCollectionExport; -use Vitorccs\LaravelCsv\Tests\Data\Exports\FromCursorExport; -use Vitorccs\LaravelCsv\Tests\Data\Exports\FromEloquentBuilderExport; -use Vitorccs\LaravelCsv\Tests\Data\Exports\FromQueryBuilderExport; +use Vitorccs\LaravelCsv\Tests\Data\Database\Seeders\TestCsvSeeder; +use Vitorccs\LaravelCsv\Tests\Data\Exports\NoHeadings\FromArrayExport; +use Vitorccs\LaravelCsv\Tests\Data\Exports\NoHeadings\FromArrayExportAlt; +use Vitorccs\LaravelCsv\Tests\Data\Exports\NoHeadings\FromCollectionExport; +use Vitorccs\LaravelCsv\Tests\Data\Exports\NoHeadings\FromCursorExport; +use Vitorccs\LaravelCsv\Tests\Data\Exports\NoHeadings\FromEloquentBuilderExport; +use Vitorccs\LaravelCsv\Tests\Data\Exports\NoHeadings\FromQueryBuilderExport; use Vitorccs\LaravelCsv\Tests\Data\Exports\WithMappingExportSimple; use Vitorccs\LaravelCsv\Tests\Data\Helpers\FakerHelper; use Vitorccs\LaravelCsv\Tests\TestCase; @@ -29,7 +30,7 @@ protected function setUp(): void { parent::setUp(); - $this->seed(TestUsersSeeder::class); + $this->seed(TestCsvSeeder::class); $this->service = app(ExportableService::class); $this->disk = 'local'; @@ -58,7 +59,7 @@ public function test_count() foreach ($databaseExports as $export) { $this->assertSame( - TestUsersSeeder::$amount, + count(TestCsvSeeder::USERS), $this->service->count($export) ); } @@ -150,23 +151,33 @@ public function test_download() $this->assertInstanceOf(StreamedResponse::class, $response); } - public function test_set_config() + public function test_stream() { $export = new FromArrayExport(); + $stream = $this->service->getStream($export); + + $this->assertTrue(is_resource($stream)); + $this->assertIsArray(fgetcsv($stream)); + } + + public function test_set_config() + { + $export = new FromArrayExportAlt(); $csvConfig = new CsvConfig(); - $csvConfig->csv_delimiter = '|'; - $csvConfig->csv_enclosure = "'"; + $csvConfig->csv_delimiter = $export->csvDelimiter(); + $csvConfig->csv_enclosure = $export->csvEnclosure(); $this->service->setConfig($csvConfig); $filename = 'test_config.csv'; $this->service->store($export, $filename); - $contents = $this->readFromDisk($filename, $csvConfig); + $actual = $this->getFromDisk($filename); - $this->assertEquals($contents, $export->array()); + $this->assertEquals($export->expected(), $actual); } - private function getExportMock(string $abstractClass, int $limit = 5) + private function getExportMock(string $abstractClass, + int $limit = 5) { $mock = $this->getMockForAbstractClass( $abstractClass, diff --git a/tests/Services/FormatterServiceTest.php b/tests/Services/FormatterServiceTest.php index 38d3297..cb70d1d 100644 --- a/tests/Services/FormatterServiceTest.php +++ b/tests/Services/FormatterServiceTest.php @@ -2,9 +2,9 @@ namespace Vitorccs\LaravelCsv\Tests\Services; -use Vitorccs\LaravelCsv\Tests\TestCase; -use Vitorccs\LaravelCsv\Tests\Data\DataProvider; use Vitorccs\LaravelCsv\Services\FormatterService; +use Vitorccs\LaravelCsv\Tests\Data\DataProvider; +use Vitorccs\LaravelCsv\Tests\TestCase; class FormatterServiceTest extends TestCase { diff --git a/tests/Services/ImportableServiceTest.php b/tests/Services/ImportableServiceTest.php index 62db8db..0e9880a 100644 --- a/tests/Services/ImportableServiceTest.php +++ b/tests/Services/ImportableServiceTest.php @@ -2,38 +2,78 @@ namespace Vitorccs\LaravelCsv\Tests\Services; +use Vitorccs\LaravelCsv\Concerns\WithHeadings; +use Vitorccs\LaravelCsv\Entities\CsvConfig; use Vitorccs\LaravelCsv\Services\ImportableService; -use Vitorccs\LaravelCsv\Tests\Data\Database\Seeders\TestUsersSeeder; +use Vitorccs\LaravelCsv\Tests\Data\Database\Seeders\TestCsvSeeder; +use Vitorccs\LaravelCsv\Tests\Data\Imports\NoHeadings\FromContentsImport; +use Vitorccs\LaravelCsv\Tests\Data\Imports\NoHeadings\FromContentsImportAlt; +use Vitorccs\LaravelCsv\Tests\Data\Imports\NoHeadings\FromDiskImport; +use Vitorccs\LaravelCsv\Tests\Data\Imports\NoHeadings\FromFileImport; +use Vitorccs\LaravelCsv\Tests\Data\Imports\NoHeadings\FromResourceImport; +use Vitorccs\LaravelCsv\Tests\Data\Imports\WithHeadingsImport; use Vitorccs\LaravelCsv\Tests\TestCase; class ImportableServiceTest extends TestCase { protected ImportableService $service; - protected string $disk; - protected array $diskOptions; - protected array $expected; protected function setUp(): void { parent::setUp(); - $this->seed(TestUsersSeeder::class); + $this->seed(TestCsvSeeder::class); $this->service = app(ImportableService::class); - $this->disk = 'samples'; - $this->diskOptions = ['option' => 'value']; + } - $this->expected = [ - ["A 1","B 1","C 1"], - ["A 2","B 2","C 2"], - ["A 3","B 3","C 3"] + public function test_count() + { + $imports = [ + new FromContentsImport(), + new FromDiskImport(), + new FromFileImport(), + new FromResourceImport(), + new WithHeadingsImport() ]; + + foreach ($imports as $import) { + $actual = $this->service->count($import); + $expected = count($import->expected()); + + if (method_exists($import, 'delete')) { + $import->delete(); + } + + if ($import instanceof WithHeadings) { + $expected--; + } + + $this->assertSame($expected, $actual); + } } public function test_from_disk() { - $contents = $this->service->fromDisk('import_test.csv', $this->disk); + $import = new FromDiskImport(); + + $actual = $this->service->getArray($import); + $import->delete(); + + $this->assertSame($import->expected(), $actual); + } + + public function test_set_config() + { + $import = new FromContentsImportAlt(); + + $csvConfig = new CsvConfig(); + $csvConfig->csv_delimiter = $import->csvDelimiter(); + $csvConfig->csv_enclosure = $import->csvEnclosure(); + $this->service->setConfig($csvConfig); + + $actual = $this->service->getArray($import); - $this->assertSame($contents, $this->expected); + $this->assertEquals($import->expected(), $actual); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 50ee4d0..a4ebd61 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,12 +4,21 @@ use Illuminate\Support\Facades\Storage; use Orchestra\Testbench\TestCase as BaseTestCase; -use Vitorccs\LaravelCsv\Entities\CsvConfig; use Vitorccs\LaravelCsv\Facades\CsvImporter; +use Vitorccs\LaravelCsv\Helpers\CsvHelper; use Vitorccs\LaravelCsv\ServiceProviders\CsvServiceProvider; abstract class TestCase extends BaseTestCase { + protected string $filename; + + protected function setUp(): void + { + parent::setUp(); + + $this->filename = uniqid() . 'csv'; + } + protected function getPackageProviders($app) { return [ @@ -28,24 +37,31 @@ protected function getApplicationTimezone($app) return 'UTC'; } - /** - * @param string $filename - * @param CsvConfig|null $csvConfig - * @return array - */ - public function readFromDisk(string $filename, CsvConfig $csvConfig = null): array + public function getFromDisk(string $filename, + bool $cleanUtf8Bom = true): string { - $csvConfig = $csvConfig ?: CsvImporter::getConfig(); + $csvConfig = CsvImporter::getConfig(); + $contents = Storage::disk($csvConfig->disk)->get($filename) ?: ''; - CsvImporter::setConfig($csvConfig); + // remove empty line break + $contents = preg_replace('/\s$/', '', $contents); - $contents = CsvImporter::fromDisk($filename); + if ($cleanUtf8Bom) { + $contents = str_replace(CsvHelper::getBom(), '', $contents); + } Storage::disk($csvConfig->disk)->delete($filename); return $contents; } + public function getFromDiskArray(string $filename, + bool $cleanUtf8Bom = true): array + { + $contents = $this->getFromDisk($filename); + return explode("\n", $contents); + } + /** * @param $app * @return void @@ -59,21 +75,16 @@ protected function getEnvironmentSetUp($app) $app['config']->set('filesystems.default', 'local'); $app['config']->set('filesystems.disks.local.root', realpath(__DIR__ . '/Data/Storage')); - $app['config']->set('filesystems.disks.samples.driver', 'local'); - $app['config']->set('filesystems.disks.samples.root', realpath(__DIR__ . '/Data/Samples')); - $app['config']->set('database.default', 'testing'); $app['config']->set('database.connections.testing', [ - 'driver' => env('DB_DRIVER', 'sqlite'), - 'host' => env('DB_HOST'), - 'port' => env('DB_PORT'), + 'driver' => env('DB_DRIVER', 'sqlite'), + 'host' => env('DB_HOST'), + 'port' => env('DB_PORT'), 'database' => env('DB_DATABASE', ':memory:'), 'username' => env('DB_USERNAME'), 'password' => env('DB_PASSWORD'), - 'prefix' => env('DB_PREFIX') + 'prefix' => env('DB_PREFIX') ]); - - } protected function defineDatabaseMigrations()