Reader   B
last analyzed

Complexity

Total Complexity 40

Size/Duplication

Total Lines 335
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
dl 0
loc 335
ccs 102
cts 102
cp 1
rs 8.2608
c 0
b 0
f 0
wmc 40

16 Methods

Rating   Name   Duplication   Size   Complexity  
A stripBOM() 0 16 3
A removeBOM() 0 14 3
A resetProperties() 0 4 1
A jsonSerialize() 0 3 1
B setHeaderOffset() 0 18 5
A combineHeader() 0 16 3
A getHeaderOffset() 0 3 1
A setHeader() 0 12 3
B seekRow() 0 18 5
A computeHeader() 0 11 3
A getRecords() 0 18 3
A createFromPath() 0 3 1
A count() 0 7 2
A getHeader() 0 13 3
A getIterator() 0 3 1
A __call() 0 8 2

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.

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
* This file is part of the League.csv library
4
*
5
* @license http://opensource.org/licenses/MIT
6
* @link https://github.com/thephpleague/csv/
7
* @version 9.1.3
8
* @package League.csv
9
*
10
* For the full copyright and license information, please view the LICENSE
11
* file that was distributed with this source code.
12
*/
13
declare(strict_types=1);
14
15
namespace League\Csv;
16
17
use BadMethodCallException;
18
use CallbackFilterIterator;
19
use Countable;
20
use Iterator;
21
use IteratorAggregate;
22
use JsonSerializable;
23
use SplFileObject;
24
use TypeError;
25
26
/**
27
 * A class to select records from a CSV document
28
 *
29
 * @package League.csv
30
 * @since  3.0.0
31
 *
32
 * @method array fetchOne(int $nth_record = 0) Returns a single record from the CSV
33
 * @method Generator fetchColumn(string|int $column_index) Returns the next value from a single CSV record field
34
 * @method Generator fetchPairs(string|int $offset_index = 0, string|int $value_index = 1) Fetches the next key-value pairs from the CSV document
35
 */
36
class Reader extends AbstractCsv implements Countable, IteratorAggregate, JsonSerializable
37
{
38
    /**
39
     * header offset
40
     *
41
     * @var int|null
42
     */
43
    protected $header_offset;
44
45
    /**
46
     * header record
47
     *
48
     * @var string[]
49
     */
50
    protected $header = [];
51
52
    /**
53
     * records count
54
     *
55
     * @var int
56
     */
57
    protected $nb_records = -1;
58
59
    /**
60
     * {@inheritdoc}
61
     */
62
    protected $stream_filter_mode = STREAM_FILTER_READ;
63
64
    /**
65
     * {@inheritdoc}
66
     */
67 2
    public static function createFromPath(string $path, string $open_mode = 'r', $context = null)
68
    {
69 2
        return new static(Stream::createFromPath($path, $open_mode, $context));
70
    }
71
72
    /**
73
     * Returns the header offset
74
     *
75
     * If no CSV header offset is set this method MUST return null
76
     *
77
     * @return int|null
78
     */
79 10
    public function getHeaderOffset()
80
    {
81 10
        return $this->header_offset;
82
    }
83
84
    /**
85
     * Returns the CSV record used as header
86
     *
87
     * The returned header is represented as an array of string values
88
     *
89
     * @return string[]
90
     */
91 10
    public function getHeader(): array
92
    {
93 10
        if (null === $this->header_offset) {
94 8
            return $this->header;
95
        }
96
97 4
        if (!empty($this->header)) {
98 2
            return $this->header;
99
        }
100
101 4
        $this->header = $this->setHeader($this->header_offset);
102
103 4
        return $this->header;
104
    }
105
106
    /**
107
     * Determine the CSV record header
108
     *
109
     * @param int $offset
110
     *
111
     * @throws Exception If the header offset is set and no record is found or is the empty array
112
     *
113
     * @return string[]
114
     */
115 8
    protected function setHeader(int $offset): array
116
    {
117 8
        $header = $this->seekRow($offset);
118 8
        if (empty($header)) {
119 4
            throw new Exception(sprintf('The header record does not exist or is empty at offset: `%s`', $offset));
120
        }
121
122 4
        if (0 === $offset) {
123 2
            return $this->removeBOM($header, mb_strlen($this->getInputBOM()), $this->enclosure);
0 ignored issues
show
Bug introduced by Ignace Nyamagana Butera
It seems like $header can also be of type string; however, parameter $record of League\Csv\Reader::removeBOM() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

123
            return $this->removeBOM(/** @scrutinizer ignore-type */ $header, mb_strlen($this->getInputBOM()), $this->enclosure);
Loading history...
124
        }
125
126 2
        return $header;
0 ignored issues
show
Bug Best Practice introduced by Ignace Nyamagana Butera
The expression return $header could return the type string which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
127
    }
128
129
    /**
130
     * Returns the row at a given offset
131
     *
132
     * @param int $offset
133
     *
134
     * @return mixed
135
     */
136 4
    protected function seekRow(int $offset)
137
    {
138 4
        $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
139 4
        $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
140 4
        $this->document->rewind();
141
142
        //Workaround for SplFileObject::seek bug in PHP7.2+ see https://bugs.php.net/bug.php?id=75917
143 4
        if (PHP_VERSION_ID >= 70200 && !$this->document instanceof Stream) {
144 1
            while ($offset !== $this->document->key() && $this->document->valid()) {
145 1
                $this->document->next();
146
            }
147
148 1
            return $this->document->current();
149
        }
150
151 3
        $this->document->seek($offset);
152
153 3
        return $this->document->current();
154
    }
155
156
    /**
157
     * Strip the BOM sequence from a record
158
     *
159
     * @param string[] $record
160
     * @param int      $bom_length
161
     * @param string   $enclosure
162
     *
163
     * @return string[]
164
     */
165 8
    protected function removeBOM(array $record, int $bom_length, string $enclosure): array
166
    {
167 8
        if (0 == $bom_length) {
168 2
            return $record;
169
        }
170
171 6
        $record[0] = mb_substr($record[0], $bom_length);
172 6
        if ($enclosure.$enclosure != substr($record[0].$record[0], strlen($record[0]) - 1, 2)) {
173 4
            return $record;
174
        }
175
176 2
        $record[0] = substr($record[0], 1, -1);
177
178 2
        return $record;
179
    }
180
181
    /**
182
     * {@inheritdoc}
183
     */
184 6
    public function __call($method, array $arguments)
185
    {
186 6
        static $whitelisted = ['fetchColumn' => 1, 'fetchOne' => 1, 'fetchPairs' => 1];
187 6
        if (isset($whitelisted[$method])) {
188 2
            return (new ResultSet($this->getRecords(), $this->getHeader()))->$method(...$arguments);
189
        }
190
191 4
        throw new BadMethodCallException(sprintf('%s::%s() method does not exist', __CLASS__, $method));
192
    }
193
194
    /**
195
     * {@inheritdoc}
196
     */
197 2
    public function count(): int
198
    {
199 2
        if (-1 === $this->nb_records) {
200 2
            $this->nb_records = iterator_count($this->getRecords());
201
        }
202
203 2
        return $this->nb_records;
204
    }
205
206
    /**
207
     * {@inheritdoc}
208
     */
209 2
    public function getIterator(): Iterator
210
    {
211 2
        return $this->getRecords();
212
    }
213
214
    /**
215
     * {@inheritdoc}
216
     */
217 2
    public function jsonSerialize(): array
218
    {
219 2
        return iterator_to_array($this->getRecords(), false);
220
    }
221
222
    /**
223
     * Returns the CSV records as an iterator object.
224
     *
225
     * Each CSV record is represented as a simple array containig strings or null values.
226
     *
227
     * If the CSV document has a header record then each record is combined
228
     * to the header record and the header record is removed from the iterator.
229
     *
230
     * If the CSV document is inconsistent. Missing record fields are
231
     * filled with null values while extra record fields are strip from
232
     * the returned object.
233
     *
234
     * @param string[] $header an optional header to use instead of the CSV document header
235
     *
236
     * @return Iterator
237
     */
238 12
    public function getRecords(array $header = []): Iterator
239
    {
240 12
        $header = $this->computeHeader($header);
241 10
        $normalized = function ($record): bool {
242 10
            return is_array($record) && $record != [null];
243 10
        };
244 10
        $bom = $this->getInputBOM();
245 10
        $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
246 10
        $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
247
248 10
        $records = $this->stripBOM(new CallbackFilterIterator($this->document, $normalized), $bom);
249 10
        if (null !== $this->header_offset) {
250 4
            $records = new CallbackFilterIterator($records, function (array $record, int $offset): bool {
251 4
                return $offset !== $this->header_offset;
252 4
            });
253
        }
254
255 10
        return $this->combineHeader($records, $header);
256
    }
257
258
    /**
259
     * Returns the header to be used for iteration
260
     *
261
     * @param string[] $header
262
     *
263
     * @throws Exception If the header contains non unique column name
264
     *
265
     * @return string[]
266
     */
267 16
    protected function computeHeader(array $header)
268
    {
269 16
        if (empty($header)) {
270 14
            $header = $this->getHeader();
271
        }
272
273 16
        if ($header === array_unique(array_filter($header, 'is_string'))) {
274 14
            return $header;
275
        }
276
277 2
        throw new Exception('The header record must be empty or a flat array with unique string values');
278
    }
279
280
    /**
281
     * Combine the CSV header to each record if present
282
     *
283
     * @param Iterator $iterator
284
     * @param string[] $header
285
     *
286
     * @return Iterator
287
     */
288 20
    protected function combineHeader(Iterator $iterator, array $header): Iterator
289
    {
290 20
        if (empty($header)) {
291 14
            return $iterator;
292
        }
293
294 8
        $field_count = count($header);
295 8
        $mapper = function (array $record) use ($header, $field_count): array {
296 8
            if (count($record) != $field_count) {
297 4
                $record = array_slice(array_pad($record, $field_count, null), 0, $field_count);
298
            }
299
300 8
            return array_combine($header, $record);
301 8
        };
302
303 8
        return new MapIterator($iterator, $mapper);
304
    }
305
306
    /**
307
     * Strip the BOM sequence from the returned records if necessary
308
     *
309
     * @param Iterator $iterator
310
     * @param string   $bom
311
     *
312
     * @return Iterator
313
     */
314 16
    protected function stripBOM(Iterator $iterator, string $bom): Iterator
315
    {
316 16
        if ('' === $bom) {
317 10
            return $iterator;
318
        }
319
320 6
        $bom_length = mb_strlen($bom);
321 6
        $mapper = function (array $record, int $index) use ($bom_length): array {
322 6
            if (0 != $index) {
323 2
                return $record;
324
            }
325
326 6
            return $this->removeBOM($record, $bom_length, $this->enclosure);
327 6
        };
328
329 6
        return new MapIterator($iterator, $mapper);
330
    }
331
332
    /**
333
     * Selects the record to be used as the CSV header
334
     *
335
     * Because the header is represented as an array, to be valid
336
     * a header MUST contain only unique string value.
337
     *
338
     * @param int|null $offset the header record offset
339
     *
340
     * @throws Exception if the offset is a negative integer
341
     *
342
     * @return static
343
     */
344 14
    public function setHeaderOffset($offset): self
345
    {
346 14
        if ($offset === $this->header_offset) {
347 8
            return $this;
348
        }
349
350 6
        if (!is_nullable_int($offset)) {
351 2
            throw new TypeError(sprintf(__METHOD__.'() expects 1 Argument to be null or an integer %s given', gettype($offset)));
352
        }
353
354 4
        if (null !== $offset && 0 > $offset) {
355 2
            throw new Exception(__METHOD__.'() expects 1 Argument to be greater or equal to 0');
356
        }
357
358 2
        $this->header_offset = $offset;
359 2
        $this->resetProperties();
360
361 2
        return $this;
362
    }
363
364
    /**
365
     * {@inheritdoc}
366
     */
367 8
    protected function resetProperties()
368
    {
369 8
        $this->nb_records = -1;
370 8
        $this->header = [];
371 8
    }
372
}
373