├── .github └── workflows │ └── php.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── config └── csv.php ├── phpunit.xml.dist ├── src ├── Concerns │ ├── Exportables │ │ ├── Exportable.php │ │ ├── FromArray.php │ │ ├── FromCollection.php │ │ ├── FromQuery.php │ │ └── FromQueryCursor.php │ ├── Importables │ │ ├── FromContents.php │ │ ├── FromDisk.php │ │ ├── FromFile.php │ │ ├── FromResource.php │ │ └── Importable.php │ ├── WithColumnFormatting.php │ ├── WithHeadings.php │ └── WithMapping.php ├── CsvExporter.php ├── CsvImporter.php ├── Entities │ └── CsvConfig.php ├── Enum │ └── CellFormat.php ├── Exceptions │ └── InvalidCellValueException.php ├── Facades │ ├── CsvExporter.php │ └── CsvImporter.php ├── Handlers │ ├── Readers │ │ ├── Handler.php │ │ └── StreamHandler.php │ └── Writers │ │ ├── ArrayHandler.php │ │ ├── Handler.php │ │ └── StreamHandler.php ├── Helpers │ ├── CsvHelper.php │ ├── FormatterHelper.php │ ├── ModelHelper.php │ ├── ParseHelper.php │ └── QueryBuilderHelper.php ├── Jobs │ └── CreateCsv.php ├── ServiceProviders │ └── CsvServiceProvider.php └── Services │ ├── ExportableService.php │ ├── FormatterService.php │ ├── ImportableService.php │ ├── ParserService.php │ ├── Reader.php │ └── Writer.php └── tests ├── Concerns ├── Exportables │ ├── ExportableTest.php │ ├── FromArrayTest.php │ ├── FromCollectionTest.php │ └── FromQueryTest.php ├── Importables │ ├── AbstractTestCase.php │ ├── FromContentsTest.php │ ├── FromDiskTest.php │ ├── FromFileTest.php │ ├── FromResourceTest.php │ └── ImportableTest.php ├── WithColumnFormattingTest.php └── WithMappingTest.php ├── Data ├── DataProvider.php ├── Database │ ├── Factories │ │ └── TestUserFactory.php │ ├── Migrations │ │ └── 0000_00_00_000000_create_test_csv_table.php │ └── Seeders │ │ └── TestCsvSeeder.php ├── Exports │ ├── NoHeadings │ │ ├── FromArrayExport.php │ │ ├── FromArrayExportAlt.php │ │ ├── FromCollectionExport.php │ │ ├── FromCursorExport.php │ │ ├── FromEloquentBuilderExport.php │ │ ├── FromExportTrait.php │ │ └── FromQueryBuilderExport.php │ ├── WithColumnFormattingExport.php │ ├── WithHeadings │ │ ├── FromArrayExport.php │ │ ├── FromCollectionExport.php │ │ ├── FromCursorExport.php │ │ ├── FromEloquentBuilderExport.php │ │ ├── FromExportTrait.php │ │ └── FromQueryBuilderExport.php │ ├── WithHeadingsExport.php │ ├── WithLimitExport.php │ ├── WithMappingExport.php │ └── WithMappingExportSimple.php ├── Helpers │ └── FakerHelper.php ├── Imports │ ├── NoHeadings │ │ ├── FromContentsImport.php │ │ ├── FromContentsImportAlt.php │ │ ├── FromDiskImport.php │ │ ├── FromFileImport.php │ │ ├── FromImportTrait.php │ │ └── FromResourceImport.php │ ├── WithColumnFormattingImport.php │ ├── WithHeadings │ │ ├── FromContentsImport.php │ │ ├── FromDiskImport.php │ │ ├── FromFileImport.php │ │ ├── FromImportTrait.php │ │ └── FromResourceImport.php │ ├── WithHeadingsImport.php │ └── WithMappingImport.php ├── Storage │ └── .gitignore └── Stubs │ ├── MockModel.php │ └── TestCsv.php ├── Entities └── CsvConfigTest.php ├── Helpers ├── CsvHelperTest.php ├── FormatterHelperTest.php ├── ModelHelperTest.php └── QueryBuilderTest.php ├── Services ├── ExportableServiceTest.php ├── FormatterServiceTest.php └── ImportableServiceTest.php └── TestCase.php /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build-test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - uses: php-actions/composer@v5 13 | 14 | - name: PHPUnit Tests 15 | uses: php-actions/phpunit@v3 16 | with: 17 | configuration: phpunit.xml.dist 18 | version: 9.5 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build 3 | composer.phar 4 | vendor 5 | phpunit.xml 6 | index.php 7 | .phpunit.result.cache 8 | /composer.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vitor Siqueira 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel CSV 2 | PHP Laravel package to export and import CSV files in a memory-optimized way. 3 | 4 | ## Description 5 | 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. 6 | 7 | Import CSV files from _Laravel Disks_, _local files_, _strings_ or _resources_ and choose to retrieve the full content or in small chunks. 8 | 9 | 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. 10 | 11 | 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. 12 | 13 | ## Upgrading from v1.0 to v2.0 14 | Version 2.0 adds the importing feature so the only required action is to change the importing namespace: 15 | ```php 16 | // v1.0 (old) 17 | use Vitorccs\LaravelCsv\Concerns\Exportable; 18 | use Vitorccs\LaravelCsv\Concerns\FromArray; 19 | use Vitorccs\LaravelCsv\Concerns\FromCollection; 20 | use Vitorccs\LaravelCsv\Concerns\FromQuery; 21 | ``` 22 | ```php 23 | // v2.0 (new) 24 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 25 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; 26 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromCollection; 27 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; 28 | ``` 29 | 30 | ## Requirements 31 | * PHP >= 8.0 32 | * Laravel >= 6.x 33 | 34 | ## Installation 35 | Step 1) Add composer dependency 36 | ```bash 37 | composer require vitorccs/laravel-csv 38 | ``` 39 | 40 | Step 2) Publish the config file 41 | ```bash 42 | php artisan vendor:publish --provider="Vitorccs\LaravelCsv\ServiceProviders\CsvServiceProvider" --tag=config 43 | ``` 44 | 45 | Step 3) Edit your local `config\csv.php` file per your project preferences 46 | 47 | ## How to Export 48 | Step 1) Create an Export class file as shown below 49 | 50 | Note: you may implement _FromArray_, _FromCollection_ or _FromQuery_ 51 | 52 | ```php 53 | namespace App\Exports; 54 | 55 | use App\User; 56 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 57 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; 58 | 59 | class UsersExport implements FromQuery 60 | { 61 | use Exportable; 62 | 63 | public function query() 64 | { 65 | return User::query() 66 | ->where('created_at', '>=', '2024-01-01 00:00:00'); 67 | } 68 | } 69 | ``` 70 | 71 | Step 2) The file can now be generated by using a single line: 72 | ```php 73 | # prompt the client browser to download the file 74 | return (new UsersExport)->download('users.csv'); 75 | ``` 76 | 77 | In case you want the file to be stored in the disk: 78 | ```php 79 | # will save the file in 's3' disk 80 | return (new UsersExport)->store('users.csv', 's3'); 81 | ``` 82 | 83 | You may also get the content as stream for better control over the output: 84 | ```php 85 | # will get the content in a stream (content placed in a temporary file) 86 | return (new UsersExport)->stream(); 87 | ``` 88 | 89 | For larger files, you may want to generate the file in background as a Laravel Job 90 | ```php 91 | use App\Jobs\NotifyCsvCreated; 92 | 93 | # generate a {uuid-v4}.csv filename 94 | $filename = CsvHelper::filename(); 95 | 96 | # will create a job to create and store the file in disk 97 | # and afterwards notify the user 98 | (new BillsExport()) 99 | ->queue($filename, 's3') 100 | ->allOnQueue('default') 101 | ->chain([ 102 | // You must create the Laravel Job below 103 | new NotifyCsvCreated($filename) 104 | ]); 105 | ``` 106 | 107 | ## Export - Data sources 108 | Note: Only `FromQuery` can chunk results per `chunk_size` parameter from config file. 109 | 110 | ### Laravel Eloquent Query Builder 111 | 112 | ```php 113 | namespace App\Exports; 114 | 115 | use App\User; 116 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 117 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; 118 | 119 | class MyQueryExport implements FromQuery 120 | { 121 | use Exportable; 122 | 123 | public function query() 124 | { 125 | return User::query(); 126 | } 127 | } 128 | ``` 129 | 130 | ### Laravel Database Query Builder 131 | 132 | ```php 133 | namespace App\Exports; 134 | 135 | use App\User; 136 | use Illuminate\Support\Facades\DB; 137 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 138 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; 139 | 140 | class MyQueryExport implements FromQuery 141 | { 142 | use Exportable; 143 | 144 | public function query() 145 | { 146 | return DB::table('users'); 147 | } 148 | } 149 | ``` 150 | 151 | ### Laravel Collection 152 | 153 | ```php 154 | namespace App\Exports; 155 | 156 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 157 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromCollection; 158 | 159 | class MyCollectionExport implements FromCollection 160 | { 161 | use Exportable; 162 | 163 | public function collection() 164 | { 165 | return collect([ 166 | ['a1', 'b1', 'c1'], 167 | ['a2', 'b2', 'c2'], 168 | ['a3', 'b3', 'c3'] 169 | ]); 170 | } 171 | } 172 | ``` 173 | 174 | ### Laravel LazyCollection 175 | 176 | ```php 177 | namespace App\Exports; 178 | 179 | use App\User; 180 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 181 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromCollection; 182 | 183 | class MyQueryExport implements FromCollection 184 | { 185 | use Exportable; 186 | 187 | public function collection() 188 | { 189 | return User::cursor(); 190 | } 191 | } 192 | ``` 193 | 194 | ### PHP Arrays 195 | 196 | ```php 197 | namespace App\Exports; 198 | 199 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 200 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; 201 | 202 | class MyArrayExport implements FromArray 203 | { 204 | use Exportable; 205 | 206 | public function array(): array 207 | { 208 | return [ 209 | ['a1', 'b1', 'c1'], 210 | ['a2', 'b2', 'c2'], 211 | ['a3', 'b3', 'c3'] 212 | ]; 213 | } 214 | } 215 | ``` 216 | 217 | ## How to Import 218 | Step 1) Create an Import class file as shown below 219 | 220 | Note: you may implement _FromDisk_, _FromFile_, _FromResource_ or _FromContents_ 221 | 222 | ```php 223 | namespace App\Exports; 224 | 225 | use Vitorccs\LaravelCsv\Concerns\Importables\Importable; 226 | use Vitorccs\LaravelCsv\Concerns\Importables\FromDisk; 227 | 228 | class UsersImport implements FromDisk 229 | { 230 | use Importable; 231 | 232 | public function disk(): ?string 233 | { 234 | return 's3'; 235 | } 236 | 237 | public function filename(): string 238 | { 239 | return 'users.csv'; 240 | } 241 | } 242 | ``` 243 | 244 | Step 2) The content can now be retrieved by using a single line: 245 | ```php 246 | # get the records in array format 247 | return (new UsersImport)->getArray(); 248 | ``` 249 | 250 | ```php 251 | # in case the result is too large, you may receive small chunk of results 252 | # at a time in your callback function, preventing memory exhaustion. 253 | (new UsersImport)->chunkArray(function(array $rows, int $index) { 254 | // do something with the rows 255 | echo "Chunk $index has the following records:"; 256 | print_r($rows); 257 | }); 258 | ``` 259 | 260 | ## Import - Data sources 261 | 262 | ### From string 263 | 264 | ```php 265 | namespace App\Imports; 266 | 267 | use Vitorccs\LaravelCsv\Concerns\Importables\Importable; 268 | use Vitorccs\LaravelCsv\Concerns\Importables\FromContents; 269 | 270 | class MyContents implements FromContents 271 | { 272 | use Importable; 273 | 274 | public function contents(): string 275 | { 276 | return "A1,B1,C1\nA2,B2,C2\n,A3,B3,C3"; 277 | } 278 | } 279 | ``` 280 | 281 | ### From local File 282 | 283 | ```php 284 | namespace App\Imports; 285 | 286 | use Vitorccs\LaravelCsv\Concerns\Importables\Importable; 287 | use Vitorccs\LaravelCsv\Concerns\Importables\FromFile; 288 | 289 | class MyFileImport implements FromFile 290 | { 291 | use Importable; 292 | 293 | public function filename(): string; 294 | { 295 | return storage_path() . '/users.csv'; 296 | } 297 | } 298 | ``` 299 | 300 | ### From resource 301 | 302 | ```php 303 | namespace App\Imports; 304 | 305 | use Vitorccs\LaravelCsv\Concerns\Importables\Importable; 306 | use Vitorccs\LaravelCsv\Concerns\Importables\FromResource; 307 | 308 | class MyResourceImport implements FromResource 309 | { 310 | use Importable; 311 | 312 | public function resource() 313 | { 314 | $contents = "A1,B1,C1\nA2,B2,C2\n,A3,B3,C3"; 315 | $resource = fopen('php://memory', 'w+'); 316 | 317 | fputs($resource, $contents); 318 | 319 | return $resource; 320 | } 321 | } 322 | ``` 323 | 324 | ### From Laravel Disk 325 | ```php 326 | namespace App\Exports; 327 | 328 | use Vitorccs\LaravelCsv\Concerns\Importables\Importable; 329 | use Vitorccs\LaravelCsv\Concerns\Importables\FromDisk; 330 | 331 | class UsersImport implements FromDisk 332 | { 333 | use Importable; 334 | 335 | public function disk(): ?string 336 | { 337 | return 'local'; 338 | } 339 | 340 | public function filename(): string 341 | { 342 | return 'my_imports/users.csv'; 343 | } 344 | } 345 | ``` 346 | 347 | ## Implementations 348 | The implementations below work with both Export and Import mode. 349 | 350 | ### Headings 351 | Implement `WithHeadings` for setting a heading to the CSV file. 352 | 353 | ```php 354 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 355 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; 356 | use Vitorccs\LaravelCsv\Concerns\WithHeadings; 357 | 358 | class UsersExport implements FromArray, WithHeadings 359 | { 360 | use Exportable; 361 | 362 | public function headings(): array 363 | { 364 | return ['ID', 'Name', 'Email']; 365 | } 366 | } 367 | ``` 368 | 369 | ### Mapping rows 370 | Implement `WithMapping` if you either need to set the value of each column or apply some custom formatting. 371 | 372 | ```php 373 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 374 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; 375 | use Vitorccs\LaravelCsv\Concerns\WithMapping; 376 | 377 | class UsersExport implements FromArray, WithMapping 378 | { 379 | use Exportable; 380 | 381 | public function map($user): array 382 | { 383 | return [ 384 | $user->id, 385 | $user->name, 386 | $user->email ?: 'N/A' 387 | ]; 388 | } 389 | } 390 | ``` 391 | 392 | ### Formatting columns 393 | Implement `WithColumnFormatting` to format date and numeric fields. 394 | 395 | 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). 396 | 397 | In import mode, the string content must match with the formatting set (e.g: yyyy-mm-dd for dates). 398 | 399 | The formatting preferences are set in the config file `csv.php`. 400 | 401 | ```php 402 | use Carbon\Carbon; 403 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 404 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromArray; 405 | use Vitorccs\LaravelCsv\Concerns\WithColumnFormatting; 406 | use Vitorccs\LaravelCsv\Enum\CellFormat; 407 | 408 | class UsersExport implements FromArray, WithColumnFormatting 409 | { 410 | use Exportable; 411 | 412 | public function array(): array 413 | { 414 | return [ 415 | [ Carbon::now(), Carbon::now(), 2.50, 1.00 ], 416 | [ new DateTime(), new DateTime(), 3, 2.00 ] 417 | ]; 418 | } 419 | 420 | public function columnFormats(): array 421 | { 422 | return [ 423 | 'A' => CellFormat::DATE, 424 | 'B' => CellFormat::DATETIME, 425 | 'C' => CellFormat::DECIMAL, 426 | 'D' => CellFormat::INTEGER, 427 | ]; 428 | } 429 | } 430 | ``` 431 | 432 | ### Limiting the results 433 | Implement the method below if you need to limit the quantity of results to be exported/imported. 434 | 435 | ```php 436 | use Vitorccs\LaravelCsv\Concerns\Exportables\Exportable; 437 | use Vitorccs\LaravelCsv\Concerns\Exportables\FromQuery; 438 | 439 | class UsersExport implements FromQuery 440 | { 441 | use Exportable; 442 | 443 | public function limit(): ?int 444 | { 445 | return 5000; 446 | } 447 | } 448 | ``` 449 | 450 | ## License 451 | Released under the [MIT License](LICENSE). 452 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vitorccs/laravel-csv", 3 | "description": "PHP Laravel package to create CSV files in a memory-optimized way", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "php", 8 | "csv", 9 | "export" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Vitor Siqueira", 14 | "email": "vitorccsiqueira@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=8.0" 19 | }, 20 | "require-dev": { 21 | "phpunit/phpunit": "^9.5", 22 | "ext-sqlite3": "*", 23 | "laravel/legacy-factories": "1.x-dev", 24 | "orchestra/testbench": "^5.0 || ^6.0 || ^7.0 || ^8.0" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Vitorccs\\LaravelCsv\\": "src/" 29 | } 30 | }, 31 | "autoload-dev": { 32 | "psr-4": { 33 | "Vitorccs\\LaravelCsv\\Tests\\": "tests/" 34 | } 35 | }, 36 | "extra": { 37 | "laravel": { 38 | "providers": [ 39 | "Vitorccs\\LaravelCsv\\ServiceProviders\\CsvServiceProvider" 40 | ] 41 | } 42 | }, 43 | "minimum-stability": "dev", 44 | "prefer-stable": true, 45 | "suggest": { 46 | "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^1.0)." 47 | }, 48 | "scripts": { 49 | "test": "phpunit --testdox" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /config/csv.php: -------------------------------------------------------------------------------- 1 | 1000, 14 | 15 | /* 16 | |-------------------------------------------------------------------------- 17 | | Disk 18 | |-------------------------------------------------------------------------- 19 | | 20 | | Set disk to store CSV file 21 | | 22 | */ 23 | 'disk' => 'local', 24 | 25 | /* 26 | |-------------------------------------------------------------------------- 27 | | CSV Settings 28 | |-------------------------------------------------------------------------- 29 | | 30 | | Configure delimiter and enclosure for CSV file 31 | | 32 | */ 33 | 'csv_delimiter' => ',', 34 | 'csv_enclosure' => '"', 35 | 'csv_escape' => '\\', 36 | 'csv_bom' => true, 37 | 38 | /* 39 | |-------------------------------------------------------------------------- 40 | | Data formatter 41 | |-------------------------------------------------------------------------- 42 | | 43 | | Configure date and currency format 44 | | 45 | */ 46 | 'format_date' => 'Y-m-d', 47 | 'format_datetime' => 'Y-m-d H:i:s', 48 | 'format_number_decimals' => 2, 49 | 'format_number_decimal_sep' => '.', 50 | 'format_number_thousand_sep' => ',', 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | Job Settings 55 | |-------------------------------------------------------------------------- 56 | | 57 | | Configure the Job settings (timeout, attempts, etc) 58 | | 59 | */ 60 | 'job_timeout' => 60 * 10, 61 | 'job_attempts' => 1, 62 | 'job_delay' => 0 63 | ]; 64 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | src/ 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | tests/ 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Concerns/Exportables/Exportable.php: -------------------------------------------------------------------------------- 1 | getFilename($filename), $disk, $diskOptions); 64 | } 65 | 66 | /** 67 | * @param string|null $filename 68 | * @return StreamedResponse 69 | */ 70 | public function download(?string $filename = null): StreamedResponse 71 | { 72 | return CsvExporter::download($this, $this->getFilename($filename)); 73 | } 74 | 75 | /** 76 | * @param string|null $filename 77 | * @param string|null $disk 78 | * @param array $diskOptions 79 | * @return PendingDispatch 80 | */ 81 | public function queue(?string $filename = null, 82 | ?string $disk = null, 83 | array $diskOptions = []): PendingDispatch 84 | { 85 | return CsvExporter::queue($this, $this->getFilename($filename), $disk, $diskOptions); 86 | } 87 | 88 | /** 89 | * @return resource 90 | */ 91 | public function stream() 92 | { 93 | return CsvExporter::stream($this); 94 | } 95 | 96 | /** 97 | * @param string|null $filename 98 | * @return string 99 | */ 100 | public function getFilename(?string $filename = null): string 101 | { 102 | return $filename ?: CsvHelper::filename(); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Concerns/Exportables/FromArray.php: -------------------------------------------------------------------------------- 1 | service = $service; 24 | } 25 | 26 | /** 27 | * @return CsvConfig 28 | */ 29 | public function getConfig(): CsvConfig 30 | { 31 | return $this->service->getConfig(); 32 | } 33 | 34 | /** 35 | * @param CsvConfig $config 36 | */ 37 | public function setConfig(CsvConfig $config): void 38 | { 39 | $this->service->setConfig($config); 40 | } 41 | 42 | /** 43 | * @param object $exportable 44 | * @return int 45 | */ 46 | public function count(object $exportable): int 47 | { 48 | return $this->service->count($exportable); 49 | } 50 | 51 | /** 52 | * @param object $exportable 53 | * @return array 54 | * @throws InvalidCellValueException 55 | */ 56 | public function toArray(object $exportable): array 57 | { 58 | return $this->service->array($exportable); 59 | } 60 | 61 | /** 62 | * @param object $exportable 63 | * @param string $filename 64 | * @param string|null $disk 65 | * @param array $diskOptions 66 | * @return string 67 | * @throws InvalidCellValueException 68 | */ 69 | public function store(object $exportable, 70 | string $filename, 71 | ?string $disk = null, 72 | array $diskOptions = []): string 73 | { 74 | return $this->service->store($exportable, $filename, $disk, $diskOptions); 75 | } 76 | 77 | /** 78 | * @param object $exportable 79 | * @param string $filename 80 | * @return StreamedResponse 81 | * @throws InvalidCellValueException 82 | */ 83 | public function download(object $exportable, string $filename): StreamedResponse 84 | { 85 | return $this->service->download($exportable, $filename); 86 | } 87 | 88 | /** 89 | * @param object $exportable 90 | * @param string $filename 91 | * @param string|null $disk 92 | * @param array $diskOptions 93 | * @return PendingDispatch 94 | */ 95 | public function queue(object $exportable, 96 | string $filename, 97 | ?string $disk = null, 98 | array $diskOptions = []): PendingDispatch 99 | { 100 | return $this->service->queue($exportable, $filename, $disk, $diskOptions); 101 | } 102 | 103 | /** 104 | * @param object $exportable 105 | * @return resource 106 | * @throws InvalidCellValueException 107 | */ 108 | public function stream(object $exportable) 109 | { 110 | return $this->service->getStream($exportable); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/CsvImporter.php: -------------------------------------------------------------------------------- 1 | service = $service; 21 | } 22 | 23 | /** 24 | * @return CsvConfig 25 | */ 26 | public function getConfig(): CsvConfig 27 | { 28 | return $this->service->getConfig(); 29 | } 30 | 31 | /** 32 | * @param CsvConfig $config 33 | */ 34 | public function setConfig(CsvConfig $config): void 35 | { 36 | $this->service->setConfig($config); 37 | } 38 | 39 | /** 40 | * @param object $importable 41 | * @return int 42 | */ 43 | public function count(object $importable): int 44 | { 45 | return $this->service->count($importable); 46 | } 47 | 48 | /** 49 | * @param object $importable 50 | * @return array 51 | */ 52 | public function getArray(object $importable): array 53 | { 54 | return $this->service->getArray($importable); 55 | } 56 | 57 | /** 58 | * @param object $importable 59 | * @param callable(array,int):void $callable 60 | * @param int|null $size 61 | * @return void 62 | */ 63 | public function chunkArray(object $importable, 64 | callable $callable, 65 | ?int $size): void 66 | { 67 | $this->service->chunkArray($importable, $callable, $size); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Entities/CsvConfig.php: -------------------------------------------------------------------------------- 1 | $value) { 31 | $this->{$key} = config("csv.{$key}"); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Enum/CellFormat.php: -------------------------------------------------------------------------------- 1 | stream = $resource; 27 | $this->csvConfig = $csvConfig; 28 | } 29 | 30 | /** 31 | * @return resource 32 | */ 33 | public function getResource() 34 | { 35 | return $this->stream; 36 | } 37 | 38 | /** 39 | * @return int 40 | */ 41 | public function count(): int 42 | { 43 | $i = 0; 44 | rewind($this->stream); 45 | 46 | while (!feof($this->stream)) { 47 | $row = fgets($this->stream); 48 | if (empty($row)) continue; 49 | $i++; 50 | } 51 | 52 | return $i; 53 | } 54 | 55 | /** 56 | * @param callable(array,int):void $callable 57 | * @param int $size 58 | * @param int|null $maxRecords 59 | * @return void 60 | */ 61 | public function getChunk(callable $callable, 62 | int $size, 63 | ?int $maxRecords = null): void 64 | { 65 | $this->prepareForReading(); 66 | $counter = 0; 67 | $isMaxRecords = false; 68 | 69 | while (!feof($this->stream) && !$isMaxRecords) { 70 | $remaining = $maxRecords 71 | ? min($maxRecords - $counter, $size) 72 | : $size; 73 | $rows = $this->readStream($remaining); 74 | $callable($rows); 75 | $counter += count($rows); 76 | $isMaxRecords = $maxRecords && $counter >= $maxRecords; 77 | } 78 | } 79 | 80 | /** 81 | * @param int|null $maxRecords 82 | * @return array 83 | */ 84 | public function getAll(?int $maxRecords = null): array 85 | { 86 | $this->prepareForReading(); 87 | 88 | return $this->readStream($maxRecords); 89 | } 90 | 91 | private function readStream(?int $quantity = null): array 92 | { 93 | $rows = []; 94 | $counter = 0; 95 | $isMaxQuantity = false; 96 | 97 | while (!feof($this->stream) && !$isMaxQuantity) { 98 | $row = fgetcsv( 99 | $this->stream, 100 | null, 101 | $this->csvConfig->csv_delimiter, 102 | $this->csvConfig->csv_enclosure, 103 | $this->csvConfig->csv_escape 104 | ); 105 | if (!is_array($row)) continue; 106 | $rows[] = $row; 107 | $counter++; 108 | $isMaxQuantity = $quantity && $counter >= $quantity; 109 | } 110 | 111 | return $rows; 112 | } 113 | 114 | /** 115 | * @return void 116 | */ 117 | private function prepareForReading(): void 118 | { 119 | rewind($this->stream); 120 | 121 | // remove UTF-8 BOM character 122 | if (fgets($this->stream, 4) !== CsvHelper::getBom()) { 123 | rewind($this->stream); 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Handlers/Writers/ArrayHandler.php: -------------------------------------------------------------------------------- 1 | handler; 18 | } 19 | 20 | /** 21 | * @param array $content 22 | * @return void 23 | */ 24 | public function addContent(array $content): void 25 | { 26 | $this->handler[] = $content; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Handlers/Writers/Handler.php: -------------------------------------------------------------------------------- 1 | stream = fopen('php://temp', 'a+') or throw new RuntimeException('Cannot open stream');; 27 | $this->csvConfig = $csvConfig; 28 | $this->init(); 29 | } 30 | 31 | /** 32 | * @return resource 33 | */ 34 | public function getResource() 35 | { 36 | return $this->stream; 37 | } 38 | 39 | /** 40 | * @param array $content 41 | * @return void 42 | */ 43 | public function addContent(array $content): void 44 | { 45 | fputcsv( 46 | $this->stream, 47 | $content, 48 | $this->csvConfig->csv_delimiter, 49 | $this->csvConfig->csv_enclosure, 50 | $this->csvConfig->csv_escape 51 | ); 52 | } 53 | 54 | /** 55 | * @return void 56 | */ 57 | private function init(): void 58 | { 59 | if ($this->csvConfig->csv_bom) { 60 | fwrite($this->stream, CsvHelper::getBom()); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Helpers/CsvHelper.php: -------------------------------------------------------------------------------- 1 | format($format); 20 | } 21 | 22 | /** 23 | * @param float|int|string $number 24 | * @param int $decimals 25 | * @param string $decimalSep 26 | * @param string $thousandsSep 27 | * @return string 28 | */ 29 | public static function number(float|int|string $number, 30 | int $decimals = 0, 31 | string $decimalSep = '.', 32 | string $thousandsSep = ','): string 33 | { 34 | if (!is_numeric($number)) { 35 | return (string)$number; 36 | } 37 | 38 | return number_format( 39 | $number, 40 | $decimals, 41 | $decimalSep, 42 | $thousandsSep, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Helpers/ModelHelper.php: -------------------------------------------------------------------------------- 1 | toArray() could not be used since 13 | * it casts the properties values which unable us to perform 14 | * formatting functions 15 | * 16 | * @param Model $model 17 | * @return array 18 | */ 19 | public static function toArrayValues(Model $model): array { 20 | return array_reduce( 21 | array_keys($model->getAttributes()), 22 | function (array $acc, string $attribute) use ($model) { 23 | $acc[] = $model[$attribute]; 24 | return $acc; 25 | }, 26 | [] 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Helpers/ParseHelper.php: -------------------------------------------------------------------------------- 1 | offset($page * $size) 38 | ->take($take) 39 | ->get(); 40 | 41 | $callback($rows); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Jobs/CreateCsv.php: -------------------------------------------------------------------------------- 1 | exportable = $exportable; 63 | $this->filename = $filename; 64 | $this->disk = $disk; 65 | $this->diskOptions = $diskOptions; 66 | } 67 | 68 | /** 69 | * @param ExportableService $exportableService 70 | * @return void 71 | * @throws InvalidCellValueException 72 | */ 73 | public function handle(ExportableService $exportableService): void 74 | { 75 | $exportableService->store( 76 | $this->exportable, 77 | $this->filename, 78 | $this->disk, 79 | $this->diskOptions 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/ServiceProviders/CsvServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 22 | $this->getConfigFile() => config_path('csv.php'), 23 | ], 'config'); 24 | } 25 | 26 | /** 27 | * Register any application services. 28 | * 29 | * @return void 30 | */ 31 | public function register() 32 | { 33 | $this->mergeConfigFrom( 34 | $this->getConfigFile(), 35 | 'csv' 36 | ); 37 | 38 | $this->app->singleton(CsvConfig::class, fn() => new CsvConfig()); 39 | 40 | $this->app->bind('csv_exporter', function ($app) { 41 | return new CsvExporter( 42 | $app->make(ExportableService::class), 43 | ); 44 | }); 45 | 46 | $this->app->bind('csv_importer', function ($app) { 47 | return new CsvImporter( 48 | $app->make(ImportableService::class), 49 | ); 50 | }); 51 | } 52 | 53 | /** 54 | * @return string 55 | */ 56 | private function getConfigFile(): string 57 | { 58 | return __DIR__ . '/../../config/csv.php'; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Services/ExportableService.php: -------------------------------------------------------------------------------- 1 | config = $config; 33 | } 34 | 35 | /** 36 | * @return CsvConfig 37 | */ 38 | public function getConfig(): CsvConfig 39 | { 40 | return $this->config; 41 | } 42 | 43 | /** 44 | * @param CsvConfig $config 45 | */ 46 | public function setConfig(CsvConfig $config): void 47 | { 48 | $this->config = $config; 49 | } 50 | 51 | /** 52 | * @param object $exportable 53 | * @return int 54 | */ 55 | public function count(object $exportable): int 56 | { 57 | if ($exportable instanceof FromArray) { 58 | return count($exportable->array()); 59 | } 60 | 61 | if ($exportable instanceof FromCollection) { 62 | return $exportable->collection()->count(); 63 | } 64 | 65 | if ($exportable instanceof FromQuery) { 66 | return $exportable->query()->count(); 67 | } 68 | 69 | throw new \RuntimeException('Missing data source trait'); 70 | } 71 | 72 | /** 73 | * @param object $exportable 74 | * @param string $filename 75 | * @param string|null $disk 76 | * @param array $diskOptions 77 | * @return string|null 78 | * @throws InvalidCellValueException 79 | */ 80 | public function store(object $exportable, 81 | string $filename, 82 | ?string $disk = null, 83 | array $diskOptions = []): ?string 84 | { 85 | $success = Storage::disk($disk ?: $this->config->disk)->put( 86 | $filename, 87 | $this->getStream($exportable), 88 | $diskOptions 89 | ); 90 | 91 | return $success ? $filename : null; 92 | } 93 | 94 | /** 95 | * @param object $exportable 96 | * @param string $filename 97 | * @return StreamedResponse 98 | * @throws InvalidCellValueException 99 | */ 100 | public function download(object $exportable, 101 | string $filename): StreamedResponse 102 | { 103 | $headers = [ 104 | 'Content-Type' => CsvHelper::$contentType, 105 | 'Content-Encoding' => 'none', 106 | 'Content-Description' => 'File Transfer' 107 | ]; 108 | 109 | $stream = $this->getStream($exportable); 110 | 111 | return Response::streamDownload( 112 | function () use ($stream) { 113 | fpassthru($stream); 114 | if (is_resource($stream)) { 115 | fclose($stream); 116 | } 117 | }, 118 | $filename, 119 | $headers 120 | ); 121 | } 122 | 123 | /** 124 | * @param object $exportable 125 | * @param string $filename 126 | * @param string|null $disk 127 | * @param array $diskOptions 128 | * @return PendingDispatch 129 | */ 130 | public function queue(object $exportable, 131 | string $filename, 132 | ?string $disk = null, 133 | array $diskOptions = []): PendingDispatch 134 | { 135 | 136 | $job = new CreateCsv($exportable, $filename, $disk, $diskOptions); 137 | 138 | $job->timeout = $this->config->job_timeout; 139 | $job->tries = $this->config->job_attempts; 140 | $delay = now()->addSeconds($this->config->job_delay); 141 | 142 | return dispatch($job)->delay($delay); 143 | } 144 | 145 | /** 146 | * @param object $exportable 147 | * @return array 148 | * @throws InvalidCellValueException 149 | */ 150 | public function array(object $exportable): array 151 | { 152 | return $this->getArray($exportable); 153 | } 154 | 155 | /** 156 | * @param object $exportable 157 | * @return resource 158 | * @throws InvalidCellValueException 159 | */ 160 | public function getStream(object $exportable) 161 | { 162 | $stream = App::make(Writer::class, [ 163 | 'formatter' => new FormatterService($this->config), 164 | 'handler' => new StreamHandler($this->config) 165 | ])->generate($exportable); 166 | 167 | rewind($stream); 168 | 169 | return $stream; 170 | } 171 | 172 | /** 173 | * @param object $exportable 174 | * @return array 175 | * @throws InvalidCellValueException 176 | */ 177 | private function getArray(object $exportable): array 178 | { 179 | return App::make(Writer::class, [ 180 | 'formatter' => new FormatterService($this->config), 181 | 'handler' => new ArrayHandler() 182 | ])->generate($exportable); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Services/FormatterService.php: -------------------------------------------------------------------------------- 1 | config = $config; 21 | } 22 | 23 | /** 24 | * @param \DateTime|string $date 25 | * @return string 26 | */ 27 | public function date(\DateTime|string $date): string 28 | { 29 | return FormatterHelper::date($date, $this->config->format_date); 30 | } 31 | 32 | /** 33 | * @param \DateTime|string $date 34 | * @return string 35 | */ 36 | public function datetime(\DateTime|string $date): string 37 | { 38 | return FormatterHelper::date($date, $this->config->format_datetime); 39 | } 40 | 41 | /** 42 | * @param float|int|string $number 43 | * @return string 44 | */ 45 | public function decimal(float|int|string $number): string 46 | { 47 | return FormatterHelper::number( 48 | $number, 49 | $this->config->format_number_decimals, 50 | $this->config->format_number_decimal_sep, 51 | $this->config->format_number_thousand_sep 52 | ); 53 | } 54 | 55 | /** 56 | * @param float|int|string $number 57 | * @return string 58 | */ 59 | public static function integer(float|int|string $number): string 60 | { 61 | return FormatterHelper::number($number); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Services/ImportableService.php: -------------------------------------------------------------------------------- 1 | config = $config; 27 | } 28 | 29 | /** 30 | * @param CsvConfig $config 31 | */ 32 | public function setConfig(CsvConfig $config): void 33 | { 34 | $this->config = $config; 35 | } 36 | 37 | /** 38 | * @return CsvConfig 39 | */ 40 | public function getConfig(): CsvConfig 41 | { 42 | return $this->config; 43 | } 44 | 45 | /** 46 | * @param object $importable 47 | * @return int 48 | */ 49 | public function count(object $importable): int 50 | { 51 | return $this->getReader($importable)->count($importable); 52 | } 53 | 54 | /** 55 | * @param object $importable 56 | * @return array 57 | */ 58 | public function getArray(object $importable): array 59 | { 60 | return $this->getReader($importable)->getRows($importable); 61 | } 62 | 63 | /** 64 | * @param object $importable 65 | * @param callable(array,int):void $callable 66 | * @param int|null $size 67 | * @return void 68 | */ 69 | public function chunkArray(object $importable, 70 | callable $callable, 71 | ?int $size = null): void 72 | { 73 | $size = $size ?: $this->config->chunk_size; 74 | 75 | $this->getReader($importable)->chunkRows($importable, $callable, $size); 76 | } 77 | 78 | /** 79 | * @param object $importable 80 | * @return resource 81 | */ 82 | protected function stream(object $importable) 83 | { 84 | if ($importable instanceof FromResource) { 85 | if (!is_resource($importable->resource())) { 86 | throw new \RuntimeException('Not a valid resource'); 87 | } 88 | return $importable->resource(); 89 | } 90 | 91 | if ($importable instanceof FromDisk) { 92 | $disk = $importable->disk() ?: $this->config->disk; 93 | return Storage::disk($disk)->readStream($importable->filename()); 94 | } 95 | 96 | if ($importable instanceof FromContents) { 97 | $stream = fopen('php://temp', 'w+') or throw new \RuntimeException('Cannot open temp stream'); 98 | fwrite($stream, $importable->contents()); 99 | return $stream; 100 | } 101 | 102 | if ($importable instanceof FromFile) { 103 | $stream = fopen($importable->filename(), 'a+') or throw new \RuntimeException('Cannot file as stream'); 104 | return $stream; 105 | } 106 | 107 | throw new \RuntimeException('Missing data source trait'); 108 | } 109 | 110 | /** 111 | * @param object $importable 112 | * @return Reader 113 | */ 114 | protected function getReader(object $importable): Reader 115 | { 116 | $resource = $this->stream($importable); 117 | 118 | /** @var Reader $reader */ 119 | $reader = App::make(Reader::class, [ 120 | 'parser' => new ParserService($this->config), 121 | 'handler' => new StreamHandler($this->config, $resource) 122 | ]); 123 | 124 | return $reader; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Services/ParserService.php: -------------------------------------------------------------------------------- 1 | config = $config; 27 | } 28 | 29 | /** 30 | * @param string $date 31 | * @return Carbon|null 32 | */ 33 | public function toCarbonDate(string $date): ?Carbon 34 | { 35 | return ParseHelper::toCarbon($date, $this->config->format_date) ?: null; 36 | } 37 | 38 | /** 39 | * @param string $date 40 | * @return Carbon|null 41 | */ 42 | public function toCarbonDatetime(string $date): ?Carbon 43 | { 44 | return ParseHelper::toCarbon($date, $this->config->format_datetime) ?: null; 45 | } 46 | 47 | /** 48 | * @param string $decimal 49 | * @return float|null 50 | */ 51 | public function toFloat(string $decimal): ?float 52 | { 53 | $value = ParseHelper::toFloat( 54 | $decimal, 55 | $this->config->format_number_decimal_sep, 56 | $this->config->format_number_thousand_sep 57 | ); 58 | 59 | return $value ?: null; 60 | } 61 | 62 | /** 63 | * @param string $integer 64 | * @return int|null 65 | */ 66 | public function toInteger(string $integer): ?int 67 | { 68 | $value = $this->toFloat($integer); 69 | 70 | return $value ? intval($value) : null; 71 | } 72 | } -------------------------------------------------------------------------------- /src/Services/Reader.php: -------------------------------------------------------------------------------- 1 | parser = $parser; 32 | $this->handler = $handler; 33 | } 34 | 35 | /** 36 | * @param object $importable 37 | * @return int 38 | */ 39 | public function count(object $importable): int 40 | { 41 | $offset = $importable instanceof WithHeadings ? 1 : 0; 42 | $count = $this->handler->count(); 43 | 44 | return $count - $offset; 45 | } 46 | 47 | /** 48 | * @param object $importable 49 | * @param callable(array,int):void $callable 50 | * @param int $size 51 | * @return void 52 | */ 53 | public function chunkRows(object $importable, 54 | callable $callable, 55 | int $size): void 56 | { 57 | $counter = 0; 58 | $hasHeadings = $importable instanceof WithHeadings; 59 | $maxRows = $this->getMaxRows($importable, $hasHeadings); 60 | 61 | $wrapper = function (array $rows) use ($callable, $hasHeadings, $importable, &$counter) { 62 | $hasHeadings = $counter === 0 && $hasHeadings; 63 | $rows = $this->prepareRows($importable, $rows, $hasHeadings); 64 | $callable($rows, $counter); 65 | $counter++; 66 | }; 67 | 68 | $this->handler->getChunk($wrapper, $size, $maxRows); 69 | } 70 | 71 | /** 72 | * @param object $importable 73 | * @return array 74 | */ 75 | public function getRows(object $importable): array 76 | { 77 | $hasHeadings = $importable instanceof WithHeadings; 78 | $maxRows = $this->getMaxRows($importable, $hasHeadings); 79 | $rows = $this->handler->getAll($maxRows); 80 | 81 | return $this->prepareRows($importable, $rows, $hasHeadings); 82 | } 83 | 84 | /** 85 | * @param object $importable 86 | * @param array $rows 87 | * @param bool $hasHeadings 88 | * @return array 89 | */ 90 | protected function prepareRows(object $importable, 91 | array $rows, 92 | bool $hasHeadings): array 93 | { 94 | $formats = $importable instanceof WithColumnFormatting ? $importable->columnFormats() : []; 95 | $withMapping = $importable instanceof WithMapping; 96 | 97 | foreach ($rows as $index => $row) { 98 | if ($index === 0 && $hasHeadings) { 99 | $formattedRow = $importable->headings(); 100 | } else { 101 | $mappedRow = $withMapping ? $importable->map($row) : $row; 102 | $formattedRow = $this->applyFormatting($mappedRow, $formats); 103 | } 104 | 105 | $rows[$index] = $formattedRow; 106 | } 107 | 108 | return $rows; 109 | } 110 | 111 | /** 112 | * @param object $importable 113 | * @param bool $hasHeadings 114 | * @return int|null 115 | */ 116 | protected function getMaxRows(object $importable, bool $hasHeadings): ?int 117 | { 118 | return $importable->limit() 119 | ? $importable->limit() + intval($hasHeadings) 120 | : null; 121 | } 122 | 123 | /** 124 | * @param array $row 125 | * @param array $formats 126 | * @return array 127 | */ 128 | protected function applyFormatting(array $row, 129 | array $formats): array 130 | { 131 | return array_map( 132 | fn($value, int $columnIndex) => $this->formatCellValue($value, $formats, $columnIndex), 133 | $row, 134 | array_keys($row) 135 | ); 136 | } 137 | 138 | /** 139 | * @param mixed $value 140 | * @param array $formats 141 | * @param int $columnIndex 142 | * @return mixed 143 | */ 144 | protected function formatCellValue(mixed $value, 145 | array $formats, 146 | int $columnIndex): mixed 147 | { 148 | $columnLetter = CsvHelper::getColumnLetter($columnIndex + 1); 149 | $format = $formats[$columnLetter] ?? null; 150 | 151 | if (!strlen(trim($value))) return $value; 152 | 153 | if ($format === CellFormat::DATE) { 154 | return $this->parser->toCarbonDate($value) ?: $value; 155 | } 156 | 157 | if ($format === CellFormat::DATETIME) { 158 | return $this->parser->toCarbonDatetime($value) ?: $value; 159 | } 160 | 161 | if ($format === CellFormat::DECIMAL) { 162 | return $this->parser->toFloat($value) ?: $value; 163 | } 164 | 165 | if ($format === CellFormat::INTEGER) { 166 | return $this->parser->toInteger($value) ?: $value; 167 | } 168 | 169 | return $value; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Services/Writer.php: -------------------------------------------------------------------------------- 1 | formatter = $formatter; 38 | $this->handler = $handler; 39 | } 40 | 41 | /** 42 | * @param object $exportable 43 | * @return resource|array 44 | * @throws InvalidCellValueException 45 | */ 46 | public function generate(object $exportable) 47 | { 48 | if ($exportable instanceof WithHeadings) { 49 | $this->writeRow($exportable->headings()); 50 | } 51 | 52 | if ($exportable instanceof FromArray) { 53 | $rows = $exportable->array(); 54 | if ($exportable->limit()) { 55 | $rows = array_splice($rows, 0, $exportable->limit()); 56 | } 57 | $this->iterateRows($exportable, $rows); 58 | } 59 | 60 | if ($exportable instanceof FromCollection) { 61 | $rows = $exportable->collection(); 62 | if ($exportable->limit()) { 63 | $rows = $rows->take($exportable->limit()); 64 | } 65 | $this->iterateRows($exportable, $rows); 66 | } 67 | 68 | if ($exportable instanceof FromQuery) { 69 | QueryBuilderHelper::chunk( 70 | $exportable->query(), 71 | $this->formatter->config->chunk_size, 72 | $exportable->count(), 73 | $exportable->limit(), 74 | fn($rows) => $this->iterateRows($exportable, $rows), 75 | ); 76 | } 77 | 78 | return $this->handler->getResource(); 79 | } 80 | 81 | /** 82 | * @param object $exportable 83 | * @param iterable $rows 84 | * @return void 85 | * @throws InvalidCellValueException 86 | */ 87 | protected function iterateRows(object $exportable, 88 | iterable $rows): void 89 | { 90 | $formats = $exportable instanceof WithColumnFormatting ? $exportable->columnFormats() : []; 91 | $withMapping = $exportable instanceof WithMapping; 92 | 93 | foreach ($rows as $index => $row) { 94 | $mappedRow = $withMapping ? $exportable->map($row) : $row; 95 | $normalizedRow = $this->normalizeRow($mappedRow); 96 | $formattedRow = $this->applyFormatting($normalizedRow, $formats, $index); 97 | 98 | $this->writeRow($formattedRow); 99 | } 100 | } 101 | 102 | /** 103 | * @param mixed $row 104 | * @return array 105 | */ 106 | protected function normalizeRow(mixed $row): array 107 | { 108 | if ($row instanceof Model) { 109 | $row = ModelHelper::toArrayValues($row); 110 | } 111 | if (is_object($row)) { 112 | $row = (array)$row; 113 | } 114 | if (is_array($row)) { 115 | $row = array_values($row); 116 | } 117 | return $row; 118 | } 119 | 120 | /** 121 | * @param array $row 122 | * @param array $formats 123 | * @param int $rowIndex 124 | * @return array 125 | * @throws InvalidCellValueException 126 | */ 127 | protected function applyFormatting(array $row, 128 | array $formats, 129 | int $rowIndex): array 130 | { 131 | return array_map( 132 | fn($value, int $columnIndex) => $this->formatCellValue($value, $formats, $rowIndex, $columnIndex), 133 | $row, 134 | array_keys($row) 135 | ); 136 | } 137 | 138 | /** 139 | * @throws InvalidCellValueException 140 | */ 141 | protected function formatCellValue(mixed $value, 142 | array $formats, 143 | int $rowIndex, 144 | int $columnIndex): string 145 | { 146 | $columnLetter = CsvHelper::getColumnLetter($columnIndex + 1); 147 | $rowNumber = $rowIndex + 1; 148 | $format = $formats[$columnLetter] ?? null; 149 | 150 | if (is_null($value)) { 151 | return ''; 152 | } 153 | 154 | if ($format === CellFormat::DATE) { 155 | return $this->formatter->date($value); 156 | } 157 | 158 | if ($format === CellFormat::DATETIME) { 159 | return $this->formatter->datetime($value); 160 | } 161 | 162 | if ($format === CellFormat::DECIMAL) { 163 | return $this->formatter->decimal($value); 164 | } 165 | 166 | if ($format === CellFormat::INTEGER) { 167 | return $this->formatter->integer($value); 168 | } 169 | 170 | try { 171 | if (!is_string($value)) { 172 | return (string)$value; 173 | } 174 | } catch (\Throwable $e) { 175 | throw new InvalidCellValueException("{$columnLetter}{$rowNumber}"); 176 | } 177 | 178 | return $value; 179 | } 180 | 181 | /** 182 | * @param array $content 183 | * @return void 184 | */ 185 | protected function writeRow(array $content): void 186 | { 187 | $this->handler->addContent($content); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /tests/Concerns/Exportables/ExportableTest.php: -------------------------------------------------------------------------------- 1 | export = new class { 20 | use Exportable; 21 | }; 22 | } 23 | 24 | public function test_count() 25 | { 26 | $count = 100; 27 | 28 | CsvExporter::shouldReceive('count') 29 | ->once() 30 | ->andReturns($count); 31 | 32 | $this->assertEquals($count, $this->export->count()); 33 | } 34 | 35 | public function test_to_array() 36 | { 37 | $array = [['a', 'b', 'c']]; 38 | 39 | CsvExporter::shouldReceive('toArray') 40 | ->once() 41 | ->andReturns($array); 42 | 43 | $this->assertEquals($array, $this->export->toArray()); 44 | } 45 | 46 | public function test_store() 47 | { 48 | CsvExporter::shouldReceive('store') 49 | ->once() 50 | ->andReturns($this->filename); 51 | 52 | $this->assertEquals($this->filename, $this->export->store($this->filename)); 53 | } 54 | 55 | public function test_download() 56 | { 57 | $mock = \Mockery::mock(StreamedResponse::class); 58 | 59 | CsvExporter::shouldReceive('download') 60 | ->once() 61 | ->andReturns($mock); 62 | 63 | $this->assertEquals($mock, $this->export->download()); 64 | } 65 | 66 | public function test_steam() 67 | { 68 | $mock = $this->getMockBuilder(\SplTempFileObject::class) 69 | ->disableOriginalConstructor(); 70 | 71 | CsvExporter::shouldReceive('stream') 72 | ->once() 73 | ->andReturns($mock); 74 | 75 | $this->assertEquals($mock, $this->export->stream()); 76 | } 77 | 78 | public function test_queue() 79 | { 80 | $mock = \Mockery::mock(PendingDispatch::class); 81 | 82 | CsvExporter::shouldReceive('queue') 83 | ->once() 84 | ->andReturns($mock); 85 | 86 | $this->assertEquals($mock, $this->export->queue()); 87 | } 88 | 89 | public function test_filename() 90 | { 91 | $filename = FakerHelper::get()->word(); 92 | 93 | $this->assertEquals($filename, $this->export->getFilename($filename)); 94 | 95 | $filename = $this->export->getFilename(); 96 | $filename = preg_replace('/\..+$/', '', $filename); 97 | 98 | $this->assertTrue(Str::isUuid($filename)); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/Concerns/Exportables/FromArrayTest.php: -------------------------------------------------------------------------------- 1 | store($this->filename); 21 | $actual = $this->getFromDisk($this->filename); 22 | 23 | $this->assertEquals($export->expected(), $actual); 24 | } 25 | } 26 | 27 | public function test_limit_from_array() 28 | { 29 | $limit = rand(1, 5); 30 | 31 | $exports = [ 32 | new NoHeadingsExport($limit), 33 | new WithHeadingsExport($limit), 34 | ]; 35 | 36 | foreach ($exports as $export) { 37 | $export->store($this->filename); 38 | $actual = $this->getFromDiskArray($this->filename); 39 | $expected = $export instanceof WithHeadings 40 | ? $limit + 1 41 | : $limit; 42 | 43 | $this->assertCount($expected, $actual); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Concerns/Exportables/FromCollectionTest.php: -------------------------------------------------------------------------------- 1 | seed(TestCsvSeeder::class); 20 | } 21 | 22 | public function test_from_collection() 23 | { 24 | $exports = [ 25 | new CollectionNoHeadingsExport(), 26 | new CollectionWithHeadingsExport(), 27 | new CursorNoHeadingsExport(), 28 | new CursorWithHeadingsExport(), 29 | ]; 30 | 31 | foreach ($exports as $export) { 32 | $export->store($this->filename); 33 | $actual = $this->getFromDisk($this->filename); 34 | 35 | $this->assertEquals($export->expected(), $actual); 36 | } 37 | } 38 | 39 | public function test_limit_from_collection() 40 | { 41 | $limit = rand(1, 5); 42 | 43 | $exports = [ 44 | new CollectionNoHeadingsExport($limit), 45 | new CollectionWithHeadingsExport($limit), 46 | new CursorNoHeadingsExport($limit), 47 | new CursorWithHeadingsExport($limit), 48 | ]; 49 | 50 | foreach ($exports as $export) { 51 | $export->store($this->filename); 52 | $actual = $this->getFromDiskArray($this->filename); 53 | $expected = $export instanceof WithHeadings 54 | ? $limit + 1 55 | : $limit; 56 | 57 | $this->assertCount($expected, $actual); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /tests/Concerns/Exportables/FromQueryTest.php: -------------------------------------------------------------------------------- 1 | seed(TestCsvSeeder::class); 20 | } 21 | 22 | public function test_from_builder() 23 | { 24 | $exports = [ 25 | new EloquentNoHeadingsExport(), 26 | new EloquentWithHeadingsExport(), 27 | new QueryNoHeadingsExport(), 28 | new QueryWithHeadingsExport(), 29 | ]; 30 | 31 | foreach ($exports as $export) { 32 | $export->store($this->filename); 33 | $actual = $this->getFromDisk($this->filename); 34 | 35 | $this->assertEquals($export->expected(), $actual); 36 | } 37 | } 38 | 39 | 40 | public function test_limit_from_builder() 41 | { 42 | $limit = rand(1, 5); 43 | 44 | $exports = [ 45 | new EloquentNoHeadingsExport($limit), 46 | new EloquentWithHeadingsExport($limit), 47 | new QueryNoHeadingsExport($limit), 48 | new QueryWithHeadingsExport($limit), 49 | ]; 50 | 51 | foreach ($exports as $export) { 52 | $export->store($this->filename); 53 | $actual = $this->getFromDiskArray($this->filename); 54 | $expected = $export instanceof WithHeadings 55 | ? $limit + 1 56 | : $limit; 57 | 58 | $this->assertCount($expected, $actual); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Concerns/Importables/AbstractTestCase.php: -------------------------------------------------------------------------------- 1 | assertSame($actualCalls, $chunk); 23 | $this->assertCount(count($expectedRows), $rows); 24 | $this->assertSame($expectedRows, $rows); 25 | 26 | $actualCalls++; 27 | }; 28 | 29 | $import->chunkArray($callable, $size); 30 | 31 | if (method_exists($import, 'delete')) { 32 | $import->delete(); 33 | }; 34 | 35 | $this->assertEquals($expectedCalls, $actualCalls); 36 | } 37 | 38 | public static function noHeadingsChunkProvider(): array 39 | { 40 | $import = new class() { 41 | use NoHeadingsTrait; 42 | }; 43 | 44 | return [ 45 | 'no limit' => [ 46 | null, 47 | 2, 48 | 5, 49 | $import->expected(), 50 | false 51 | ], 52 | 'multiple of chunk size' => [ 53 | 6, 54 | 2, 55 | 3, 56 | array_slice($import->expected(), 0, 6), 57 | false 58 | ], 59 | 'less than chunk size' => [ 60 | 5, 61 | 2, 62 | 3, 63 | array_slice($import->expected(), 0, 5), 64 | false 65 | ], 66 | 'same of results quantity' => [ 67 | 10, 68 | 2, 69 | 5, 70 | $import->expected(), 71 | false 72 | ], 73 | 'greater than results quantity' => [ 74 | 100, 75 | 2, 76 | 5, 77 | $import->expected(), 78 | false 79 | ], 80 | ]; 81 | } 82 | 83 | public static function withHeadingChunkProvider(): array 84 | { 85 | $import = new class() { 86 | use WithHeadingsTrait; 87 | }; 88 | 89 | return [ 90 | 'no limit' => [ 91 | null, 92 | 2, 93 | 6, 94 | $import->expected(), 95 | true 96 | ], 97 | 'multiple of chunk size' => [ 98 | 6, 99 | 2, 100 | 4, 101 | array_slice($import->expected(), 0, 7), 102 | true 103 | ], 104 | 'less than chunk size' => [ 105 | 5, 106 | 2, 107 | 3, 108 | array_slice($import->expected(), 0, 6), 109 | true 110 | ], 111 | 'same of results quantity' => [ 112 | 10, 113 | 2, 114 | 6, 115 | $import->expected(), 116 | true 117 | ], 118 | 'greater than results quantity' => [ 119 | 100, 120 | 2, 121 | 6, 122 | $import->expected(), 123 | true 124 | ], 125 | ]; 126 | } 127 | } -------------------------------------------------------------------------------- /tests/Concerns/Importables/FromContentsTest.php: -------------------------------------------------------------------------------- 1 | getArray(); 20 | 21 | $this->assertEquals($actual, $import->expected()); 22 | } 23 | } 24 | 25 | public function test_limit_from_contents() 26 | { 27 | $limit = rand(1, 9); 28 | 29 | $imports = [ 30 | new NoHeadingsImport($limit), 31 | new WithHeadingsImport($limit), 32 | ]; 33 | 34 | foreach ($imports as $import) { 35 | $actual = $import->getArray(); 36 | $expected = $import instanceof WithHeadings 37 | ? $limit + 1 38 | : $limit; 39 | 40 | $this->assertCount($expected, $actual); 41 | } 42 | } 43 | 44 | /** 45 | * @dataProvider noHeadingsChunkProvider 46 | * @dataProvider withHeadingChunkProvider 47 | */ 48 | public function test_chunk_contents(?int $limit, 49 | int $size, 50 | int $expectedCalls, 51 | array $expectedRecords, 52 | bool $withHeadings) 53 | { 54 | $import = $withHeadings 55 | ? new WithHeadingsImport($limit) 56 | : new NoHeadingsImport($limit); 57 | 58 | $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); 59 | } 60 | } -------------------------------------------------------------------------------- /tests/Concerns/Importables/FromDiskTest.php: -------------------------------------------------------------------------------- 1 | getArray(); 20 | $import->delete(); 21 | 22 | $this->assertEquals($actual, $import->expected()); 23 | } 24 | } 25 | 26 | public function test_limit_from_disk() 27 | { 28 | $limit = rand(1, 9); 29 | 30 | $imports = [ 31 | new NoHeadingsImport($limit), 32 | new WithHeadingsImport($limit), 33 | ]; 34 | 35 | foreach ($imports as $import) { 36 | $actual = $import->getArray(); 37 | $expected = $import instanceof WithHeadings 38 | ? $limit + 1 39 | : $limit; 40 | $import->delete(); 41 | 42 | $this->assertCount($expected, $actual); 43 | } 44 | } 45 | 46 | /** 47 | * @dataProvider noHeadingsChunkProvider 48 | * @dataProvider withHeadingChunkProvider 49 | */ 50 | public function test_chunk_contents(?int $limit, 51 | int $size, 52 | int $expectedCalls, 53 | array $expectedRecords, 54 | bool $withHeadings) 55 | { 56 | $import = $withHeadings 57 | ? new WithHeadingsImport($limit) 58 | : new NoHeadingsImport($limit); 59 | 60 | $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); 61 | } 62 | } -------------------------------------------------------------------------------- /tests/Concerns/Importables/FromFileTest.php: -------------------------------------------------------------------------------- 1 | getArray(); 20 | $import->delete(); 21 | 22 | $this->assertEquals($actual, $import->expected()); 23 | } 24 | } 25 | 26 | public function test_limit_from_file() 27 | { 28 | $limit = rand(1, 9); 29 | 30 | $imports = [ 31 | new NoHeadingsImport($limit), 32 | new WithHeadingsImport($limit), 33 | ]; 34 | 35 | foreach ($imports as $import) { 36 | $actual = $import->getArray(); 37 | $expected = $import instanceof WithHeadings 38 | ? $limit + 1 39 | : $limit; 40 | $import->delete(); 41 | 42 | $this->assertCount($expected, $actual); 43 | } 44 | } 45 | 46 | /** 47 | * @dataProvider noHeadingsChunkProvider 48 | * @dataProvider withHeadingChunkProvider 49 | */ 50 | public function test_chunk_from_file(?int $limit, 51 | int $size, 52 | int $expectedCalls, 53 | array $expectedRecords, 54 | bool $withHeadings) 55 | { 56 | $import = $withHeadings 57 | ? new WithHeadingsImport($limit) 58 | : new NoHeadingsImport($limit); 59 | 60 | $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); 61 | } 62 | } -------------------------------------------------------------------------------- /tests/Concerns/Importables/FromResourceTest.php: -------------------------------------------------------------------------------- 1 | getArray(); 23 | 24 | $this->assertEquals($actual, $import->expected()); 25 | } 26 | } 27 | 28 | /** 29 | * @dataProvider diskDataProvider 30 | */ 31 | public function test_limit_from_resource(string $source) 32 | { 33 | $limit = rand(1, 9); 34 | 35 | $imports = [ 36 | new NoHeadingsImport($source, $limit), 37 | new WithHeadingsImport($source, $limit), 38 | ]; 39 | 40 | foreach ($imports as $import) { 41 | $actual = $import->getArray(); 42 | $expected = $import instanceof WithHeadings 43 | ? $limit + 1 44 | : $limit; 45 | 46 | $this->assertCount($expected, $actual); 47 | } 48 | } 49 | 50 | /** 51 | * @dataProvider noHeadingsChunkProvider 52 | * @dataProvider withHeadingChunkProvider 53 | */ 54 | public function test_chunk_from_file(?int $limit, 55 | int $size, 56 | int $expectedCalls, 57 | array $expectedRecords, 58 | bool $withHeadings) 59 | { 60 | $import = $withHeadings 61 | ? new WithHeadingsImport(limit: $limit) 62 | : new NoHeadingsImport(limit: $limit); 63 | 64 | $this->assertChunk($import, $size, $expectedCalls, $expectedRecords); 65 | } 66 | 67 | public static function diskDataProvider(): array 68 | { 69 | return [ 70 | 'from temp' => [ 71 | 'php://temp', 72 | ], 73 | 'from memory' => [ 74 | 'php://memory', 75 | ] 76 | ]; 77 | } 78 | } -------------------------------------------------------------------------------- /tests/Concerns/Importables/ImportableTest.php: -------------------------------------------------------------------------------- 1 | import = new FromContentsImport(); 16 | } 17 | 18 | public function test_count() 19 | { 20 | $count = 100; 21 | 22 | CsvImporter::shouldReceive('count') 23 | ->once() 24 | ->with($this->import) 25 | ->andReturn($count); 26 | 27 | $this->assertEquals($count, $this->import->count()); 28 | } 29 | 30 | public function test_get_array() 31 | { 32 | $array = [1, 2, 3]; 33 | 34 | CsvImporter::shouldReceive('getArray') 35 | ->once() 36 | ->with($this->import) 37 | ->andReturn($array); 38 | 39 | $this->assertEquals($array, $this->import->getArray()); 40 | } 41 | 42 | public function test_chunk_array() 43 | { 44 | $callable = function (array $rows) { 45 | $this->assertCount(count($this->import->getArray()), $rows); 46 | }; 47 | $size = 100; 48 | 49 | CsvImporter::shouldReceive('chunkArray') 50 | ->once() 51 | ->with($this->import, $callable, $size); 52 | 53 | $this->import->chunkArray($callable, $size); 54 | } 55 | } -------------------------------------------------------------------------------- /tests/Concerns/WithColumnFormattingTest.php: -------------------------------------------------------------------------------- 1 | getConfig(); 17 | $config->format_date = $export->formatDate(); 18 | $config->format_datetime = $export->formatDateTime(); 19 | $config->format_number_thousand_sep = $export->thousandSeparator(); 20 | $config->format_number_decimal_sep = $export->decimalSeparator(); 21 | $export->setConfig($config); 22 | 23 | $export->store($this->filename); 24 | $actual = $this->getFromDisk($this->filename); 25 | 26 | $this->assertEquals($export->expected(), $actual); 27 | } 28 | 29 | public function test_import_with_column_formatting() 30 | { 31 | $import = new WithColumnFormattingImport(); 32 | 33 | $config = $import->getConfig(); 34 | $config->format_date = $import->formatDate(); 35 | $config->format_datetime = $import->formatDateTime(); 36 | $config->format_number_thousand_sep = $import->thousandSeparator(); 37 | $config->format_number_decimal_sep = $import->decimalSeparator(); 38 | $import->setConfig($config); 39 | 40 | $expected = $import->expected(); 41 | $actualRows = $import->getArray(); 42 | 43 | foreach ($actualRows as $i => $actualRow) { 44 | $this->assertEquals($expected[$i][0], $actualRow[0]); 45 | $this->assertEquals($expected[$i][1], $actualRow[1]); 46 | if ($i == 0) { 47 | $this->assertInstanceOf(Carbon::class, $actualRow[2]); 48 | $this->assertEquals($expected[$i][2]->toDateString(), $actualRow[2]->toDateString()); 49 | $this->assertInstanceOf(Carbon::class, $actualRow[3]); 50 | $this->assertEquals($expected[$i][3]->toDateTimeString(), $actualRow[3]->toDateTimeString()); 51 | } else { 52 | $this->assertSame($expected[$i][2], $actualRow[2]); 53 | $this->assertSame($expected[$i][3], $actualRow[3]); 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Concerns/WithMappingTest.php: -------------------------------------------------------------------------------- 1 | seed(TestCsvSeeder::class); 17 | } 18 | 19 | public function test_export_mapping() 20 | { 21 | $export = new WithMappingExport(); 22 | 23 | $export->store($this->filename); 24 | $actual = $this->getFromDisk($this->filename); 25 | 26 | $this->assertSame($export->expected(), $actual); 27 | } 28 | 29 | public function test_import_mapping() 30 | { 31 | $import = new WithMappingImport(); 32 | 33 | $rows = $import->getArray(); 34 | $expected = $import->expected(); 35 | 36 | $this->assertSame($rows, $expected); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Data/DataProvider.php: -------------------------------------------------------------------------------- 1 | [ 22 | Carbon::create(2021, 12, 31, 21, 45, 55, 'UTC'), 23 | '2021-12-31' 24 | ], 25 | 'valid datetime' => [ 26 | \Datetime::createFromFormat('Y-m-d H:i:s', '2021-12-31 21:45:55'), 27 | '2021-12-31' 28 | ] 29 | ]; 30 | } 31 | 32 | /** 33 | * @return array[] 34 | */ 35 | public function valid_datetimes(): array 36 | { 37 | return [ 38 | 'valid carbon' => [ 39 | Carbon::create(2021, 12, 31, 21, 45, 55, 'UTC'), 40 | '2021-12-31 21:45:55' 41 | ], 42 | 'valid datetime' => [ 43 | \Datetime::createFromFormat('Y-m-d H:i:s', '2021-12-31 21:45:55'), 44 | '2021-12-31 21:45:55' 45 | ] 46 | ]; 47 | } 48 | 49 | /** 50 | * @return array[] 51 | */ 52 | public function invalid_dates(): array 53 | { 54 | return [ 55 | 'invalid string' => [ 56 | 'any', 57 | 'any' 58 | ], 59 | 'invalid integer' => [ 60 | 2, 61 | '2' 62 | ] 63 | ]; 64 | } 65 | 66 | /** 67 | * @return array[] 68 | */ 69 | public function valid_integers(): array 70 | { 71 | return [ 72 | 'valid int' => [ 73 | 2, 74 | '2' 75 | ], 76 | 'valid float' => [ 77 | 3.40, 78 | '3' 79 | ], 80 | 'valid string' => [ 81 | '500', 82 | '500' 83 | ] 84 | ]; 85 | } 86 | 87 | /** 88 | * @return array[] 89 | */ 90 | public function valid_decimals(): array 91 | { 92 | return [ 93 | 'valid int' => [ 94 | 2, 95 | '2.00' 96 | ], 97 | 'valid float' => [ 98 | 3.40, 99 | '3.40' 100 | ], 101 | 'valid string' => [ 102 | '500', 103 | '500.00' 104 | ] 105 | ]; 106 | } 107 | 108 | /** 109 | * @return array[] 110 | */ 111 | public function invalid_numbers(): array 112 | { 113 | $carbon = now(); 114 | 115 | return [ 116 | 'invalid string' => [ 117 | 'any', 118 | 'any' 119 | ], 120 | 'invalid carbon' => [ 121 | $carbon, 122 | (string) $carbon 123 | ] 124 | ]; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/Data/Database/Factories/TestUserFactory.php: -------------------------------------------------------------------------------- 1 | define(TestCsv::class, function (Faker $faker) { 8 | return [ 9 | 'integer' => $faker->numberBetween(-1000, 1000), 10 | 'decimal' => $faker->randomFloat(), 11 | 'string' => $faker->word(), 12 | 'timestamp' => $faker->dateTime() 13 | ]; 14 | }); 15 | -------------------------------------------------------------------------------- /tests/Data/Database/Migrations/0000_00_00_000000_create_test_csv_table.php: -------------------------------------------------------------------------------- 1 | mediumIncrements('id'); 18 | $table->mediumInteger('integer')->nullable(); 19 | $table->decimal('decimal', 8, 2)->nullable(); 20 | $table->string('string')->nullable(); 21 | $table->timestamp('timestamp')->nullable(); 22 | }); 23 | } 24 | 25 | /** 26 | * Reverse the Migrations. 27 | * 28 | * @return void 29 | */ 30 | public function down() 31 | { 32 | Schema::dropIfExists('test_users'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Data/Database/Seeders/TestCsvSeeder.php: -------------------------------------------------------------------------------- 1 | 1, 13 | 'integer' => 1, 14 | 'decimal' => 1.23, 15 | 'string' => 'text_1', 16 | 'timestamp' => '2025-01-01' 17 | ], 18 | [ 19 | 'id' => 2, 20 | 'integer' => -1, 21 | 'decimal' => -1.23, 22 | 'string' => 'text_2', 23 | 'timestamp' => '2025-01-02' 24 | ], 25 | [ 26 | 'id' => 3, 27 | 'integer' => 1000, 28 | 'decimal' => 1000.23, 29 | 'string' => 'text_3', 30 | 'timestamp' => '2025-01-03' 31 | ], 32 | [ 33 | 'id' => 4, 34 | 'integer' => -1000, 35 | 'decimal' => -1000.23, 36 | 'string' => 'text_4', 37 | 'timestamp' => '2025-01-04' 38 | ], 39 | [ 40 | 'id' => 5, 41 | 'integer' => 1000000, 42 | 'decimal' => 1000000.23, 43 | 'string' => 'text_5', 44 | 'timestamp' => '2025-01-05' 45 | ], 46 | [ 47 | 'id' => 6, 48 | 'integer' => -1000000, 49 | 'decimal' => -1000000.23, 50 | 'string' => 'text_6', 51 | 'timestamp' => '2025-01-06' 52 | ], 53 | ]; 54 | 55 | /** 56 | * Run the database seeds. 57 | * 58 | * @return void 59 | */ 60 | public function run() 61 | { 62 | foreach (self::USERS as $user) { 63 | TestCsv::create($user); 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/Data/Exports/NoHeadings/FromArrayExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 17 | } 18 | 19 | public function limit(): ?int 20 | { 21 | return $this->limit; 22 | } 23 | 24 | public function array(): array 25 | { 26 | return $this->contents(); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Data/Exports/NoHeadings/FromArrayExportAlt.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 17 | } 18 | 19 | public function limit(): ?int 20 | { 21 | return $this->limit; 22 | } 23 | 24 | public function expected(): string 25 | { 26 | return "'a 1'|'b 1'|'c 1'\n'a 2'|'b 2'|'c 2'"; 27 | } 28 | 29 | public function csvDelimiter(): string 30 | { 31 | return '|'; 32 | } 33 | 34 | public function csvEnclosure(): string 35 | { 36 | return "'"; 37 | } 38 | 39 | public function array(): array 40 | { 41 | return [ 42 | ['a 1', 'b 1', 'c 1'], 43 | ['a 2', 'b 2', 'c 2'], 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Data/Exports/NoHeadings/FromCollectionExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 18 | } 19 | 20 | public function limit(): ?int 21 | { 22 | return $this->limit; 23 | } 24 | 25 | public function collection(): Collection 26 | { 27 | return collect($this->contents()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Data/Exports/NoHeadings/FromCursorExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 19 | } 20 | 21 | public function limit(): ?int 22 | { 23 | return $this->limit; 24 | } 25 | 26 | public function collection(): LazyCollection 27 | { 28 | return TestCsv::cursor(); 29 | } 30 | } -------------------------------------------------------------------------------- /tests/Data/Exports/NoHeadings/FromEloquentBuilderExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 18 | } 19 | 20 | public function limit(): ?int 21 | { 22 | return $this->limit; 23 | } 24 | 25 | public function query() 26 | { 27 | return TestCsv::query(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Data/Exports/NoHeadings/FromExportTrait.php: -------------------------------------------------------------------------------- 1 | array_values($user), TestCsvSeeder::USERS); 12 | } 13 | 14 | public function expected(): string 15 | { 16 | return '1,1,1.23,text_1,2025-01-01' . "\n" . 17 | '2,-1,-1.23,text_2,2025-01-02' . "\n" . 18 | '3,1000,1000.23,text_3,2025-01-03' . "\n" . 19 | '4,-1000,-1000.23,text_4,2025-01-04' . "\n" . 20 | '5,1000000,1000000.23,text_5,2025-01-05' . "\n" . 21 | '6,-1000000,-1000000.23,text_6,2025-01-06'; 22 | } 23 | } -------------------------------------------------------------------------------- /tests/Data/Exports/NoHeadings/FromQueryBuilderExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 18 | } 19 | 20 | public function limit(): ?int 21 | { 22 | return $this->limit; 23 | } 24 | 25 | public function query() 26 | { 27 | return DB::table('test_csvs'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Data/Exports/WithColumnFormattingExport.php: -------------------------------------------------------------------------------- 1 | toDateString(), Carbon::parse('2021-12-31 23:15:46')->toDateTimeString()] 21 | ]; 22 | } 23 | 24 | public function formatDate(): string 25 | { 26 | return 'Y_m_d'; 27 | } 28 | 29 | public function formatDateTime(): string 30 | { 31 | return 'd/m/Y H_i_s'; 32 | } 33 | 34 | public function decimalSeparator(): string 35 | { 36 | return ':'; 37 | } 38 | 39 | public function thousandSeparator(): string 40 | { 41 | return '#'; 42 | } 43 | 44 | public function columnFormats(): array 45 | { 46 | return [ 47 | 'A' => CellFormat::INTEGER, 48 | 'B' => CellFormat::DECIMAL, 49 | 'C' => CellFormat::DATE, 50 | 'D' => CellFormat::DATETIME 51 | ]; 52 | } 53 | 54 | public function expected(): string 55 | { 56 | return '1,2:30,2021_02_03,"31/12/2021 12_34_56"' . "\n" . 57 | '2,5#300:91,2021-02-03,"2021-12-31 23:15:46"'; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Data/Exports/WithHeadings/FromArrayExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 18 | } 19 | 20 | public function limit(): ?int 21 | { 22 | return $this->limit; 23 | } 24 | 25 | public function array(): array 26 | { 27 | return $this->contents(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Data/Exports/WithHeadings/FromCollectionExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 19 | } 20 | 21 | public function limit(): ?int 22 | { 23 | return $this->limit; 24 | } 25 | 26 | public function collection(): Collection 27 | { 28 | return collect($this->contents()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Data/Exports/WithHeadings/FromCursorExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 20 | } 21 | 22 | public function limit(): ?int 23 | { 24 | return $this->limit; 25 | } 26 | 27 | public function collection(): LazyCollection 28 | { 29 | return TestCsv::cursor(); 30 | } 31 | } -------------------------------------------------------------------------------- /tests/Data/Exports/WithHeadings/FromEloquentBuilderExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 19 | } 20 | 21 | public function limit(): ?int 22 | { 23 | return $this->limit; 24 | } 25 | 26 | public function query() 27 | { 28 | return TestCsv::query(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Data/Exports/WithHeadings/FromExportTrait.php: -------------------------------------------------------------------------------- 1 | array_values($user), TestCsvSeeder::USERS); 23 | } 24 | 25 | public function expected(): string 26 | { 27 | return 'id,integer,decimal,string,timestamp' . "\n" . 28 | '1,1,1.23,text_1,2025-01-01' . "\n" . 29 | '2,-1,-1.23,text_2,2025-01-02' . "\n" . 30 | '3,1000,1000.23,text_3,2025-01-03' . "\n" . 31 | '4,-1000,-1000.23,text_4,2025-01-04' . "\n" . 32 | '5,1000000,1000000.23,text_5,2025-01-05' . "\n" . 33 | '6,-1000000,-1000000.23,text_6,2025-01-06'; 34 | } 35 | } -------------------------------------------------------------------------------- /tests/Data/Exports/WithHeadings/FromQueryBuilderExport.php: -------------------------------------------------------------------------------- 1 | limit = $limit; 19 | } 20 | 21 | public function limit(): ?int 22 | { 23 | return $this->limit; 24 | } 25 | 26 | public function query() 27 | { 28 | return DB::table('test_csvs'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Data/Exports/WithHeadingsExport.php: -------------------------------------------------------------------------------- 1 | string, 24 | $row->integer + 1 25 | ]; 26 | } 27 | 28 | public function expected(): string 29 | { 30 | return 'replace,concatenate_text_1,2' . "\n" . 31 | 'replace,concatenate_text_2,0' . "\n" . 32 | 'replace,concatenate_text_3,1001' . "\n" . 33 | 'replace,concatenate_text_4,-999' . "\n" . 34 | 'replace,concatenate_text_5,1000001' . "\n" . 35 | 'replace,concatenate_text_6,-999999'; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Data/Exports/WithMappingExportSimple.php: -------------------------------------------------------------------------------- 1 | id, 23 | $row->name, 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Data/Helpers/FakerHelper.php: -------------------------------------------------------------------------------- 1 | limit; 19 | } 20 | } -------------------------------------------------------------------------------- /tests/Data/Imports/NoHeadings/FromContentsImportAlt.php: -------------------------------------------------------------------------------- 1 | limit; 19 | } 20 | 21 | public function contents(): string 22 | { 23 | 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"; 24 | } 25 | 26 | public function csvDelimiter(): string 27 | { 28 | return '|'; 29 | } 30 | 31 | public function csvEnclosure(): string 32 | { 33 | return "'"; 34 | } 35 | } -------------------------------------------------------------------------------- /tests/Data/Imports/NoHeadings/FromDiskImport.php: -------------------------------------------------------------------------------- 1 | filename = uniqid(); 18 | 19 | Storage::put($this->filename, $this->contents()); 20 | } 21 | 22 | public function delete(): void 23 | { 24 | Storage::delete($this->filename); 25 | } 26 | 27 | public function limit(): ?int 28 | { 29 | return $this->limit; 30 | } 31 | 32 | public function filename(): string 33 | { 34 | return $this->filename; 35 | } 36 | 37 | public function disk(): ?string 38 | { 39 | return null; 40 | } 41 | } -------------------------------------------------------------------------------- /tests/Data/Imports/NoHeadings/FromFileImport.php: -------------------------------------------------------------------------------- 1 | uniqId = uniqid(); 17 | 18 | file_put_contents($this->filename(), $this->contents()) or throw new \RuntimeException('Unable to write file'); 19 | } 20 | 21 | public function limit(): ?int 22 | { 23 | return $this->limit; 24 | } 25 | 26 | public function delete(): void 27 | { 28 | unlink($this->filename()); 29 | } 30 | 31 | public function filename(): string 32 | { 33 | return sprintf('%s/%s.csv', realpath(__DIR__ . '/../../Storage'), $this->uniqId); 34 | } 35 | } -------------------------------------------------------------------------------- /tests/Data/Imports/NoHeadings/FromImportTrait.php: -------------------------------------------------------------------------------- 1 | resource = fopen($source, 'w+') or throw new \RuntimeException('Fail to create resource'); 21 | fputs($this->resource, $this->contents()); 22 | } 23 | 24 | public function limit(): ?int 25 | { 26 | return $this->limit; 27 | } 28 | 29 | /** 30 | * @return resource 31 | */ 32 | public function resource() 33 | { 34 | return $this->resource; 35 | } 36 | } -------------------------------------------------------------------------------- /tests/Data/Imports/WithColumnFormattingImport.php: -------------------------------------------------------------------------------- 1 | CellFormat::INTEGER, 45 | 'B' => CellFormat::DECIMAL, 46 | 'C' => CellFormat::DATE, 47 | 'D' => CellFormat::DATETIME 48 | ]; 49 | } 50 | 51 | public function expected(): array 52 | { 53 | return [ 54 | [1, 2.30, Carbon::parse('2021-02-03'), Carbon::parse('2021-12-31 12:34:56')], 55 | [2, 5300.91, '2021-02-03', '2021-12-31 23:15'] 56 | ]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Data/Imports/WithHeadings/FromContentsImport.php: -------------------------------------------------------------------------------- 1 | limit; 20 | } 21 | } -------------------------------------------------------------------------------- /tests/Data/Imports/WithHeadings/FromDiskImport.php: -------------------------------------------------------------------------------- 1 | filename = uniqid(); 19 | 20 | Storage::put($this->filename, $this->contents()); 21 | } 22 | 23 | public function delete(): void 24 | { 25 | Storage::delete($this->filename); 26 | } 27 | 28 | public function limit(): ?int 29 | { 30 | return $this->limit; 31 | } 32 | 33 | public function filename(): string 34 | { 35 | return $this->filename; 36 | } 37 | 38 | public function disk(): ?string 39 | { 40 | return null; 41 | } 42 | } -------------------------------------------------------------------------------- /tests/Data/Imports/WithHeadings/FromFileImport.php: -------------------------------------------------------------------------------- 1 | uniqId = uniqid(); 18 | 19 | file_put_contents($this->filename(), $this->contents()) or throw new \RuntimeException('Unable to write file'); 20 | } 21 | 22 | public function limit(): ?int 23 | { 24 | return $this->limit; 25 | } 26 | 27 | public function delete(): void 28 | { 29 | unlink($this->filename()); 30 | } 31 | 32 | public function filename(): string 33 | { 34 | return sprintf('%s/%s.csv', realpath(__DIR__ . '/../../Storage'), $this->uniqId); 35 | } 36 | } -------------------------------------------------------------------------------- /tests/Data/Imports/WithHeadings/FromImportTrait.php: -------------------------------------------------------------------------------- 1 | resource = fopen($source, 'w+') or throw new \RuntimeException('Fail to create resource'); 22 | fputs($this->resource, $this->contents()); 23 | } 24 | 25 | public function limit(): ?int 26 | { 27 | return $this->limit; 28 | } 29 | 30 | /** 31 | * @return resource 32 | */ 33 | public function resource() 34 | { 35 | return $this->resource; 36 | } 37 | } -------------------------------------------------------------------------------- /tests/Data/Imports/WithHeadingsImport.php: -------------------------------------------------------------------------------- 1 | $value) { 16 | $this->assertEquals($instance->{$prop} ?? null, $value); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Helpers/CsvHelperTest.php: -------------------------------------------------------------------------------- 1 | assertEquals( 18 | CsvHelper::getColumnLetter($number), 19 | $letter 20 | ); 21 | } 22 | 23 | public function test_uuid_filename() { 24 | $this->assertMatchesRegularExpression( 25 | '/\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/', 26 | CsvHelper::filename() 27 | ); 28 | } 29 | 30 | /** 31 | * @return array 32 | */ 33 | public function columnLetters(): array 34 | { 35 | $letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']; 36 | 37 | $tests = $letters; 38 | 39 | foreach ($letters as $outerLetter) { 40 | foreach ($letters as $innerLetter) { 41 | $tests[] = "{$outerLetter}{$innerLetter}"; 42 | } 43 | } 44 | 45 | $tests = array_map( 46 | fn(string $letter, int $index) => [$letter, $index + 1], 47 | $tests, 48 | array_keys($tests) 49 | ); 50 | 51 | return [ 52 | $tests 53 | ]; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/Helpers/FormatterHelperTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 21 | $formatted, 22 | FormatterHelper::date($date, $this->datetime), 23 | ); 24 | } 25 | 26 | /** 27 | * @dataProvider valid_decimals 28 | * @dataProvider invalid_numbers 29 | */ 30 | public function test_format_number($number, string $formatted): void 31 | { 32 | $this->assertSame( 33 | $formatted, 34 | FormatterHelper::number($number, $this->decimals, $this->decimalSep, $this->thousandSep), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Helpers/ModelHelperTest.php: -------------------------------------------------------------------------------- 1 | $value) { 19 | $model->{$property} = $value; 20 | } 21 | 22 | $arrayValues = ModelHelper::toArrayValues($model); 23 | 24 | $this->assertSame($arrayValues, array_values($properties)); 25 | } 26 | 27 | /** 28 | * @return array[] 29 | */ 30 | public function mockProperties(): array 31 | { 32 | return [ 33 | 'valid' => [ 34 | [ 35 | 'intProp' => FakerHelper::get()->numberBetween(), 36 | 'dateProp' => FakerHelper::get()->dateTime(), 37 | 'strProp' => FakerHelper::get()->text() 38 | ] 39 | ] 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Helpers/QueryBuilderTest.php: -------------------------------------------------------------------------------- 1 | seed(TestCsvSeeder::class); 18 | } 19 | 20 | public function test_from_query() 21 | { 22 | $builder = TestCsv::query(); 23 | $chunks = []; 24 | $chunkSize = 9; 25 | $countResults = $builder->count(); 26 | 27 | QueryBuilderHelper::chunk( 28 | $builder, 29 | $chunkSize, 30 | $countResults, 31 | null, 32 | function (Collection $collection) use (&$chunks) { 33 | $chunks[] = $collection->pluck('id')->toArray(); 34 | } 35 | ); 36 | 37 | $countChunks = ceil($countResults / $chunkSize); 38 | 39 | $userIds = $builder 40 | ->make() 41 | ->get() 42 | ->pluck('id') 43 | ->toArray(); 44 | 45 | $flattenUserIds = collect($chunks) 46 | ->flatten() 47 | ->toArray(); 48 | 49 | $this->assertCount($countChunks, $chunks); 50 | $this->assertEquals($userIds, $flattenUserIds); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/Services/ExportableServiceTest.php: -------------------------------------------------------------------------------- 1 | seed(TestCsvSeeder::class); 34 | 35 | $this->service = app(ExportableService::class); 36 | $this->disk = 'local'; 37 | $this->diskOptions = ['option' => 'value']; 38 | } 39 | 40 | public function test_count() 41 | { 42 | $arrayExport = new FromArrayExport(); 43 | $collectionExport = new FromCollectionExport(); 44 | $databaseExports = [ 45 | new FromEloquentBuilderExport(), 46 | new FromQueryBuilderExport(), 47 | new FromCursorExport() 48 | ]; 49 | 50 | $this->assertSame( 51 | count($arrayExport->array()), 52 | $this->service->count($arrayExport) 53 | ); 54 | 55 | $this->assertSame( 56 | $collectionExport->collection()->count(), 57 | $this->service->count($collectionExport) 58 | ); 59 | 60 | foreach ($databaseExports as $export) { 61 | $this->assertSame( 62 | count(TestCsvSeeder::USERS), 63 | $this->service->count($export) 64 | ); 65 | } 66 | } 67 | 68 | public function test_limit() 69 | { 70 | $exports = [ 71 | $this->getExportMock(FromArrayExport::class), 72 | $this->getExportMock(FromCollectionExport::class), 73 | $this->getExportMock(FromCursorExport::class), 74 | $this->getExportMock(FromQueryBuilderExport::class), 75 | $this->getExportMock(FromEloquentBuilderExport::class), 76 | ]; 77 | 78 | foreach ($exports as $export) { 79 | $this->assertSame( 80 | $export->limit(), 81 | count($this->service->array($export)) 82 | ); 83 | } 84 | } 85 | 86 | public function test_queue() 87 | { 88 | $filename = FakerHelper::get()->word(); 89 | 90 | Bus::fake(); 91 | 92 | $this->service->queue( 93 | new FromArrayExport(), 94 | $filename, 95 | $this->disk, 96 | $this->diskOptions 97 | ); 98 | 99 | Bus::assertDispatched(function (CreateCsv $job) use ($filename) { 100 | return $job->filename === $filename && 101 | $job->disk === $this->disk && 102 | $job->diskOptions === $this->diskOptions; 103 | }); 104 | } 105 | 106 | public function test_array() 107 | { 108 | $export = new WithMappingExportSimple(); 109 | 110 | $results = $this->service->array($export); 111 | 112 | $fromLaravel = $export->query() 113 | ->get() 114 | ->map(fn($user) => [$user->id, $user->name]) 115 | ->toArray(); 116 | 117 | $this->assertEquals($results, $fromLaravel); 118 | } 119 | 120 | public function test_store() 121 | { 122 | $filename = FakerHelper::get()->word(); 123 | 124 | Storage::shouldReceive('disk->put') 125 | ->once() 126 | ->andReturns($filename); 127 | 128 | $response = $this->service->store( 129 | new WithMappingExportSimple(), 130 | $filename, 131 | $this->disk, 132 | $this->diskOptions 133 | ); 134 | 135 | $this->assertEquals($response, $filename); 136 | } 137 | 138 | public function test_download() 139 | { 140 | $filename = FakerHelper::get()->word(); 141 | 142 | Response::shouldReceive('streamDownload') 143 | ->once() 144 | ->andReturns(\Mockery::mock(StreamedResponse::class)); 145 | 146 | $response = $this->service->download( 147 | new WithMappingExportSimple(), 148 | $filename 149 | ); 150 | 151 | $this->assertInstanceOf(StreamedResponse::class, $response); 152 | } 153 | 154 | public function test_stream() 155 | { 156 | $export = new FromArrayExport(); 157 | $stream = $this->service->getStream($export); 158 | 159 | $this->assertTrue(is_resource($stream)); 160 | $this->assertIsArray(fgetcsv($stream)); 161 | } 162 | 163 | public function test_set_config() 164 | { 165 | $export = new FromArrayExportAlt(); 166 | 167 | $csvConfig = new CsvConfig(); 168 | $csvConfig->csv_delimiter = $export->csvDelimiter(); 169 | $csvConfig->csv_enclosure = $export->csvEnclosure(); 170 | $this->service->setConfig($csvConfig); 171 | 172 | $filename = 'test_config.csv'; 173 | $this->service->store($export, $filename); 174 | $actual = $this->getFromDisk($filename); 175 | 176 | $this->assertEquals($export->expected(), $actual); 177 | } 178 | 179 | private function getExportMock(string $abstractClass, 180 | int $limit = 5) 181 | { 182 | $mock = $this->getMockForAbstractClass( 183 | $abstractClass, 184 | mockedMethods: ['limit'] 185 | ); 186 | 187 | $mock->method('limit') 188 | ->willReturn($limit); 189 | 190 | return $mock; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /tests/Services/FormatterServiceTest.php: -------------------------------------------------------------------------------- 1 | assertSame( 22 | $formatted, 23 | $service->date($date), 24 | ); 25 | } 26 | 27 | /** 28 | * @dataProvider valid_datetimes 29 | * @dataProvider invalid_dates 30 | */ 31 | public function test_format_datetime($date, string $formatted): void 32 | { 33 | $service = app(FormatterService::class); 34 | 35 | $this->assertSame( 36 | $formatted, 37 | $service->datetime($date), 38 | ); 39 | } 40 | 41 | /** 42 | * @dataProvider valid_decimals 43 | * @dataProvider invalid_numbers 44 | */ 45 | public function test_format_decimal($number, string $formatted): void 46 | { 47 | $service = app(FormatterService::class); 48 | 49 | $this->assertSame( 50 | $service->decimal($number), 51 | $formatted 52 | ); 53 | } 54 | 55 | /** 56 | * @dataProvider valid_integers 57 | * @dataProvider invalid_numbers 58 | */ 59 | public function test_format_integer($number, string $formatted): void 60 | { 61 | $service = app(FormatterService::class); 62 | 63 | $this->assertSame( 64 | $service->integer($number), 65 | $formatted 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Services/ImportableServiceTest.php: -------------------------------------------------------------------------------- 1 | seed(TestCsvSeeder::class); 26 | 27 | $this->service = app(ImportableService::class); 28 | } 29 | 30 | public function test_count() 31 | { 32 | $imports = [ 33 | new FromContentsImport(), 34 | new FromDiskImport(), 35 | new FromFileImport(), 36 | new FromResourceImport(), 37 | new WithHeadingsImport() 38 | ]; 39 | 40 | foreach ($imports as $import) { 41 | $actual = $this->service->count($import); 42 | $expected = count($import->expected()); 43 | 44 | if (method_exists($import, 'delete')) { 45 | $import->delete(); 46 | } 47 | 48 | if ($import instanceof WithHeadings) { 49 | $expected--; 50 | } 51 | 52 | $this->assertSame($expected, $actual); 53 | } 54 | } 55 | 56 | public function test_from_disk() 57 | { 58 | $import = new FromDiskImport(); 59 | 60 | $actual = $this->service->getArray($import); 61 | $import->delete(); 62 | 63 | $this->assertSame($import->expected(), $actual); 64 | } 65 | 66 | public function test_set_config() 67 | { 68 | $import = new FromContentsImportAlt(); 69 | 70 | $csvConfig = new CsvConfig(); 71 | $csvConfig->csv_delimiter = $import->csvDelimiter(); 72 | $csvConfig->csv_enclosure = $import->csvEnclosure(); 73 | $this->service->setConfig($csvConfig); 74 | 75 | $actual = $this->service->getArray($import); 76 | 77 | $this->assertEquals($import->expected(), $actual); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | filename = uniqid() . 'csv'; 20 | } 21 | 22 | protected function getPackageProviders($app) 23 | { 24 | return [ 25 | CsvServiceProvider::class 26 | ]; 27 | } 28 | 29 | /** 30 | * Get application timezone. 31 | * 32 | * @param \Illuminate\Foundation\Application $app 33 | * @return string|null 34 | */ 35 | protected function getApplicationTimezone($app) 36 | { 37 | return 'UTC'; 38 | } 39 | 40 | public function getFromDisk(string $filename, 41 | bool $cleanUtf8Bom = true): string 42 | { 43 | $csvConfig = CsvImporter::getConfig(); 44 | $contents = Storage::disk($csvConfig->disk)->get($filename) ?: ''; 45 | 46 | // remove empty line break 47 | $contents = preg_replace('/\s$/', '', $contents); 48 | 49 | if ($cleanUtf8Bom) { 50 | $contents = str_replace(CsvHelper::getBom(), '', $contents); 51 | } 52 | 53 | Storage::disk($csvConfig->disk)->delete($filename); 54 | 55 | return $contents; 56 | } 57 | 58 | public function getFromDiskArray(string $filename, 59 | bool $cleanUtf8Bom = true): array 60 | { 61 | $contents = $this->getFromDisk($filename); 62 | return explode("\n", $contents); 63 | } 64 | 65 | /** 66 | * @param $app 67 | * @return void 68 | */ 69 | protected function getEnvironmentSetUp($app) 70 | { 71 | $app->useStoragePath(realpath(__DIR__ . '/Data/Storage')); 72 | 73 | $app['config']->set('app.debug', env('APP_DEBUG', true)); 74 | 75 | $app['config']->set('filesystems.default', 'local'); 76 | $app['config']->set('filesystems.disks.local.root', realpath(__DIR__ . '/Data/Storage')); 77 | 78 | $app['config']->set('database.default', 'testing'); 79 | $app['config']->set('database.connections.testing', [ 80 | 'driver' => env('DB_DRIVER', 'sqlite'), 81 | 'host' => env('DB_HOST'), 82 | 'port' => env('DB_PORT'), 83 | 'database' => env('DB_DATABASE', ':memory:'), 84 | 'username' => env('DB_USERNAME'), 85 | 'password' => env('DB_PASSWORD'), 86 | 'prefix' => env('DB_PREFIX') 87 | ]); 88 | } 89 | 90 | protected function defineDatabaseMigrations() 91 | { 92 | $this->loadMigrationsFrom(__DIR__ . '/Data/Database/Migrations'); 93 | 94 | // Provides support for the previous generation of Laravel factories (<= 7.x) for Laravel 8.x+. 95 | $this->withFactories(__DIR__ . '/Data/Database/Factories'); 96 | } 97 | } 98 | --------------------------------------------------------------------------------