Extract conversion of positional to named placeholder to an utility class

parent 2da86a36
......@@ -29,7 +29,7 @@
This one is just too convoluted for Psalm to figure out, by
its author's own admission
-->
<file name="src/Driver/OCI8/OCI8Statement.php"/>
<file name="src/Driver/OCI8/ConvertPositionalToNamedPlaceholders.php"/>
</errorLevel>
</ConflictingReferenceConstraint>
<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 @@
namespace Doctrine\DBAL\Driver\OCI8;
use Doctrine\DBAL\Driver\OCI8\Exception\NonTerminatedStringLiteral;
use Doctrine\DBAL\Driver\OCI8\Exception\UnknownParameterIndex;
use Doctrine\DBAL\Driver\Result as ResultInterface;
use Doctrine\DBAL\Driver\Statement as StatementInterface;
use Doctrine\DBAL\ParameterType;
use function assert;
use function count;
use function implode;
use function is_int;
use function is_resource;
use function oci_bind_by_name;
......@@ -18,9 +15,6 @@ use function oci_error;
use function oci_execute;
use function oci_new_descriptor;
use function oci_parse;
use function preg_match;
use function preg_quote;
use function substr;
use const OCI_B_BIN;
use const OCI_B_BLOB;
......@@ -28,7 +22,6 @@ use const OCI_COMMIT_ON_SUCCESS;
use const OCI_D_LOB;
use const OCI_NO_AUTO_COMMIT;
use const OCI_TEMP_BLOB;
use const PREG_OFFSET_CAPTURE;
use const SQLT_CHR;
/**
......@@ -67,7 +60,7 @@ class OCI8Statement implements StatementInterface
*/
public function __construct($dbh, $query, ExecutionMode $executionMode)
{
[$query, $paramMap] = self::convertPositionalToNamedPlaceholders($query);
[$query, $paramMap] = (new ConvertPositionalToNamedPlaceholders())($query);
$stmt = oci_parse($dbh, $query);
assert(is_resource($stmt));
......@@ -78,154 +71,6 @@ class OCI8Statement implements StatementInterface
$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}
*/
......
<?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;
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[][]
*/
public static function dataConvertPositionalToNamedParameters(): iterable
public static function positionalToNamedPlaceholdersProvider(): iterable
{
return [
[
......@@ -67,15 +91,33 @@ class UtilTest extends TestCase
}
/**
* @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] = 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