Completed
Pull Request — master (#3808)
by Sergei
60:47
created

OCI8Statement::execute()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 28
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 7.0368

Importance

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

152
                $result = self::findClosingQuote($statement, $tokenOffset, /** @scrutinizer ignore-type */ $currentLiteralDelimiter);
Loading history...
153
            }
154 624
        } while ($result);
155 6
156 6
        if ($currentLiteralDelimiter) {
157 6
            throw new OCI8Exception(sprintf(
158
                'The statement contains non-terminated string literal starting at offset %d.',
159
                $tokenOffset - 1
160
            ));
161 618
        }
162 618
163
        $fragments[] = substr($statement, $fragmentOffset);
164 618
        $statement   = implode('', $fragments);
165
166
        return [$statement, $paramMap];
167
    }
168
169
    /**
170
     * Finds next placeholder or opening quote.
171
     *
172
     * @param string      $statement               The SQL statement to parse
173
     * @param int         $tokenOffset             The offset to start searching from
174
     * @param int         $fragmentOffset          The offset to build the next fragment from
175
     * @param string[]    $fragments               Fragments of the original statement not containing placeholders
176
     * @param string|null $currentLiteralDelimiter The delimiter of the current string literal
177
     *                                             or NULL if not currently in a literal
178
     * @param string[]    $paramMap                Mapping of the original parameter positions to their named replacements
179
     *
180 624
     * @return bool Whether the token was found
181
     */
182
    private static function findPlaceholderOrOpeningQuote(
183
        string $statement,
184
        int &$tokenOffset,
185
        int &$fragmentOffset,
186
        array &$fragments,
187
        ?string &$currentLiteralDelimiter,
188 624
        array &$paramMap
189
    ) : bool {
190 624
        $token = self::findToken($statement, $tokenOffset, '/[?\'"]/');
191 618
192
        if (! $token) {
193
            return false;
194 541
        }
195 414
196 414
        if ($token === '?') {
197 414
            $position            = count($paramMap) + 1;
198 414
            $param               = ':param' . $position;
199 414
            $fragments[]         = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
200 414
            $fragments[]         = $param;
201 414
            $paramMap[$position] = $param;
202
            $tokenOffset        += 1;
203 414
            $fragmentOffset      = $tokenOffset;
204
205
            return true;
206 388
        }
207 388
208
        $currentLiteralDelimiter = $token;
209 388
        ++$tokenOffset;
210
211
        return true;
212
    }
213
214
    /**
215
     * Finds closing quote
216
     *
217
     * @param string $statement               The SQL statement to parse
218
     * @param int    $tokenOffset             The offset to start searching from
219
     * @param string $currentLiteralDelimiter The delimiter of the current string literal
220
     *
221 388
     * @return bool Whether the token was found
222
     */
223
    private static function findClosingQuote(
224
        string $statement,
225
        int &$tokenOffset,
226 388
        string &$currentLiteralDelimiter
227 188
    ) : bool {
228
        $token = self::findToken(
229 388
            $statement,
230
            $tokenOffset,
231
            '/' . preg_quote($currentLiteralDelimiter, '/') . '/'
232 388
        );
233 6
234
        if (! $token) {
235
            return false;
236 384
        }
237 384
238
        $currentLiteralDelimiter = null;
239 384
        ++$tokenOffset;
240
241
        return true;
242
    }
243
244
    /**
245
     * Finds the token described by regex starting from the given offset. Updates the offset with the position
246
     * where the token was found.
247
     *
248
     * @param string $statement The SQL statement to parse
249
     * @param int    $offset    The offset to start searching from
250
     * @param string $regex     The regex containing token pattern
251
     *
252 624
     * @return string|null Token or NULL if not found
253
     */
254 624
    private static function findToken(string $statement, int &$offset, string $regex) : ?string
255 541
    {
256
        if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset)) {
257 541
            $offset = $matches[0][1];
258
259
            return $matches[0][0];
260 624
        }
261
262
        return null;
263
    }
264
265
    /**
266 124
     * {@inheritdoc}
267
     */
268 124
    public function bindValue($param, $value, int $type = ParameterType::STRING) : void
269 123
    {
270
        $this->bindParam($param, $value, $type, null);
271
    }
272
273
    /**
274 145
     * {@inheritdoc}
275
     */
276 145
    public function bindParam($param, &$variable, int $type = ParameterType::STRING, ?int $length = null) : void
277
    {
278 145
        if (is_int($param)) {
279 4
            if (! isset($this->_paramMap[$param])) {
280
                throw new OCI8Exception(sprintf('Could not find variable mapping with index %d, in the SQL statement', $param));
281 4
            }
282 4
283
            $param = $this->_paramMap[$param];
284 4
        }
285
286 4
        if ($type === ParameterType::LARGE_OBJECT) {
287
            $lob = oci_new_descriptor($this->_dbh, OCI_D_LOB);
288
289 145
            $class = 'OCI-Lob';
290
            assert($lob instanceof $class);
291 145
292 145
            $lob->writeTemporary($variable, OCI_TEMP_BLOB);
293 145
294 145
            $variable =& $lob;
295 145
        }
296 145
297
        $this->boundValues[$param] =& $variable;
298
299
        if (! oci_bind_by_name(
300 144
            $this->_sth,
301
            $param,
302
            $variable,
303
            $length ?? -1,
304
            $this->convertParameterType($type)
305 145
        )) {
306
            throw OCI8Exception::fromErrorInfo(oci_error($this->_sth));
307 145
        }
308
    }
309 1
310
    /**
311
     * Converts DBAL parameter type to oci8 parameter type
312 4
     */
313
    private function convertParameterType(int $type) : int
314
    {
315 143
        switch ($type) {
316
            case ParameterType::BINARY:
317
                return OCI_B_BIN;
318
319
            case ParameterType::LARGE_OBJECT:
320
                return OCI_B_BLOB;
321
322 19
            default:
323
                return SQLT_CHR;
324
        }
325 19
    }
326 3
327
    /**
328
     * {@inheritdoc}
329 16
     */
330
    public function closeCursor() : void
331 16
    {
332 16
        // not having the result means there's nothing to close
333
        if (! $this->result) {
334
            return;
335
        }
336
337 4
        oci_cancel($this->_sth);
338
339 4
        $this->result = false;
340
    }
341
342
    /**
343
     * {@inheritdoc}
344
     */
345 351
    public function columnCount() : int
346
    {
347 351
        return oci_num_fields($this->_sth) ?: 0;
348 99
    }
349
350 99
    /**
351 99
     * {@inheritdoc}
352 99
     */
353
    public function execute(?array $params = null) : void
354
    {
355
        if ($params) {
356
            $hasZeroIndex = array_key_exists(0, $params);
357 99
358
            foreach ($params as $key => $val) {
359
                if ($hasZeroIndex && is_int($key)) {
360
                    $param = $key + 1;
361 347
                } else {
362 347
                    $param = $key;
363 152
                }
364
365
                $this->bindValue($param, $val);
366 336
            }
367 336
        }
368
369
        if ($this->executionMode->isAutoCommitEnabled()) {
370
            $mode = OCI_COMMIT_ON_SUCCESS;
371
        } else {
372 311
            $mode = OCI_NO_AUTO_COMMIT;
373
        }
374 311
375 311
        $ret = @oci_execute($this->_sth, $mode);
376
        if (! $ret) {
377
            throw OCI8Exception::fromErrorInfo(oci_error($this->_sth));
378
        }
379
380 5
        $this->result = true;
381
    }
382 5
383
    /**
384
     * {@inheritdoc}
385
     */
386
    public function setFetchMode(int $fetchMode, ...$args) : void
387
    {
388 117
        $this->_defaultFetchMode = $fetchMode;
389
    }
390
391
    /**
392 117
     * {@inheritdoc}
393 3
     */
394
    public function getIterator()
395
    {
396 114
        return new StatementIterator($this);
397
    }
398 114
399 1
    /**
400
     * {@inheritdoc}
401
     */
402 113
    public function fetch(?int $fetchMode = null, ...$args)
403 1
    {
404
        // do not try fetching from the statement if it's not expected to contain result
405
        // in order to prevent exceptional situation
406 112
        if (! $this->result) {
407
            return false;
408
        }
409
410 112
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
411 112
412 112
        if ($fetchMode === FetchMode::COLUMN) {
413
            return $this->fetchColumn();
414
        }
415
416
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
417
            return oci_fetch_object($this->_sth);
418
        }
419 109
420
        if (! isset(self::$fetchModeMap[$fetchMode])) {
421 109
            throw new InvalidArgumentException(sprintf('Invalid fetch mode %d.', $fetchMode));
422
        }
423 109
424
        return oci_fetch_array(
425 109
            $this->_sth,
426 1
            self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | OCI_RETURN_LOBS
427 1
        );
428
    }
429
430 1
    /**
431
     * {@inheritdoc}
432
     */
433 108
    public function fetchAll(?int $fetchMode = null, ...$args) : array
434
    {
435
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
436
437 108
        $result = [];
438 1
439 1
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
440
            while ($row = $this->fetch($fetchMode)) {
441
                $result[] = $row;
442 107
            }
443
444 107
            return $result;
445 7
        }
446
447
        if (! isset(self::$fetchModeMap[$fetchMode])) {
448
            throw new InvalidArgumentException(sprintf('Invalid fetch mode %d.', $fetchMode));
449
        }
450 107
451 3
        if (self::$fetchModeMap[$fetchMode] === OCI_BOTH) {
452
            while ($row = $this->fetch($fetchMode)) {
453
                $result[] = $row;
454 104
            }
455 104
        } else {
456 104
            $fetchStructure = OCI_FETCHSTATEMENT_BY_ROW;
457 104
458 104
            if ($fetchMode === FetchMode::COLUMN) {
459 104
                $fetchStructure = OCI_FETCHSTATEMENT_BY_COLUMN;
460
            }
461
462 104
            // do not try fetching from the statement if it's not expected to contain result
463 7
            // in order to prevent exceptional situation
464
            if (! $this->result) {
465
                return [];
466
            }
467 105
468
            oci_fetch_all(
469
                $this->_sth,
470
                $result,
471
                0,
472
                -1,
473 184
                self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | $fetchStructure | OCI_RETURN_LOBS
474
            );
475
476
            if ($fetchMode === FetchMode::COLUMN) {
477 184
                $result = $result[0];
478 3
            }
479
        }
480
481 181
        return $result;
482
    }
483 181
484 3
    /**
485
     * {@inheritdoc}
486
     */
487 178
    public function fetchColumn(int $columnIndex = 0)
488 2
    {
489
        // do not try fetching from the statement if it's not expected to contain result
490
        // in order to prevent exceptional situation
491 176
        if (! $this->result) {
492
            return false;
493
        }
494
495
        $row = oci_fetch_array($this->_sth, OCI_NUM | OCI_RETURN_NULLS | OCI_RETURN_LOBS);
496
497 174
        if ($row === false) {
498
            return false;
499 174
        }
500
501
        if (! array_key_exists($columnIndex, $row)) {
502
            throw InvalidColumnIndex::new($columnIndex, count($row));
503
        }
504
505
        return $row[$columnIndex];
506
    }
507
508
    /**
509
     * {@inheritdoc}
510
     */
511
    public function rowCount() : int
512
    {
513
        return oci_num_rows($this->_sth) ?: 0;
514
    }
515
}
516