Completed
Pull Request — 2.11.x (#3891)
by Malte
63:10 queued 11s
created

MysqliStatement::sendLongData()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 7
dl 0
loc 12
ccs 6
cts 6
cp 1
rs 9.6111
c 0
b 0
f 0
cc 5
nc 5
nop 1
crap 5
1
<?php
2
3
namespace Doctrine\DBAL\Driver\Mysqli;
4
5
use Doctrine\DBAL\Driver\Statement;
6
use Doctrine\DBAL\Driver\StatementIterator;
7
use Doctrine\DBAL\Exception\InvalidArgumentException;
8
use Doctrine\DBAL\FetchMode;
9
use Doctrine\DBAL\ParameterType;
10
use Doctrine\DBAL\SQLParserUtils;
11
use IteratorAggregate;
12
use mysqli;
13
use mysqli_stmt;
14
use PDO;
15
use function array_combine;
16
use function array_fill;
17
use function array_filter;
18
use function array_key_exists;
19
use function array_keys;
20
use function assert;
21
use function count;
22
use function feof;
23
use function fread;
24
use function get_resource_type;
25
use function is_array;
26
use function is_int;
27
use function is_resource;
28
use function sprintf;
29
use function str_repeat;
30
use function strlen;
31
use function substr;
32
33
class MysqliStatement implements IteratorAggregate, Statement
34
{
35
    /** @var string[] */
36
    protected static $_paramTypeMap = [
37
        ParameterType::STRING       => 's',
38
        ParameterType::BINARY       => 's',
39
        ParameterType::BOOLEAN      => 'i',
40
        ParameterType::NULL         => 's',
41
        ParameterType::INTEGER      => 'i',
42
        ParameterType::LARGE_OBJECT => 'b',
43
    ];
44
45
    /** @var mysqli */
46
    protected $_conn;
47
48
    /** @var mysqli_stmt */
49
    protected $_stmt;
50
51
    /** @var string[]|false|null */
52
    protected $_columnNames;
53
54
    /** @var mixed[] */
55
    protected $_rowBindedValues = [];
56
57
    /** @var mixed[] */
58
    protected $_bindedValues;
59
60
    /** @var string */
61
    protected $types;
62
63
    /** @var array<string, array<int, int>> maps parameter names to their placeholder number(s). */
64
    private $placeholderNamesToNumbers = [];
65
66
    /**
67
     * Contains ref values for bindValue().
68
     *
69
     * @var mixed[]
70
     */
71
    protected $_values = [];
72
73
    /** @var int */
74
    protected $_defaultFetchMode = FetchMode::MIXED;
75
76
    /**
77
     * Indicates whether the statement is in the state when fetching results is possible
78
     *
79 1805
     * @var bool
80
     */
81 1805
    private $result = false;
82
83 1805
    /**
84
     * @param string $prepareString
85 1805
     *
86 1378
     * @throws MysqliException
87
     */
88
    public function __construct(mysqli $conn, $prepareString)
89 1805
    {
90
        $this->_conn = $conn;
91 1805
92 1805
        $queryWithoutNamedParameters = $this->convertNamedToPositionalPlaceholders($prepareString);
93 1798
        $stmt                        = $conn->prepare($queryWithoutNamedParameters);
94
95
        if ($stmt === false) {
96 1805
            throw new MysqliException($this->_conn->error, $this->_conn->sqlstate, $this->_conn->errno);
97 1805
        }
98 1805
99
        $this->_stmt = $stmt;
100
101
        $paramCount = $this->_stmt->param_count;
102
        if (0 >= $paramCount) {
103 1770
            return;
104
        }
105 1770
106
        $this->types         = str_repeat('s', $paramCount);
107 1770
        $this->_bindedValues = array_fill(1, $paramCount, null);
108
    }
109
110
    /**
111 1770
     * Converts named placeholders (":parameter") into positional ones ("?"), as MySQL does not support them.
112 1770
     *
113
     * @param string $query The query string to create a prepared statement of.
114 1770
     */
115
    private function convertNamedToPositionalPlaceholders(string $query) : string
116
    {
117
        $numberOfCharsQueryIsShortenedBy = 0;
118
        $placeholderNumber               = 0;
119
120 1805
        foreach (SQLParserUtils::getPlaceholderPositions($query, false) as $placeholderPosition => $placeholderName) {
121
            $placeholderName = (string) $placeholderName;
122 1805
            if (array_key_exists($placeholderName, $this->placeholderNamesToNumbers) === false) {
123
                $this->placeholderNamesToNumbers[$placeholderName] = [];
124 1805
            }
125
126
            $this->placeholderNamesToNumbers[$placeholderName][] = $placeholderNumber++;
127
128 1805
            $placeholderPositionInShortenedQuery = $placeholderPosition - $numberOfCharsQueryIsShortenedBy;
129 1805
            $placeholderNameLength               = strlen($placeholderName);
130 1805
            $query                               = substr($query, 0, $placeholderPositionInShortenedQuery) . '?' .
131
                substr($query, $placeholderPositionInShortenedQuery + $placeholderNameLength + 1);
132 1805
            $numberOfCharsQueryIsShortenedBy    += $placeholderNameLength;
133
        }
134
135
        return $query;
136
    }
137
138 1805
    /**
139
     * {@inheritdoc}
140 1805
     */
141 1805
    public function bindParam($column, &$variable, $type = ParameterType::STRING, $length = null)
142 1742
    {
143 1742
        assert(is_int($column));
144
145
        if (! isset(self::$_paramTypeMap[$type])) {
146 1805
            throw new MysqliException(sprintf("Unknown type: '%s'", $type));
147
        }
148
149
        $this->_bindedValues[$column] =& $variable;
150 1805
        $this->types[$column - 1]     = self::$_paramTypeMap[$type];
151 1385
152
        return true;
153
    }
154 1805
155 1805
    /**
156 1805
     * {@inheritdoc}
157 1798
     */
158 1798
    public function bindValue($param, $value, $type = ParameterType::STRING)
159
    {
160 1798
        assert(is_int($param));
161 1798
162 1798
        if (! isset(self::$_paramTypeMap[$type])) {
163
            throw new MysqliException(sprintf("Unknown type: '%s'", $type));
164
        }
165 1798
166
        $this->_values[$param]       = $value;
167 1798
        $this->_bindedValues[$param] =& $this->_values[$param];
168
        $this->types[$param - 1]     = self::$_paramTypeMap[$type];
169 1805
170
        return true;
171
    }
172
173 1805
    /**
174
     * {@inheritdoc}
175
     */
176
    public function execute($params = null)
177 1798
    {
178
        $params = $this->convertNamedToPositionalParamsIfNeeded($params);
179
180
        if ($this->_bindedValues !== null) {
181
            if ($params !== null) {
182
                if (! $this->bindUntypedValues($params)) {
183
                    throw new MysqliException($this->_stmt->error, $this->_stmt->errno);
184
                }
185
            } else {
186
                $this->bindTypedParameters();
187
            }
188
        }
189
190 1798
        if (! $this->_stmt->execute()) {
191
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
192 1798
        }
193 1798
194 1798
        $this->initializeColumnNamesIfNeeded();
195
196
        if ($this->_columnNames === false) {
197 1798
            $this->result = true;
198
199
            return true;
200
        }
201
202 1805
        // Store result of every execution which has it. Otherwise it will be impossible
203
        // to execute a new statement in case if the previous one has non-fetched rows
204 1805
        // @link http://dev.mysql.com/doc/refman/5.7/en/commands-out-of-sync.html
205
        $this->_stmt->store_result();
206
207
        // Bind row values _after_ storing the result. Otherwise, if mysqli is compiled with libmysql,
208
        // it will have to allocate as much memory as it may be needed for the given column type
209
        // (e.g. for a LONGBLOB field it's 4 gigabytes)
210 1805
        // @link https://bugs.php.net/bug.php?id=51386#1270673122
211
        //
212 1805
        // Make sure that the values are bound after each execution. Otherwise, if closeCursor() has been
213 1805
        // previously called on the statement, the values are unbound making the statement unusable.
214
        //
215 1805
        // It's also important that row values are bound after _each_ call to store_result(). Otherwise,
216 1805
        // if mysqli is compiled with libmysql, subsequently fetched string values will get truncated
217
        // to the length of the ones fetched during the previous execution.
218
        assert(is_array($this->_columnNames));
219
        $this->_rowBindedValues = array_fill(0, count($this->_columnNames), null);
220 1805
221 1805
        $refs = [];
222 1798
        foreach ($this->_rowBindedValues as $key => &$value) {
223
            $refs[$key] =& $value;
224
        }
225 1798
226 1798
        if (! $this->_stmt->bind_result(...$refs)) {
227 1798
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
228
        }
229
230 1805
        $this->result = true;
231
232
        return true;
233 1805
    }
234
235
    /**
236 1805
     * Converts an array of named parameters, e.g. ['id' => 1, 'foo' => 'bar'] to the corresponding array with
237
     * positional parameters referring to the prepared query, e.g. [1 => 1, 2 => 'bar', 3 => 'bar'] for a prepared query
238
     * such as "SELECT id FROM table WHERE foo = :foo and baz = :foo".
239
     *
240 1805
     * @param array<int|string, mixed>|null $params
241 1805
     *
242
     * @return mixed[]|null more specific: array<int, mixed>, I just don't know an elegant way to convince phpstan
243
     */
244
    private function convertNamedToPositionalParamsIfNeeded(?array $params = null) : ?array
245
    {
246
        if ($params === null) {
247
            return $params;
248 1805
        }
249
250 1805
        if ($this->arrayHasOnlyIntegerKeys($params)) {
251 1798
            return $params;
252 1798
        }
253
254 1798
        $positionalParameters = [];
255
256
        foreach ($params as $paramName => $paramValue) {
257
            foreach ($this->placeholderNamesToNumbers[$paramName] as $number) {
258 1798
                $positionalParameters[$number] = $paramValue;
259
            }
260
        }
261
262
        return $positionalParameters;
263 1805
    }
264
265
    /**
266
     * @param mixed[] $array
267
     */
268
    private function arrayHasOnlyIntegerKeys(array $array) : bool
269
    {
270
        return count(array_filter(array_keys($array), 'is_int')) === count($array);
271
    }
272 1742
273
    /**
274 1742
     * Binds parameters with known types previously bound to the statement
275 1742
     */
276
    private function bindTypedParameters()
277 1742
    {
278 1742
        $streams = $values = [];
279
        $types   = $this->types;
280
281 1742
        foreach ($this->_bindedValues as $parameter => $value) {
282
            if (! isset($types[$parameter - 1])) {
283
                $types[$parameter - 1] = static::$_paramTypeMap[ParameterType::STRING];
284
            }
285
286
            if ($types[$parameter - 1] === static::$_paramTypeMap[ParameterType::LARGE_OBJECT]) {
287 1798
                if (is_resource($value)) {
288
                    if (get_resource_type($value) !== 'stream') {
289 1798
                        throw new InvalidArgumentException('Resources passed with the LARGE_OBJECT parameter type must be stream resources.');
290
                    }
291 1798
                    $streams[$parameter] = $value;
292 1798
                    $values[$parameter]  = null;
293 1798
                    continue;
294 1798
                }
295
296
                $types[$parameter - 1] = static::$_paramTypeMap[ParameterType::STRING];
297 1798
            }
298
299
            $values[$parameter] = $value;
300 1798
        }
301
302
        if (! $this->_stmt->bind_param($types, ...$values)) {
303
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
304
        }
305
306 1798
        $this->sendLongData($streams);
307
    }
308
309
    /**
310 1798
     * Handle $this->_longData after regular query parameters have been bound
311 348
     *
312
     * @throws MysqliException
313
     */
314 1798
    private function sendLongData($streams)
315
    {
316 1798
        foreach ($streams as $paramNr => $stream) {
317 271
            while (! feof($stream)) {
318
                $chunk = fread($stream, 8192);
319
320 1798
                if ($chunk === false) {
321
                    throw new MysqliException("Failed reading the stream resource for parameter offset ${paramNr}.");
322 1798
                }
323 1798
324
                if (! $this->_stmt->send_long_data($paramNr - 1, $chunk)) {
325
                    throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
326 1798
                }
327
            }
328
        }
329
    }
330 1798
331 1798
    /**
332
     * Binds a array of values to bound parameters.
333
     *
334 1742
     * @param mixed[] $values
335 1742
     *
336 1742
     * @return bool
337
     */
338 1742
    private function bindUntypedValues(array $values)
339
    {
340 1742
        $params = [];
341
        $types  = str_repeat('s', count($values));
342
343 1721
        foreach ($values as &$v) {
344
            $params[] =& $v;
345
        }
346 1441
347
        return $this->_stmt->bind_param($types, ...$params);
348
    }
349
350
    /**
351
     * @return mixed[]|false|null
352
     */
353
    private function _fetch()
354
    {
355
        $ret = $this->_stmt->fetch();
356 1798
357
        if ($ret === true) {
358 1798
            $values = [];
359
            foreach ($this->_rowBindedValues as $v) {
360 1798
                $values[] = $v;
361
            }
362 1798
363 1798
            return $values;
364 1798
        }
365
366
        return $ret;
367 1728
    }
368 1728
369
    /**
370
     * {@inheritdoc}
371
     */
372 1798
    public function fetch($fetchMode = null, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0)
373
    {
374
        // do not try fetching from the statement if it's not expected to contain result
375
        // in order to prevent exceptional situation
376
        if (! $this->result) {
377
            return false;
378 1798
        }
379
380 1798
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
381
382 1798
        if ($fetchMode === FetchMode::COLUMN) {
383 1798
            return $this->fetchColumn();
384
        }
385
386 1798
        $values = $this->_fetch();
387
388
        if ($values === null) {
389
            return false;
390
        }
391
392
        if ($values === false) {
393
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
394
        }
395
396
        if ($fetchMode === FetchMode::NUMERIC) {
397
            return $values;
398
        }
399
400
        assert(is_array($this->_columnNames));
401
        $assoc = array_combine($this->_columnNames, $values);
402
        assert(is_array($assoc));
403
404
        switch ($fetchMode) {
405
            case FetchMode::ASSOCIATIVE:
406
                return $assoc;
407
408 1053
            case FetchMode::MIXED:
409
                return $assoc + $values;
410 1053
411 1053
            case FetchMode::STANDARD_OBJECT:
412
                return (object) $assoc;
413 1053
414
            default:
415
                throw new MysqliException(sprintf("Unknown fetch type '%s'", $fetchMode));
416
        }
417
    }
418
419 1805
    /**
420
     * {@inheritdoc}
421 1805
     */
422 1805
    public function fetchAll($fetchMode = null, $fetchArgument = null, $ctorArgs = null)
423
    {
424
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
425
426
        $rows = [];
427
428
        if ($fetchMode === FetchMode::COLUMN) {
429
            while (($row = $this->fetchColumn()) !== false) {
430
                $rows[] = $row;
431 1053
            }
432
        } else {
433 1053
            while (($row = $this->fetch($fetchMode)) !== false) {
434
                $rows[] = $row;
435
            }
436
        }
437
438
        return $rows;
439 1798
    }
440
441 1798
    /**
442
     * {@inheritdoc}
443 1798
     */
444
    public function fetchColumn($columnIndex = 0)
445
    {
446
        $row = $this->fetch(FetchMode::NUMERIC);
447
448
        if ($row === false) {
449 1858
            return false;
450
        }
451 1858
452
        return $row[$columnIndex] ?? null;
453
    }
454
455
    /**
456
     * {@inheritdoc}
457
     */
458
    public function errorCode()
459
    {
460
        return $this->_stmt->errno;
461
    }
462
463
    /**
464
     * {@inheritdoc}
465
     */
466
    public function errorInfo()
467
    {
468
        return $this->_stmt->error;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->_stmt->error returns the type string which is incompatible with the return type mandated by Doctrine\DBAL\Driver\Statement::errorInfo() of array<mixed,mixed>.

In the issue above, the returned value is violating the contract defined by the mentioned interface.

Let's take a look at an example:

interface HasName {
    /** @return string */
    public function getName();
}

class Name {
    public $name;
}

class User implements HasName {
    /** @return string|Name */
    public function getName() {
        return new Name('foo'); // This is a violation of the ``HasName`` interface
                                // which only allows a string value to be returned.
    }
}
Loading history...
469
    }
470
471
    /**
472
     * {@inheritdoc}
473
     */
474
    public function closeCursor()
475
    {
476
        $this->_stmt->free_result();
477
        $this->result = false;
478
479
        return true;
480
    }
481
482
    /**
483
     * {@inheritdoc}
484
     */
485
    public function rowCount()
486
    {
487
        if ($this->_columnNames === false) {
488
            return $this->_stmt->affected_rows;
489
        }
490
491
        return $this->_stmt->num_rows;
492
    }
493
494
    /**
495
     * {@inheritdoc}
496
     */
497
    public function columnCount()
498
    {
499
        return $this->_stmt->field_count;
500
    }
501
502
    /**
503
     * {@inheritdoc}
504
     */
505
    public function setFetchMode($fetchMode, $arg2 = null, $arg3 = null)
506
    {
507
        $this->_defaultFetchMode = $fetchMode;
508
509
        return true;
510
    }
511
512
    /**
513
     * {@inheritdoc}
514
     */
515
    public function getIterator()
516
    {
517
        return new StatementIterator($this);
518
    }
519
520
    private function initializeColumnNamesIfNeeded() : void
521
    {
522
        if ($this->_columnNames !== null) {
523
            return;
524
        }
525
526
        $meta = $this->_stmt->result_metadata();
527
        if ($meta === false) {
528
            $this->_columnNames = false;
529
530
            return;
531
        }
532
533
        $fields = $meta->fetch_fields();
534
        assert(is_array($fields));
535
536
        $this->_columnNames = [];
537
        foreach ($fields as $col) {
538
            $this->_columnNames[] = $col->name;
539
        }
540
541
        $meta->free();
542
    }
543
}
544