Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.40% covered (success)
99.40%
165 / 166
96.77% covered (success)
96.77%
30 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Csv
99.40% covered (success)
99.40%
165 / 166
96.77% covered (success)
96.77%
30 / 31
102
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 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
 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%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 serialize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 unserialize
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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
 writeBlankFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 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%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 serializeData
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
15
 unserializeString
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
10
 serializeRow
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
11
 getFieldHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 isValid
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 __toString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Pop PHP Framework (http://www.popphp.org/)
4 *
5 * @link       https://github.com/popphp/popphp-framework
6 * @author     Nick Sagona, III <dev@nolainteractive.com>
7 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
8 * @license    http://www.popphp.org/license     New BSD License
9 */
10
11/**
12 * @namespace
13 */
14namespace Pop\Csv;
15
16/**
17 * CSV class
18 *
19 * @category   Pop
20 * @package    Pop\Csv
21 * @author     Nick Sagona, III <dev@nolainteractive.com>
22 * @copyright  Copyright (c) 2009-2024 NOLA Interactive, LLC. (http://www.nolainteractive.com)
23 * @license    http://www.popphp.org/license     New BSD License
24 * @version    4.0.0
25 */
26class Csv
27{
28
29    /**
30     * CSV data in PHP
31     * @var mixed
32     */
33    protected mixed $data = null;
34
35    /**
36     * CSV string
37     * @var ?string
38     */
39    protected ?string $string = null;
40
41    /**
42     * Constructor
43     *
44     * Instantiate the Csv object.
45     *
46     * @param  mixed $data
47     */
48    public function __construct(mixed $data = null)
49    {
50        if ($data !== null) {
51            // If data is a file
52            if (is_string($data) && (stripos($data, '.csv') !== false) && file_exists($data)) {
53                $this->string = file_get_contents($data);
54            // Else, if it's just data
55            } else if (!is_string($data)) {
56                $this->data = $data;
57            // Else if it's a string or stream of data
58            } else {
59                $this->string = $data;
60            }
61        }
62    }
63
64    /**
65     * Load CSV file
66     *
67     * @param  string $file
68     * @param  array $options
69     * @return Csv
70     */
71    public static function loadFile(string $file, array $options = []): Csv
72    {
73        $csv = new self($file);
74        $csv->unserialize($options);
75        return $csv;
76    }
77
78    /**
79     * Load CSV string
80     *
81     * @param  string $string
82     * @param  array $options
83     * @return Csv
84     */
85    public static function loadString(string $string, array $options = []): Csv
86    {
87        $csv = new self($string);
88        $csv->unserialize($options);
89        return $csv;
90    }
91
92    /**
93     * Load CSV data
94     *
95     * @param  array $data
96     * @param  array $options
97     * @return Csv
98     */
99    public static function loadData(array $data, array $options = []): Csv
100    {
101        $csv = new self($data);
102        $csv->serialize($options);
103        return $csv;
104    }
105
106    /**
107     * Load CSV file and get data
108     *
109     * @param  string $file
110     * @param  array  $options
111     * @return array
112     */
113    public static function getDataFromFile(string $file, array $options = []): array
114    {
115        $csv = new self($file);
116        return $csv->unserialize($options);
117    }
118
119    /**
120     * Write data to file
121     *
122     * @param  array  $data
123     * @param  string $to
124     * @param  array  $options
125     * @return void
126     */
127    public static function writeDataToFile(array $data, string $to, array $options = []): void
128    {
129        $csv = new self($data);
130        $csv->serialize($options);
131        $csv->writeToFile($to);
132    }
133
134    /**
135     * Write data to file
136     *
137     * @param  array  $data
138     * @param  string $to
139     * @param  string $delimiter
140     * @param  array  $exclude
141     * @param  array  $include
142     * @return void
143     *@throws Exception
144     */
145    public static function writeTemplateToFile(
146        array $data, string $to, string $delimiter = ',', array $exclude = [], array $include = []
147    ): void
148    {
149        $csv = new self($data);
150        $csv->writeBlankFile($to, $delimiter, $exclude, $include);
151    }
152
153    /**
154     * Write data to file
155     *
156     * @param  array  $data
157     * @param  array  $options
158     * @param  string $filename
159     * @param  bool   $forceDownload
160     * @param  array  $headers
161     * @return void
162     */
163    public static function outputDataToHttp(
164        array $data, array $options = [], string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []
165    ): void
166    {
167        $csv = new self($data);
168        $csv->serialize($options);
169        $csv->outputToHttp($filename, $forceDownload, $headers);
170    }
171
172    /**
173     * Write data to file
174     *
175     * @param  array  $data
176     * @param  string $filename
177     * @param  bool   $forceDownload
178     * @param  array  $headers
179     * @param  string $delimiter
180     * @param  array  $exclude
181     * @throws Exception
182     * @return void
183     */
184    public static function outputTemplateToHttp(
185        array $data, string $filename = 'pop-data-template.csv', bool $forceDownload = true,
186        array $headers = [], string $delimiter = ',', array $exclude = []
187    )
188    {
189        $csv = new self($data);
190        $csv->outputBlankFileToHttp($filename, $forceDownload, $headers, $delimiter, $exclude);
191    }
192
193    /**
194     * Process CSV options
195     *
196     * @param  array $options
197     * @return array
198     */
199    public static function processOptions(array $options): array
200    {
201        $options['delimiter'] = (isset($options['delimiter'])) ? $options['delimiter']     : ',';
202        $options['enclosure'] = (isset($options['enclosure'])) ? $options['enclosure']     : '"';
203        $options['escape']    = (isset($options['escape']))    ? $options['escape']        : '"';
204        $options['fields']    = (isset($options['fields']))    ? (bool)$options['fields']  : true;
205        $options['newline']   = (isset($options['newline']))   ? (bool)$options['newline'] : true;
206        $options['limit']     = (isset($options['limit']))     ? (int)$options['limit']    : 0;
207
208        return $options;
209    }
210
211    /**
212     * Serialize the data to a CSV string
213     *
214     * @param  array $options
215     * @return string
216     */
217    public function serialize(array $options = []): string
218    {
219        $this->string = self::serializeData($this->data, $options);
220        return $this->string;
221    }
222
223    /**
224     * Unserialize the string to data
225     *
226     * @param  array  $options
227     * @return mixed
228     */
229    public function unserialize(array $options = []): mixed
230    {
231        $this->data = self::unserializeString($this->string, $options);
232        return $this->data;
233    }
234
235    /**
236     * Set data
237     *
238     * @param  array $data
239     * @return Csv
240     */
241    public function setData(array $data): Csv
242    {
243        $this->data = $data;
244        return $this;
245    }
246
247    /**
248     * Get data
249     *
250     * @return array
251     */
252    public function getData(): array
253    {
254        return $this->data;
255    }
256
257    /**
258     * Set string
259     *
260     * @param  string $string
261     * @return Csv
262     */
263    public function setString(string $string): Csv
264    {
265        $this->string = $string;
266        return $this;
267    }
268
269    /**
270     * Get string
271     *
272     * @return string
273     */
274    public function getString(): string
275    {
276        return $this->string;
277    }
278
279    /**
280     * Check if data was serialized
281     *
282     * @return bool
283     */
284    public function isSerialized(): bool
285    {
286        return ($this->string !== null);
287    }
288
289    /**
290     * Check if string was unserialized
291     *
292     * @return bool
293     */
294    public function isUnserialized(): bool
295    {
296        return ($this->data !== null);
297    }
298
299    /**
300     * Output CSV string data to HTTP
301     *
302     * @param  string $filename
303     * @param  bool   $forceDownload
304     * @param  array  $headers
305     * @return void
306     */
307    public function outputToHttp(string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []): void
308    {
309        // Attempt to serialize data if it hasn't been done yet
310        if (($this->string === null) && ($this->data !== null)) {
311            $this->serialize();
312        }
313
314        $this->prepareHttp($filename, $forceDownload, $headers);
315
316        echo $this->string;
317    }
318
319    /**
320     * Output CSV headers only in a blank file to HTTP
321     *
322     * @param  string $filename
323     * @param  bool   $forceDownload
324     * @param  array  $headers
325     * @param  string $delimiter
326     * @param  array  $exclude
327     * @throws Exception
328     * @return void
329     */
330    public function outputBlankFileToHttp(
331        string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = [], string $delimiter = ',', array $exclude = []
332    ): void
333    {
334        // Attempt to serialize data if it hasn't been done yet
335        if (($this->string === null) && ($this->data !== null) && isset($this->data[0])) {
336            $fieldHeaders = self::getFieldHeaders($this->data[0], $delimiter, $exclude);
337        } else {
338            throw new Exception('Error: The data has not been set.');
339        }
340
341        $this->prepareHttp($filename, $forceDownload, $headers);
342        echo $fieldHeaders;
343    }
344
345    /**
346     * Prepare output to HTTP
347     *
348     * @param  string $filename
349     * @param  bool   $forceDownload
350     * @param  array  $headers
351     * @return void
352     */
353    public function prepareHttp(string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []): void
354    {
355        if (!isset($headers['Content-Type'])) {
356            $headers['Content-Type'] = 'text/csv';
357        }
358        if (!isset($headers['Content-Disposition'])) {
359            $headers['Content-Disposition'] = (($forceDownload) ? 'attachment; ' : null) . 'filename=' . $filename;
360        }
361
362        // Send the headers and output the file
363        if (!headers_sent()) {
364            header('HTTP/1.1 200 OK');
365            foreach ($headers as $name => $value) {
366                header($name . ': ' . $value);
367            }
368        }
369    }
370
371    /**
372     * Output CSV data to a file
373     *
374     * @param  string $to
375     * @return void
376     */
377    public function writeToFile(string $to): void
378    {
379        // Attempt to serialize data if it hasn't been done yet
380        if (($this->string === null) && ($this->data !== null)) {
381            $this->serialize();
382        }
383
384        file_put_contents($to, $this->string);
385    }
386
387    /**
388     * Output CSV headers only to a blank file
389     *
390     * @param  string $to
391     * @param  string $delimiter
392     * @param  array  $exclude
393     * @param  array  $include
394     * @return void
395     *@throws Exception
396     */
397    public function writeBlankFile(string $to, string $delimiter = ',', array $exclude = [], array $include = []): void
398    {
399        // Attempt to get field headers and output file
400        if (($this->string === null) && ($this->data !== null) && isset($this->data[0])) {
401            file_put_contents($to, self::getFieldHeaders($this->data[0], $delimiter, $exclude, $include));
402        } else {
403            throw new Exception('Error: The data has not been set.');
404        }
405    }
406
407    /**
408     * Append additional CSV data to a pre-existing file
409     *
410     * @param  string $file
411     * @param  array  $data
412     * @param  array  $options
413     * @param  bool   $validate
414     * @throws Exception
415     * @return void
416     */
417    public static function appendDataToFile(string $file, array $data, array $options = [], bool $validate = true): void
418    {
419        if (!file_exists($file)) {
420            throw new Exception("Error: The file '" . $file . "' does not exist.");
421        }
422
423        foreach ($data as $row) {
424            self::appendRowToFile($file, $row, $options, $validate);
425        }
426    }
427
428    /**
429     * Append additional CSV row of data to a pre-existing file
430     *
431     * @param  string $file
432     * @param  array  $row
433     * @param  array  $options
434     * @param  bool   $validate
435     * @throws Exception
436     * @return void
437     */
438    public static function appendRowToFile(string $file, array $row, array $options = [], bool $validate = true): void
439    {
440        if (!file_exists($file)) {
441            throw new Exception("Error: The file '" . $file . "' does not exist.");
442        }
443
444        if ($validate) {
445            $keys    = array_keys($row);
446            $headers = array_map(
447                function($value) { return str_replace('"', '', $value); }, explode(',', trim(fgets(fopen($file, 'r'))))
448            );
449
450            if ($keys != $headers) {
451                throw new Exception("Error: The new data's columns do not match the CSV files columns.");
452            }
453        }
454
455        $options = self::processOptions($options);
456        $csvRow  = self::serializeRow(
457            (array)$row, [], [], $options['delimiter'], $options['enclosure'],
458            $options['escape'], $options['newline'], $options['limit']
459        );
460
461        file_put_contents($file, $csvRow, FILE_APPEND);
462    }
463
464    /**
465     * Convert the data into CSV format.
466     *
467     * @param  mixed $data
468     * @param  array $options
469     * @return string
470     */
471    public static function serializeData(mixed $data, array $options = []): string
472    {
473        $keys    = array_keys($data);
474        $isAssoc = false;
475
476        foreach ($keys as $key) {
477            if (!is_numeric($key)) {
478                $isAssoc = true;
479            }
480        }
481
482        if ($isAssoc) {
483            $newData = [];
484            foreach ($data as $key => $value) {
485                $newData = array_merge($newData, $value);
486            }
487            $data = $newData;
488        }
489
490        if (isset($options['exclude'])) {
491            $exclude = (!is_array($options['exclude'])) ? [$options['exclude']] : $options['exclude'];
492        } else {
493            $exclude = [];
494        }
495
496        if (isset($options['include'])) {
497            $include = (!is_array($options['include'])) ? [$options['include']] : $options['include'];
498        } else {
499            $include = [];
500        }
501
502        $options  = self::processOptions($options);
503        $csv      = '';
504        $firstKey = array_keys($data)[0];
505
506        if (is_array($data) && isset($data[$firstKey]) &&
507            (is_array($data[$firstKey]) || ($data[$firstKey] instanceof \ArrayObject)) && ($options['fields'])) {
508            $csv .= self::getFieldHeaders((array)$data[$firstKey], $options['delimiter'], $exclude, $include);
509        }
510
511        // Initialize and clean the field values.
512        foreach ($data as $value) {
513            $csv .= self::serializeRow(
514                (array)$value, $exclude, $include, $options['delimiter'], $options['enclosure'],
515                $options['escape'], $options['newline'], $options['limit']
516            );
517        }
518
519        return $csv;
520    }
521
522    /**
523     * Parse the CSV string into a PHP array
524     *
525     * @param  string $string
526     * @param  array  $options
527     * @return array
528     */
529    public static function unserializeString(string $string, array $options = []): array
530    {
531        $options   = self::processOptions($options);
532        $lines     = preg_split("/((\r?\n)|(\r\n?))/", $string);
533        $data      = [];
534        $fieldKeys = [];
535
536        $tempFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pop-csv-tmp-' . time() . '.csv';
537        file_put_contents($tempFile, $string);
538
539        if ($options['fields']) {
540            $fieldNames = str_getcsv($lines[0], $options['delimiter'], $options['enclosure'], $options['escape']);
541            foreach ($fieldNames as $name) {
542                $fieldKeys[] = trim($name);
543            }
544        }
545
546        if (($handle = fopen($tempFile, 'r')) !== false) {
547            while (($dataFields = fgetcsv($handle, 1000, $options['delimiter'], $options['enclosure'], $options['escape'])) !== false) {
548                if (($options['fields']) && (count($dataFields) == count($fieldKeys)) && ($dataFields != $fieldKeys)) {
549                    $d = [];
550                    foreach ($dataFields as $i => $value) {
551                        $d[$fieldKeys[$i]] = $value;
552                    }
553                    $data[] = $d;
554                } else if ($dataFields != $fieldKeys) {
555                    $data[] = $dataFields;
556                }
557            }
558            fclose($handle);
559            unlink($tempFile);
560        }
561
562        return $data;
563    }
564
565    /**
566     * Serialize single row of data
567     *
568     * @param  array  $value
569     * @param  array  $exclude
570     * @param  array  $include
571     * @param  string $delimiter
572     * @param  string $enclosure
573     * @param  string $escape
574     * @param  bool   $newline
575     * @param  int    $limit
576     * @return string
577     */
578    public static function serializeRow(
579        array $value, array $exclude = [], array $include = [], string $delimiter = ',', string $enclosure = '"',
580        string $escape = '"', bool $newline = true, int $limit = 0
581    ): string
582    {
583        $rowAry = [];
584        foreach ($value as $key => $val) {
585            if (!in_array($key, $exclude) && (empty($include) || in_array($key, $include))) {
586                if (!$newline) {
587                    $val = str_replace(["\n", "\r"], [" ", " "], $val);
588                }
589                if ((int)$limit > 0) {
590                    $val = substr($val, 0, (int)$limit);
591                }
592                if (str_contains($val, $enclosure)) {
593                    $val = str_replace($enclosure, $escape . $enclosure, $val);
594                }
595                if ((str_contains($val, $delimiter)) || (str_contains($val, "\n")) ||
596                    (str_contains($val, $escape . $enclosure))) {
597                    $val = $enclosure . $val . $enclosure;
598                }
599                $rowAry[] = $val;
600            }
601        }
602        return implode($delimiter, $rowAry) . "\n";
603    }
604
605    /**
606     * Get field headers
607     *
608     * @param  mixed  $data
609     * @param  string $delimiter
610     * @param  array  $exclude
611     * @param  array  $include
612     * @return string
613     */
614    public static function getFieldHeaders(mixed $data, string $delimiter = ',', array $exclude = [], array $include = []): string
615    {
616        $headers    = array_keys($data);
617        $headersAry = [];
618        foreach ($headers as $header) {
619            if (!in_array($header, $exclude) && (empty($include) || in_array($header, $include))) {
620                $headersAry[] = $header;
621            }
622        }
623        return implode($delimiter, $headersAry) . PHP_EOL;
624    }
625
626    /**
627     * Determine if the string is valid CSV
628     *
629     * @param  string $string
630     * @return bool
631     */
632    public static function isValid(string $string): bool
633    {
634        $lines  = preg_split("/((\r?\n)|(\r\n?))/", $string);
635        $fields = [];
636        if (isset($lines[0])) {
637            $fields = str_getcsv($lines[0]);
638        }
639        return (count($fields) > 0);
640    }
641
642    /**
643     * Render CSV string data to string
644     *
645     * @return string
646     */
647    public function __toString(): string
648    {
649        // Attempt to serialize data if it hasn't been done yet
650        if (($this->string === null) && ($this->data !== null)) {
651            $this->serialize();
652        }
653
654        return $this->string;
655    }
656
657}