Failed Conditions
Pull Request — master (#3369)
by Sergei
65:22
created

convertPositionalToNamedPlaceholders()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 32
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 4

Importance

Changes 0
Metric Value
eloc 22
dl 0
loc 32
ccs 21
cts 21
cp 1
rs 9.568
c 0
b 0
f 0
cc 4
nc 4
nop 1
crap 4
1
<?php
2
3
namespace Doctrine\DBAL\Driver\OCI8;
4
5
use Doctrine\DBAL\Driver\Statement;
6
use Doctrine\DBAL\Driver\StatementIterator;
7
use Doctrine\DBAL\FetchMode;
8
use Doctrine\DBAL\ParameterType;
9
use InvalidArgumentException;
10
use IteratorAggregate;
11
use PDO;
12
use const OCI_ASSOC;
13
use const OCI_B_BIN;
14
use const OCI_B_BLOB;
15
use const OCI_BOTH;
16
use const OCI_D_LOB;
17
use const OCI_FETCHSTATEMENT_BY_COLUMN;
18
use const OCI_FETCHSTATEMENT_BY_ROW;
19
use const OCI_NUM;
20
use const OCI_RETURN_LOBS;
21
use const OCI_RETURN_NULLS;
22
use const OCI_TEMP_BLOB;
23
use const PREG_OFFSET_CAPTURE;
24
use const SQLT_CHR;
25
use function array_key_exists;
26
use function count;
27
use function implode;
28
use function is_numeric;
29
use function oci_bind_by_name;
30
use function oci_cancel;
31
use function oci_error;
32
use function oci_execute;
33
use function oci_fetch_all;
34
use function oci_fetch_array;
35
use function oci_fetch_object;
36
use function oci_new_descriptor;
37
use function oci_num_fields;
38
use function oci_num_rows;
39
use function oci_parse;
40
use function preg_match;
41
use function preg_quote;
42
use function sprintf;
43
use function substr;
44
45
/**
46
 * The OCI8 implementation of the Statement interface.
47
 */
48
class OCI8Statement implements IteratorAggregate, Statement
49
{
50
    /** @var resource */
51
    protected $_dbh;
52
53
    /** @var resource */
54
    protected $_sth;
55
56
    /** @var OCI8Connection */
57
    protected $_conn;
58
59
    /** @var string */
60
    protected static $_PARAM = ':param';
61
62
    /** @var int[] */
63
    protected static $fetchModeMap = [
64
        FetchMode::MIXED       => OCI_BOTH,
65
        FetchMode::ASSOCIATIVE => OCI_ASSOC,
66
        FetchMode::NUMERIC     => OCI_NUM,
67
        FetchMode::COLUMN      => OCI_NUM,
68
    ];
69
70
    /** @var int */
71
    protected $_defaultFetchMode = FetchMode::MIXED;
72
73
    /** @var string[] */
74
    protected $_paramMap = [];
75
76
    /**
77
     * Holds references to bound parameter values.
78
     *
79
     * This is a new requirement for PHP7's oci8 extension that prevents bound values from being garbage collected.
80
     *
81
     * @var mixed[]
82
     */
83
    private $boundValues = [];
84
85
    /**
86
     * Indicates whether the statement is in the state when fetching results is possible
87
     *
88
     * @var bool
89
     */
90
    private $result = false;
91
92
    /**
93
     * Creates a new OCI8Statement that uses the given connection handle and SQL statement.
94
     *
95
     * @param resource $dbh       The connection handle.
96
     * @param string   $statement The SQL statement.
97
     */
98 261
    public function __construct($dbh, $statement, OCI8Connection $conn)
99
    {
100 261
        [$statement, $paramMap] = self::convertPositionalToNamedPlaceholders($statement);
101 261
        $this->_sth             = oci_parse($dbh, $statement);
102 261
        $this->_dbh             = $dbh;
103 261
        $this->_paramMap        = $paramMap;
104 261
        $this->_conn            = $conn;
105 261
    }
106
107
    /**
108
     * Converts positional (?) into named placeholders (:param<num>).
109
     *
110
     * Oracle does not support positional parameters, hence this method converts all
111
     * positional parameters into artificially named parameters. Note that this conversion
112
     * is not perfect. All question marks (?) in the original statement are treated as
113
     * placeholders and converted to a named parameter.
114
     *
115
     * The algorithm uses a state machine with two possible states: InLiteral and NotInLiteral.
116
     * Question marks inside literal strings are therefore handled correctly by this method.
117
     * This comes at a cost, the whole sql statement has to be looped over.
118
     *
119
     * @param string $statement The SQL statement to convert.
120
     *
121
     * @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array).
122
     *
123
     * @throws OCI8Exception
124
     *
125
     * @todo extract into utility class in Doctrine\DBAL\Util namespace
126
     * @todo review and test for lost spaces. we experienced missing spaces with oci8 in some sql statements.
127
     */
128 504
    public static function convertPositionalToNamedPlaceholders($statement)
129
    {
130 504
        $fragmentOffset          = $tokenOffset = 0;
131 504
        $fragments               = $paramMap = [];
132 504
        $currentLiteralDelimiter = null;
133
134
        do {
135 504
            if (! $currentLiteralDelimiter) {
136 504
                $result = self::findPlaceholderOrOpeningQuote(
137 504
                    $statement,
138 504
                    $tokenOffset,
139 504
                    $fragmentOffset,
140 504
                    $fragments,
141 504
                    $currentLiteralDelimiter,
142 504
                    $paramMap
143
                );
144
            } else {
145 312
                $result = self::findClosingQuote($statement, $tokenOffset, $currentLiteralDelimiter);
0 ignored issues
show
Bug introduced by
$currentLiteralDelimiter of type void is incompatible with the type null|string expected by parameter $currentLiteralDelimiter of Doctrine\DBAL\Driver\OCI...ent::findClosingQuote(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

145
                $result = self::findClosingQuote($statement, $tokenOffset, /** @scrutinizer ignore-type */ $currentLiteralDelimiter);
Loading history...
146
            }
147 504
        } while ($result);
148
149 504
        if ($currentLiteralDelimiter) {
150 3
            throw new OCI8Exception(sprintf(
151 3
                'The statement contains non-terminated string literal starting at offset %d',
152 3
                $tokenOffset - 1
153
            ));
154
        }
155
156 501
        $fragments[] = substr($statement, $fragmentOffset);
157 501
        $statement   = implode('', $fragments);
158
159 501
        return [$statement, $paramMap];
160
    }
161
162
    /**
163
     * Finds next placeholder or opening quote.
164
     *
165
     * @param string             $statement               The SQL statement to parse
166
     * @param string             $tokenOffset             The offset to start searching from
167
     * @param int                $fragmentOffset          The offset to build the next fragment from
168
     * @param string[]           $fragments               Fragments of the original statement not containing placeholders
169
     * @param string|null        $currentLiteralDelimiter The delimiter of the current string literal
170
     *                                                    or NULL if not currently in a literal
171
     * @param array<int, string> $paramMap                Mapping of the original parameter positions to their named replacements
172
     *
173
     * @return bool Whether the token was found
174
     */
175 504
    private static function findPlaceholderOrOpeningQuote(
176
        $statement,
177
        &$tokenOffset,
178
        &$fragmentOffset,
179
        &$fragments,
180
        &$currentLiteralDelimiter,
181
        &$paramMap
182
    ) {
183 504
        $token = self::findToken($statement, $tokenOffset, '/[?\'"]/');
184
185 504
        if (! $token) {
186 501
            return false;
187
        }
188
189 469
        if ($token === '?') {
190 363
            $position            = count($paramMap) + 1;
191 363
            $param               = ':param' . $position;
192 363
            $fragments[]         = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
193 363
            $fragments[]         = $param;
194 363
            $paramMap[$position] = $param;
195 363
            $tokenOffset        += 1;
196 363
            $fragmentOffset      = $tokenOffset;
197
198 363
            return true;
199
        }
200
201 312
        $currentLiteralDelimiter = $token;
202 312
        ++$tokenOffset;
203
204 312
        return true;
205
    }
206
207
    /**
208
     * Finds closing quote
209
     *
210
     * @param string      $statement               The SQL statement to parse
211
     * @param string      $tokenOffset             The offset to start searching from
212
     * @param string|null $currentLiteralDelimiter The delimiter of the current string literal
213
     *                                             or NULL if not currently in a literal
214
     *
215
     * @return bool Whether the token was found
216
     */
217 312
    private static function findClosingQuote(
218
        $statement,
219
        &$tokenOffset,
220
        &$currentLiteralDelimiter
221
    ) {
222 312
        $token = self::findToken(
223 312
            $statement,
224 312
            $tokenOffset,
225 312
            '/' . preg_quote($currentLiteralDelimiter, '/') . '/'
226
        );
227
228 312
        if (! $token) {
229 3
            return false;
230
        }
231
232 310
        $currentLiteralDelimiter = false;
233 310
        ++$tokenOffset;
234
235 310
        return true;
236
    }
237
238
    /**
239
     * Finds the token described by regex starting from the given offset. Updates the offset with the position
240
     * where the token was found.
241
     *
242
     * @param string $statement The SQL statement to parse
243
     * @param string $offset    The offset to start searching from
244
     * @param string $regex     The regex containing token pattern
245
     *
246
     * @return string|null Token or NULL if not found
247
     */
248 504
    private static function findToken($statement, &$offset, $regex)
249
    {
250 504
        if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset)) {
0 ignored issues
show
Bug introduced by
$offset of type string is incompatible with the type integer expected by parameter $offset of preg_match(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

250
        if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, /** @scrutinizer ignore-type */ $offset)) {
Loading history...
251 469
            $offset = $matches[0][1];
252 469
            return $matches[0][0];
253
        }
254
255 504
        return null;
256
    }
257
258
    /**
259
     * {@inheritdoc}
260
     */
261 118
    public function bindValue($param, $value, $type = ParameterType::STRING)
262
    {
263 118
        return $this->bindParam($param, $value, $type, null);
264
    }
265
266
    /**
267
     * {@inheritdoc}
268
     */
269 123
    public function bindParam($column, &$variable, $type = ParameterType::STRING, $length = null)
270
    {
271 123
        $column = $this->_paramMap[$column] ?? $column;
272
273 123
        if ($type === ParameterType::LARGE_OBJECT) {
274 4
            $lob = oci_new_descriptor($this->_dbh, OCI_D_LOB);
275 4
            $lob->writeTemporary($variable, OCI_TEMP_BLOB);
276
277 4
            $variable =& $lob;
278
        }
279
280 123
        $this->boundValues[$column] =& $variable;
281
282 123
        return oci_bind_by_name(
283 123
            $this->_sth,
284 123
            $column,
285 123
            $variable,
286 123
            $length ?? -1,
287 123
            $this->convertParameterType($type)
288
        );
289
    }
290
291
    /**
292
     * Converts DBAL parameter type to oci8 parameter type
293
     */
294 123
    private function convertParameterType(int $type) : int
295
    {
296 123
        switch ($type) {
297
            case ParameterType::BINARY:
298 1
                return OCI_B_BIN;
299
300
            case ParameterType::LARGE_OBJECT:
301 4
                return OCI_B_BLOB;
302
303
            default:
304 121
                return SQLT_CHR;
305
        }
306
    }
307
308
    /**
309
     * {@inheritdoc}
310
     */
311 19
    public function closeCursor()
312
    {
313
        // not having the result means there's nothing to close
314 19
        if (! $this->result) {
315 4
            return true;
316
        }
317
318 15
        oci_cancel($this->_sth);
319
320 15
        $this->result = false;
321
322 15
        return true;
323
    }
324
325
    /**
326
     * {@inheritdoc}
327
     */
328 4
    public function columnCount()
329
    {
330 4
        return oci_num_fields($this->_sth);
331
    }
332
333
    /**
334
     * {@inheritdoc}
335
     */
336
    public function errorCode()
337
    {
338
        $error = oci_error($this->_sth);
339
        if ($error !== false) {
340
            $error = $error['code'];
341
        }
342
343
        return $error;
344
    }
345
346
    /**
347
     * {@inheritdoc}
348
     */
349 139
    public function errorInfo()
350
    {
351 139
        return oci_error($this->_sth);
352
    }
353
354
    /**
355
     * {@inheritdoc}
356
     */
357 262
    public function execute($params = null)
358
    {
359 262
        if ($params) {
360 91
            $hasZeroIndex = array_key_exists(0, $params);
361 91
            foreach ($params as $key => $val) {
362 91
                if ($hasZeroIndex && is_numeric($key)) {
363 91
                    $param = $key + 1;
364
                } else {
365 91
                    $param = $key;
366
                }
367
368
                if (! $this->bindValue($param, $val)) {
369
                    throw OCI8Exception::fromErrorInfo($this->errorInfo());
370 258
                }
371 258
            }
372 141
        }
373
374
        $ret = @oci_execute($this->_sth, $this->_conn->getExecuteMode());
375 255
        if (! $ret) {
376
            throw OCI8Exception::fromErrorInfo($this->errorInfo());
377 255
        }
378
379
        $this->result = true;
380
381
        return $ret;
382
    }
383 228
384
    /**
385 228
     * {@inheritdoc}
386
     */
387 228
    public function setFetchMode($fetchMode, $arg2 = null, $arg3 = null)
388
    {
389
        $this->_defaultFetchMode = $fetchMode;
390
391
        return true;
392
    }
393 3
394
    /**
395 3
     * {@inheritdoc}
396
     */
397
    public function getIterator()
398
    {
399
        return new StatementIterator($this);
400
    }
401 78
402
    /**
403
     * {@inheritdoc}
404
     */
405 78
    public function fetch($fetchMode = null, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0)
406 3
    {
407
        // do not try fetching from the statement if it's not expected to contain result
408
        // in order to prevent exceptional situation
409 75
        if (! $this->result) {
410
            return false;
411 75
        }
412 1
413
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
414
415 74
        if ($fetchMode === FetchMode::COLUMN) {
416 1
            return $this->fetchColumn();
417
        }
418
419 73
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
420
            return oci_fetch_object($this->_sth);
421
        }
422
423 73
        if (! isset(self::$fetchModeMap[$fetchMode])) {
424 73
            throw new InvalidArgumentException('Invalid fetch style: ' . $fetchMode);
425 73
        }
426
427
        return oci_fetch_array(
428
            $this->_sth,
429
            self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | OCI_RETURN_LOBS
430
        );
431
    }
432 97
433
    /**
434 97
     * {@inheritdoc}
435
     */
436 97
    public function fetchAll($fetchMode = null, $fetchArgument = null, $ctorArgs = null)
437
    {
438 97
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
439 1
440 1
        $result = [];
441
442
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
443 1
            while ($row = $this->fetch($fetchMode)) {
444
                $result[] = $row;
445
            }
446 96
447
            return $result;
448
        }
449
450 96
        if (! isset(self::$fetchModeMap[$fetchMode])) {
451 1
            throw new InvalidArgumentException('Invalid fetch style: ' . $fetchMode);
452 1
        }
453
454
        if (self::$fetchModeMap[$fetchMode] === OCI_BOTH) {
455 95
            while ($row = $this->fetch($fetchMode)) {
456
                $result[] = $row;
457 95
            }
458 6
        } else {
459
            $fetchStructure = OCI_FETCHSTATEMENT_BY_ROW;
460
461
            if ($fetchMode === FetchMode::COLUMN) {
462
                $fetchStructure = OCI_FETCHSTATEMENT_BY_COLUMN;
463 95
            }
464 3
465
            // do not try fetching from the statement if it's not expected to contain result
466
            // in order to prevent exceptional situation
467 92
            if (! $this->result) {
468 92
                return [];
469 92
            }
470 92
471 92
            oci_fetch_all(
472 92
                $this->_sth,
473
                $result,
474
                0,
475 92
                -1,
476 6
                self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | $fetchStructure | OCI_RETURN_LOBS
477
            );
478
479
            if ($fetchMode === FetchMode::COLUMN) {
480 93
                $result = $result[0];
481
            }
482
        }
483
484
        return $result;
485
    }
486 53
487
    /**
488
     * {@inheritdoc}
489
     */
490 53
    public function fetchColumn($columnIndex = 0)
491 3
    {
492
        // do not try fetching from the statement if it's not expected to contain result
493
        // in order to prevent exceptional situation
494 50
        if (! $this->result) {
495
            return false;
496 50
        }
497 3
498
        $row = oci_fetch_array($this->_sth, OCI_NUM | OCI_RETURN_NULLS | OCI_RETURN_LOBS);
499
500 47
        if ($row === false) {
501
            return false;
502
        }
503
504
        return $row[$columnIndex] ?? null;
505
    }
506 175
507
    /**
508 175
     * {@inheritdoc}
509
     */
510
    public function rowCount()
511
    {
512
        return oci_num_rows($this->_sth);
513
    }
514
}
515