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

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

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