Completed
Push — master ( a3add9...6ca7fe )
by ignace nyamagana
02:56
created

Reader   B

Complexity

Total Complexity 36

Size/Duplication

Total Lines 311
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 5

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
dl 0
loc 311
ccs 91
cts 91
cp 1
rs 8.8
c 0
b 0
f 0
wmc 36
lcom 1
cbo 5

15 Methods

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