Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.39% covered (success)
99.39%
162 / 163
96.77% covered (success)
96.77%
30 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
Csv
99.39% covered (success)
99.39%
162 / 163
96.77% covered (success)
96.77%
30 / 31
96
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%
25 / 25
100.00% covered (success)
100.00%
1 / 1
13
 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
9
 getFieldHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 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  $omit
141     * @throws Exception
142     * @return void
143     */
144    public static function writeTemplateToFile(array $data, string $to, string $delimiter = ',', array $omit = []): void
145    {
146        $csv = new self($data);
147        $csv->writeBlankFile($to, $delimiter, $omit);
148    }
149
150    /**
151     * Write data to file
152     *
153     * @param  array  $data
154     * @param  array  $options
155     * @param  string $filename
156     * @param  bool   $forceDownload
157     * @param  array  $headers
158     * @return void
159     */
160    public static function outputDataToHttp(
161        array $data, array $options = [], string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []
162    ): void
163    {
164        $csv = new self($data);
165        $csv->serialize($options);
166        $csv->outputToHttp($filename, $forceDownload, $headers);
167    }
168
169    /**
170     * Write data to file
171     *
172     * @param  array  $data
173     * @param  string $filename
174     * @param  bool   $forceDownload
175     * @param  array  $headers
176     * @param  string $delimiter
177     * @param  array  $omit
178     * @throws Exception
179     * @return void
180     */
181    public static function outputTemplateToHttp(
182        array $data, string $filename = 'pop-data-template.csv', bool $forceDownload = true,
183        array $headers = [], string $delimiter = ',', array $omit = []
184    )
185    {
186        $csv = new self($data);
187        $csv->outputBlankFileToHttp($filename, $forceDownload, $headers, $delimiter, $omit);
188    }
189
190    /**
191     * Process CSV options
192     *
193     * @param  array $options
194     * @return array
195     */
196    public static function processOptions(array $options): array
197    {
198        $options['delimiter'] = (isset($options['delimiter'])) ? $options['delimiter']     : ',';
199        $options['enclosure'] = (isset($options['enclosure'])) ? $options['enclosure']     : '"';
200        $options['escape']    = (isset($options['escape']))    ? $options['escape']        : '"';
201        $options['fields']    = (isset($options['fields']))    ? (bool)$options['fields']  : true;
202        $options['newline']   = (isset($options['newline']))   ? (bool)$options['newline'] : true;
203        $options['limit']     = (isset($options['limit']))     ? (int)$options['limit']    : 0;
204
205        return $options;
206    }
207
208    /**
209     * Serialize the data to a CSV string
210     *
211     * @param  array $options
212     * @return string
213     */
214    public function serialize(array $options = []): string
215    {
216        $this->string = self::serializeData($this->data, $options);
217        return $this->string;
218    }
219
220    /**
221     * Unserialize the string to data
222     *
223     * @param  array  $options
224     * @return mixed
225     */
226    public function unserialize(array $options = []): mixed
227    {
228        $this->data = self::unserializeString($this->string, $options);
229        return $this->data;
230    }
231
232    /**
233     * Set data
234     *
235     * @param  array $data
236     * @return Csv
237     */
238    public function setData(array $data): Csv
239    {
240        $this->data = $data;
241        return $this;
242    }
243
244    /**
245     * Get data
246     *
247     * @return array
248     */
249    public function getData(): array
250    {
251        return $this->data;
252    }
253
254    /**
255     * Set string
256     *
257     * @param  string $string
258     * @return Csv
259     */
260    public function setString(string $string): Csv
261    {
262        $this->string = $string;
263        return $this;
264    }
265
266    /**
267     * Get string
268     *
269     * @return string
270     */
271    public function getString(): string
272    {
273        return $this->string;
274    }
275
276    /**
277     * Check if data was serialized
278     *
279     * @return bool
280     */
281    public function isSerialized(): bool
282    {
283        return ($this->string !== null);
284    }
285
286    /**
287     * Check if string was unserialized
288     *
289     * @return bool
290     */
291    public function isUnserialized(): bool
292    {
293        return ($this->data !== null);
294    }
295
296    /**
297     * Output CSV string data to HTTP
298     *
299     * @param  string $filename
300     * @param  bool   $forceDownload
301     * @param  array  $headers
302     * @return void
303     */
304    public function outputToHttp(string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []): void
305    {
306        // Attempt to serialize data if it hasn't been done yet
307        if (($this->string === null) && ($this->data !== null)) {
308            $this->serialize();
309        }
310
311        $this->prepareHttp($filename, $forceDownload, $headers);
312
313        echo $this->string;
314    }
315
316    /**
317     * Output CSV headers only in a blank file to HTTP
318     *
319     * @param  string $filename
320     * @param  bool   $forceDownload
321     * @param  array  $headers
322     * @param  string $delimiter
323     * @param  array  $omit
324     * @throws Exception
325     * @return void
326     */
327    public function outputBlankFileToHttp(
328        string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = [], string $delimiter = ',', array $omit = []
329    ): void
330    {
331        // Attempt to serialize data if it hasn't been done yet
332        if (($this->string === null) && ($this->data !== null) && isset($this->data[0])) {
333            $fieldHeaders = self::getFieldHeaders($this->data[0], $delimiter, $omit);
334        } else {
335            throw new Exception('Error: The data has not been set.');
336        }
337
338        $this->prepareHttp($filename, $forceDownload, $headers);
339        echo $fieldHeaders;
340    }
341
342    /**
343     * Prepare output to HTTP
344     *
345     * @param  string $filename
346     * @param  bool   $forceDownload
347     * @param  array  $headers
348     * @return void
349     */
350    public function prepareHttp(string $filename = 'pop-data.csv', bool $forceDownload = true, array $headers = []): void
351    {
352        if (!isset($headers['Content-Type'])) {
353            $headers['Content-Type'] = 'text/csv';
354        }
355        if (!isset($headers['Content-Disposition'])) {
356            $headers['Content-Disposition'] = (($forceDownload) ? 'attachment; ' : null) . 'filename=' . $filename;
357        }
358
359        // Send the headers and output the file
360        if (!headers_sent()) {
361            header('HTTP/1.1 200 OK');
362            foreach ($headers as $name => $value) {
363                header($name . ': ' . $value);
364            }
365        }
366    }
367
368    /**
369     * Output CSV data to a file
370     *
371     * @param  string $to
372     * @return void
373     */
374    public function writeToFile(string $to): void
375    {
376        // Attempt to serialize data if it hasn't been done yet
377        if (($this->string === null) && ($this->data !== null)) {
378            $this->serialize();
379        }
380
381        file_put_contents($to, $this->string);
382    }
383
384    /**
385     * Output CSV headers only to a blank file
386     *
387     * @param  string $to
388     * @param  string $delimiter
389     * @param  array  $omit
390     * @throws Exception
391     * @return void
392     */
393    public function writeBlankFile(string $to, string $delimiter = ',', array $omit = []): void
394    {
395        // Attempt to get field headers and output file
396        if (($this->string === null) && ($this->data !== null) && isset($this->data[0])) {
397            file_put_contents($to, self::getFieldHeaders($this->data[0], $delimiter, $omit));
398        } else {
399            throw new Exception('Error: The data has not been set.');
400        }
401    }
402
403    /**
404     * Append additional CSV data to a pre-existing file
405     *
406     * @param  string $file
407     * @param  array  $data
408     * @param  array  $options
409     * @param  bool   $validate
410     * @throws Exception
411     * @return void
412     */
413    public static function appendDataToFile(string $file, array $data, array $options = [], bool $validate = true): void
414    {
415        if (!file_exists($file)) {
416            throw new Exception("Error: The file '" . $file . "' does not exist.");
417        }
418
419        foreach ($data as $row) {
420            self::appendRowToFile($file, $row, $options, $validate);
421        }
422    }
423
424    /**
425     * Append additional CSV row of data to a pre-existing file
426     *
427     * @param  string $file
428     * @param  array  $row
429     * @param  array  $options
430     * @param  bool   $validate
431     * @throws Exception
432     * @return void
433     */
434    public static function appendRowToFile(string $file, array $row, array $options = [], bool $validate = true): void
435    {
436        if (!file_exists($file)) {
437            throw new Exception("Error: The file '" . $file . "' does not exist.");
438        }
439
440        if ($validate) {
441            $keys    = array_keys($row);
442            $headers = array_map(
443                function($value) { return str_replace('"', '', $value); }, explode(',', trim(fgets(fopen($file, 'r'))))
444            );
445
446            if ($keys != $headers) {
447                throw new Exception("Error: The new data's columns do not match the CSV files columns.");
448            }
449        }
450
451        $options = self::processOptions($options);
452        $csvRow  = self::serializeRow(
453            (array)$row, [], $options['delimiter'], $options['enclosure'],
454            $options['escape'], $options['newline'], $options['limit']
455        );
456
457        file_put_contents($file, $csvRow, FILE_APPEND);
458    }
459
460    /**
461     * Convert the data into CSV format.
462     *
463     * @param  mixed $data
464     * @param  array $options
465     * @return string
466     */
467    public static function serializeData(mixed $data, array $options = []): string
468    {
469        $keys    = array_keys($data);
470        $isAssoc = false;
471
472        foreach ($keys as $key) {
473            if (!is_numeric($key)) {
474                $isAssoc = true;
475            }
476        }
477
478        if ($isAssoc) {
479            $newData = [];
480            foreach ($data as $key => $value) {
481                $newData = array_merge($newData, $value);
482            }
483            $data = $newData;
484        }
485
486        if (isset($options['omit'])) {
487            $omit = (!is_array($options['omit'])) ? [$options['omit']] : $options['omit'];
488        } else {
489            $omit = [];
490        }
491
492        $options  = self::processOptions($options);
493        $csv      = '';
494        $firstKey = array_keys($data)[0];
495
496        if (is_array($data) && isset($data[$firstKey]) &&
497            (is_array($data[$firstKey]) || ($data[$firstKey] instanceof \ArrayObject)) && ($options['fields'])) {
498            $csv .= self::getFieldHeaders((array)$data[$firstKey], $options['delimiter'], $omit);
499        }
500
501        // Initialize and clean the field values.
502        foreach ($data as $value) {
503            $csv .= self::serializeRow(
504                (array)$value, $omit, $options['delimiter'], $options['enclosure'],
505                $options['escape'], $options['newline'], $options['limit']
506            );
507        }
508
509        return $csv;
510    }
511
512    /**
513     * Parse the CSV string into a PHP array
514     *
515     * @param  string $string
516     * @param  array  $options
517     * @return array
518     */
519    public static function unserializeString(string $string, array $options = []): array
520    {
521        $options   = self::processOptions($options);
522        $lines     = preg_split("/((\r?\n)|(\r\n?))/", $string);
523        $data      = [];
524        $fieldKeys = [];
525
526        $tempFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pop-csv-tmp-' . time() . '.csv';
527        file_put_contents($tempFile, $string);
528
529        if ($options['fields']) {
530            $fieldNames = str_getcsv($lines[0], $options['delimiter'], $options['enclosure'], $options['escape']);
531            foreach ($fieldNames as $name) {
532                $fieldKeys[] = trim($name);
533            }
534        }
535
536        if (($handle = fopen($tempFile, 'r')) !== false) {
537            while (($dataFields = fgetcsv($handle, 1000, $options['delimiter'], $options['enclosure'], $options['escape'])) !== false) {
538                if (($options['fields']) && (count($dataFields) == count($fieldKeys)) && ($dataFields != $fieldKeys)) {
539                    $d = [];
540                    foreach ($dataFields as $i => $value) {
541                        $d[$fieldKeys[$i]] = $value;
542                    }
543                    $data[] = $d;
544                } else if ($dataFields != $fieldKeys) {
545                    $data[] = $dataFields;
546                }
547            }
548            fclose($handle);
549            unlink($tempFile);
550        }
551
552        return $data;
553    }
554
555    /**
556     * Serialize single row of data
557     *
558     * @param  array  $value
559     * @param  array  $omit
560     * @param  string $delimiter
561     * @param  string $enclosure
562     * @param  string $escape
563     * @param  bool   $newline
564     * @param  int    $limit
565     * @return string
566     */
567    public static function serializeRow(
568        array $value, array $omit = [], string $delimiter = ',', string $enclosure = '"',
569        string $escape = '"', bool $newline = true, int $limit = 0
570    ): string
571    {
572        $rowAry = [];
573        foreach ($value as $key => $val) {
574            if (!in_array($key, $omit)) {
575                if (!$newline) {
576                    $val = str_replace(["\n", "\r"], [" ", " "], $val);
577                }
578                if ((int)$limit > 0) {
579                    $val = substr($val, 0, (int)$limit);
580                }
581                if (str_contains($val, $enclosure)) {
582                    $val = str_replace($enclosure, $escape . $enclosure, $val);
583                }
584                if ((str_contains($val, $delimiter)) || (str_contains($val, "\n")) ||
585                    (str_contains($val, $escape . $enclosure))) {
586                    $val = $enclosure . $val . $enclosure;
587                }
588                $rowAry[] = $val;
589            }
590        }
591        return implode($delimiter, $rowAry) . "\n";
592    }
593
594    /**
595     * Get field headers
596     *
597     * @param  mixed  $data
598     * @param  string $delimiter
599     * @param  array  $omit
600     * @return string
601     */
602    public static function getFieldHeaders(mixed $data, string $delimiter = ',', array $omit = []): string
603    {
604        $headers    = array_keys($data);
605        $headersAry = [];
606        foreach ($headers as $header) {
607            if (!in_array($header, $omit)) {
608                $headersAry[] = $header;
609            }
610        }
611        return implode($delimiter, $headersAry) . PHP_EOL;
612    }
613
614    /**
615     * Determine if the string is valid CSV
616     *
617     * @param  string $string
618     * @return bool
619     */
620    public static function isValid(string $string): bool
621    {
622        $lines  = preg_split("/((\r?\n)|(\r\n?))/", $string);
623        $fields = [];
624        if (isset($lines[0])) {
625            $fields = str_getcsv($lines[0]);
626        }
627        return (count($fields) > 0);
628    }
629
630    /**
631     * Render CSV string data to string
632     *
633     * @return string
634     */
635    public function __toString(): string
636    {
637        // Attempt to serialize data if it hasn't been done yet
638        if (($this->string === null) && ($this->data !== null)) {
639            $this->serialize();
640        }
641
642        return $this->string;
643    }
644
645}