Made the OCI8Statement class final

parent d0fe5727
# Upgrade to 3.0
## BC BREAK: `OCI8Statement::convertPositionalToNamedPlaceholders()` is removed.
The `OCI8Statement::convertPositionalToNamedPlaceholders()` method has been extracted to an internal utility class.
## BC BREAK: Dropped handling of one-based numeric arrays of parameters in `Statement::execute()`
The statement implementations no longer detect whether `$params` is a zero- or one-based array. A zero-based numeric array is expected.
......@@ -56,9 +60,10 @@ Table columns are no longer indexed by column name. Use the `name` attribute of
- Class `Doctrine\DBAL\Sharding\SQLAzure\SQLAzureFederationsSynchronizer` was made final.
- Class `Doctrine\DBAL\Sharding\PoolingShardManager` was made final.
- Class `Doctrine\DBAL\Id\TableGeneratorSchemaVisitor` was made final.
- Class `Doctrine\DBAL\Driver\OCI8\Driver` was made final.
- Class `Doctrine\DBAL\Driver\Mysqli\Driver` was made final.
- Class `Doctrine\DBAL\Driver\Mysqli\MysqliStatement` was made final.
- Class `Doctrine\DBAL\Driver\OCI8\Driver` was made final.
- Class `Doctrine\DBAL\Driver\OCI8\OCI8Statement` was made final.
- Class `Doctrine\DBAL\Driver\PDOSqlsrv\Driver` was made final.
- Class `Doctrine\DBAL\Driver\PDOSqlsrv\Statement` was made final.
- Class `Doctrine\DBAL\Driver\PDOMySql\Driver` was made final.
......
<?php
declare(strict_types=1);
namespace Doctrine\DBAL\Driver\OCI8;
use const PREG_OFFSET_CAPTURE;
use function count;
use function implode;
use function preg_match;
use function preg_quote;
use function substr;
/**
* Converts positional (?) into named placeholders (:param<num>).
*
* 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.
*
* @internal This class is not covered by the backward compatibility promise
*/
final class ConvertPositionalToNamedPlaceholders
{
/**
* @param string $statement The SQL statement to convert.
*
* @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array).
*
* @throws OCI8Exception
*/
public function __invoke(string $statement) : array
{
$fragmentOffset = $tokenOffset = 0;
$fragments = $paramMap = [];
$currentLiteralDelimiter = null;
do {
if ($currentLiteralDelimiter === null) {
$result = $this->findPlaceholderOrOpeningQuote(
$statement,
$tokenOffset,
$fragmentOffset,
$fragments,
$currentLiteralDelimiter,
$paramMap
);
} else {
$result = $this->findClosingQuote($statement, $tokenOffset, $currentLiteralDelimiter);
}
} while ($result);
if ($currentLiteralDelimiter) {
throw NonTerminatedStringLiteral::new($tokenOffset - 1);
}
$fragments[] = substr($statement, $fragmentOffset);
$statement = implode('', $fragments);
return [$statement, $paramMap];
}
/**
* Finds next placeholder or opening quote.
*
* @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
*
* @return bool Whether the token was found
*/
private function findPlaceholderOrOpeningQuote(
string $statement,
int &$tokenOffset,
int &$fragmentOffset,
array &$fragments,
?string &$currentLiteralDelimiter,
array &$paramMap
) : bool {
$token = $this->findToken($statement, $tokenOffset, '/[?\'"]/');
if (! $token) {
return false;
}
if ($token === '?') {
$position = count($paramMap) + 1;
$param = ':param' . $position;
$fragments[] = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
$fragments[] = $param;
$paramMap[$position] = $param;
$tokenOffset += 1;
$fragmentOffset = $tokenOffset;
return true;
}
$currentLiteralDelimiter = $token;
++$tokenOffset;
return true;
}
/**
* Finds closing quote
*
* @param string $statement The SQL statement to parse
* @param int $tokenOffset The offset to start searching from
* @param string $currentLiteralDelimiter The delimiter of the current string literal
*
* @return bool Whether the token was found
*/
private function findClosingQuote(
string $statement,
int &$tokenOffset,
string &$currentLiteralDelimiter
) : bool {
$token = $this->findToken(
$statement,
$tokenOffset,
'/' . preg_quote($currentLiteralDelimiter, '/') . '/'
);
if (! $token) {
return false;
}
$currentLiteralDelimiter = null;
++$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
* @param int $offset The offset to start searching from
* @param string $regex The regex containing token pattern
*
* @return string|null Token or NULL if not found
*/
private function findToken(string $statement, int &$offset, string $regex) : ?string
{
if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset)) {
$offset = $matches[0][1];
return $matches[0][0];
}
return null;
}
}
<?php
declare(strict_types=1);
namespace Doctrine\DBAL\Driver\OCI8;
use function sprintf;
final class NonTerminatedStringLiteral extends OCI8Exception
{
public static function new(int $offset) : self
{
return new self(
sprintf(
'The statement contains non-terminated string literal starting at offset %d.',
$offset
)
);
}
}
......@@ -22,12 +22,10 @@ use const OCI_NUM;
use const OCI_RETURN_LOBS;
use const OCI_RETURN_NULLS;
use const OCI_TEMP_BLOB;
use const PREG_OFFSET_CAPTURE;
use const SQLT_CHR;
use function array_key_exists;
use function assert;
use function count;
use function implode;
use function is_int;
use function is_resource;
use function oci_bind_by_name;
......@@ -41,15 +39,12 @@ 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;
/**
* The OCI8 implementation of the Statement interface.
*/
class OCI8Statement implements IteratorAggregate, Statement
final class OCI8Statement implements IteratorAggregate, Statement
{
/** @var resource */
protected $_dbh;
......@@ -95,10 +90,12 @@ class OCI8Statement implements IteratorAggregate, Statement
*
* @param resource $dbh The connection handle.
* @param string $query The SQL query.
*
* @throws OCI8Exception
*/
public function __construct($dbh, string $query, OCI8Connection $conn)
{
[$query, $paramMap] = self::convertPositionalToNamedPlaceholders($query);
[$query, $paramMap] = (new ConvertPositionalToNamedPlaceholders())($query);
$stmt = oci_parse($dbh, $query);
assert(is_resource($stmt));
......@@ -109,157 +106,6 @@ class OCI8Statement implements IteratorAggregate, Statement
$this->_conn = $conn;
}
/**
* Converts positional (?) into named placeholders (:param<num>).
*
* 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.
*
* 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.
*
* @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array).
*
* @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.
*/
public static function convertPositionalToNamedPlaceholders(string $statement) : array
{
$fragmentOffset = $tokenOffset = 0;
$fragments = $paramMap = [];
$currentLiteralDelimiter = null;
do {
if ($currentLiteralDelimiter === null) {
$result = self::findPlaceholderOrOpeningQuote(
$statement,
$tokenOffset,
$fragmentOffset,
$fragments,
$currentLiteralDelimiter,
$paramMap
);
} else {
$result = self::findClosingQuote($statement, $tokenOffset, $currentLiteralDelimiter);
}
} while ($result);
if ($currentLiteralDelimiter) {
throw new OCI8Exception(sprintf(
'The statement contains non-terminated string literal starting at offset %d.',
$tokenOffset - 1
));
}
$fragments[] = substr($statement, $fragmentOffset);
$statement = implode('', $fragments);
return [$statement, $paramMap];
}
/**
* Finds next placeholder or opening quote.
*
* @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
*
* @return bool Whether the token was found
*/
private static function findPlaceholderOrOpeningQuote(
string $statement,
int &$tokenOffset,
int &$fragmentOffset,
array &$fragments,
?string &$currentLiteralDelimiter,
array &$paramMap
) : bool {
$token = self::findToken($statement, $tokenOffset, '/[?\'"]/');
if (! $token) {
return false;
}
if ($token === '?') {
$position = count($paramMap) + 1;
$param = ':param' . $position;
$fragments[] = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
$fragments[] = $param;
$paramMap[$position] = $param;
$tokenOffset += 1;
$fragmentOffset = $tokenOffset;
return true;
}
$currentLiteralDelimiter = $token;
++$tokenOffset;
return true;
}
/**
* Finds closing quote
*
* @param string $statement The SQL statement to parse
* @param int $tokenOffset The offset to start searching from
* @param string $currentLiteralDelimiter The delimiter of the current string literal
*
* @return bool Whether the token was found
*/
private static function findClosingQuote(
string $statement,
int &$tokenOffset,
string &$currentLiteralDelimiter
) : bool {
$token = self::findToken(
$statement,
$tokenOffset,
'/' . preg_quote($currentLiteralDelimiter, '/') . '/'
);
if (! $token) {
return false;
}
$currentLiteralDelimiter = null;
++$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
* @param int $offset The offset to start searching from
* @param string $regex The regex containing token pattern
*
* @return string|null Token or NULL if not found
*/
private static function findToken(string $statement, int &$offset, string $regex) : ?string
{
if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset)) {
$offset = $matches[0][1];
return $matches[0][0];
}
return null;
}
/**
* {@inheritdoc}
*/
......
......@@ -2,17 +2,39 @@
declare(strict_types=1);
namespace Doctrine\Tests\DBAL;
namespace Doctrine\Tests\DBAL\Driver\OCI8;
use Doctrine\DBAL\Driver\OCI8\OCI8Statement;
use Doctrine\DBAL\Driver\OCI8\ConvertPositionalToNamedPlaceholders;
use Doctrine\DBAL\Driver\OCI8\OCI8Exception;
use Doctrine\Tests\DbalTestCase;
class UtilTest extends DbalTestCase
class ConvertPositionalToNamedPlaceholdersTest extends DbalTestCase
{
/** @var ConvertPositionalToNamedPlaceholders */
private $convertPositionalToNamedPlaceholders;
protected function setUp() : void
{
$this->convertPositionalToNamedPlaceholders = new ConvertPositionalToNamedPlaceholders();
}
/**
* @param mixed[] $expectedOutputParamsMap
*
* @dataProvider positionalToNamedPlaceholdersProvider
*/
public function testConvertPositionalToNamedParameters(string $inputSQL, string $expectedOutputSQL, array $expectedOutputParamsMap) : void
{
[$statement, $params] = ($this->convertPositionalToNamedPlaceholders)($inputSQL);
self::assertEquals($expectedOutputSQL, $statement);
self::assertEquals($expectedOutputParamsMap, $params);
}
/**
* @return mixed[][]
*/
public static function dataConvertPositionalToNamedParameters() : iterable
public static function positionalToNamedPlaceholdersProvider() : iterable
{
return [
[
......@@ -69,15 +91,33 @@ class UtilTest extends DbalTestCase
}
/**
* @param mixed[] $expectedOutputParamsMap
*
* @dataProvider dataConvertPositionalToNamedParameters
* @dataProvider nonTerminatedLiteralProvider
*/
public function testConvertPositionalToNamedParameters(string $inputSQL, string $expectedOutputSQL, array $expectedOutputParamsMap) : void
public function testConvertNonTerminatedLiteral(string $sql, string $expectedExceptionMessageRegExp) : void
{
[$statement, $params] = OCI8Statement::convertPositionalToNamedPlaceholders($inputSQL);
$this->expectException(OCI8Exception::class);
$this->expectExceptionMessageMatches($expectedExceptionMessageRegExp);
($this->convertPositionalToNamedPlaceholders)($sql);
}
self::assertEquals($expectedOutputSQL, $statement);
self::assertEquals($expectedOutputParamsMap, $params);
/**
* @return array<string, array<int, mixed>>
*/
public static function nonTerminatedLiteralProvider() : iterable
{
return [
'no-matching-quote' => [
"SELECT 'literal FROM DUAL",
'/offset 7./',
],
'no-matching-double-quote' => [
'SELECT 1 "COL1 FROM DUAL',
'/offset 9./',
],
'incorrect-escaping-syntax' => [
"SELECT 'quoted \\'string' FROM DUAL",
'/offset 23./',
],
];
}
}
<?php
declare(strict_types=1);
namespace Doctrine\Tests\DBAL\Driver\OCI8;
use Doctrine\DBAL\Driver\OCI8\OCI8Exception;
use Doctrine\DBAL\Driver\OCI8\OCI8Statement;
use Doctrine\Tests\DbalTestCase;
use function extension_loaded;
class OCI8StatementTest extends DbalTestCase
{
protected function setUp() : void
{
if (! extension_loaded('oci8')) {
$this->markTestSkipped('oci8 is not installed.');
}
parent::setUp();
}
/**
* @dataProvider nonTerminatedLiteralProvider
*/
public function testConvertNonTerminatedLiteral(string $sql, string $message) : void
{
$this->expectException(OCI8Exception::class);
$this->expectExceptionMessageRegExp($message);
OCI8Statement::convertPositionalToNamedPlaceholders($sql);
}
/**
* @return array<string, array<int, mixed>>
*/
public static function nonTerminatedLiteralProvider() : iterable
{
return [
'no-matching-quote' => [
"SELECT 'literal FROM DUAL",
'/offset 7./',
],
'no-matching-double-quote' => [
'SELECT 1 "COL1 FROM DUAL',
'/offset 9./',
],
'incorrect-escaping-syntax' => [
"SELECT 'quoted \\'string' FROM DUAL",
'/offset 23./',
],
];
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment