Completed
Pull Request — 2.11.x (#3891)
by Malte
65:11
created

MysqliStatement::bindParam()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 6
c 0
b 0
f 0
dl 0
loc 12
ccs 5
cts 5
cp 1
rs 10
cc 2
nc 2
nop 4
crap 2
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
    protected $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
     * @return string
116
     */
117
    private function convertNamedToPositionalPlaceholders($query)
118
    {
119
        $numberOfCharsQueryIsShortenedBy = 0;
120 1805
        $placeholderNumber               = 0;
121
122 1805
        foreach (SQLParserUtils::getPlaceholderPositions($query, false) as $placeholderPosition => $placeholderName) {
123
            $placeholderName = (string) $placeholderName;
124 1805
            if (array_key_exists($placeholderName, $this->placeholderNamesToNumbers) === false) {
125
                $this->placeholderNamesToNumbers[$placeholderName] = [];
126
            }
127
128 1805
            $this->placeholderNamesToNumbers[$placeholderName][] = $placeholderNumber++;
129 1805
130 1805
            $placeholderPositionInShortenedQuery = $placeholderPosition - $numberOfCharsQueryIsShortenedBy;
131
            $placeholderNameLength               = strlen($placeholderName);
132 1805
            $query                               = substr($query, 0, $placeholderPositionInShortenedQuery) . '?' . substr($query, ($placeholderPositionInShortenedQuery + $placeholderNameLength + 1));
133
            $numberOfCharsQueryIsShortenedBy    += $placeholderNameLength;
134
        }
135
136
        return $query;
137
    }
138 1805
139
    /**
140 1805
     * {@inheritdoc}
141 1805
     */
142 1742
    public function bindParam($column, &$variable, $type = ParameterType::STRING, $length = null)
143 1742
    {
144
        assert(is_int($column));
145
146 1805
        if (! isset(self::$_paramTypeMap[$type])) {
147
            throw new MysqliException(sprintf("Unknown type: '%s'", $type));
148
        }
149
150 1805
        $this->_bindedValues[$column] =& $variable;
151 1385
        $this->types[$column - 1]     = self::$_paramTypeMap[$type];
152
153
        return true;
154 1805
    }
155 1805
156 1805
    /**
157 1798
     * {@inheritdoc}
158 1798
     */
159
    public function bindValue($param, $value, $type = ParameterType::STRING)
160 1798
    {
161 1798
        assert(is_int($param));
162 1798
163
        if (! isset(self::$_paramTypeMap[$type])) {
164
            throw new MysqliException(sprintf("Unknown type: '%s'", $type));
165 1798
        }
166
167 1798
        $this->_values[$param]       = $value;
168
        $this->_bindedValues[$param] =& $this->_values[$param];
169 1805
        $this->types[$param - 1]     = self::$_paramTypeMap[$type];
170
171
        return true;
172
    }
173 1805
174
    /**
175
     * {@inheritdoc}
176
     */
177 1798
    public function execute($params = null)
178
    {
179
        $params = $this->convertNamedToPositionalParamsIfNeeded($params);
180
181
        if ($this->_bindedValues !== null) {
182
            if ($params !== null) {
183
                if (! $this->bindUntypedValues($params)) {
184
                    throw new MysqliException($this->_stmt->error, $this->_stmt->errno);
185
                }
186
            } else {
187
                $this->bindTypedParameters();
188
            }
189
        }
190 1798
191
        if (! $this->_stmt->execute()) {
192 1798
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
193 1798
        }
194 1798
195
        $this->initializeColumnNamesIfNeeded();
196
197 1798
        if ($this->_columnNames === false) {
198
            $this->result = true;
199
200
            return true;
201
        }
202 1805
203
        // Store result of every execution which has it. Otherwise it will be impossible
204 1805
        // to execute a new statement in case if the previous one has non-fetched rows
205
        // @link http://dev.mysql.com/doc/refman/5.7/en/commands-out-of-sync.html
206
        $this->_stmt->store_result();
207
208
        // Bind row values _after_ storing the result. Otherwise, if mysqli is compiled with libmysql,
209
        // it will have to allocate as much memory as it may be needed for the given column type
210 1805
        // (e.g. for a LONGBLOB field it's 4 gigabytes)
211
        // @link https://bugs.php.net/bug.php?id=51386#1270673122
212 1805
        //
213 1805
        // Make sure that the values are bound after each execution. Otherwise, if closeCursor() has been
214
        // previously called on the statement, the values are unbound making the statement unusable.
215 1805
        //
216 1805
        // It's also important that row values are bound after _each_ call to store_result(). Otherwise,
217
        // if mysqli is compiled with libmysql, subsequently fetched string values will get truncated
218
        // to the length of the ones fetched during the previous execution.
219
        assert(is_array($this->_columnNames));
220 1805
        $this->_rowBindedValues = array_fill(0, count($this->_columnNames), null);
221 1805
222 1798
        $refs = [];
223
        foreach ($this->_rowBindedValues as $key => &$value) {
224
            $refs[$key] =& $value;
225 1798
        }
226 1798
227 1798
        if (! $this->_stmt->bind_result(...$refs)) {
228
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
229
        }
230 1805
231
        $this->result = true;
232
233 1805
        return true;
234
    }
235
236 1805
    /**
237
     * Converts an array of named parameters, e.g. ['id' => 1, 'foo' => 'bar'] to the corresponding array with
238
     * positional parameters referring to the prepared query, e.g. [1 => 1, 2 => 'bar', 3 => 'bar'] for a prepared query
239
     * like "SELECT id FROM table WHERE foo = :foo and baz = :foo".
240 1805
     *
241 1805
     * @param array<int|string, mixed>|null $params
242
     *
243
     * @return mixed[]|null more specific: array<int, mixed>, I just don't know an elegant way to convince phpstan
244
     */
245
    private function convertNamedToPositionalParamsIfNeeded(?array $params = null)
246
    {
247
        if ($params === null || count($params) === 0) {
248 1805
            return $params;
249
        }
250 1805
251 1798
        if ($this->arrayHasOnlyIntegerKeys($params)) {
252 1798
            return $params;
253
        }
254 1798
255
        $positionalParameters = [];
256
257
        foreach ($params as $paramName => $paramValue) {
258 1798
            foreach ($this->placeholderNamesToNumbers[$paramName] as $number) {
259
                $positionalParameters[$number] = $paramValue;
260
            }
261
        }
262
263 1805
        return $positionalParameters;
264
    }
265
266
    /**
267
     * @param mixed[] $array
268
     *
269
     * @return bool
270
     */
271
    private function arrayHasOnlyIntegerKeys(array $array)
272 1742
    {
273
        return count(array_filter(array_keys($array), 'is_int')) === count($array);
274 1742
    }
275 1742
276
    /**
277 1742
     * Binds parameters with known types previously bound to the statement
278 1742
     */
279
    private function bindTypedParameters()
280
    {
281 1742
        $streams = $values = [];
282
        $types   = $this->types;
283
284
        foreach ($this->_bindedValues as $parameter => $value) {
285
            if (! isset($types[$parameter - 1])) {
286
                $types[$parameter - 1] = static::$_paramTypeMap[ParameterType::STRING];
287 1798
            }
288
289 1798
            if ($types[$parameter - 1] === static::$_paramTypeMap[ParameterType::LARGE_OBJECT]) {
290
                if (is_resource($value)) {
291 1798
                    if (get_resource_type($value) !== 'stream') {
292 1798
                        throw new InvalidArgumentException('Resources passed with the LARGE_OBJECT parameter type must be stream resources.');
293 1798
                    }
294 1798
                    $streams[$parameter] = $value;
295
                    $values[$parameter]  = null;
296
                    continue;
297 1798
                }
298
299
                $types[$parameter - 1] = static::$_paramTypeMap[ParameterType::STRING];
300 1798
            }
301
302
            $values[$parameter] = $value;
303
        }
304
305
        if (! $this->_stmt->bind_param($types, ...$values)) {
306 1798
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
307
        }
308
309
        $this->sendLongData($streams);
310 1798
    }
311 348
312
    /**
313
     * Handle $this->_longData after regular query parameters have been bound
314 1798
     *
315
     * @throws MysqliException
316 1798
     */
317 271
    private function sendLongData($streams)
318
    {
319
        foreach ($streams as $paramNr => $stream) {
320 1798
            while (! feof($stream)) {
321
                $chunk = fread($stream, 8192);
322 1798
323 1798
                if ($chunk === false) {
324
                    throw new MysqliException("Failed reading the stream resource for parameter offset ${paramNr}.");
325
                }
326 1798
327
                if (! $this->_stmt->send_long_data($paramNr - 1, $chunk)) {
328
                    throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
329
                }
330 1798
            }
331 1798
        }
332
    }
333
334 1742
    /**
335 1742
     * Binds a array of values to bound parameters.
336 1742
     *
337
     * @param mixed[] $values
338 1742
     *
339
     * @return bool
340 1742
     */
341
    private function bindUntypedValues(array $values)
342
    {
343 1721
        $params = [];
344
        $types  = str_repeat('s', count($values));
345
346 1441
        foreach ($values as &$v) {
347
            $params[] =& $v;
348
        }
349
350
        return $this->_stmt->bind_param($types, ...$params);
351
    }
352
353
    /**
354
     * @return mixed[]|false|null
355
     */
356 1798
    private function _fetch()
357
    {
358 1798
        $ret = $this->_stmt->fetch();
359
360 1798
        if ($ret === true) {
361
            $values = [];
362 1798
            foreach ($this->_rowBindedValues as $v) {
363 1798
                $values[] = $v;
364 1798
            }
365
366
            return $values;
367 1728
        }
368 1728
369
        return $ret;
370
    }
371
372 1798
    /**
373
     * {@inheritdoc}
374
     */
375
    public function fetch($fetchMode = null, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0)
376
    {
377
        // do not try fetching from the statement if it's not expected to contain result
378 1798
        // in order to prevent exceptional situation
379
        if (! $this->result) {
380 1798
            return false;
381
        }
382 1798
383 1798
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
384
385
        if ($fetchMode === FetchMode::COLUMN) {
386 1798
            return $this->fetchColumn();
387
        }
388
389
        $values = $this->_fetch();
390
391
        if ($values === null) {
392
            return false;
393
        }
394
395
        if ($values === false) {
396
            throw new MysqliException($this->_stmt->error, $this->_stmt->sqlstate, $this->_stmt->errno);
397
        }
398
399
        if ($fetchMode === FetchMode::NUMERIC) {
400
            return $values;
401
        }
402
403
        assert(is_array($this->_columnNames));
404
        $assoc = array_combine($this->_columnNames, $values);
405
        assert(is_array($assoc));
406
407
        switch ($fetchMode) {
408 1053
            case FetchMode::ASSOCIATIVE:
409
                return $assoc;
410 1053
411 1053
            case FetchMode::MIXED:
412
                return $assoc + $values;
413 1053
414
            case FetchMode::STANDARD_OBJECT:
415
                return (object) $assoc;
416
417
            default:
418
                throw new MysqliException(sprintf("Unknown fetch type '%s'", $fetchMode));
419 1805
        }
420
    }
421 1805
422 1805
    /**
423
     * {@inheritdoc}
424
     */
425
    public function fetchAll($fetchMode = null, $fetchArgument = null, $ctorArgs = null)
426
    {
427
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
428
429
        $rows = [];
430
431 1053
        if ($fetchMode === FetchMode::COLUMN) {
432
            while (($row = $this->fetchColumn()) !== false) {
433 1053
                $rows[] = $row;
434
            }
435
        } else {
436
            while (($row = $this->fetch($fetchMode)) !== false) {
437
                $rows[] = $row;
438
            }
439 1798
        }
440
441 1798
        return $rows;
442
    }
443 1798
444
    /**
445
     * {@inheritdoc}
446
     */
447
    public function fetchColumn($columnIndex = 0)
448
    {
449 1858
        $row = $this->fetch(FetchMode::NUMERIC);
450
451 1858
        if ($row === false) {
452
            return false;
453
        }
454
455
        return $row[$columnIndex] ?? null;
456
    }
457
458
    /**
459
     * {@inheritdoc}
460
     */
461
    public function errorCode()
462
    {
463
        return $this->_stmt->errno;
464
    }
465
466
    /**
467
     * {@inheritdoc}
468
     */
469
    public function errorInfo()
470
    {
471
        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...
472
    }
473
474
    /**
475
     * {@inheritdoc}
476
     */
477
    public function closeCursor()
478
    {
479
        $this->_stmt->free_result();
480
        $this->result = false;
481
482
        return true;
483
    }
484
485
    /**
486
     * {@inheritdoc}
487
     */
488
    public function rowCount()
489
    {
490
        if ($this->_columnNames === false) {
491
            return $this->_stmt->affected_rows;
492
        }
493
494
        return $this->_stmt->num_rows;
495
    }
496
497
    /**
498
     * {@inheritdoc}
499
     */
500
    public function columnCount()
501
    {
502
        return $this->_stmt->field_count;
503
    }
504
505
    /**
506
     * {@inheritdoc}
507
     */
508
    public function setFetchMode($fetchMode, $arg2 = null, $arg3 = null)
509
    {
510
        $this->_defaultFetchMode = $fetchMode;
511
512
        return true;
513
    }
514
515
    /**
516
     * {@inheritdoc}
517
     */
518
    public function getIterator()
519
    {
520
        return new StatementIterator($this);
521
    }
522
523
    private function initializeColumnNamesIfNeeded()
524
    {
525
        if ($this->_columnNames !== null) {
526
            return;
527
        }
528
529
        $meta = $this->_stmt->result_metadata();
530
        if ($meta === false) {
531
            $this->_columnNames = false;
532
533
            return;
534
        }
535
536
        $fields = $meta->fetch_fields();
537
        assert(is_array($fields));
538
539
        $this->_columnNames = [];
540
        foreach ($fields as $col) {
541
            $this->_columnNames[] = $col->name;
542
        }
543
544
        $meta->free();
545
    }
546
}
547