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