Completed
Pull Request — master (#309)
by ignace nyamagana
02:15
created

Writer::toRFC4180Field()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 1
dl 0
loc 9
ccs 5
cts 5
cp 1
crap 2
rs 9.9666
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 Traversable;
20
use TypeError;
21
use const SEEK_CUR;
22
use const STREAM_FILTER_WRITE;
23
use function array_map;
24
use function array_reduce;
25
use function gettype;
26
use function implode;
27
use function is_iterable;
28
use function sprintf;
29
use function str_replace;
30
use function strlen;
31
32
/**
33
 * A class to insert records into a CSV Document.
34
 *
35
 * @package League.csv
36
 * @since   4.0.0
37
 * @author  Ignace Nyamagana Butera <[email protected]>
38
 */
39
class Writer extends AbstractCsv
40
{
41
    const MODE_PHP = 'MODE_PHP';
42
43
    const MODE_RFC4180 = 'MODE_RFC4180';
44
45
    /**
46
     * callable collection to format the record before insertion.
47
     *
48
     * @var callable[]
49
     */
50
    protected $formatters = [];
51
52
    /**
53
     * callable collection to validate the record before insertion.
54
     *
55
     * @var callable[]
56
     */
57
    protected $validators = [];
58
59
    /**
60
     * newline character.
61
     *
62
     * @var string
63
     */
64
    protected $newline = "\n";
65
66
    /**
67
     * Insert records count for flushing.
68
     *
69
     * @var int
70
     */
71
    protected $flush_counter = 0;
72
73
    /**
74
     * Buffer flush threshold.
75
     *
76
     * @var int|null
77
     */
78
    protected $flush_threshold;
79
80
    /**
81
     * {@inheritdoc}
82
     */
83
    protected $stream_filter_mode = STREAM_FILTER_WRITE;
84
85
    /**
86
     * Regular expression used to detect if RFC4180 formatting is necessary.
87
     *
88
     * @var string
89
     */
90
    protected $rfc4180_regexp;
91
92
    /**
93
     * double enclosure for RFC4180 compliance.
94
     *
95
     * @var string
96
     */
97
    protected $rfc4180_enclosure;
98
99
    /**
100
     * Returns the current newline sequence characters.
101
     */
102 3
    public function getNewline(): string
103
    {
104 3
        return $this->newline;
105
    }
106
107
    /**
108
     * Get the flush threshold.
109
     *
110
     * @return int|null
111
     */
112 3
    public function getFlushThreshold()
113
    {
114 3
        return $this->flush_threshold;
115
    }
116
117
    /**
118
     * Adds multiple records to the CSV document.
119
     *
120
     * @see Writer::insertOne
121
     *
122
     * @param Traversable|array $records
123
     */
124 9
    public function insertAll($records, string $mode = self::MODE_PHP): int
125
    {
126 9
        if (!is_iterable($records)) {
127 3
            throw new TypeError(sprintf('%s() expects argument passed to be iterable, %s given', __METHOD__, gettype($records)));
128
        }
129
130 6
        $bytes = 0;
131 6
        foreach ($records as $record) {
132 6
            $bytes += $this->insertOne($record, $mode);
133
        }
134
135 6
        $this->flush_counter = 0;
136 6
        $this->document->fflush();
137
138 6
        return $bytes;
139
    }
140
141
    /**
142
     * Adds a single record to a CSV document.
143
     *
144
     * A record is an array that can contains scalar types values, NULL values
145
     * or objects implementing the __toString method.
146
     *
147
     * @throws CannotInsertRecord If the record can not be inserted
148
     */
149 57
    public function insertOne(array $record, string $mode = self::MODE_PHP): int
150
    {
151 57
        static $methodList = [self::MODE_PHP => 'addPHPCompliantRecord', self::MODE_RFC4180 => 'addRFC4180CompliantRecord'];
152 57
        $method = $methodList[$mode] ?? null;
153 57
        if (null === $method) {
154 3
            throw new Exception(sprintf('Unknown or unsupported writing mode %s', $mode));
155
        }
156
157 54
        $record = array_reduce($this->formatters, [$this, 'formatRecord'], $record);
158 54
        $this->validateRecord($record);
159 51
        $bytes = $this->$method($record);
160 51
        if (false !== $bytes && 0 !== $bytes) {
161 45
            return $bytes + $this->consolidate();
162
        }
163
164 6
        throw CannotInsertRecord::triggerOnInsertion($record);
165
    }
166
167
    /**
168
     * Adds a single record to a CSV Document using PHP algorithm.
169
     *
170
     * @see https://php.net/manual/en/function.fputcsv.php
171
     *
172
     * @return int|bool
173
     */
174 12
    protected function addPHPCompliantRecord(array $record)
175
    {
176 12
        return $this->document->fputcsv($record, $this->delimiter, $this->enclosure, $this->escape);
177
    }
178
179
    /**
180
     * Adds a single record to a CSV Document using RFC4180 algorithm.
181
     *
182
     * @see https://php.net/manual/en/function.fwrite.php
183
     * @see https://php.net/manual/en/function.fputcsv.php
184
     *
185
     * the LF character is added at the end of each record to mimic fputcsv behavior
186
     *
187
     * @return int|bool
188
     */
189 27
    protected function addRFC4180CompliantRecord(array $record)
190
    {
191 27
        return $this->document->fwrite(implode($this->delimiter, array_map([$this, 'toRFC4180Field'], $record))."\n");
192
    }
193
194
    /**
195
     * Converts and Format a record field to be inserted into a CSV Document using RFC4180 rules.
196
     *
197
     * @see https://php.net/manual/en/function.fputcsv.php
198
     * @see https://tools.ietf.org/html/rfc4180
199
     * @see http://edoceo.com/utilitas/csv-file-format
200
     *
201
     * Fields must be delimited with enclosures if they contains :
202
     *
203
     *     - Leading or trailing whitespaces
204
     *     - Embedded delimiters
205
     *     - Embedded line-breaks
206
     *     - Embedded enclosures.
207
     *
208
     * Embedded enclosures must be doubled.
209
     *
210
     * String conversion is done without any check like fputcsv:
211
     *
212
     *     - Emits E_NOTICE on Array conversion (returns the 'Array' string)
213
     *     - Throws catchable fatal error on objects that can not be converted
214
     *     - Returns resource id without notice or error (returns 'Resource id #2')
215
     *     - Converts boolean true to '1', boolean false to the empty string
216
     */
217 27
    protected function toRFC4180Field($field): string
218
    {
219 27
        $field = (string) $field;
220 27
        if (!preg_match($this->rfc4180_regexp, $field)) {
221 24
            return $field;
222
        }
223
224 21
        return $this->enclosure.str_replace($this->enclosure, $this->rfc4180_enclosure, $field).$this->enclosure;
225
    }
226
227
    /**
228
     * {@inheritdoc}
229
     */
230 15
    protected function resetProperties()
231
    {
232 15
        parent::resetProperties();
233 15
        $embedded_characters = "\n|\r".preg_quote($this->delimiter, '/').'|'.preg_quote($this->enclosure, '/');
234
235 15
        $this->rfc4180_regexp = '/^
236
            (\ +)                          # leading whitespaces
237 15
            |(['.$embedded_characters.'])  # embedded characters
238
            |(\ +)                         # trailing whitespaces
239
        $/x';
240
241 15
        $this->rfc4180_enclosure = $this->enclosure.$this->enclosure;
242 15
    }
243
244
    /**
245
     * Format a record.
246
     *
247
     * The returned array must contain
248
     *   - scalar types values,
249
     *   - NULL values,
250
     *   - or objects implementing the __toString() method.
251
     */
252 3
    protected function formatRecord(array $record, callable $formatter): array
253
    {
254 3
        return $formatter($record);
255
    }
256
257
    /**
258
     * Validate a record.
259
     *
260
     * @throws CannotInsertRecord If the validation failed
261
     */
262 12
    protected function validateRecord(array $record)
263
    {
264 12
        foreach ($this->validators as $name => $validator) {
265 3
            if (true !== $validator($record)) {
266 3
                throw CannotInsertRecord::triggerOnValidation($name, $record);
267
            }
268
        }
269 9
    }
270
271
    /**
272
     * Apply post insertion actions.
273
     */
274 12
    protected function consolidate(): int
275
    {
276 12
        $bytes = 0;
277 12
        if ("\n" !== $this->newline) {
278 3
            $this->document->fseek(-1, SEEK_CUR);
279 3
            $bytes = $this->document->fwrite($this->newline, strlen($this->newline)) - 1;
280
        }
281
282 12
        if (null === $this->flush_threshold) {
283 9
            return $bytes;
284
        }
285
286 3
        ++$this->flush_counter;
287 3
        if (0 === $this->flush_counter % $this->flush_threshold) {
288 3
            $this->flush_counter = 0;
289 3
            $this->document->fflush();
290
        }
291
292 3
        return $bytes;
293
    }
294
295
    /**
296
     * Adds a record formatter.
297
     */
298 3
    public function addFormatter(callable $formatter): self
299
    {
300 3
        $this->formatters[] = $formatter;
301
302 3
        return $this;
303
    }
304
305
    /**
306
     * Adds a record validator.
307
     */
308 3
    public function addValidator(callable $validator, string $validator_name): self
309
    {
310 3
        $this->validators[$validator_name] = $validator;
311
312 3
        return $this;
313
    }
314
315
    /**
316
     * Sets the newline sequence.
317
     */
318 3
    public function setNewline(string $newline): self
319
    {
320 3
        $this->newline = $newline;
321
322 3
        return $this;
323
    }
324
325
    /**
326
     * Set the flush threshold.
327
     *
328
     * @param int|null $threshold
329
     *
330
     * @throws Exception if the threshold is a integer lesser than 1
331
     */
332 12
    public function setFlushThreshold($threshold): self
333
    {
334 12
        if ($threshold === $this->flush_threshold) {
335 3
            return $this;
336
        }
337
338 12
        if (!is_nullable_int($threshold)) {
339 3
            throw new TypeError(sprintf(__METHOD__.'() expects 1 Argument to be null or an integer %s given', gettype($threshold)));
340
        }
341
342 9
        if (null !== $threshold && 1 > $threshold) {
343 3
            throw new Exception(__METHOD__.'() expects 1 Argument to be null or a valid integer greater or equal to 1');
344
        }
345
346 9
        $this->flush_threshold = $threshold;
347 9
        $this->flush_counter = 0;
348 9
        $this->document->fflush();
349
350 9
        return $this;
351
    }
352
}
353