Reader::setHeaderOffset()   A
last analyzed

Complexity

Conditions 4
Paths 3

Size

Total Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 4

Importance

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