Completed
Pull Request — master (#3738)
by
unknown
60:54
created

OCI8Statement::findPlaceholderOrOpeningQuote()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 30
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 3

Importance

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

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