Completed
Pull Request — master (#379)
by ignace nyamagana
01:16
created

Reader::fetchColumn()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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