Skip to content

Commit

Permalink
Port SQL parser from PDO
Browse files Browse the repository at this point in the history
  • Loading branch information
morozov committed Nov 11, 2020
1 parent f91860d commit d0f0e8d
Show file tree
Hide file tree
Showing 18 changed files with 1,030 additions and 788 deletions.
8 changes: 0 additions & 8 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,6 @@
<file name="src/Driver/AbstractSQLiteDriver.php"/>
</errorLevel>
</NullableReturnStatement>
<PossiblyInvalidOperand>
<errorLevel type="suppress">
<!--
This code relies on certain elements of a mixed-type array to be of a certain type.
-->
<file name="src/SQLParserUtils.php"/>
</errorLevel>
</PossiblyInvalidOperand>
<PossiblyNullArgument>
<errorLevel type="suppress">
<!-- See https://github.com/doctrine/dbal/pull/3488 -->
Expand Down
12 changes: 12 additions & 0 deletions src/ArrayParameters/Exception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Doctrine\DBAL\ArrayParameters;

use Throwable;

/**
* @internal
*/
interface Exception extends Throwable
{
}
21 changes: 21 additions & 0 deletions src/ArrayParameters/Exception/MissingNamedParameter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Doctrine\DBAL\ArrayParameters\Exception;

use Doctrine\DBAL\ArrayParameters\Exception;
use LogicException;

use function sprintf;

/**
* @psalm-immutable
*/
class MissingNamedParameter extends LogicException implements Exception
{
public static function new(string $name): self
{
return new self(
sprintf('Named parameter "%s" does not have a bound value.', $name)
);
}
}
23 changes: 23 additions & 0 deletions src/ArrayParameters/Exception/MissingPositionalParameter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Doctrine\DBAL\ArrayParameters\Exception;

use Doctrine\DBAL\ArrayParameters\Exception;
use LogicException;

use function sprintf;

/**
* @internal
*
* @psalm-immutable
*/
class MissingPositionalParameter extends LogicException implements Exception
{
public static function new(int $index): self
{
return new self(
sprintf('Positional parameter at index %d does not have a bound value.', $index)
);
}
}
66 changes: 57 additions & 9 deletions src/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\DBAL\Schema\AbstractSchemaManager;
use Doctrine\DBAL\SQL\Parser;
use Doctrine\DBAL\Types\Type;
use Throwable;
use Traversable;
Expand Down Expand Up @@ -114,6 +115,9 @@ class Connection
/** @var ExceptionConverter|null */
private $exceptionConverter;

/** @var Parser|null */
private $parser;

/**
* The schema manager.
*
Expand Down Expand Up @@ -1016,7 +1020,9 @@ public function executeQuery(

try {
if (count($params) > 0) {
[$sql, $params, $types] = SQLParserUtils::expandListParameters($sql, $params, $types);
if ($this->needsArrayParameterConversion($params, $types)) {
[$sql, $params, $types] = $this->expandArrayParameters($sql, $params, $types);
}

$stmt = $connection->prepare($sql);
if (count($types) > 0) {
Expand Down Expand Up @@ -1118,7 +1124,9 @@ public function executeStatement($sql, array $params = [], array $types = [])

try {
if (count($params) > 0) {
[$sql, $params, $types] = SQLParserUtils::expandListParameters($sql, $params, $types);
if ($this->needsArrayParameterConversion($params, $types)) {
[$sql, $params, $types] = $this->expandArrayParameters($sql, $params, $types);
}

$stmt = $connection->prepare($sql);

Expand Down Expand Up @@ -1581,13 +1589,11 @@ private function _bindTypedValues(DriverStatement $stmt, array $params, array $t
{
// Check whether parameters are positional or named. Mixing is not allowed.
if (is_int(key($params))) {
// Positional parameters
$typeOffset = array_key_exists(0, $types) ? -1 : 0;
$bindIndex = 1;
foreach ($params as $value) {
$typeIndex = $bindIndex + $typeOffset;
if (isset($types[$typeIndex])) {
$type = $types[$typeIndex];
$bindIndex = 1;

foreach ($params as $key => $value) {
if (isset($types[$key])) {
$type = $types[$key];
[$value, $bindingType] = $this->getBindingInfo($value, $type);
$stmt->bindValue($bindIndex, $value, $bindingType);
} else {
Expand Down Expand Up @@ -1669,6 +1675,48 @@ final public function convertException(Driver\Exception $e): DriverException
return $this->handleDriverException($e, null);
}

/**
* @param array<int, mixed>|array<string, mixed> $params
* @param array<int, int|string|Type|null>|array<string, int|string|Type|null> $types
*
* @return array{0: string, 1: list<mixed>, 2: array<int,Type|int|string|null>}
*/
private function expandArrayParameters(string $sql, array $params, array $types): array
{
if ($this->parser === null) {
$this->parser = $this->getDatabasePlatform()->createSQLParser();
}

$visitor = new ExpandArrayParameters($params, $types);

$this->parser->parse($sql, $visitor);

return [
$visitor->getSQL(),
$visitor->getParameters(),
$visitor->getTypes(),
];
}

/**
* @param array<int, mixed>|array<string, mixed> $params
* @param array<int, int|string|Type|null>|array<string, int|string|Type|null> $types
*/
private function needsArrayParameterConversion(array $params, array $types): bool
{
if (is_string(key($params))) {
return true;
}

foreach ($types as $type) {
if ($type === self::PARAM_INT_ARRAY || $type === self::PARAM_STR_ARRAY) {
return true;
}
}

return false;
}

private function handleDriverException(
Driver\Exception $driverException,
?Query $query
Expand Down
158 changes: 26 additions & 132 deletions src/Driver/OCI8/ConvertPositionalToNamedPlaceholders.php
Original file line number Diff line number Diff line change
@@ -1,164 +1,58 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Driver\OCI8;

use Doctrine\DBAL\Driver\Exception;
use Doctrine\DBAL\Driver\OCI8\Exception\NonTerminatedStringLiteral;
use Doctrine\DBAL\SQL\Parser\Visitor;

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.
* positional parameters into artificially named parameters.
*
* @internal This class is not covered by the backward compatibility promise
*/
final class ConvertPositionalToNamedPlaceholders
final class ConvertPositionalToNamedPlaceholders implements Visitor
{
/**
* @param string $statement The SQL statement to convert.
*
* @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array).
*
* @throws Exception
*/
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);
/** @var list<string> */
private $buffer = [];

if ($currentLiteralDelimiter !== null) {
throw NonTerminatedStringLiteral::new($tokenOffset - 1);
}
/** @var array<int,string> */
private $parameterMap = [];

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

return [$statement, $paramMap];
public function acceptOther(string $sql): void
{
$this->buffer[] = $sql;
}

/**
* 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;
}
public function acceptPositionalParameter(string $sql): void
{
$position = count($this->parameterMap) + 1;
$param = ':param' . $position;

$currentLiteralDelimiter = $token;
++$tokenOffset;
$this->parameterMap[$position] = $param;

return true;
$this->buffer[] = $param;
}

/**
* 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;
public function acceptNamedParameter(string $sql): void
{
$this->buffer[] = $sql;
}

return true;
public function getSQL(): string
{
return implode('', $this->buffer);
}

/**
* 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
* @return array<int,string>
*/
private function findToken(string $statement, int &$offset, string $regex): ?string
public function getParameterMap(): array
{
if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset) === 1) {
$offset = $matches[0][1];

return $matches[0][0];
}

return null;
return $this->parameterMap;
}
}
Loading

0 comments on commit d0f0e8d

Please sign in to comment.