Completed
Push — master ( ecd0f8...3ad52b )
by ignace nyamagana
15:08
created

Reader::seekRow()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
nc 3
nop 1
dl 0
loc 19
ccs 4
cts 4
cp 1
crap 5
rs 9.3222
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * League.Csv (https://csv.thephpleague.com).
5
 *
6
 * @author  Ignace Nyamagana Butera <[email protected]>
7
 * @license https://github.com/thephpleague/csv/blob/master/LICENSE (MIT License)
8
 * @version 9.1.5
9
 * @link    https://github.com/thephpleague/csv
10
 *
11
 * For the full copyright and license information, please view the LICENSE
12
 * file that was distributed with this source code.
13
 */
14
15
declare(strict_types=1);
16
17
namespace League\Csv;
18
19
use BadMethodCallException;
20
use CallbackFilterIterator;
21
use Countable;
22
use Iterator;
23
use IteratorAggregate;
24
use JsonSerializable;
25
use SplFileObject;
26
use TypeError;
27
use const STREAM_FILTER_READ;
28
use function array_combine;
29
use function array_filter;
30
use function array_pad;
31
use function array_slice;
32
use function array_unique;
33
use function gettype;
34
use function is_array;
35
use function iterator_count;
36
use function iterator_to_array;
37
use function mb_strlen;
38
use function mb_substr;
39
use function sprintf;
40
use function strlen;
41
use function substr;
42
43
/**
44
 * A class to select records from a CSV document.
45
 *
46
 * @package League.csv
47
 * @since  3.0.0
48
 *
49
 * @method array fetchOne(int $nth_record = 0) Returns a single record from the CSV
50
 * @method Generator fetchColumn(string|int $column_index) Returns the next value from a single CSV record field
51
 * @method Generator fetchPairs(string|int $offset_index = 0, string|int $value_index = 1) Fetches the next key-value pairs from the CSV document
52
 */
53
class Reader extends AbstractCsv implements Countable, IteratorAggregate, JsonSerializable
54
{
55
    /**
56
     * header offset.
57
     *
58
     * @var int|null
59
     */
60
    protected $header_offset;
61
62
    /**
63
     * header record.
64
     *
65
     * @var string[]
66
     */
67 2
    protected $header = [];
68
69 2
    /**
70
     * records count.
71
     *
72
     * @var int
73
     */
74
    protected $nb_records = -1;
75
76
    /**
77
     * {@inheritdoc}
78
     */
79 10
    protected $stream_filter_mode = STREAM_FILTER_READ;
80
81 10
    /**
82
     * {@inheritdoc}
83
     */
84
    public static function createFromPath(string $path, string $open_mode = 'r', $context = null)
85
    {
86
        return new static(Stream::createFromPath($path, $open_mode, $context));
87
    }
88
89
    /**
90
     * Returns the header offset.
91 10
     *
92
     * If no CSV header offset is set this method MUST return null
93 10
     *
94 8
     * @return int|null
95
     */
96
    public function getHeaderOffset()
97 4
    {
98 2
        return $this->header_offset;
99
    }
100
101 4
    /**
102
     * Returns the CSV record used as header.
103 4
     *
104
     * The returned header is represented as an array of string values
105
     *
106
     * @return string[]
107
     */
108
    public function getHeader(): array
109
    {
110
        if (null === $this->header_offset) {
111
            return $this->header;
112
        }
113
114
        if ([] !== $this->header) {
115 8
            return $this->header;
116
        }
117 8
118 8
        $this->header = $this->setHeader($this->header_offset);
119 4
120
        return $this->header;
121
    }
122 4
123 2
    /**
124
     * Determine the CSV record header.
125
     *
126 2
     * @throws Exception If the header offset is set and no record is found or is the empty array
127
     *
128
     * @return string[]
129
     */
130
    protected function setHeader(int $offset): array
131
    {
132
        $header = $this->seekRow($offset);
133
        if (false === $header || [] === $header) {
134
            throw new Exception(sprintf('The header record does not exist or is empty at offset: `%s`', $offset));
135
        }
136 4
137
        if (0 === $offset) {
138 4
            return $this->removeBOM($header, mb_strlen($this->getInputBOM()), $this->enclosure);
139 4
        }
140 4
141
        return $header;
142
    }
143 4
144 1
    /**
145 1
     * Returns the row at a given offset.
146
     */
147
    protected function seekRow(int $offset)
148 1
    {
149
        $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
150
        $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
151 3
        $this->document->rewind();
152
153 3
        //Workaround for SplFileObject::seek bug in PHP7.2+ see https://bugs.php.net/bug.php?id=75917
154
        if (PHP_VERSION_ID >= 70200 && !$this->document instanceof Stream) {
155
            while ($offset !== $this->document->key() && $this->document->valid()) {
156
                $this->document->next();
157
            }
158
159
            return $this->document->current();
160
        }
161
162
        $this->document->seek($offset);
163
164
        return $this->document->current();
165 8
    }
166
167 8
    /**
168 2
     * Strip the BOM sequence from a record.
169
     *
170
     * @param string[] $record
171 6
     *
172 6
     * @return string[]
173 4
     */
174
    protected function removeBOM(array $record, int $bom_length, string $enclosure): array
175
    {
176 2
        if (0 == $bom_length) {
177
            return $record;
178 2
        }
179
180
        $record[0] = mb_substr($record[0], $bom_length);
181
        if ($enclosure.$enclosure != substr($record[0].$record[0], strlen($record[0]) - 1, 2)) {
182
            return $record;
183
        }
184 6
185
        $record[0] = substr($record[0], 1, -1);
186 6
187 6
        return $record;
188 2
    }
189
190
    /**
191 4
     * {@inheritdoc}
192
     */
193
    public function __call($method, array $arguments)
194
    {
195
        static $whitelisted = ['fetchColumn' => 1, 'fetchOne' => 1, 'fetchPairs' => 1];
196
        if (isset($whitelisted[$method])) {
197 2
            return (new ResultSet($this->getRecords(), $this->getHeader()))->$method(...$arguments);
198
        }
199 2
200 2
        throw new BadMethodCallException(sprintf('%s::%s() method does not exist', __CLASS__, $method));
201
    }
202
203 2
    /**
204
     * {@inheritdoc}
205
     */
206
    public function count(): int
207
    {
208
        if (-1 === $this->nb_records) {
209 2
            $this->nb_records = iterator_count($this->getRecords());
210
        }
211 2
212
        return $this->nb_records;
213
    }
214
215
    /**
216
     * {@inheritdoc}
217 2
     */
218
    public function getIterator(): Iterator
219 2
    {
220
        return $this->getRecords();
221
    }
222
223
    /**
224
     * {@inheritdoc}
225
     */
226
    public function jsonSerialize(): array
227
    {
228
        return iterator_to_array($this->getRecords(), false);
229
    }
230
231
    /**
232
     * Returns the CSV records as an iterator object.
233
     *
234
     * Each CSV record is represented as a simple array containig strings or null values.
235
     *
236
     * If the CSV document has a header record then each record is combined
237
     * to the header record and the header record is removed from the iterator.
238 12
     *
239
     * If the CSV document is inconsistent. Missing record fields are
240 12
     * filled with null values while extra record fields are strip from
241 10
     * the returned object.
242 10
     *
243 10
     * @param string[] $header an optional header to use instead of the CSV document header
244 10
     */
245 10
    public function getRecords(array $header = []): Iterator
246 10
    {
247
        $header = $this->computeHeader($header);
248 10
        $normalized = function ($record): bool {
249 10
            return is_array($record) && $record != [null];
250 4
        };
251 4
        $bom = $this->getInputBOM();
252 4
        $this->document->setFlags(SplFileObject::READ_CSV | SplFileObject::READ_AHEAD | SplFileObject::SKIP_EMPTY);
253
        $this->document->setCsvControl($this->delimiter, $this->enclosure, $this->escape);
254
255 10
        $records = $this->stripBOM(new CallbackFilterIterator($this->document, $normalized), $bom);
256
        if (null !== $this->header_offset) {
257
            $records = new CallbackFilterIterator($records, function (array $record, int $offset): bool {
258
                return $offset !== $this->header_offset;
259
            });
260
        }
261
262
        return $this->combineHeader($records, $header);
263
    }
264
265
    /**
266
     * Returns the header to be used for iteration.
267 16
     *
268
     * @param string[] $header
269 16
     *
270 14
     * @throws Exception If the header contains non unique column name
271
     *
272
     * @return string[]
273 16
     */
274 14
    protected function computeHeader(array $header)
275
    {
276
        if ([] === $header) {
277 2
            $header = $this->getHeader();
278
        }
279
280
        if ($header === array_unique(array_filter($header, 'is_string'))) {
281
            return $header;
282
        }
283
284
        throw new Exception('The header record must be empty or a flat array with unique string values');
285
    }
286
287
    /**
288 20
     * Combine the CSV header to each record if present.
289
     *
290 20
     * @param string[] $header
291 14
     */
292
    protected function combineHeader(Iterator $iterator, array $header): Iterator
293
    {
294 8
        if ([] === $header) {
295 8
            return $iterator;
296 8
        }
297 4
298
        $field_count = count($header);
299
        $mapper = function (array $record) use ($header, $field_count): array {
300 8
            if (count($record) != $field_count) {
301 8
                $record = array_slice(array_pad($record, $field_count, null), 0, $field_count);
302
            }
303 8
304
            return array_combine($header, $record);
305
        };
306
307
        return new MapIterator($iterator, $mapper);
308
    }
309
310
    /**
311
     * Strip the BOM sequence from the returned records if necessary.
312
     */
313
    protected function stripBOM(Iterator $iterator, string $bom): Iterator
314 16
    {
315
        if ('' === $bom) {
316 16
            return $iterator;
317 10
        }
318
319
        $bom_length = mb_strlen($bom);
320 6
        $mapper = function (array $record, int $index) use ($bom_length): array {
321 6
            if (0 != $index) {
322 6
                return $record;
323 2
            }
324
325
            return $this->removeBOM($record, $bom_length, $this->enclosure);
326 6
        };
327 6
328
        return new MapIterator($iterator, $mapper);
329 6
    }
330
331
    /**
332
     * Selects the record to be used as the CSV header.
333
     *
334
     * Because the header is represented as an array, to be valid
335
     * a header MUST contain only unique string value.
336
     *
337
     * @param int|null $offset the header record offset
338
     *
339
     * @throws Exception if the offset is a negative integer
340
     *
341
     * @return static
342
     */
343
    public function setHeaderOffset($offset): self
344 14
    {
345
        if ($offset === $this->header_offset) {
346 14
            return $this;
347 8
        }
348
349
        if (!is_nullable_int($offset)) {
350 6
            throw new TypeError(sprintf(__METHOD__.'() expects 1 Argument to be null or an integer %s given', gettype($offset)));
351 2
        }
352
353
        if (null !== $offset && 0 > $offset) {
354 4
            throw new Exception(__METHOD__.'() expects 1 Argument to be greater or equal to 0');
355 2
        }
356
357
        $this->header_offset = $offset;
358 2
        $this->resetProperties();
359 2
360
        return $this;
361 2
    }
362
363
    /**
364
     * {@inheritdoc}
365
     */
366
    protected function resetProperties()
367 8
    {
368
        $this->nb_records = -1;
369 8
        $this->header = [];
370 8
    }
371
}
372