Passed
Push — 2.9 ( d389f3...7345cd )
by Sergei
31:44 queued 28:51
created

OCI8Statement::findClosingQuote()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 2.004

Importance

Changes 0
Metric Value
eloc 9
dl 0
loc 19
ccs 9
cts 10
cp 0.9
rs 9.9666
c 0
b 0
f 0
cc 2
nc 2
nop 3
crap 2.004
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
    public function __construct($dbh, $statement, OCI8Connection $conn)
99
    {
100
        [$statement, $paramMap] = self::convertPositionalToNamedPlaceholders($statement);
101
        $this->_sth             = oci_parse($dbh, $statement);
0 ignored issues
show
Documentation Bug introduced by
It seems like oci_parse($dbh, $statement) can also be of type false. However, the property $_sth is declared as type resource. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
102
        $this->_dbh             = $dbh;
103
        $this->_paramMap        = $paramMap;
104
        $this->_conn            = $conn;
105
    }
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 300
    public static function convertPositionalToNamedPlaceholders($statement)
129
    {
130 300
        $fragmentOffset          = $tokenOffset = 0;
131 300
        $fragments               = $paramMap = [];
132 300
        $currentLiteralDelimiter = null;
133
134
        do {
135 300
            if (! $currentLiteralDelimiter) {
136 300
                $result = self::findPlaceholderOrOpeningQuote(
137 300
                    $statement,
138 300
                    $tokenOffset,
139 300
                    $fragmentOffset,
140 300
                    $fragments,
141 300
                    $currentLiteralDelimiter,
142 300
                    $paramMap
143
                );
144
            } else {
145 240
                $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 300
        } while ($result);
148
149 300
        if ($currentLiteralDelimiter) {
150
            throw new OCI8Exception(sprintf(
151
                'The statement contains non-terminated string literal starting at offset %d',
152
                $tokenOffset - 1
153
            ));
154
        }
155
156 300
        $fragments[] = substr($statement, $fragmentOffset);
157 300
        $statement   = implode('', $fragments);
158
159 300
        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 300
    private static function findPlaceholderOrOpeningQuote(
176
        $statement,
177
        &$tokenOffset,
178
        &$fragmentOffset,
179
        &$fragments,
180
        &$currentLiteralDelimiter,
181
        &$paramMap
182
    ) {
183 300
        $token = self::findToken($statement, $tokenOffset, '/[?\'"]/');
184
185 300
        if (! $token) {
186 300
            return false;
187
        }
188
189 300
        if ($token === '?') {
190 300
            $position            = count($paramMap) + 1;
191 300
            $param               = ':param' . $position;
192 300
            $fragments[]         = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
193 300
            $fragments[]         = $param;
194 300
            $paramMap[$position] = $param;
195 300
            $tokenOffset        += 1;
196 300
            $fragmentOffset      = $tokenOffset;
197
198 300
            return true;
199
        }
200
201 240
        $currentLiteralDelimiter = $token;
202 240
        ++$tokenOffset;
203
204 240
        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 240
    private static function findClosingQuote(
218
        $statement,
219
        &$tokenOffset,
220
        &$currentLiteralDelimiter
221
    ) {
222 240
        $token = self::findToken(
223 240
            $statement,
224 240
            $tokenOffset,
225 240
            '/' . preg_quote($currentLiteralDelimiter, '/') . '/'
226
        );
227
228 240
        if (! $token) {
229
            return false;
230
        }
231
232 240
        $currentLiteralDelimiter = false;
233 240
        ++$tokenOffset;
234
235 240
        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 300
    private static function findToken($statement, &$offset, $regex)
249
    {
250 300
        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 300
            $offset = $matches[0][1];
252 300
            return $matches[0][0];
253
        }
254
255 300
        return null;
256
    }
257
258
    /**
259
     * {@inheritdoc}
260
     */
261
    public function bindValue($param, $value, $type = ParameterType::STRING)
262
    {
263
        return $this->bindParam($param, $value, $type, null);
264
    }
265
266
    /**
267
     * {@inheritdoc}
268
     */
269
    public function bindParam($column, &$variable, $type = ParameterType::STRING, $length = null)
270
    {
271
        $column = $this->_paramMap[$column] ?? $column;
272
273
        if ($type === ParameterType::LARGE_OBJECT) {
274
            $lob = oci_new_descriptor($this->_dbh, OCI_D_LOB);
275
            $lob->writeTemporary($variable, OCI_TEMP_BLOB);
276
277
            $variable =& $lob;
278
        }
279
280
        $this->boundValues[$column] =& $variable;
281
282
        return oci_bind_by_name(
283
            $this->_sth,
284
            $column,
285
            $variable,
286
            $length ?? -1,
287
            $this->convertParameterType($type)
288
        );
289
    }
290
291
    /**
292
     * Converts DBAL parameter type to oci8 parameter type
293
     */
294
    private function convertParameterType(int $type) : int
295
    {
296
        switch ($type) {
297
            case ParameterType::BINARY:
298
                return OCI_B_BIN;
299
300
            case ParameterType::LARGE_OBJECT:
301
                return OCI_B_BLOB;
302
303
            default:
304
                return SQLT_CHR;
305
        }
306
    }
307
308
    /**
309
     * {@inheritdoc}
310
     */
311
    public function closeCursor()
312
    {
313
        // not having the result means there's nothing to close
314
        if (! $this->result) {
315
            return true;
316
        }
317
318
        oci_cancel($this->_sth);
319
320
        $this->result = false;
321
322
        return true;
323
    }
324
325
    /**
326
     * {@inheritdoc}
327
     */
328
    public function columnCount()
329
    {
330
        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
    public function errorInfo()
350
    {
351
        return oci_error($this->_sth);
0 ignored issues
show
Bug Best Practice introduced by
The expression return oci_error($this->_sth) also could return the type false which is incompatible with the return type mandated by Doctrine\DBAL\Driver\Statement::errorInfo() of array<mixed,mixed>.
Loading history...
352
    }
353
354
    /**
355
     * {@inheritdoc}
356
     */
357
    public function execute($params = null)
358
    {
359
        if ($params) {
360
            $hasZeroIndex = array_key_exists(0, $params);
361
            foreach ($params as $key => $val) {
362
                if ($hasZeroIndex && is_numeric($key)) {
363
                    $this->bindValue($key + 1, $val);
364
                } else {
365
                    $this->bindValue($key, $val);
366
                }
367
            }
368
        }
369
370
        $ret = @oci_execute($this->_sth, $this->_conn->getExecuteMode());
371
        if (! $ret) {
372
            throw OCI8Exception::fromErrorInfo($this->errorInfo());
373
        }
374
375
        $this->result = true;
376
377
        return $ret;
378
    }
379
380
    /**
381
     * {@inheritdoc}
382
     */
383
    public function setFetchMode($fetchMode, $arg2 = null, $arg3 = null)
384
    {
385
        $this->_defaultFetchMode = $fetchMode;
386
387
        return true;
388
    }
389
390
    /**
391
     * {@inheritdoc}
392
     */
393
    public function getIterator()
394
    {
395
        return new StatementIterator($this);
396
    }
397
398
    /**
399
     * {@inheritdoc}
400
     */
401
    public function fetch($fetchMode = null, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0)
402
    {
403
        // do not try fetching from the statement if it's not expected to contain result
404
        // in order to prevent exceptional situation
405
        if (! $this->result) {
406
            return false;
407
        }
408
409
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
410
411
        if ($fetchMode === FetchMode::COLUMN) {
412
            return $this->fetchColumn();
413
        }
414
415
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
416
            return oci_fetch_object($this->_sth);
417
        }
418
419
        if (! isset(self::$fetchModeMap[$fetchMode])) {
420
            throw new InvalidArgumentException('Invalid fetch style: ' . $fetchMode);
421
        }
422
423
        return oci_fetch_array(
424
            $this->_sth,
425
            self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | OCI_RETURN_LOBS
426
        );
427
    }
428
429
    /**
430
     * {@inheritdoc}
431
     */
432
    public function fetchAll($fetchMode = null, $fetchArgument = null, $ctorArgs = null)
433
    {
434
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
435
436
        $result = [];
437
438
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
439
            while ($row = $this->fetch($fetchMode)) {
440
                $result[] = $row;
441
            }
442
443
            return $result;
444
        }
445
446
        if (! isset(self::$fetchModeMap[$fetchMode])) {
447
            throw new InvalidArgumentException('Invalid fetch style: ' . $fetchMode);
448
        }
449
450
        if (self::$fetchModeMap[$fetchMode] === OCI_BOTH) {
451
            while ($row = $this->fetch($fetchMode)) {
452
                $result[] = $row;
453
            }
454
        } else {
455
            $fetchStructure = OCI_FETCHSTATEMENT_BY_ROW;
456
457
            if ($fetchMode === FetchMode::COLUMN) {
458
                $fetchStructure = OCI_FETCHSTATEMENT_BY_COLUMN;
459
            }
460
461
            // do not try fetching from the statement if it's not expected to contain result
462
            // in order to prevent exceptional situation
463
            if (! $this->result) {
464
                return [];
465
            }
466
467
            oci_fetch_all(
468
                $this->_sth,
469
                $result,
470
                0,
471
                -1,
472
                self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | $fetchStructure | OCI_RETURN_LOBS
473
            );
474
475
            if ($fetchMode === FetchMode::COLUMN) {
476
                $result = $result[0];
477
            }
478
        }
479
480
        return $result;
481
    }
482
483
    /**
484
     * {@inheritdoc}
485
     */
486
    public function fetchColumn($columnIndex = 0)
487
    {
488
        // do not try fetching from the statement if it's not expected to contain result
489
        // in order to prevent exceptional situation
490
        if (! $this->result) {
491
            return false;
492
        }
493
494
        $row = oci_fetch_array($this->_sth, OCI_NUM | OCI_RETURN_NULLS | OCI_RETURN_LOBS);
495
496
        if ($row === false) {
497
            return false;
498
        }
499
500
        return $row[$columnIndex] ?? null;
501
    }
502
503
    /**
504
     * {@inheritdoc}
505
     */
506
    public function rowCount()
507
    {
508
        return oci_num_rows($this->_sth);
509
    }
510
}
511