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

Writer::resetProperties()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 1

Importance

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