Completed
Pull Request — master (#309)
by ignace nyamagana
01:58
created

Reader::createFromPath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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