OCI8Statement.php 14.2 KB
Newer Older
1 2
<?php

Michael Moravec's avatar
Michael Moravec committed
3 4
declare(strict_types=1);

5 6
namespace Doctrine\DBAL\Driver\OCI8;

7
use Doctrine\DBAL\Driver\Statement;
8
use Doctrine\DBAL\Driver\StatementIterator;
9
use Doctrine\DBAL\Exception\InvalidColumnIndex;
10 11
use Doctrine\DBAL\FetchMode;
use Doctrine\DBAL\ParameterType;
12
use InvalidArgumentException;
13
use IteratorAggregate;
14
use const OCI_ASSOC;
15
use const OCI_B_BIN;
16 17 18 19 20 21 22 23 24 25
use const OCI_B_BLOB;
use const OCI_BOTH;
use const OCI_D_LOB;
use const OCI_FETCHSTATEMENT_BY_COLUMN;
use const OCI_FETCHSTATEMENT_BY_ROW;
use const OCI_NUM;
use const OCI_RETURN_LOBS;
use const OCI_RETURN_NULLS;
use const OCI_TEMP_BLOB;
use const PREG_OFFSET_CAPTURE;
26
use const SQLT_CHR;
27
use function array_key_exists;
Sergei Morozov's avatar
Sergei Morozov committed
28
use function assert;
29 30
use function count;
use function implode;
Sergei Morozov's avatar
Sergei Morozov committed
31 32
use function is_int;
use function is_resource;
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
use function oci_bind_by_name;
use function oci_cancel;
use function oci_error;
use function oci_execute;
use function oci_fetch_all;
use function oci_fetch_array;
use function oci_fetch_object;
use function oci_new_descriptor;
use function oci_num_fields;
use function oci_num_rows;
use function oci_parse;
use function preg_match;
use function preg_quote;
use function sprintf;
use function substr;
48 49 50 51

/**
 * The OCI8 implementation of the Statement interface.
 */
52
class OCI8Statement implements IteratorAggregate, Statement
53
{
54
    /** @var resource */
55
    protected $_dbh;
Benjamin Morel's avatar
Benjamin Morel committed
56

57
    /** @var resource */
58
    protected $_sth;
Benjamin Morel's avatar
Benjamin Morel committed
59

60
    /** @var OCI8Connection */
61
    protected $_conn;
Benjamin Morel's avatar
Benjamin Morel committed
62

63 64 65 66 67
    /**
     * @deprecated
     *
     * @var string
     */
68
    protected static $_PARAM = ':param';
Benjamin Morel's avatar
Benjamin Morel committed
69

70
    /** @var int[] */
71
    protected static $fetchModeMap = [
Sergei Morozov's avatar
Sergei Morozov committed
72
        FetchMode::MIXED       => OCI_BOTH,
73
        FetchMode::ASSOCIATIVE => OCI_ASSOC,
Sergei Morozov's avatar
Sergei Morozov committed
74 75
        FetchMode::NUMERIC     => OCI_NUM,
        FetchMode::COLUMN      => OCI_NUM,
76
    ];
Benjamin Morel's avatar
Benjamin Morel committed
77

78
    /** @var int */
79
    protected $_defaultFetchMode = FetchMode::MIXED;
Benjamin Morel's avatar
Benjamin Morel committed
80

81
    /** @var string[] */
82
    protected $_paramMap = [];
83

84 85 86 87 88
    /**
     * Holds references to bound parameter values.
     *
     * This is a new requirement for PHP7's oci8 extension that prevents bound values from being garbage collected.
     *
89
     * @var mixed[]
90
     */
91
    private $boundValues = [];
92

93 94 95 96 97 98 99
    /**
     * Indicates whether the statement is in the state when fetching results is possible
     *
     * @var bool
     */
    private $result = false;

100 101 102
    /**
     * Creates a new OCI8Statement that uses the given connection handle and SQL statement.
     *
Sergei Morozov's avatar
Sergei Morozov committed
103 104
     * @param resource $dbh   The connection handle.
     * @param string   $query The SQL query.
105
     */
106
    public function __construct($dbh, string $query, OCI8Connection $conn)
107
    {
Sergei Morozov's avatar
Sergei Morozov committed
108 109 110 111 112 113 114 115 116
        [$query, $paramMap] = self::convertPositionalToNamedPlaceholders($query);

        $stmt = oci_parse($dbh, $query);
        assert(is_resource($stmt));

        $this->_sth      = $stmt;
        $this->_dbh      = $dbh;
        $this->_paramMap = $paramMap;
        $this->_conn     = $conn;
117
    }
118 119

    /**
Benjamin Morel's avatar
Benjamin Morel committed
120
     * Converts positional (?) into named placeholders (:param<num>).
121
     *
122 123 124 125 126
     * Oracle does not support positional parameters, hence this method converts all
     * positional parameters into artificially named parameters. Note that this conversion
     * is not perfect. All question marks (?) in the original statement are treated as
     * placeholders and converted to a named parameter.
     *
127 128 129 130 131
     * The algorithm uses a state machine with two possible states: InLiteral and NotInLiteral.
     * Question marks inside literal strings are therefore handled correctly by this method.
     * This comes at a cost, the whole sql statement has to be looped over.
     *
     * @param string $statement The SQL statement to convert.
Benjamin Morel's avatar
Benjamin Morel committed
132
     *
133
     * @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array).
134 135 136 137 138
     *
     * @throws OCI8Exception
     *
     * @todo extract into utility class in Doctrine\DBAL\Util namespace
     * @todo review and test for lost spaces. we experienced missing spaces with oci8 in some sql statements.
139
     */
140
    public static function convertPositionalToNamedPlaceholders(string $statement) : array
141
    {
142 143
        $fragmentOffset          = $tokenOffset = 0;
        $fragments               = $paramMap = [];
Sergei Morozov's avatar
Sergei Morozov committed
144
        $currentLiteralDelimiter = null;
145 146

        do {
147
            if ($currentLiteralDelimiter === null) {
Sergei Morozov's avatar
Sergei Morozov committed
148
                $result = self::findPlaceholderOrOpeningQuote(
149
                    $statement,
Sergei Morozov's avatar
Sergei Morozov committed
150 151 152 153 154 155
                    $tokenOffset,
                    $fragmentOffset,
                    $fragments,
                    $currentLiteralDelimiter,
                    $paramMap
                );
156
            } else {
Sergei Morozov's avatar
Sergei Morozov committed
157
                $result = self::findClosingQuote($statement, $tokenOffset, $currentLiteralDelimiter);
158 159 160
            }
        } while ($result);

Sergei Morozov's avatar
Sergei Morozov committed
161
        if ($currentLiteralDelimiter) {
162
            throw new OCI8Exception(sprintf(
163
                'The statement contains non-terminated string literal starting at offset %d.',
Sergei Morozov's avatar
Sergei Morozov committed
164
                $tokenOffset - 1
165
            ));
166
        }
167

168
        $fragments[] = substr($statement, $fragmentOffset);
169
        $statement   = implode('', $fragments);
170

171
        return [$statement, $paramMap];
172 173
    }

Sergei Morozov's avatar
Sergei Morozov committed
174 175 176
    /**
     * Finds next placeholder or opening quote.
     *
177 178 179 180 181 182 183
     * @param string      $statement               The SQL statement to parse
     * @param int         $tokenOffset             The offset to start searching from
     * @param int         $fragmentOffset          The offset to build the next fragment from
     * @param string[]    $fragments               Fragments of the original statement not containing placeholders
     * @param string|null $currentLiteralDelimiter The delimiter of the current string literal
     *                                             or NULL if not currently in a literal
     * @param string[]    $paramMap                Mapping of the original parameter positions to their named replacements
184
     *
Sergei Morozov's avatar
Sergei Morozov committed
185 186 187
     * @return bool Whether the token was found
     */
    private static function findPlaceholderOrOpeningQuote(
188 189 190 191 192 193 194
        string $statement,
        int &$tokenOffset,
        int &$fragmentOffset,
        array &$fragments,
        ?string &$currentLiteralDelimiter,
        array &$paramMap
    ) : bool {
Sergei Morozov's avatar
Sergei Morozov committed
195 196
        $token = self::findToken($statement, $tokenOffset, '/[?\'"]/');

197
        if (! $token) {
Sergei Morozov's avatar
Sergei Morozov committed
198 199 200
            return false;
        }

201
        if ($token === '?') {
202 203 204 205
            $position            = count($paramMap) + 1;
            $param               = ':param' . $position;
            $fragments[]         = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
            $fragments[]         = $param;
Sergei Morozov's avatar
Sergei Morozov committed
206
            $paramMap[$position] = $param;
207 208
            $tokenOffset        += 1;
            $fragmentOffset      = $tokenOffset;
209 210

            return true;
Sergei Morozov's avatar
Sergei Morozov committed
211 212
        }

213 214 215
        $currentLiteralDelimiter = $token;
        ++$tokenOffset;

Sergei Morozov's avatar
Sergei Morozov committed
216 217 218 219 220 221
        return true;
    }

    /**
     * Finds closing quote
     *
Sergei Morozov's avatar
Sergei Morozov committed
222
     * @param string $statement               The SQL statement to parse
223
     * @param int    $tokenOffset             The offset to start searching from
Sergei Morozov's avatar
Sergei Morozov committed
224
     * @param string $currentLiteralDelimiter The delimiter of the current string literal
225
     *
Sergei Morozov's avatar
Sergei Morozov committed
226 227 228
     * @return bool Whether the token was found
     */
    private static function findClosingQuote(
229 230 231 232
        string $statement,
        int &$tokenOffset,
        string &$currentLiteralDelimiter
    ) : bool {
233 234 235
        $token = self::findToken(
            $statement,
            $tokenOffset,
236
            '/' . preg_quote($currentLiteralDelimiter, '/') . '/'
237
        );
Sergei Morozov's avatar
Sergei Morozov committed
238

239
        if (! $token) {
Sergei Morozov's avatar
Sergei Morozov committed
240 241 242
            return false;
        }

243
        $currentLiteralDelimiter = null;
Sergei Morozov's avatar
Sergei Morozov committed
244 245 246 247 248 249 250 251 252 253
        ++$tokenOffset;

        return true;
    }

    /**
     * Finds the token described by regex starting from the given offset. Updates the offset with the position
     * where the token was found.
     *
     * @param string $statement The SQL statement to parse
Sergei Morozov's avatar
Sergei Morozov committed
254
     * @param int    $offset    The offset to start searching from
255
     * @param string $regex     The regex containing token pattern
256
     *
Sergei Morozov's avatar
Sergei Morozov committed
257 258
     * @return string|null Token or NULL if not found
     */
259
    private static function findToken(string $statement, int &$offset, string $regex) : ?string
Sergei Morozov's avatar
Sergei Morozov committed
260 261 262
    {
        if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset)) {
            $offset = $matches[0][1];
263

Sergei Morozov's avatar
Sergei Morozov committed
264 265 266 267 268 269
            return $matches[0][0];
        }

        return null;
    }

270 271 272
    /**
     * {@inheritdoc}
     */
273
    public function bindValue($param, $value, int $type = ParameterType::STRING) : void
274
    {
275
        $this->bindParam($param, $value, $type, null);
276 277 278 279 280
    }

    /**
     * {@inheritdoc}
     */
281
    public function bindParam($param, &$variable, int $type = ParameterType::STRING, ?int $length = null) : void
282
    {
283
        $param = $this->_paramMap[$param];
284

Sergei Morozov's avatar
Sergei Morozov committed
285
        if ($type === ParameterType::LARGE_OBJECT) {
286
            $lob = oci_new_descriptor($this->_dbh, OCI_D_LOB);
Sergei Morozov's avatar
Sergei Morozov committed
287 288 289 290

            $class = 'OCI-Lob';
            assert($lob instanceof $class);

291 292
            $lob->writeTemporary($variable, OCI_TEMP_BLOB);

293
            $variable =& $lob;
294
        }
Benjamin Eberlei's avatar
Benjamin Eberlei committed
295

296
        $this->boundValues[$param] =& $variable;
297

298
        if (! oci_bind_by_name(
299
            $this->_sth,
300
            $param,
301 302 303
            $variable,
            $length ?? -1,
            $this->convertParameterType($type)
304
        )) {
Michael Moravec's avatar
Michael Moravec committed
305
            throw OCI8Exception::fromErrorInfo(oci_error($this->_sth));
306
        }
307 308 309 310 311 312 313 314 315 316
    }

    /**
     * Converts DBAL parameter type to oci8 parameter type
     */
    private function convertParameterType(int $type) : int
    {
        switch ($type) {
            case ParameterType::BINARY:
                return OCI_B_BIN;
317

318 319
            case ParameterType::LARGE_OBJECT:
                return OCI_B_BLOB;
320

321 322 323
            default:
                return SQLT_CHR;
        }
324 325 326
    }

    /**
Benjamin Morel's avatar
Benjamin Morel committed
327
     * {@inheritdoc}
328
     */
329
    public function closeCursor() : void
330
    {
331
        // not having the result means there's nothing to close
332
        if (! $this->result) {
333
            return;
334 335
        }

336
        oci_cancel($this->_sth);
337

338
        $this->result = false;
339 340
    }

341
    /**
342 343
     * {@inheritdoc}
     */
344
    public function columnCount() : int
345
    {
Sergei Morozov's avatar
Sergei Morozov committed
346
        return oci_num_fields($this->_sth) ?: 0;
347 348 349 350 351
    }

    /**
     * {@inheritdoc}
     */
352
    public function execute(?array $params = null) : void
353
    {
354
        if ($params) {
355
            $hasZeroIndex = array_key_exists(0, $params);
Sergei Morozov's avatar
Sergei Morozov committed
356

357
            foreach ($params as $key => $val) {
Sergei Morozov's avatar
Sergei Morozov committed
358
                if ($hasZeroIndex && is_int($key)) {
359
                    $param = $key + 1;
360
                } else {
361 362 363
                    $param = $key;
                }

364
                $this->bindValue($param, $val);
365 366
            }
        }
367

368
        $ret = @oci_execute($this->_sth, $this->_conn->getExecuteMode());
369
        if (! $ret) {
Michael Moravec's avatar
Michael Moravec committed
370
            throw OCI8Exception::fromErrorInfo(oci_error($this->_sth));
371
        }
Benjamin Morel's avatar
Benjamin Morel committed
372

373
        $this->result = true;
374 375
    }

376 377 378
    /**
     * {@inheritdoc}
     */
379
    public function setFetchMode(int $fetchMode, ...$args) : void
380
    {
381
        $this->_defaultFetchMode = $fetchMode;
382 383 384 385 386 387 388
    }

    /**
     * {@inheritdoc}
     */
    public function getIterator()
    {
389
        return new StatementIterator($this);
390 391
    }

392 393 394
    /**
     * {@inheritdoc}
     */
395
    public function fetch(?int $fetchMode = null, ...$args)
396
    {
397 398
        // do not try fetching from the statement if it's not expected to contain result
        // in order to prevent exceptional situation
399
        if (! $this->result) {
400 401 402
            return false;
        }

403
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
404

405
        if ($fetchMode === FetchMode::COLUMN) {
406 407 408
            return $this->fetchColumn();
        }

409
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
410 411 412 413
            return oci_fetch_object($this->_sth);
        }

        if (! isset(self::$fetchModeMap[$fetchMode])) {
414
            throw new InvalidArgumentException(sprintf('Invalid fetch mode %d.', $fetchMode));
415
        }
416

417 418 419 420
        return oci_fetch_array(
            $this->_sth,
            self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | OCI_RETURN_LOBS
        );
421 422 423 424 425
    }

    /**
     * {@inheritdoc}
     */
426
    public function fetchAll(?int $fetchMode = null, ...$args) : array
427
    {
428
        $fetchMode = $fetchMode ?: $this->_defaultFetchMode;
429

430
        $result = [];
431

Sergei Morozov's avatar
Sergei Morozov committed
432
        if ($fetchMode === FetchMode::STANDARD_OBJECT) {
433 434 435 436 437 438 439
            while ($row = $this->fetch($fetchMode)) {
                $result[] = $row;
            }

            return $result;
        }

440
        if (! isset(self::$fetchModeMap[$fetchMode])) {
441
            throw new InvalidArgumentException(sprintf('Invalid fetch mode %d.', $fetchMode));
442
        }
443

444 445
        if (self::$fetchModeMap[$fetchMode] === OCI_BOTH) {
            while ($row = $this->fetch($fetchMode)) {
446 447 448
                $result[] = $row;
            }
        } else {
449
            $fetchStructure = OCI_FETCHSTATEMENT_BY_ROW;
450

Sergei Morozov's avatar
Sergei Morozov committed
451
            if ($fetchMode === FetchMode::COLUMN) {
452 453
                $fetchStructure = OCI_FETCHSTATEMENT_BY_COLUMN;
            }
454

455 456
            // do not try fetching from the statement if it's not expected to contain result
            // in order to prevent exceptional situation
457
            if (! $this->result) {
458
                return [];
459 460
            }

461 462 463 464 465 466 467
            oci_fetch_all(
                $this->_sth,
                $result,
                0,
                -1,
                self::$fetchModeMap[$fetchMode] | OCI_RETURN_NULLS | $fetchStructure | OCI_RETURN_LOBS
            );
468

Sergei Morozov's avatar
Sergei Morozov committed
469
            if ($fetchMode === FetchMode::COLUMN) {
470 471
                $result = $result[0];
            }
472
        }
473

474 475 476 477 478 479
        return $result;
    }

    /**
     * {@inheritdoc}
     */
480
    public function fetchColumn(int $columnIndex = 0)
481
    {
482 483
        // do not try fetching from the statement if it's not expected to contain result
        // in order to prevent exceptional situation
484
        if (! $this->result) {
485 486 487
            return false;
        }

488
        $row = oci_fetch_array($this->_sth, OCI_NUM | OCI_RETURN_NULLS | OCI_RETURN_LOBS);
Benjamin Morel's avatar
Benjamin Morel committed
489

490
        if ($row === false) {
491 492 493
            return false;
        }

494
        if (! array_key_exists($columnIndex, $row)) {
495
            throw InvalidColumnIndex::new($columnIndex, count($row));
496 497 498
        }

        return $row[$columnIndex];
499 500 501 502 503
    }

    /**
     * {@inheritdoc}
     */
504
    public function rowCount() : int
505
    {
Sergei Morozov's avatar
Sergei Morozov committed
506
        return oci_num_rows($this->_sth) ?: 0;
507
    }
508
}