Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Port the SQL parser from PDO #4397

Merged
merged 1 commit into from Nov 14, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 0 additions & 8 deletions psalm.xml
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
@@ -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
@@ -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
@@ -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
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{string, list<mixed>, 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
@@ -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;
}
}