Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.18% covered (success)
97.18%
241 / 248
85.00% covered (success)
85.00%
34 / 40
CRAP
0.00% covered (danger)
0.00%
0 / 1
Csv
97.18% covered (success)
97.18%
241 / 248
85.00% covered (success)
85.00%
34 / 40
148
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 setOptions
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 serialize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 unserialize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isSerialized
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isUnserialized
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 outputToHttp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 outputBlankFileToHttp
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 prepareHttp
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 writeToFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 appendData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 appendRow
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 writeBlankFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 loadFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 loadString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 loadData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDataFromFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getRowCountFromFile
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
7.01
 writeDataToFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 writeTemplateToFile
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 outputDataToHttp
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 outputTemplateToHttp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 processOptions
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 normalizeExcludeInclude
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 buildFieldKeys
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 appendDataToFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 appendRowToFile
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
7
 serializeData
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
11
 unserializeString
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 readRowsFromFile
88.89% covered (success)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
10.14
 serializeRow
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 resolveArrayValue
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
8.51
 formatFieldValue
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
12.05
 getFieldHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 isValid
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
6.04
 __toString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Csv;
16
17/**
18 * CSV class
19 *
20 * @category   Pop
21 * @package    Pop\Csv
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    5.0.0
26 */
27class Csv
28{
29
30    /**
31     * CSV data in PHP
32     * @var mixed
33     */
34    protected mixed $data = null;
35
36    /**
37     * CSV string
38     * @var ?string
39     */
40    protected ?string $string = null;
41
42    /**
43     * CSV options
44     * @var array
45     */
46    protected array $options = [
47        'exclude'        => [],
48        'include'        => [],
49        'delimiter'      => ',',
50        'enclosure'      => '"',
51        'escape'         => '"',
52        'fields'         => true,
53        'newline'        => true,
54        'limit'          => 0,
55        'map'            => [],
56        'columns'        => [],
57        'escapeFormulas' => false,
58    ];
59
60    /**
61     * Constructor
62     *
63     * Instantiate the Csv object.
64     *
65     * @param  mixed $data
66     * @param ?array $options
67     */
68    public function __construct(mixed $data = null, ?array $options = null)
69    {
70        if ($data !== null) {
71            // If data is a file
72            if (is_string($data) && ((stripos($data, '.csv') !== false) || (stripos($data, '.tsv') !== false)) && file_exists($data)) {
73                $this->string = file_get_contents($data);
74            // Else, if it's just data
75            } else if (!is_string($data)) {
76                $this->data = $data;
77            // Else if it's a string or stream of data
78            } else {
79                $this->string = $data;
80            }
81        }
82        if (!empty($options)) {
83            $this->setOptions($options);
84        }
85    }
86
87    /**
88     * Serialize the data to a CSV string
89     *
90     * @param  array $options
91     * @return self
92     */
93    public function setOptions(array $options): self
94    {
95        if (!empty($options)) {
96            $this->options = self::processOptions($options);
97        }
98
99        return $this;
100    }
101
102    /**
103     * Serialize the data to a CSV string
104     *
105     * @param  ?array $options
106     * @return string
107     */
108    public function serialize(?array $options = null): string
109    {
110        if (!empty($options)) {
111            $this->setOptions($options);
112        }
113        $this->string = self::serializeData($this->data, $this->options);
114        return $this->string;
115    }
116
117    /**
118     * Unserialize the string to data
119     *
120     * @param  ?array $options
121     * @return mixed
122     */
123    public function unserialize(?array $options = null): mixed
124    {
125        if (!empty($options)) {
126            $this->setOptions($options);
127        }
128        $this->data = self::unserializeString($this->string, $this->options);
129        return $this->data;
130    }
131
132    /**
133     * Set data
134     *
135     * @param  array $data
136     * @return Csv
137     */
138    public function setData(array $data): Csv
139    {
140        $this->data = $data;
141        return $this;
142    }
143
144    /**
145     * Get data
146     *
147     * @return ?array
148     */
149    public function getData(): ?array
150    {
151        return $this->data;
152    }
153
154    /**
155     * Set string
156     *
157     * @param  string $string
158     * @return Csv
159     */
160    public function setString(string $string): Csv
161    {
162        $this->string = $string;
163        return $this;
164    }
165
166    /**
167     * Get string
168     *
169     * @return ?string
170     */
171    public function getString(): ?string
172    {
173        return $this->string;
174    }
175
176    /**
177     * Check if data was serialized
178     *
179     * @return bool
180     */
181    public function isSerialized(): bool
182    {
183        return ($this->string !== null);
184    }
185
186    /**
187     * Check if string was unserialized
188     *
189     * @return bool
190     */
191    public function isUnserialized(): bool
192    {
193        return ($this->data !== null);
194    }
195
196    /**
197     * Output CSV string data to HTTP
198     *
199     * @param  string $filename
200     * @param  bool   $forceDownload
201     * @param  array  $headers
202     * @return void
203     */
204    public function outputToHttp(string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []): void
205    {
206        // Attempt to serialize data if it hasn't been done yet
207        if (($this->string === null) && ($this->data !== null)) {
208            $this->serialize();
209        }
210
211        $this->prepareHttp($filename, $forceDownload, $headers);
212
213        echo $this->string;
214    }
215
216    /**
217     * Output CSV headers only in a blank file to HTTP
218     *
219     * @param  string $filename
220     * @param  bool   $forceDownload
221     * @param  array  $headers
222     * @param  string $delimiter
223     * @param  array  $exclude
224     * @param  array  $include
225     * @return void
226     *@throws Exception
227     */
228    public function outputBlankFileToHttp(
229        string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = [],
230        string $delimiter = ',', array $exclude = [], array $include = []
231    ): void
232    {
233        // Attempt to serialize data if it hasn't been done yet
234        if (($this->string === null) && ($this->data !== null) && isset($this->data[0])) {
235            $fieldHeaders = self::getFieldHeaders($this->data[0], $delimiter, $exclude, $include);
236        } else {
237            throw new Exception('Error: The data has not been set.');
238        }
239
240        $this->prepareHttp($filename, $forceDownload, $headers);
241        echo $fieldHeaders;
242    }
243
244    /**
245     * Prepare output to HTTP
246     *
247     * @param  string $filename
248     * @param  bool   $forceDownload
249     * @param  array  $headers
250     * @return void
251     */
252    public function prepareHttp(string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []): void
253    {
254        if (!isset($headers['Content-Type'])) {
255            $headers['Content-Type'] = 'text/csv';
256        }
257        if (!isset($headers['Content-Disposition'])) {
258            $headers['Content-Disposition'] = (($forceDownload) ? 'attachment; ' : null) . 'filename=' . $filename;
259        }
260
261        // Send the headers and output the file
262        if (!headers_sent()) {
263            header('HTTP/1.1 200 OK');
264            foreach ($headers as $name => $value) {
265                header($name . ': ' . $value);
266            }
267        }
268    }
269
270    /**
271     * Output CSV data to a file
272     *
273     * @param  string $to
274     * @return void
275     */
276    public function writeToFile(string $to): void
277    {
278        // Attempt to serialize data if it hasn't been done yet
279        if (($this->string === null) && ($this->data !== null)) {
280            $this->serialize();
281        }
282
283        file_put_contents($to, $this->string);
284    }
285
286    /**
287     * Append additional CSV data to a pre-existing file
288     *
289     * @param  string $file
290     * @param  array  $data
291     * @param  bool   $validate
292     * @return self
293     */
294    public function appendData(string $file, array $data, bool $validate = true): self
295    {
296        self::appendDataToFile($file, $data, $this->options, $validate);
297        return $this;
298    }
299
300    /**
301     * Append additional CSV row of data to a pre-existing file
302     *
303     * @param  string $file
304     * @param  array  $row
305     * @param  bool   $validate
306     * @return self
307     */
308    public function appendRow(string $file, array $row, bool $validate = true): self
309    {
310        self::appendRowToFile($file, $row, $this->options, $validate);
311        return $this;
312    }
313
314    /**
315     * Output CSV headers only to a blank file
316     *
317     * @param  string $to
318     * @param  string $delimiter
319     * @param  array  $exclude
320     * @param  array  $include
321     * @return void
322     *@throws Exception
323     */
324    public function writeBlankFile(string $to, string $delimiter = ',', array $exclude = [], array $include = []): void
325    {
326        // Attempt to get field headers and output file
327        if (($this->string === null) && ($this->data !== null) && isset($this->data[0])) {
328            file_put_contents($to, self::getFieldHeaders($this->data[0], $delimiter, $exclude, $include));
329        } else {
330            throw new Exception('Error: The data has not been set.');
331        }
332    }
333
334    /**
335     * Load CSV file
336     *
337     * @param  string $file
338     * @param  ?array $options
339     * @return Csv
340     */
341    public static function loadFile(string $file, ?array $options = null): Csv
342    {
343        $csv = new self($file, $options);
344        $csv->unserialize();
345        return $csv;
346    }
347
348    /**
349     * Load CSV string
350     *
351     * @param  string $string
352     * @param  ?array $options
353     * @return Csv
354     */
355    public static function loadString(string $string, ?array $options = null): Csv
356    {
357        $csv = new self($string, $options);
358        $csv->unserialize();
359        return $csv;
360    }
361
362    /**
363     * Load CSV data
364     *
365     * @param  array  $data
366     * @param  ?array $options
367     * @return Csv
368     */
369    public static function loadData(array $data, ?array $options = null): Csv
370    {
371        $csv = new self($data, $options);
372        $csv->serialize();
373        return $csv;
374    }
375
376    /**
377     * Load CSV file and get data
378     *
379     * @param  string $file
380     * @param  ?array $options
381     * @return array
382     */
383    public static function getDataFromFile(string $file, ?array $options = null): array
384    {
385        $csv = new self($file, $options);
386        return $csv->unserialize();
387    }
388
389    /**
390     * Get row count from file
391     *
392     * @param  string $file
393     * @param  array  $options
394     * @return int
395     */
396    public static function getRowCountFromFile(string $file, array $options = []): int
397    {
398        $count = 0;
399
400        if (($handle = fopen($file, 'r')) !== false) {
401            $headers   = $options['headers'] ?? false;
402            $skipBlank = $options['skip_blank'] ?? true;
403            $length    = $options['length'] ?? null;
404            $delimiter = $options['delimiter'] ?? ',';
405            $enclosure = $options['enclosure'] ?? "\"";
406            $escape    = $options['escape'] ?? "\"";
407
408            while (($row = fgetcsv($handle, $length, $delimiter, $enclosure, $escape)) !== false) {
409                if (($skipBlank) && ($row === [null])) {
410                    continue;
411                }
412                $count++;
413            }
414
415            if (($count > 0) && ($headers)) {
416                $count--;
417            }
418        }
419
420        return $count;
421    }
422
423    /**
424     * Write data to file
425     *
426     * @param  array  $data
427     * @param  string $to
428     * @param  ?array $options
429     * @return void
430     */
431    public static function writeDataToFile(array $data, string $to, ?array $options = null): void
432    {
433        $csv = new self($data, $options);
434        $csv->serialize();
435        $csv->writeToFile($to);
436    }
437
438    /**
439     * Write template to file
440     *
441     * @param  array  $data
442     * @param  string $to
443     * @param  string $delimiter
444     * @param  array  $exclude
445     * @param  array  $include
446     * @return void
447     * @throws Exception
448     */
449    public static function writeTemplateToFile(
450        array $data, string $to, string $delimiter = ',', array $exclude = [], array $include = []
451    ): void
452    {
453        $csv = new self($data);
454        $csv->writeBlankFile($to, $delimiter, $exclude, $include);
455    }
456
457    /**
458     * Output data to HTTP
459     *
460     * @param  array  $data
461     * @param  ?array $options
462     * @param  string $filename
463     * @param  bool   $forceDownload
464     * @param  array  $headers
465     * @return void
466     */
467    public static function outputDataToHttp(
468        array $data, ?array $options = null, string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []
469    ): void
470    {
471        $csv = new self($data, $options);
472        $csv->serialize();
473        $csv->outputToHttp($filename, $forceDownload, $headers);
474    }
475
476    /**
477     * Output template to HTTP
478     *
479     * @param  array  $data
480     * @param  string $filename
481     * @param  bool   $forceDownload
482     * @param  array  $headers
483     * @param  string $delimiter
484     * @param  array  $exclude
485     * @param  array  $include
486     * @return void
487     *@throws Exception
488     */
489    public static function outputTemplateToHttp(
490        array $data, string $filename = 'pop-data-template.csv', bool $forceDownload = true,
491        array $headers = [], string $delimiter = ',', array $exclude = [], array $include = []
492    ): void
493    {
494        $csv = new self($data);
495        $csv->outputBlankFileToHttp($filename, $forceDownload, $headers, $delimiter, $exclude, $include);
496    }
497
498    /**
499     * Process CSV options
500     *
501     * @param  array $options
502     * @return array
503     */
504    public static function processOptions(array $options): array
505    {
506        $options['exclude']        ??= [];
507        $options['include']        ??= [];
508        $options['delimiter']      ??= ',';
509        $options['enclosure']      ??= '"';
510        $options['escape']         ??= '"';
511        $options['fields']         ??= true;
512        $options['newline']        ??= true;
513        $options['limit']          ??= 0;
514        $options['length']         ??= 0;
515        $options['map']            ??= [];
516        $options['columns']        ??= [];
517        $options['escapeFormulas'] ??= false;
518
519        return $options;
520    }
521
522    /**
523     * Normalize the exclude/include options into a pair of arrays
524     *
525     * @param  array $options
526     * @return array
527     */
528    protected static function normalizeExcludeInclude(array $options): array
529    {
530        $exclude = isset($options['exclude']) ? ((!is_array($options['exclude'])) ? [$options['exclude']] : $options['exclude']) : [];
531        $include = isset($options['include']) ? ((!is_array($options['include'])) ? [$options['include']] : $options['include']) : [];
532
533        return [$exclude, $include];
534    }
535
536    /**
537     * Build a trimmed field-key list from a raw fgetcsv/str_getcsv header row
538     *
539     * @param  array|false $fieldNames
540     * @return array
541     */
542    protected static function buildFieldKeys(array|false $fieldNames): array
543    {
544        if ($fieldNames === false) {
545            return [];
546        }
547
548        $fieldKeys = [];
549        foreach ($fieldNames as $name) {
550            $fieldKeys[] = trim((string)$name);
551        }
552
553        return $fieldKeys;
554    }
555
556    /**
557     * Append additional CSV data to a pre-existing file
558     *
559     * @param  string $file
560     * @param  array  $data
561     * @param  array  $options
562     * @param  bool   $validate
563     * @throws Exception
564     * @return void
565     */
566    public static function appendDataToFile(string $file, array $data, array $options = [], bool $validate = true): void
567    {
568        if (!file_exists($file)) {
569            throw new Exception("Error: The file '" . $file . "' does not exist.");
570        }
571
572        foreach ($data as $row) {
573            self::appendRowToFile($file, $row, $options, $validate);
574        }
575    }
576
577    /**
578     * Append additional CSV row of data to a pre-existing file
579     *
580     * @param  string $file
581     * @param  array  $row
582     * @param  array  $options
583     * @param  bool   $validate
584     * @throws Exception
585     * @return void
586     */
587    public static function appendRowToFile(string $file, array $row, array $options = [], bool $validate = true): void
588    {
589        if (!file_exists($file)) {
590            throw new Exception("Error: The file '" . $file . "' does not exist.");
591        }
592
593        if ($validate) {
594            $keys       = array_keys($row);
595            $handle     = fopen($file, 'r');
596            $headerLine = ($handle !== false) ? fgets($handle) : false;
597            if ($handle !== false) {
598                fclose($handle);
599            }
600            $headers = array_map(
601                function($value) { return str_replace('"', '', $value); },
602                explode(',', trim($headerLine !== false ? $headerLine : ''))
603            );
604
605            if ($keys != $headers) {
606                throw new Exception("Error: The new data's columns do not match the CSV files columns.");
607            }
608        }
609
610        [$exclude, $include] = self::normalizeExcludeInclude($options);
611
612        $options = self::processOptions($options);
613        $csvRow  = self::serializeRow(
614            (array)$row, $exclude, $include, $options['delimiter'], $options['enclosure'], $options['escape'],
615            $options['newline'], $options['limit'], $options['map'], $options['columns'], $options['escapeFormulas']
616        );
617
618        file_put_contents($file, $csvRow, FILE_APPEND);
619    }
620
621    /**
622     * Convert the data into CSV format.
623     *
624     * @param  mixed $data
625     * @param  array $options
626     * @return string
627     */
628    public static function serializeData(mixed $data, array $options = []): string
629    {
630        $keys    = array_keys($data);
631        $isAssoc = false;
632
633        foreach ($keys as $key) {
634            if (!is_numeric($key)) {
635                $isAssoc = true;
636            }
637        }
638
639        if ($isAssoc) {
640            $data = array_merge(...array_values($data));
641        }
642
643        [$exclude, $include] = self::normalizeExcludeInclude($options);
644
645        $options = self::processOptions($options);
646        $csv     = '';
647
648        if (!empty($data)) {
649            $firstKey = array_keys($data)[0];
650
651            if (is_array($data) && isset($data[$firstKey]) &&
652                (is_array($data[$firstKey]) || ($data[$firstKey] instanceof \ArrayObject)) && ($options['fields'])) {
653                $csv .= self::getFieldHeaders((array)$data[$firstKey], $options['delimiter'], $exclude, $include);
654            }
655
656            // Initialize and clean the field values.
657            foreach ($data as $value) {
658                $csv .= self::serializeRow(
659                    (array)$value, $exclude, $include, $options['delimiter'], $options['enclosure'], $options['escape'],
660                    $options['newline'], $options['limit'], $options['map'], $options['columns'], $options['escapeFormulas']
661                );
662            }
663        }
664
665        return $csv;
666    }
667
668    /**
669     * Parse the CSV string into a PHP array
670     *
671     * @param  string $string
672     * @param  array  $options
673     * @return array
674     */
675    public static function unserializeString(string $string, array $options = []): array
676    {
677        if (str_starts_with($string, "\xEF\xBB\xBF")) {
678            $string = substr($string, 3);
679        }
680
681        $options   = self::processOptions($options);
682        $firstLine = substr($string, 0, strcspn($string, "\r\n"));
683        $data      = [];
684        $fieldKeys = [];
685
686        $tempFile = tempnam(sys_get_temp_dir(), 'pop-csv-tmp-');
687        file_put_contents($tempFile, $string);
688
689        if ($options['fields']) {
690            $fieldKeys = self::buildFieldKeys(str_getcsv($firstLine, $options['delimiter'], $options['enclosure'], $options['escape']));
691        }
692
693        try {
694            if (($handle = fopen($tempFile, 'r')) !== false) {
695                try {
696                    while (($dataFields = fgetcsv($handle, $options['length'], $options['delimiter'], $options['enclosure'], $options['escape'])) !== false) {
697                        if (($options['fields']) && (count($dataFields) == count($fieldKeys)) && ($dataFields != $fieldKeys)) {
698                            $d = [];
699                            foreach ($dataFields as $i => $value) {
700                                $d[$fieldKeys[$i]] = $value;
701                            }
702                            $data[] = $d;
703                        } else if ($dataFields != $fieldKeys) {
704                            $data[] = $dataFields;
705                        }
706                    }
707                } finally {
708                    fclose($handle);
709                }
710            }
711        } finally {
712            unlink($tempFile);
713        }
714
715        return $data;
716    }
717
718    /**
719     * Read a CSV file row-by-row without loading the whole file into memory
720     *
721     * @param  string $file
722     * @param  ?array $options
723     * @throws Exception
724     * @return \Generator
725     */
726    public static function readRowsFromFile(string $file, ?array $options = null): \Generator
727    {
728        if (!file_exists($file)) {
729            throw new Exception("Error: The file '" . $file . "' does not exist.");
730        }
731
732        $options   = self::processOptions($options ?? []);
733        $fieldKeys = [];
734        $handle    = fopen($file, 'r');
735
736        try {
737            // Strip a leading UTF-8 BOM, if present, without disturbing the rest of the stream
738            if (fread($handle, 3) !== "\xEF\xBB\xBF") {
739                rewind($handle);
740            }
741
742            if ($options['fields']) {
743                $fieldKeys = self::buildFieldKeys(fgetcsv($handle, $options['length'], $options['delimiter'], $options['enclosure'], $options['escape']));
744            }
745
746            while (($dataFields = fgetcsv($handle, $options['length'], $options['delimiter'], $options['enclosure'], $options['escape'])) !== false) {
747                if (($options['fields']) && (count($dataFields) == count($fieldKeys)) && ($dataFields != $fieldKeys)) {
748                    $row = [];
749                    foreach ($dataFields as $i => $value) {
750                        $row[$fieldKeys[$i]] = $value;
751                    }
752                    yield $row;
753                } else if ($dataFields != $fieldKeys) {
754                    yield $dataFields;
755                }
756            }
757        } finally {
758            fclose($handle);
759        }
760    }
761
762    /**
763     * Serialize single row of data
764     *
765     * @param  array  $value
766     * @param  array  $exclude
767     * @param  array  $include
768     * @param  string $delimiter
769     * @param  string $enclosure
770     * @param  string $escape
771     * @param  bool   $newline
772     * @param  int    $limit
773     * @param  array  $map
774     * @param  array  $columns
775     * @param  bool   $escapeFormulas
776     * @return string
777     */
778    public static function serializeRow(
779        array $value, array $exclude = [], array $include = [], string $delimiter = ',', string $enclosure = '"',
780        string $escape = '"', bool $newline = true, int $limit = 0, array $map = [], array $columns = [],
781        bool $escapeFormulas = false
782    ): string
783    {
784        $rowAry = [];
785        foreach ($value as $key => $val) {
786            if (!in_array($key, $exclude) && (empty($include) || in_array($key, $include))) {
787                if (is_array($val)) {
788                    $val = self::resolveArrayValue($val, $key, $map, $columns);
789                }
790
791                if ($val !== null) {
792                    $isNumeric = is_numeric($val);
793                    $val       = self::formatFieldValue(
794                        (string)$val, $isNumeric, $delimiter, $enclosure, $escape, $newline, $limit, $escapeFormulas
795                    );
796                }
797
798                $rowAry[] = $val;
799            }
800        }
801
802        return implode($delimiter, $rowAry) . "\n";
803    }
804
805    /**
806     * Resolve a nested array field value down to a scalar via the map/columns options
807     *
808     * @param  array      $val
809     * @param  int|string $key
810     * @param  array      $map
811     * @param  array      $columns
812     * @return mixed
813     */
814    protected static function resolveArrayValue(array $val, int|string $key, array $map, array $columns): mixed
815    {
816        if (!empty($val) && isset($map[$key]) && isset($val[$map[$key]])) {
817            return $val[$map[$key]];
818        } else if (!empty($val) && isset($columns[$key]) && isset($val[0]) && isset($val[0][$columns[$key]])) {
819            return implode(',', array_column($val, $columns[$key]));
820        }
821
822        return null;
823    }
824
825    /**
826     * Apply newline-stripping, length-limiting, formula-escaping and quoting rules to a single field value
827     *
828     * @param  string $val
829     * @param  bool   $isNumeric
830     * @param  string $delimiter
831     * @param  string $enclosure
832     * @param  string $escape
833     * @param  bool   $newline
834     * @param  int    $limit
835     * @param  bool   $escapeFormulas
836     * @return string
837     */
838    protected static function formatFieldValue(
839        string $val, bool $isNumeric, string $delimiter, string $enclosure, string $escape, bool $newline,
840        int $limit, bool $escapeFormulas
841    ): string
842    {
843        if (!$newline) {
844            $val = str_replace(["\n", "\r"], [" ", " "], $val);
845        }
846        if ($limit > 0) {
847            $val = substr($val, 0, $limit);
848        }
849        if ($escapeFormulas && !$isNumeric && in_array(substr($val, 0, 1), ['=', '+', '-', '@'], true)) {
850            $val = "'" . $val;
851        }
852        if (str_contains($val, $enclosure)) {
853            $val = str_replace($enclosure, $escape . $enclosure, $val);
854        }
855        if ($isNumeric && str_starts_with($val, '0')) {
856            $val = $enclosure . $val . $enclosure;
857        }
858        if ((str_contains($val, $delimiter)) || (str_contains($val, "\n")) ||
859            (str_contains($val, $escape . $enclosure))) {
860            $val = $enclosure . $val . $enclosure;
861        }
862
863        return $val;
864    }
865
866    /**
867     * Get field headers
868     *
869     * @param  mixed  $data
870     * @param  string $delimiter
871     * @param  array  $exclude
872     * @param  array  $include
873     * @return string
874     */
875    public static function getFieldHeaders(mixed $data, string $delimiter = ',', array $exclude = [], array $include = []): string
876    {
877        $headers    = array_keys($data);
878        $headersAry = [];
879        foreach ($headers as $header) {
880            if (!in_array($header, $exclude) && (empty($include) || in_array($header, $include))) {
881                $headersAry[] = $header;
882            }
883        }
884        return implode($delimiter, $headersAry) . "\n";
885    }
886
887    /**
888     * Determine if the string is valid CSV
889     *
890     * @param  string $string
891     * @return bool
892     */
893    public static function isValid(string $string): bool
894    {
895        $lines = preg_split("/((\r?\n)|(\r\n?))/", trim($string));
896
897        if (empty($lines) || $lines[0] === '') {
898            return false;
899        }
900
901        $expectedCount = count(str_getcsv($lines[0], escape: "\\"));
902
903        foreach ($lines as $line) {
904            if ($line === '') {
905                continue;
906            }
907            if (count(str_getcsv($line, escape: "\\")) !== $expectedCount) {
908                return false;
909            }
910        }
911
912        return true;
913    }
914
915    /**
916     * Render CSV string data to string
917     *
918     * @return string
919     */
920    public function __toString(): string
921    {
922        // Attempt to serialize data if it hasn't been done yet
923        if (($this->string === null) && ($this->data !== null)) {
924            $this->serialize();
925        }
926
927        return $this->string;
928    }
929
930}