Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.29% covered (warning)
84.29%
59 / 70
12.50% covered (danger)
12.50%
1 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
CsvTabularStreamReader
84.06% covered (warning)
84.06%
58 / 69
12.50% covered (danger)
12.50%
1 / 8
33.65
0.00% covered (danger)
0.00%
0 / 1
 supports
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getHeaders
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 getSampleRows
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 iterateRows
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
5.12
 detectDelimiter
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
4.01
 openFile
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 isEmptyRow
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 normalizeEncoding
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\DataExchange\Infrastructure\Reader;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\DataExchange\Domain\Exception\DataExchangeException;
12use Generator;
13
14/**
15 * High-performance streaming reader for CSV and delimited TXT files with auto-detection.
16 *
17 * @package App\Core\DataExchange\Infrastructure\Reader
18 */
19final class CsvTabularStreamReader implements TabularStreamReaderInterface
20{
21    private const array CANDIDATE_DELIMITERS = [',', ';', "\t", '|'];
22
23    /** {@inheritdoc} */
24    public function supports(string $filePath): bool
25    {
26        $ext = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
27        return in_array($ext, ['csv', 'txt'], true);
28    }
29
30    /** {@inheritdoc} */
31    public function getHeaders(string $filePath): array
32    {
33        $handle = $this->openFile($filePath);
34        try {
35            $delimiter = $this->detectDelimiter($filePath);
36            $rawRow = fgetcsv($handle, 0, $delimiter, '"', '\\');
37            if ($rawRow === false || empty($rawRow)) {
38                return [];
39            }
40
41            // Strip UTF-8 BOM on first column header
42            $rawRow[0] = preg_replace('/^\xEF\xBB\xBF/', '', (string) $rawRow[0]);
43
44            $headers = [];
45            foreach ($rawRow as $idx => $header) {
46                $trimmed = trim((string) $header);
47                $headers[] = $trimmed !== '' ? $trimmed : 'Column_' . ($idx + 1);
48            }
49
50            return $headers;
51        } finally {
52            fclose($handle);
53        }
54    }
55
56    /** {@inheritdoc} */
57    public function getSampleRows(string $filePath, int $limit = 3): array
58    {
59        $sample = [];
60        $headers = $this->getHeaders($filePath);
61        if (empty($headers)) {
62            return [];
63        }
64
65        $count = 0;
66        foreach ($this->iterateRows($filePath) as $row) {
67            $sample[] = $row;
68            $count++;
69            if ($count >= $limit) {
70                break;
71            }
72        }
73
74        return $sample;
75    }
76
77    /** {@inheritdoc} */
78    public function iterateRows(string $filePath): Generator
79    {
80        $headers = $this->getHeaders($filePath);
81        if (empty($headers)) {
82            return;
83        }
84
85        $handle = $this->openFile($filePath);
86        $delimiter = $this->detectDelimiter($filePath);
87
88        try {
89            // Skip header line
90            fgetcsv($handle, 0, $delimiter, '"', '\\');
91
92            $rowNum = 2; // 1-indexed data row (row 1 was header)
93            while (($raw = fgetcsv($handle, 0, $delimiter, '"', '\\')) !== false) {
94                if ($this->isEmptyRow($raw)) {
95                    $rowNum++;
96                    continue;
97                }
98
99                $mapped = [];
100                foreach ($headers as $idx => $headerKey) {
101                    $val = $raw[$idx] ?? '';
102                    $mapped[$headerKey] = $this->normalizeEncoding((string) $val);
103                }
104
105                yield $rowNum => $mapped;
106                $rowNum++;
107            }
108        } finally {
109            fclose($handle);
110        }
111    }
112
113    /**
114     * Auto-detects column delimiter by inspecting the first line.
115     */
116    private function detectDelimiter(string $filePath): string
117    {
118        $handle = $this->openFile($filePath);
119        try {
120            $firstLine = (string) fgets($handle);
121            if ($firstLine === '') {
122                return ',';
123            }
124
125            $bestDelimiter = ',';
126            $maxColumns = 0;
127
128            foreach (self::CANDIDATE_DELIMITERS as $del) {
129                $cols = count(str_getcsv($firstLine, $del, '"', '\\'));
130                if ($cols > $maxColumns) {
131                    $maxColumns = $cols;
132                    $bestDelimiter = $del;
133                }
134            }
135
136            return $bestDelimiter;
137        } finally {
138            fclose($handle);
139        }
140    }
141
142    /**
143     * Opens file handle with error checking.
144     *
145     * @return resource File handle.
146     */
147    private function openFile(string $filePath)
148    {
149        if (!file_exists($filePath) || !is_readable($filePath)) {
150            throw new DataExchangeException("Cannot read source file: {$filePath}");
151        }
152
153        $handle = fopen($filePath, 'r');
154        if ($handle === false) {
155            throw new DataExchangeException("Failed to open file stream: {$filePath}");
156        }
157
158        return $handle;
159    }
160
161    /**
162     * Checks if entire CSV row contains only blank/null cells.
163     *
164     * @param list<string|null> $row Raw row array.
165     */
166    private function isEmptyRow(array $row): bool
167    {
168        foreach ($row as $cell) {
169            if ($cell !== null && trim((string) $cell) !== '') {
170                return false;
171            }
172        }
173        return true;
174    }
175
176    /**
177     * Converts Windows-1250 or ISO-8859-2 to clean UTF-8.
178     */
179    private function normalizeEncoding(string $value): string
180    {
181        if (mb_check_encoding($value, 'UTF-8')) {
182            return trim($value);
183        }
184
185        $converted = mb_convert_encoding($value, 'UTF-8', ['Windows-1250', 'ISO-8859-2', 'ISO-8859-1']);
186        return is_string($converted) ? trim($converted) : trim($value);
187    }
188}