Completed
Push — master ( 7506b8...20b3b0 )
by ignace nyamagana
03:13 queued 02:08
created

Reader   A

Complexity

Total Complexity 42

Size/Duplication

Total Lines 328
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 5

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
dl 0
loc 328
ccs 105
cts 105
cp 1
rs 9.0399
c 0
b 0
f 0
wmc 42
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 getDocument() 0 12 3
A removeBOM() 0 15 3
A __call() 0 9 2
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 19 5
A seekRow() 0 10 3

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