Extract conversion of positional to named placeholder to an utility class

parent 2da86a36
...@@ -29,7 +29,7 @@ ...@@ -29,7 +29,7 @@
This one is just too convoluted for Psalm to figure out, by This one is just too convoluted for Psalm to figure out, by
its author's own admission its author's own admission
--> -->
<file name="src/Driver/OCI8/OCI8Statement.php"/> <file name="src/Driver/OCI8/ConvertPositionalToNamedPlaceholders.php"/>
</errorLevel> </errorLevel>
</ConflictingReferenceConstraint> </ConflictingReferenceConstraint>
<FalsableReturnStatement> <FalsableReturnStatement>
......
<?php
declare(strict_types=1);
namespace Doctrine\DBAL\Driver\OCI8;
use Doctrine\DBAL\Driver\OCI8\Exception\NonTerminatedStringLiteral;
use function count;
use function implode;
use function preg_match;
use function preg_quote;
use function substr;
use const PREG_OFFSET_CAPTURE;
/**
* 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 !== null) {
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 === null) {
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 === null) {
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) === 1) {
$offset = $matches[0][1];
return $matches[0][0];
}
return null;
}
}
...@@ -2,15 +2,12 @@ ...@@ -2,15 +2,12 @@
namespace Doctrine\DBAL\Driver\OCI8; namespace Doctrine\DBAL\Driver\OCI8;
use Doctrine\DBAL\Driver\OCI8\Exception\NonTerminatedStringLiteral;
use Doctrine\DBAL\Driver\OCI8\Exception\UnknownParameterIndex; use Doctrine\DBAL\Driver\OCI8\Exception\UnknownParameterIndex;
use Doctrine\DBAL\Driver\Result as ResultInterface; use Doctrine\DBAL\Driver\Result as ResultInterface;
use Doctrine\DBAL\Driver\Statement as StatementInterface; use Doctrine\DBAL\Driver\Statement as StatementInterface;
use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\ParameterType;
use function assert; use function assert;
use function count;
use function implode;
use function is_int; use function is_int;
use function is_resource; use function is_resource;
use function oci_bind_by_name; use function oci_bind_by_name;
...@@ -18,9 +15,6 @@ use function oci_error; ...@@ -18,9 +15,6 @@ use function oci_error;
use function oci_execute; use function oci_execute;
use function oci_new_descriptor; use function oci_new_descriptor;
use function oci_parse; use function oci_parse;
use function preg_match;
use function preg_quote;
use function substr;
use const OCI_B_BIN; use const OCI_B_BIN;
use const OCI_B_BLOB; use const OCI_B_BLOB;
...@@ -28,7 +22,6 @@ use const OCI_COMMIT_ON_SUCCESS; ...@@ -28,7 +22,6 @@ use const OCI_COMMIT_ON_SUCCESS;
use const OCI_D_LOB; use const OCI_D_LOB;
use const OCI_NO_AUTO_COMMIT; use const OCI_NO_AUTO_COMMIT;
use const OCI_TEMP_BLOB; use const OCI_TEMP_BLOB;
use const PREG_OFFSET_CAPTURE;
use const SQLT_CHR; use const SQLT_CHR;
/** /**
...@@ -67,7 +60,7 @@ class OCI8Statement implements StatementInterface ...@@ -67,7 +60,7 @@ class OCI8Statement implements StatementInterface
*/ */
public function __construct($dbh, $query, ExecutionMode $executionMode) public function __construct($dbh, $query, ExecutionMode $executionMode)
{ {
[$query, $paramMap] = self::convertPositionalToNamedPlaceholders($query); [$query, $paramMap] = (new ConvertPositionalToNamedPlaceholders())($query);
$stmt = oci_parse($dbh, $query); $stmt = oci_parse($dbh, $query);
assert(is_resource($stmt)); assert(is_resource($stmt));
...@@ -78,154 +71,6 @@ class OCI8Statement implements StatementInterface ...@@ -78,154 +71,6 @@ class OCI8Statement implements StatementInterface
$this->executionMode = $executionMode; $this->executionMode = $executionMode;
} }
/**
* 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($statement)
{
$fragmentOffset = $tokenOffset = 0;
$fragments = $paramMap = [];
$currentLiteralDelimiter = null;
do {
if (! $currentLiteralDelimiter) {
$result = self::findPlaceholderOrOpeningQuote(
$statement,
$tokenOffset,
$fragmentOffset,
$fragments,
$currentLiteralDelimiter,
$paramMap
);
} else {
$result = self::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 string $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 array<int, string> $paramMap Mapping of the original parameter positions to their named replacements
*
* @return bool Whether the token was found
*/
private static function findPlaceholderOrOpeningQuote(
$statement,
&$tokenOffset,
&$fragmentOffset,
&$fragments,
&$currentLiteralDelimiter,
&$paramMap
) {
$token = self::findToken($statement, $tokenOffset, '/[?\'"]/');
if ($token === null) {
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 string $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(
$statement,
&$tokenOffset,
&$currentLiteralDelimiter
) {
$token = self::findToken(
$statement,
$tokenOffset,
'/' . preg_quote($currentLiteralDelimiter, '/') . '/'
);
if ($token === null) {
return false;
}
$currentLiteralDelimiter = false;
++$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($statement, &$offset, $regex)
{
if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset) === 1) {
$offset = $matches[0][1];
return $matches[0][0];
}
return null;
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
......
<?php <?php
namespace Doctrine\DBAL\Tests; declare(strict_types=1);
use Doctrine\DBAL\Driver\OCI8\Statement; namespace Doctrine\Tests\DBAL\Driver\OCI8;
use Doctrine\DBAL\Driver\OCI8\ConvertPositionalToNamedPlaceholders;
use Doctrine\DBAL\Driver\OCI8\OCI8Exception;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
class UtilTest extends TestCase class ConvertPositionalToNamedPlaceholdersTest extends TestCase
{ {
/** @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[][] * @return mixed[][]
*/ */
public static function dataConvertPositionalToNamedParameters(): iterable public static function positionalToNamedPlaceholdersProvider(): iterable
{ {
return [ return [
[ [
...@@ -67,15 +91,33 @@ class UtilTest extends TestCase ...@@ -67,15 +91,33 @@ class UtilTest extends TestCase
} }
/** /**
* @param mixed[] $expectedOutputParamsMap * @dataProvider nonTerminatedLiteralProvider
*
* @dataProvider dataConvertPositionalToNamedParameters
*/ */
public function testConvertPositionalToNamedParameters(string $inputSQL, string $expectedOutputSQL, array $expectedOutputParamsMap): void public function testConvertNonTerminatedLiteral(string $sql, string $expectedExceptionMessageRegExp): void
{ {
[$statement, $params] = Statement::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
namespace Doctrine\DBAL\Tests\Driver\OCI8;
use Doctrine\DBAL\Driver\OCI8\OCI8Exception;
use Doctrine\DBAL\Driver\OCI8\OCI8Statement;
use PHPUnit\Framework\TestCase;
use function extension_loaded;
class OCI8StatementTest extends TestCase
{
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->expectExceptionMessageMatches($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