Completed
Push — master ( 48cbe3...3422ff )
by ignace nyamagana
03:36
created

Reader   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 318
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
dl 0
loc 318
ccs 89
cts 89
cp 1
rs 9.2
c 0
b 0
f 0
wmc 34
lcom 1
cbo 4

15 Methods

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