Skip to content

Commit 041df42

Browse files
authored
Merge pull request #4397 from morozov/issues/4383
Port the SQL parser from PDO
2 parents e456ab1 + 4fbd7ac commit 041df42

18 files changed

+1004
-790
lines changed

psalm.xml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,6 @@
7575
<file name="src/Driver/AbstractSQLiteDriver.php"/>
7676
</errorLevel>
7777
</NullableReturnStatement>
78-
<PossiblyInvalidOperand>
79-
<errorLevel type="suppress">
80-
<!--
81-
This code relies on certain elements of a mixed-type array to be of a certain type.
82-
-->
83-
<file name="src/SQLParserUtils.php"/>
84-
</errorLevel>
85-
</PossiblyInvalidOperand>
8678
<PossiblyNullArgument>
8779
<errorLevel type="suppress">
8880
<!-- See https://github.com/doctrine/dbal/pull/3488 -->

src/ArrayParameters/Exception.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
namespace Doctrine\DBAL\ArrayParameters;
4+
5+
use Throwable;
6+
7+
/**
8+
* @internal
9+
*/
10+
interface Exception extends Throwable
11+
{
12+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace Doctrine\DBAL\ArrayParameters\Exception;
4+
5+
use Doctrine\DBAL\ArrayParameters\Exception;
6+
use LogicException;
7+
8+
use function sprintf;
9+
10+
/**
11+
* @psalm-immutable
12+
*/
13+
class MissingNamedParameter extends LogicException implements Exception
14+
{
15+
public static function new(string $name): self
16+
{
17+
return new self(
18+
sprintf('Named parameter "%s" does not have a bound value.', $name)
19+
);
20+
}
21+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace Doctrine\DBAL\ArrayParameters\Exception;
4+
5+
use Doctrine\DBAL\ArrayParameters\Exception;
6+
use LogicException;
7+
8+
use function sprintf;
9+
10+
/**
11+
* @internal
12+
*
13+
* @psalm-immutable
14+
*/
15+
class MissingPositionalParameter extends LogicException implements Exception
16+
{
17+
public static function new(int $index): self
18+
{
19+
return new self(
20+
sprintf('Positional parameter at index %d does not have a bound value.', $index)
21+
);
22+
}
23+
}

src/Connection.php

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
2020
use Doctrine\DBAL\Query\QueryBuilder;
2121
use Doctrine\DBAL\Schema\AbstractSchemaManager;
22+
use Doctrine\DBAL\SQL\Parser;
2223
use Doctrine\DBAL\Types\Type;
2324
use Throwable;
2425
use Traversable;
@@ -114,6 +115,9 @@ class Connection
114115
/** @var ExceptionConverter|null */
115116
private $exceptionConverter;
116117

118+
/** @var Parser|null */
119+
private $parser;
120+
117121
/**
118122
* The schema manager.
119123
*
@@ -1016,7 +1020,9 @@ public function executeQuery(
10161020

10171021
try {
10181022
if (count($params) > 0) {
1019-
[$sql, $params, $types] = SQLParserUtils::expandListParameters($sql, $params, $types);
1023+
if ($this->needsArrayParameterConversion($params, $types)) {
1024+
[$sql, $params, $types] = $this->expandArrayParameters($sql, $params, $types);
1025+
}
10201026

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

11191125
try {
11201126
if (count($params) > 0) {
1121-
[$sql, $params, $types] = SQLParserUtils::expandListParameters($sql, $params, $types);
1127+
if ($this->needsArrayParameterConversion($params, $types)) {
1128+
[$sql, $params, $types] = $this->expandArrayParameters($sql, $params, $types);
1129+
}
11221130

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

@@ -1581,13 +1589,11 @@ private function _bindTypedValues(DriverStatement $stmt, array $params, array $t
15811589
{
15821590
// Check whether parameters are positional or named. Mixing is not allowed.
15831591
if (is_int(key($params))) {
1584-
// Positional parameters
1585-
$typeOffset = array_key_exists(0, $types) ? -1 : 0;
1586-
$bindIndex = 1;
1587-
foreach ($params as $value) {
1588-
$typeIndex = $bindIndex + $typeOffset;
1589-
if (isset($types[$typeIndex])) {
1590-
$type = $types[$typeIndex];
1592+
$bindIndex = 1;
1593+
1594+
foreach ($params as $key => $value) {
1595+
if (isset($types[$key])) {
1596+
$type = $types[$key];
15911597
[$value, $bindingType] = $this->getBindingInfo($value, $type);
15921598
$stmt->bindValue($bindIndex, $value, $bindingType);
15931599
} else {
@@ -1669,6 +1675,48 @@ final public function convertException(Driver\Exception $e): DriverException
16691675
return $this->handleDriverException($e, null);
16701676
}
16711677

1678+
/**
1679+
* @param array<int, mixed>|array<string, mixed> $params
1680+
* @param array<int, int|string|Type|null>|array<string, int|string|Type|null> $types
1681+
*
1682+
* @return array{string, list<mixed>, array<int,Type|int|string|null>}
1683+
*/
1684+
private function expandArrayParameters(string $sql, array $params, array $types): array
1685+
{
1686+
if ($this->parser === null) {
1687+
$this->parser = $this->getDatabasePlatform()->createSQLParser();
1688+
}
1689+
1690+
$visitor = new ExpandArrayParameters($params, $types);
1691+
1692+
$this->parser->parse($sql, $visitor);
1693+
1694+
return [
1695+
$visitor->getSQL(),
1696+
$visitor->getParameters(),
1697+
$visitor->getTypes(),
1698+
];
1699+
}
1700+
1701+
/**
1702+
* @param array<int, mixed>|array<string, mixed> $params
1703+
* @param array<int, int|string|Type|null>|array<string, int|string|Type|null> $types
1704+
*/
1705+
private function needsArrayParameterConversion(array $params, array $types): bool
1706+
{
1707+
if (is_string(key($params))) {
1708+
return true;
1709+
}
1710+
1711+
foreach ($types as $type) {
1712+
if ($type === self::PARAM_INT_ARRAY || $type === self::PARAM_STR_ARRAY) {
1713+
return true;
1714+
}
1715+
}
1716+
1717+
return false;
1718+
}
1719+
16721720
private function handleDriverException(
16731721
Driver\Exception $driverException,
16741722
?Query $query
Lines changed: 26 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,58 @@
11
<?php
22

3-
declare(strict_types=1);
4-
53
namespace Doctrine\DBAL\Driver\OCI8;
64

7-
use Doctrine\DBAL\Driver\Exception;
8-
use Doctrine\DBAL\Driver\OCI8\Exception\NonTerminatedStringLiteral;
5+
use Doctrine\DBAL\SQL\Parser\Visitor;
96

107
use function count;
118
use function implode;
12-
use function preg_match;
13-
use function preg_quote;
14-
use function substr;
15-
16-
use const PREG_OFFSET_CAPTURE;
179

1810
/**
1911
* Converts positional (?) into named placeholders (:param<num>).
2012
*
2113
* Oracle does not support positional parameters, hence this method converts all
22-
* positional parameters into artificially named parameters. Note that this conversion
23-
* is not perfect. All question marks (?) in the original statement are treated as
24-
* placeholders and converted to a named parameter.
14+
* positional parameters into artificially named parameters.
2515
*
2616
* @internal This class is not covered by the backward compatibility promise
2717
*/
28-
final class ConvertPositionalToNamedPlaceholders
18+
final class ConvertPositionalToNamedPlaceholders implements Visitor
2919
{
30-
/**
31-
* @param string $statement The SQL statement to convert.
32-
*
33-
* @return mixed[] [0] => the statement value (string), [1] => the paramMap value (array).
34-
*
35-
* @throws Exception
36-
*/
37-
public function __invoke(string $statement): array
38-
{
39-
$fragmentOffset = $tokenOffset = 0;
40-
$fragments = $paramMap = [];
41-
$currentLiteralDelimiter = null;
42-
43-
do {
44-
if ($currentLiteralDelimiter === null) {
45-
$result = $this->findPlaceholderOrOpeningQuote(
46-
$statement,
47-
$tokenOffset,
48-
$fragmentOffset,
49-
$fragments,
50-
$currentLiteralDelimiter,
51-
$paramMap
52-
);
53-
} else {
54-
$result = $this->findClosingQuote($statement, $tokenOffset, $currentLiteralDelimiter);
55-
}
56-
} while ($result);
20+
/** @var list<string> */
21+
private $buffer = [];
5722

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

62-
$fragments[] = substr($statement, $fragmentOffset);
63-
$statement = implode('', $fragments);
64-
65-
return [$statement, $paramMap];
26+
public function acceptOther(string $sql): void
27+
{
28+
$this->buffer[] = $sql;
6629
}
6730

68-
/**
69-
* Finds next placeholder or opening quote.
70-
*
71-
* @param string $statement The SQL statement to parse
72-
* @param int $tokenOffset The offset to start searching from
73-
* @param int $fragmentOffset The offset to build the next fragment from
74-
* @param string[] $fragments Fragments of the original statement not containing placeholders
75-
* @param string|null $currentLiteralDelimiter The delimiter of the current string literal
76-
* or NULL if not currently in a literal
77-
* @param string[] $paramMap Mapping of the original parameter positions
78-
* to their named replacements
79-
*
80-
* @return bool Whether the token was found
81-
*/
82-
private function findPlaceholderOrOpeningQuote(
83-
string $statement,
84-
int &$tokenOffset,
85-
int &$fragmentOffset,
86-
array &$fragments,
87-
?string &$currentLiteralDelimiter,
88-
array &$paramMap
89-
): bool {
90-
$token = $this->findToken($statement, $tokenOffset, '/[?\'"]/');
91-
92-
if ($token === null) {
93-
return false;
94-
}
95-
96-
if ($token === '?') {
97-
$position = count($paramMap) + 1;
98-
$param = ':param' . $position;
99-
$fragments[] = substr($statement, $fragmentOffset, $tokenOffset - $fragmentOffset);
100-
$fragments[] = $param;
101-
$paramMap[$position] = $param;
102-
$tokenOffset += 1;
103-
$fragmentOffset = $tokenOffset;
104-
105-
return true;
106-
}
31+
public function acceptPositionalParameter(string $sql): void
32+
{
33+
$position = count($this->parameterMap) + 1;
34+
$param = ':param' . $position;
10735

108-
$currentLiteralDelimiter = $token;
109-
++$tokenOffset;
36+
$this->parameterMap[$position] = $param;
11037

111-
return true;
38+
$this->buffer[] = $param;
11239
}
11340

114-
/**
115-
* Finds closing quote
116-
*
117-
* @param string $statement The SQL statement to parse
118-
* @param int $tokenOffset The offset to start searching from
119-
* @param string $currentLiteralDelimiter The delimiter of the current string literal
120-
*
121-
* @return bool Whether the token was found
122-
*/
123-
private function findClosingQuote(
124-
string $statement,
125-
int &$tokenOffset,
126-
string &$currentLiteralDelimiter
127-
): bool {
128-
$token = $this->findToken(
129-
$statement,
130-
$tokenOffset,
131-
'/' . preg_quote($currentLiteralDelimiter, '/') . '/'
132-
);
133-
134-
if ($token === null) {
135-
return false;
136-
}
137-
138-
$currentLiteralDelimiter = null;
139-
++$tokenOffset;
41+
public function acceptNamedParameter(string $sql): void
42+
{
43+
$this->buffer[] = $sql;
44+
}
14045

141-
return true;
46+
public function getSQL(): string
47+
{
48+
return implode('', $this->buffer);
14249
}
14350

14451
/**
145-
* Finds the token described by regex starting from the given offset. Updates the offset with the position
146-
* where the token was found.
147-
*
148-
* @param string $statement The SQL statement to parse
149-
* @param int $offset The offset to start searching from
150-
* @param string $regex The regex containing token pattern
151-
*
152-
* @return string|null Token or NULL if not found
52+
* @return array<int,string>
15353
*/
154-
private function findToken(string $statement, int &$offset, string $regex): ?string
54+
public function getParameterMap(): array
15555
{
156-
if (preg_match($regex, $statement, $matches, PREG_OFFSET_CAPTURE, $offset) === 1) {
157-
$offset = $matches[0][1];
158-
159-
return $matches[0][0];
160-
}
161-
162-
return null;
56+
return $this->parameterMap;
16357
}
16458
}

0 commit comments

Comments
 (0)