Completed
Pull Request — 3.0.x (#3070)
by Sergei
63:18
created

OCI8Statement::findPlaceholderOrOpeningQuote()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 30
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 30
rs 9.7666
c 0
b 0
f 0
cc 3
nc 3
nop 6
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 assert;
26
use function count;
27
use function implode;
28
use function is_int;
29
use function is_resource;
30
use function oci_bind_by_name;
31
use function oci_cancel;
32
use function oci_error;
33
use function oci_execute;
34
use function oci_fetch_all;
35
use function oci_fetch_array;
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
    /**
60
     * @deprecated
61
     *
62
     * @var string
63
     */
64
    protected static $_PARAM = ':param';
65
66
    /** @var int[] */
67
    protected static $fetchModeMap = [
68
        FetchMode::MIXED       => OCI_BOTH,
69
        FetchMode::ASSOCIATIVE => OCI_ASSOC,
70
        FetchMode::NUMERIC     => OCI_NUM,
71
        FetchMode::COLUMN      => OCI_NUM,
72
    ];
73
74
    /** @var int */
75
    protected $_defaultFetchMode = FetchMode::MIXED;
76
77
    /** @var string[] */
78
    protected $_paramMap = [];
79
80
    /**
81
     * Holds references to bound parameter values.
82
     *
83
     * This is a new requirement for PHP7's oci8 extension that prevents bound values from being garbage collected.
84
     *
85
     * @var mixed[]
86
     */
87
    private $boundValues = [];
88
89
    /**
90
     * Indicates whether the statement is in the state when fetching results is possible
91
     *
92
     * @var bool
93
     */
94
    private $result = false;
95
96
    /**
97
     * Creates a new OCI8Statement that uses the given connection handle and SQL statement.
98
     *
99
     * @param resource $dbh   The connection handle.
100
     * @param string   $query The SQL query.
101
     */
102
    public function __construct($dbh, $query, OCI8Connection $conn)
103
    {
104
        [$query, $paramMap] = self::convertPositionalToNamedPlaceholders($query);
105
106
        $stmt = oci_parse($dbh, $query);
107
        assert(is_resource($stmt));
108
109
        $this->_sth      = $stmt;
110
        $this->_dbh      = $dbh;
111
        $this->_paramMap = $paramMap;
112
        $this->_conn     = $conn;
113
    }
114
115
    /**
116
     * Converts positional (?) into named placeholders (:param<num>).
117
     *
118
     * Oracle does not support positional parameters, hence this method converts all
119
     * positional parameters into artificially named parameters. Note that this conversion
120
     * is not perfect. All question marks (?) in the original statement are treated as
121
     * placeholders and converted to a named parameter.
122
     *
123
     * The algorithm uses a state machine with two possible states: InLiteral and NotInLiteral.
124
     * Question marks inside literal strings are therefore handled correctly by this method.
125
     * This comes at a cost, the whole sql statement has to be looped over.
126
     *
127
     * @param string $statement The SQL statement to convert.
128
     *
129
     * @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array).
130
     *
131
     * @throws OCI8Exception
132
     *
133
     * @todo extract into utility class in Doctrine\DBAL\Util namespace
134
     * @todo review and test for lost spaces. we experienced missing spaces with oci8 in some sql statements.
135
     */
136
    public static function convertPositionalToNamedPlaceholders($statement)
137
    {
138
        $fragmentOffset          = $tokenOffset = 0;
139
        $fragments               = $paramMap = [];
140
        $currentLiteralDelimiter = null;
141
142
        do {
143
            if (! $currentLiteralDelimiter) {
144
                $result = self::findPlaceholderOrOpeningQuote(
145
                    $statement,
146
                    $tokenOffset,
147
                    $fragmentOffset,
148
                    $fragments,
149
                    $currentLiteralDelimiter,
150
                    $paramMap
151
                );
152
            } else {
153
                $result = self::findClosingQuote($statement, $tokenOffset, $currentLiteralDelimiter);
0 ignored issues
show
Bug introduced by
$currentLiteralDelimiter of type void 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

153
                $result = self::findClosingQuote($statement, $tokenOffset, /** @scrutinizer ignore-type */ $currentLiteralDelimiter);
Loading history...
154
            }
155
        } while ($result);
156
157
        if ($currentLiteralDelimiter) {
158
            throw new OCI8Exception(sprintf(
159
                'The statement contains non-terminated string literal starting at offset %d',
160
                $tokenOffset - 1
161
            ));
162
        }
163
164
        $fragments[] = substr($statement, $fragmentOffset);
165
        $statement   = implode('', $fragments);
166
167
        return [$statement, $paramMap];
168
    }
169
170
    /**
171
     * Finds next placeholder or opening quote.
172
     *
173
     * @param string             $statement               The SQL statement to parse
174
     * @param string             $tokenOffset             The offset to start searching from
175
     * @param int                $fragmentOffset          The offset to build the next fragment from
176
     * @param string[]           $fragments               Fragments of the original statement not containing placeholders
177
     * @param string|null        $currentLiteralDelimiter The delimiter of the current string literal
178
     *                                                    or NULL if not currently in a literal
179
     * @param array<int, string> $paramMap                Mapping of the original parameter positions to their named replacements
180
     *
181
     * @return bool Whether the token was found
182
     */
183
    private static function findPlaceholderOrOpeningQuote(
184
        $statement,
185
        &$tokenOffset,
186
        &$fragmentOffset,
187
        &$fragments,
188
        &$currentLiteralDelimiter,
189
        &$paramMap
190
    ) {
191
        $token = self::findToken($statement, $tokenOffset, '/[?\'"]/');
0 ignored issues
show
Bug introduced by
$tokenOffset of type string is incompatible with the type integer expected by parameter $offset of Doctrine\DBAL\Driver\OCI...8Statement::findToken(). ( Ignorable by Annotation )

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

191
        $token = self::findToken($statement, /** @scrutinizer ignore-type */ $tokenOffset, '/[?\'"]/');
Loading history...
192
193
        if ($token === null) {
194
            return false;
195
        }
196
197
        if ($token === '?') {
198
            $position            = count($paramMap) + 1;
199
            $param               = ':param' . $position;
200
            $fragments[]         = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
201
            $fragments[]         = $param;
202
            $paramMap[$position] = $param;
203
            $tokenOffset        += 1;
204
            $fragmentOffset      = $tokenOffset;
205
206
            return true;
207
        }
208
209
        $currentLiteralDelimiter = $token;
210
        ++$tokenOffset;
211
212
        return true;
213
    }
214
215
    /**
216
     * Finds closing quote
217
     *
218
     * @param string $statement               The SQL statement to parse
219
     * @param string $tokenOffset             The offset to start searching from
220
     * @param string $currentLiteralDelimiter The delimiter of the current string literal
221
     *
222
     * @return bool Whether the token was found
223
     */
224
    private static function findClosingQuote(
225
        $statement,
226
        &$tokenOffset,
227
        &$currentLiteralDelimiter
228
    ) {
229
        $token = self::findToken(
230
            $statement,
231
            $tokenOffset,
0 ignored issues
show
Bug introduced by
$tokenOffset of type string is incompatible with the type integer expected by parameter $offset of Doctrine\DBAL\Driver\OCI...8Statement::findToken(). ( Ignorable by Annotation )

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

231
            /** @scrutinizer ignore-type */ $tokenOffset,
Loading history...
232
            '/' . preg_quote($currentLiteralDelimiter, '/') . '/'
233
        );
234
235
        if ($token === null) {
236
            return false;
237
        }
238
239
        $currentLiteralDelimiter = false;
240
        ++$tokenOffset;
241
242
        return true;
243
    }
244
245
    /**
246
     * Finds the token described by regex starting from the given offset. Updates the offset with the position
247
     * where the token was found.
248
     *
249
     * @param string $statement The SQL statement to parse
250
     * @param int    $offset    The offset to start searching from
251
     * @param string $regex     The regex containing token pattern
252
     *
253
     * @return string|null Token or NULL if not found
254
     */
255
    private static function findToken($statement, &$offset, $regex)
256
    {
257
        if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset) === 1) {
258
            $offset = $matches[0][1];
259
260
            return $matches[0][0];
261
        }
262
263
        return null;
264
    }
265
266
    /**
267
     * {@inheritdoc}
268
     */
269
    public function bindValue($param, $value, $type = ParameterType::STRING)
270
    {
271
        return $this->bindParam($param, $value, $type, null);
272
    }
273
274
    /**
275
     * {@inheritdoc}
276
     */
277
    public function bindParam($param, &$variable, $type = ParameterType::STRING, $length = null)
278
    {
279
        if (is_int($param)) {
280
            if (! isset($this->_paramMap[$param])) {
281
                throw new OCI8Exception(sprintf('Could not find variable mapping with index %d, in the SQL statement', $param));
282
            }
283
284
            $param = $this->_paramMap[$param];
285
        }
286
287
        if ($type === ParameterType::LARGE_OBJECT) {
288
            $lob = oci_new_descriptor($this->_dbh, OCI_D_LOB);
289
290
            $class = 'OCI-Lob';
291
            assert($lob instanceof $class);
292
293
            $lob->writetemporary($variable, OCI_TEMP_BLOB);
294
295
            $variable =& $lob;
296
        }
297
298
        $this->boundValues[$param] =& $variable;
299
300
        return oci_bind_by_name(
301
            $this->_sth,
302
            $param,
303
            $variable,
304
            $length ?? -1,
305
            $this->convertParameterType($type)
306
        );
307
    }
308
309
    /**
310
     * Converts DBAL parameter type to oci8 parameter type
311
     */
312
    private function convertParameterType(int $type) : int
313
    {
314
        switch ($type) {
315
            case ParameterType::BINARY:
316
                return OCI_B_BIN;
317
318
            case ParameterType::LARGE_OBJECT:
319
                return OCI_B_BLOB;
320
321
            default:
322
                return SQLT_CHR;
323
        }
324
    }
325
326
    /**
327
     * {@inheritdoc}
328
     */
329
    public function closeCursor()
330
    {
331
        // not having the result means there's nothing to close
332
        if (! $this->result) {
333
            return true;
334
        }
335
336
        oci_cancel($this->_sth);
337
338
        $this->result = false;
339
340
        return true;
341
    }
342
343
    /**
344
     * {@inheritdoc}
345
     */
346
    public function columnCount()
347
    {
348
        $count = oci_num_fields($this->_sth);
349
350
        if ($count !== false) {
351
            return $count;
352
        }
353
354
        return 0;
355
    }
356
357
    /**
358
     * {@inheritdoc}
359
     */
360
    public function errorCode()
361
    {
362
        $error = oci_error($this->_sth);
363
        if ($error !== false) {
364
            $error = $error['code'];
365
        }
366
367
        return $error;
368
    }
369
370
    /**
371
     * {@inheritdoc}
372
     */
373
    public function errorInfo()
374
    {
375
        $error = oci_error($this->_sth);
376
377
        if ($error === false) {
378
            return [];
379
        }
380
381
        return $error;
382
    }
383
384
    /**
385
     * {@inheritdoc}
386
     */
387
    public function execute($params = null)
388
    {
389
        if ($params !== null) {
390
            $hasZeroIndex = array_key_exists(0, $params);
391
            foreach ($params as $key => $val) {
392
                if ($hasZeroIndex && is_int($key)) {
393
                    $this->bindValue($key + 1, $val);
394
                } else {
395
                    $this->bindValue($key, $val);
396
                }
397
            }
398
        }
399
400
        $ret = @oci_execute($this->_sth, $this->_conn->getExecuteMode());
401
        if (! $ret) {
402
            throw OCI8Exception::fromErrorInfo($this->errorInfo());
403
        }
404
405
        $this->result = true;
406
407
        return $ret;
408
    }
409
410
    /**
411
     * {@inheritdoc}
412
     */
413
    public function setFetchMode($fetchMode)
414
    {
415
        $this->_defaultFetchMode = $fetchMode;
416
417
        return true;
418
    }
419
420
    /**
421
     * {@inheritdoc}
422
     */
423
    public function getIterator()
424
    {
425
        return new StatementIterator($this);
426
    }
427
428
    /**
429
     * {@inheritdoc}
430
     */
431
    public function fetch($fetchMode = null)
432
    {
433
        // do not try fetching from the statement if it's not expected to contain result
434
        // in order to prevent exceptional situation
435
        if (! $this->result) {
436
            return false;
437
        }
438
439
        $fetchMode = $fetchMode ?? $this->_defaultFetchMode;
440
441
        if ($fetchMode === FetchMode::COLUMN) {
442
            return $this->fetchColumn();
443
        }
444
445
        if (! isset(self::$fetchModeMap[$fetchMode])) {
446
            throw new InvalidArgumentException('Invalid fetch style: ' . $fetchMode);
447
        }
448
449
        return oci_fetch_array(
450
            $this->_sth,
451
            self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | OCI_RETURN_LOBS
452
        );
453
    }
454
455
    /**
456
     * {@inheritdoc}
457
     */
458
    public function fetchAll($fetchMode = null)
459
    {
460
        $fetchMode = $fetchMode ?? $this->_defaultFetchMode;
461
462
        $result = [];
463
464
        if (! isset(self::$fetchModeMap[$fetchMode])) {
465
            throw new InvalidArgumentException('Invalid fetch style: ' . $fetchMode);
466
        }
467
468
        if (self::$fetchModeMap[$fetchMode] === OCI_BOTH) {
469
            while ($row = $this->fetch($fetchMode)) {
470
                $result[] = $row;
471
            }
472
        } else {
473
            $fetchStructure = OCI_FETCHSTATEMENT_BY_ROW;
474
475
            if ($fetchMode === FetchMode::COLUMN) {
476
                $fetchStructure = OCI_FETCHSTATEMENT_BY_COLUMN;
477
            }
478
479
            // do not try fetching from the statement if it's not expected to contain result
480
            // in order to prevent exceptional situation
481
            if (! $this->result) {
482
                return [];
483
            }
484
485
            oci_fetch_all(
486
                $this->_sth,
487
                $result,
488
                0,
489
                -1,
490
                self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | $fetchStructure | OCI_RETURN_LOBS
491
            );
492
493
            if ($fetchMode === FetchMode::COLUMN) {
494
                $result = $result[0];
495
            }
496
        }
497
498
        return $result;
499
    }
500
501
    /**
502
     * {@inheritdoc}
503
     */
504
    public function fetchColumn()
505
    {
506
        // do not try fetching from the statement if it's not expected to contain result
507
        // in order to prevent exceptional situation
508
        if (! $this->result) {
509
            return false;
510
        }
511
512
        $row = oci_fetch_array($this->_sth, OCI_NUM | OCI_RETURN_NULLS | OCI_RETURN_LOBS);
513
514
        if ($row === false) {
515
            return false;
516
        }
517
518
        return $row[0] ?? null;
519
    }
520
521
    /**
522
     * {@inheritdoc}
523
     */
524
    public function rowCount() : int
525
    {
526
        $count = oci_num_rows($this->_sth);
527
528
        if ($count !== false) {
529
            return $count;
530
        }
531
532
        return 0;
533
    }
534
}
535