Completed
Pull Request — master (#342)
by ignace nyamagana
14:03
created

Reader   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 334
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 5

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
dl 0
loc 334
ccs 106
cts 106
cp 1
rs 8.96
c 0
b 0
f 0
wmc 43
lcom 1
cbo 5

17 Methods

Rating   Name   Duplication   Size   Complexity  
A createFromPath() 0 4 1
A resetProperties() 0 6 1
A getHeaderOffset() 0 4 1
A getHeader() 0 14 3
A setHeader() 0 13 4
A seekRow() 0 10 3
A getDocument() 0 14 3
A removeBOM() 0 15 3
A __call() 0 18 4
A count() 0 8 2
A getIterator() 0 4 1
A jsonSerialize() 0 4 1
A getRecords() 0 18 3
A computeHeader() 0 12 3
A combineHeader() 0 17 3
A stripBOM() 0 17 3
A setHeaderOffset() 0 15 4

How to fix   Complexity   

Complex Class

Complex classes like Reader often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Reader, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * League.Csv (https://csv.thephpleague.com)
5
 *
6
 * (c) Ignace Nyamagana Butera <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
declare(strict_types=1);
13
14
namespace League\Csv;
15
16
use BadMethodCallException;
17
use CallbackFilterIterator;
18
use Countable;
19
use Iterator;
20
use IteratorAggregate;
21
use JsonSerializable;
22
use League\Csv\Polyfill\EmptyEscapeParser;
23
use SplFileObject;
24
use function array_combine;
25
use function array_filter;
26
use function array_pad;
27
use function array_slice;
28
use function array_unique;
29
use function is_array;
30
use function iterator_count;
31
use function iterator_to_array;
32
use function mb_strlen;
33
use function mb_substr;
34
use function sprintf;
35
use function strlen;
36
use function substr;
37
use const PHP_VERSION_ID;
38
use const STREAM_FILTER_READ;
39
40
/**
41
 * A class to parse and read records from a CSV document.
42
 *
43
 * @method array fetchOne(int $nth_record = 0) Returns a single record from the CSV
44
 * @method Generator fetchColumn(string|int $column_index) Returns the next value from a single CSV record field
45
 * @method Generator fetchPairs(string|int $offset_index = 0, string|int $value_index = 1) Fetches the next key-value pairs from the CSV document
46
 */
47
class Reader extends AbstractCsv implements Countable, IteratorAggregate, JsonSerializable
48
{
49
    /**
50
     * header offset.
51
     *
52
     * @var int|null
53
     */
54
    protected $header_offset;
55
56
    /**
57
     * header record.
58
     *
59
     * @var string[]
60
     */
61
    protected $header = [];
62
63
    /**
64
     * records count.
65
     *
66
     * @var int
67
     */
68
    protected $nb_records = -1;
69
70
    /**
71
     * {@inheritdoc}
72
     */
73
    protected $stream_filter_mode = STREAM_FILTER_READ;
74
75
    /**
76
     * {@inheritdoc}
77
     */
78
    public static function createFromPath(string $path, string $open_mode = 'r', $context = null)
79 3
    {
80
        return parent::createFromPath($path, $open_mode, $context);
81 3
    }
82
83
    /**
84
     * {@inheritdoc}
85
     */
86
    protected function resetProperties(): void
87 24
    {
88
        parent::resetProperties();
89 24
        $this->nb_records = -1;
90 24
        $this->header = [];
91 24
    }
92 24
93
    /**
94
     * Returns the header offset.
95
     *
96
     * If no CSV header offset is set this method MUST return null
97
     *
98
     */
99
    public function getHeaderOffset(): ?int
100
    {
101 15
        return $this->header_offset;
102
    }
103 15
104
    /**
105
     * Returns the CSV record used as header.
106
     *
107
     * The returned header is represented as an array of string values
108
     *
109
     * @return string[]
110
     */
111
    public function getHeader(): array
112
    {
113 18
        if (null === $this->header_offset) {
114
            return $this->header;
115 18
        }
116 12
117
        if ([] !== $this->header) {
118
            return $this->header;
119 9
        }
120 3
121
        $this->header = $this->setHeader($this->header_offset);
122
123 9
        return $this->header;
124
    }
125 6
126
    /**
127
     * Determine the CSV record header.
128
     *
129
     * @throws Exception If the header offset is set and no record is found or is the empty array
130
     *
131
     * @return string[]
132
     */
133
    protected function setHeader(int $offset): array
134
    {
135 12
        $header = $this->seekRow($offset);
136
        if (false === $header || [] === $header) {
137 12
            throw new Exception(sprintf('The header record does not exist or is empty at offset: `%s`', $offset));
138 12
        }
139 6
140
        if (0 === $offset) {
141
            return $this->removeBOM($header, mb_strlen($this->getInputBOM()), $this->enclosure);
142 6
        }
143 3
144
        return $header;
145
    }
146 3
147
    /**
148
     * Returns the row at a given offset.
149
     *
150
     * @return string[]|false
151
     */
152
    protected function seekRow(int $offset)
153
    {
154 9
        foreach ($this->getDocument() as $index => $record) {
155
            if ($offset === $index) {
156 9
                return $record;
157 9
            }
158 3
        }
159
160
        return false;
161
    }
162 9
163
    /**
164
     * Returns the document as an Iterator.
165
     */
166
    protected function getDocument(): Iterator
167
    {
168 15
        if (70400 > PHP_VERSION_ID && '' === $this->escape) {
169
            $this->document->setCsvControl($this->delimiter, $this->enclosure);
170 15
171 6
            return EmptyEscapeParser::parse($this->document);
172
        }
173 6
174
        $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
175
        $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
176 9
        $this->document->rewind();
177 9
178 9
        return $this->document;
179
    }
180 9
181
    /**
182
     * Strip the BOM sequence from a record.
183
     *
184
     * @param string[] $record
185
     *
186
     * @return string[]
187
     */
188
    protected function removeBOM(array $record, int $bom_length, string $enclosure): array
189
    {
190 12
        if (0 === $bom_length) {
191
            return $record;
192 12
        }
193 3
194
        $record[0] = mb_substr($record[0], $bom_length);
195
        if ($enclosure.$enclosure != substr($record[0].$record[0], strlen($record[0]) - 1, 2)) {
196 9
            return $record;
197 9
        }
198 6
199
        $record[0] = substr($record[0], 1, -1);
200
201 3
        return $record;
202
    }
203 3
204
    /**
205
     * {@inheritdoc}
206
     */
207
    public function __call(string $method, array $arguments): iterable
208
    {
209 9
        $resultset = new ResultSet($this->getRecords(), $this->getHeader());
210
        $normalized = strtolower($method);
211 9
        if ('fetchcolumn' === $normalized) {
212 9
            return $resultset->fetchColumn(...$arguments);
213 3
        }
214
215
        if ('fetchpairs' === $normalized) {
216 6
            return $resultset->fetchPairs(...$arguments);
217
        }
218
219
        if ('fetchone' === $normalized) {
220
            return $resultset->fetchOne(...$arguments);
221
        }
222 3
223
        throw new BadMethodCallException(sprintf('%s::%s() method does not exist', static::class, $method));
224 3
    }
225 3
226
    /**
227
     * {@inheritdoc}
228 3
     */
229
    public function count(): int
230
    {
231
        if (-1 === $this->nb_records) {
232
            $this->nb_records = iterator_count($this->getRecords());
233
        }
234 3
235
        return $this->nb_records;
236 3
    }
237
238
    /**
239
     * {@inheritdoc}
240
     */
241
    public function getIterator(): Iterator
242 3
    {
243
        return $this->getRecords();
244 3
    }
245
246
    /**
247
     * {@inheritdoc}
248
     */
249
    public function jsonSerialize(): array
250
    {
251
        return iterator_to_array($this->getRecords(), false);
252
    }
253
254
    /**
255
     * Returns the CSV records as an iterator object.
256
     *
257
     * Each CSV record is represented as a simple array containing strings or null values.
258
     *
259
     * If the CSV document has a header record then each record is combined
260
     * to the header record and the header record is removed from the iterator.
261 18
     *
262
     * If the CSV document is inconsistent. Missing record fields are
263 18
     * filled with null values while extra record fields are strip from
264 15
     * the returned object.
265 15
     *
266 15
     * @param string[] $header an optional header to use instead of the CSV document header
267 15
     */
268 15
    public function getRecords(array $header = []): Iterator
269
    {
270 15
        $header = $this->computeHeader($header);
271 15
        $normalized = static function ($record): bool {
272 6
            return is_array($record) && $record != [null];
273 6
        };
274 6
        $bom = $this->getInputBOM();
275
        $document = $this->getDocument();
276
277 15
        $records = $this->stripBOM(new CallbackFilterIterator($document, $normalized), $bom);
278
        if (null !== $this->header_offset) {
279
            $records = new CallbackFilterIterator($records, function (array $record, int $offset): bool {
280
                return $offset !== $this->header_offset;
281
            });
282
        }
283
284
        return $this->combineHeader($records, $header);
285
    }
286
287
    /**
288
     * Returns the header to be used for iteration.
289 24
     *
290
     * @param string[] $header
291 24
     *
292 21
     * @throws Exception If the header contains non unique column name
293
     *
294
     * @return string[]
295 24
     */
296 21
    protected function computeHeader(array $header): array
297
    {
298
        if ([] === $header) {
299 3
            $header = $this->getHeader();
300
        }
301
302
        if ($header === array_unique(array_filter($header, 'is_string'))) {
303
            return $header;
304
        }
305
306
        throw new Exception('The header record must be empty or a flat array with unique string values');
307 30
    }
308
309 30
    /**
310 21
     * Combine the CSV header to each record if present.
311
     *
312
     * @param string[] $header
313 12
     */
314 12
    protected function combineHeader(Iterator $iterator, array $header): Iterator
315 12
    {
316 6
        if ([] === $header) {
317
            return $iterator;
318
        }
319 12
320 12
        $count = count($header);
321
        $mapper = function (array $record) use ($header, $count): array {
322 12
            if (count($record) != $count) {
323
                $record = array_slice(array_pad($record, $count, null), 0, $count);
324
            }
325
326
            return (array) array_combine($header, $record);
327
        };
328 24
329
        return new MapIterator($iterator, $mapper);
330 24
    }
331 15
332
    /**
333
     * Strip the BOM sequence from the returned records if necessary.
334 9
     */
335 9
    protected function stripBOM(Iterator $iterator, string $bom): Iterator
336 9
    {
337 3
        if ('' === $bom) {
338
            return $iterator;
339
        }
340 9
341 9
        $bom_length = mb_strlen($bom);
342
        $mapper = function (array $record, int $index) use ($bom_length): array {
343 9
            if (0 !== $index) {
344
                return $record;
345
            }
346
347
            return $this->removeBOM($record, $bom_length, $this->enclosure);
348
        };
349
350
        return new MapIterator($iterator, $mapper);
351
    }
352
353
    /**
354
     * Selects the record to be used as the CSV header.
355
     *
356
     * Because the header is represented as an array, to be valid
357
     * a header MUST contain only unique string value.
358 24
     *
359
     * @param int|null $offset the header record offset
360 24
     *
361 12
     * @throws Exception if the offset is a negative integer
362
     *
363
     * @return static
364 12
     */
365 3
    public function setHeaderOffset(?int $offset): self
366
    {
367
        if ($offset === $this->header_offset) {
368 9
            return $this;
369 3
        }
370
371
        if (null !== $offset && 0 > $offset) {
372 6
            throw new Exception(__METHOD__.'() expects 1 Argument to be greater or equal to 0');
373 6
        }
374
375 6
        $this->header_offset = $offset;
376
        $this->resetProperties();
377
378
        return $this;
379
    }
380
}
381