Skip to content

Commit 6456ffd

Browse files
committed
Merge branch '4.3.x' into 5.0.x
2 parents a56e4c1 + 43d8490 commit 6456ffd

15 files changed

+628
-4
lines changed

UPGRADE.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,28 @@ all drivers and middleware.
3333

3434
# Upgrade to 4.3
3535

36+
## Deprecated relying on the current implementation of the database object name parser
37+
38+
The current object name parser implicitly quotes identifiers in the following cases:
39+
40+
1. If the object name is a reserved keyword (e.g., `select`).
41+
2. If an unquoted identifier is preceded by a quoted identifier (e.g., `"inventory".product`).
42+
43+
As a result, the original case of such identifiers is preserved on platforms that respect the SQL-92 standard (i.e.,
44+
identifiers are not upper-cased on Oracle and IBM DB2, and not lower-cased on PostgreSQL). This behavior is deprecated.
45+
46+
If preserving the original case of an identifier is required, please explicitly quote it (e.g., `select``"select"`).
47+
48+
Additionally, the current parser exhibits the following defects:
49+
50+
1. It ignores a missing closing quote in a quoted identifier (e.g., `"inventory`).
51+
2. It allows names with more than two identifiers (e.g., `warehouse.inventory.product`) but only uses the first two,
52+
ignoring the remaining ones.
53+
3. If a quoted identifier contains a dot, it incorrectly treats the part before the dot as a qualifier, despite the
54+
identifier being quoted.
55+
56+
Relying on the above behaviors is deprecated.
57+
3658
## Deprecated `AbstractPlatform::quoteIdentifier()` and `Connection::quoteIdentifier()`
3759

3860
The `AbstractPlatform::quoteIdentifier()` and `Connection::quoteIdentifier()` methods have been deprecated.

src/Platforms/AbstractMySQLPlatform.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -868,4 +868,9 @@ public function fetchTableOptionsByTable(bool $includeTableName): string
868868

869869
return $sql . ' WHERE ' . implode(' AND ', $conditions);
870870
}
871+
872+
public function normalizeUnquotedIdentifier(string $identifier): string
873+
{
874+
return $identifier;
875+
}
871876
}

src/Platforms/AbstractPlatform.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2247,6 +2247,18 @@ public function getUnionDistinctSQL(): string
22472247
return 'UNION';
22482248
}
22492249

2250+
/**
2251+
* Changes the case of unquoted identifier in the same way as the given platform would change it if it was specified
2252+
* in an SQL statement.
2253+
*
2254+
* Even though the default behavior is not the most common across supported platforms, it is part of the SQL92
2255+
* standard.
2256+
*/
2257+
public function normalizeUnquotedIdentifier(string $identifier): string
2258+
{
2259+
return strtoupper($identifier);
2260+
}
2261+
22502262
/**
22512263
* Creates the schema manager that can be used to inspect and change the underlying
22522264
* database schema according to the dialect of the platform.

src/Platforms/PostgreSQLPlatform.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,4 +773,9 @@ public function createSchemaManager(Connection $connection): PostgreSQLSchemaMan
773773
{
774774
return new PostgreSQLSchemaManager($connection, $this);
775775
}
776+
777+
public function normalizeUnquotedIdentifier(string $identifier): string
778+
{
779+
return strtolower($identifier);
780+
}
776781
}

src/Platforms/SQLServerPlatform.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1241,4 +1241,9 @@ public function createSchemaManager(Connection $connection): SQLServerSchemaMana
12411241
{
12421242
return new SQLServerSchemaManager($connection, $this);
12431243
}
1244+
1245+
public function normalizeUnquotedIdentifier(string $identifier): string
1246+
{
1247+
return $identifier;
1248+
}
12441249
}

src/Platforms/SQLitePlatform.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,4 +973,9 @@ public function getUnionSelectPartSQL(string $subQuery): string
973973
{
974974
return $subQuery;
975975
}
976+
977+
public function normalizeUnquotedIdentifier(string $identifier): string
978+
{
979+
return $identifier;
980+
}
976981
}

src/Schema/AbstractAsset.php

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@
55
namespace Doctrine\DBAL\Schema;
66

77
use Doctrine\DBAL\Platforms\AbstractPlatform;
8+
use Doctrine\DBAL\Schema\Name\Parser;
9+
use Doctrine\DBAL\Schema\Name\Parser\Identifier;
10+
use Doctrine\Deprecations\Deprecation;
811

912
use function array_map;
13+
use function count;
1014
use function crc32;
1115
use function dechex;
1216
use function explode;
1317
use function implode;
18+
use function sprintf;
1419
use function str_contains;
1520
use function str_replace;
1621
use function strtolower;
@@ -35,11 +40,18 @@ abstract class AbstractAsset
3540

3641
protected bool $_quoted = false;
3742

43+
/** @var list<Identifier> */
44+
private array $identifiers = [];
45+
46+
private bool $validateFuture = false;
47+
3848
/**
3949
* Sets the name of this asset.
4050
*/
4151
protected function _setName(string $name): void
4252
{
53+
$input = $name;
54+
4355
if ($this->isIdentifierQuoted($name)) {
4456
$this->_quoted = true;
4557
$name = $this->trimQuotes($name);
@@ -52,6 +64,81 @@ protected function _setName(string $name): void
5264
}
5365

5466
$this->_name = $name;
67+
68+
$this->validateFuture = false;
69+
70+
if ($input !== '') {
71+
$parser = new Parser();
72+
73+
try {
74+
$identifiers = $parser->parse($input);
75+
} catch (Parser\Exception $e) {
76+
Deprecation::trigger(
77+
'doctrine/dbal',
78+
'https://github.com/doctrine/dbal/pull/6592',
79+
'Unable to parse object name: %s.',
80+
$e->getMessage(),
81+
);
82+
83+
return;
84+
}
85+
} else {
86+
$identifiers = [];
87+
}
88+
89+
switch (count($identifiers)) {
90+
case 0:
91+
$this->identifiers = [];
92+
93+
return;
94+
case 1:
95+
$namespace = null;
96+
$name = $identifiers[0];
97+
break;
98+
99+
case 2:
100+
/** @psalm-suppress PossiblyUndefinedArrayOffset */
101+
[$namespace, $name] = $identifiers;
102+
break;
103+
104+
default:
105+
Deprecation::trigger(
106+
'doctrine/dbal',
107+
'https://github.com/doctrine/dbal/pull/6592',
108+
'An object name may consist of at most 2 identifiers (<namespace>.<name>), %d given.',
109+
count($identifiers),
110+
);
111+
112+
return;
113+
}
114+
115+
$this->identifiers = $identifiers;
116+
$this->validateFuture = true;
117+
118+
$futureName = $name->getValue();
119+
$futureNamespace = $namespace?->getValue();
120+
121+
if ($this->_name !== $futureName) {
122+
Deprecation::trigger(
123+
'doctrine/dbal',
124+
'https://github.com/doctrine/dbal/pull/6592',
125+
'Instead of "%s", this name will be interpreted as "%s" in 5.0',
126+
$this->_name,
127+
$futureName,
128+
);
129+
}
130+
131+
if ($this->_namespace === $futureNamespace) {
132+
return;
133+
}
134+
135+
Deprecation::trigger(
136+
'doctrine/dbal',
137+
'https://github.com/doctrine/dbal/pull/6592',
138+
'Instead of %s, the namespace in this name will be interpreted as %s in 5.0.',
139+
$this->_namespace !== null ? sprintf('"%s"', $this->_namespace) : 'null',
140+
$futureNamespace !== null ? sprintf('"%s"', $futureNamespace) : 'null',
141+
);
55142
}
56143

57144
/**
@@ -129,12 +216,47 @@ public function getName(): string
129216
public function getQuotedName(AbstractPlatform $platform): string
130217
{
131218
$keywords = $platform->getReservedKeywordsList();
132-
$parts = explode('.', $this->getName());
133-
foreach ($parts as $k => $v) {
134-
$parts[$k] = $this->_quoted || $keywords->isKeyword($v) ? $platform->quoteSingleIdentifier($v) : $v;
219+
$parts = $normalizedParts = [];
220+
221+
foreach (explode('.', $this->getName()) as $identifier) {
222+
$isQuoted = $this->_quoted || $keywords->isKeyword($identifier);
223+
224+
if (! $isQuoted) {
225+
$parts[] = $identifier;
226+
$normalizedParts[] = $platform->normalizeUnquotedIdentifier($identifier);
227+
} else {
228+
$parts[] = $platform->quoteSingleIdentifier($identifier);
229+
$normalizedParts[] = $identifier;
230+
}
231+
}
232+
233+
$name = implode('.', $parts);
234+
235+
if ($this->validateFuture) {
236+
$futureParts = array_map(static function (Identifier $identifier) use ($platform): string {
237+
$value = $identifier->getValue();
238+
239+
if (! $identifier->isQuoted()) {
240+
$value = $platform->normalizeUnquotedIdentifier($value);
241+
}
242+
243+
return $value;
244+
}, $this->identifiers);
245+
246+
if ($normalizedParts !== $futureParts) {
247+
Deprecation::trigger(
248+
'doctrine/dbal',
249+
'https://github.com/doctrine/dbal/pull/6592',
250+
'Relying on implicitly quoted identifiers preserving their original case is deprecated. '
251+
. 'The current name %s will become %s in 5.0. '
252+
. 'Please quote the name if the case needs to be preserved.',
253+
$name,
254+
implode('.', array_map([$platform, 'quoteSingleIdentifier'], $futureParts)),
255+
);
256+
}
135257
}
136258

137-
return implode('.', $parts);
259+
return $name;
138260
}
139261

140262
/**

src/Schema/Name/Parser.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\DBAL\Schema\Name;
6+
7+
use Doctrine\DBAL\Schema\Name\Parser\Exception;
8+
use Doctrine\DBAL\Schema\Name\Parser\Exception\ExpectedDot;
9+
use Doctrine\DBAL\Schema\Name\Parser\Exception\ExpectedNextIdentifier;
10+
use Doctrine\DBAL\Schema\Name\Parser\Exception\UnableToParseIdentifier;
11+
use Doctrine\DBAL\Schema\Name\Parser\Identifier;
12+
13+
use function assert;
14+
use function preg_match;
15+
use function str_replace;
16+
use function strlen;
17+
18+
/**
19+
* Parses a qualified or unqualified SQL-like name.
20+
*
21+
* A name can be either unqualified or qualified:
22+
* - An unqualified name consists of a single identifier.
23+
* - A qualified name is a sequence of two or more identifiers separated by dots.
24+
*
25+
* An identifier can be quoted or unquoted:
26+
* - A quoted identifier is enclosed in double quotes ("), backticks (`), or square brackets ([]).
27+
* The closing quote character can be escaped by doubling it.
28+
* - An unquoted identifier may contain any character except whitespace, dots, or any of the quote characters.
29+
*
30+
* Differences from SQL:
31+
* 1. Identifiers that are reserved keywords or start with a digit do not need to be quoted.
32+
* 2. Whitespace is not allowed between identifiers.
33+
*
34+
* @internal
35+
*/
36+
final class Parser
37+
{
38+
private const IDENTIFIER_PATTERN = <<<'PATTERN'
39+
/\G
40+
(?:
41+
"(?<ansi>[^"]*(?:""[^"]*)*)" # ANSI SQL double-quoted
42+
| `(?<mysql>[^`]*(?:``[^`]*)*)` # MySQL-style backtick-quoted
43+
| \[(?<sqlserver>[^]]*(?:]][^]]*)*)] # SQL Server-style square-bracket-quoted
44+
| (?<unquoted>[^\s."`\[\]]+) # Unquoted
45+
)
46+
/x
47+
PATTERN;
48+
49+
/**
50+
* @return list<Identifier>
51+
*
52+
* @throws Exception
53+
*/
54+
public function parse(string $input): array
55+
{
56+
if ($input === '') {
57+
return [];
58+
}
59+
60+
$offset = 0;
61+
$identifiers = [];
62+
$length = strlen($input);
63+
64+
while (true) {
65+
if ($offset >= $length) {
66+
throw ExpectedNextIdentifier::new();
67+
}
68+
69+
if (preg_match(self::IDENTIFIER_PATTERN, $input, $matches, 0, $offset) === 0) {
70+
throw UnableToParseIdentifier::new($offset);
71+
}
72+
73+
if (isset($matches['ansi']) && strlen($matches['ansi']) > 0) {
74+
$identifier = Identifier::quoted(str_replace('""', '"', $matches['ansi']));
75+
} elseif (isset($matches['mysql']) && strlen($matches['mysql']) > 0) {
76+
$identifier = Identifier::quoted(str_replace('``', '`', $matches['mysql']));
77+
} elseif (isset($matches['sqlserver']) && strlen($matches['sqlserver']) > 0) {
78+
$identifier = Identifier::quoted(str_replace(']]', ']', $matches['sqlserver']));
79+
} else {
80+
assert(isset($matches['unquoted']) && strlen($matches['unquoted']) > 0);
81+
$identifier = Identifier::unquoted($matches['unquoted']);
82+
}
83+
84+
$identifiers[] = $identifier;
85+
86+
$offset += strlen($matches[0]);
87+
88+
if ($offset >= $length) {
89+
break;
90+
}
91+
92+
$character = $input[$offset];
93+
94+
if ($character !== '.') {
95+
throw ExpectedDot::new($offset, $character);
96+
}
97+
98+
$offset++;
99+
}
100+
101+
return $identifiers;
102+
}
103+
}

src/Schema/Name/Parser/Exception.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\DBAL\Schema\Name\Parser;
6+
7+
use Throwable;
8+
9+
interface Exception extends Throwable
10+
{
11+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\DBAL\Schema\Name\Parser\Exception;
6+
7+
use Doctrine\DBAL\Schema\Name\Parser\Exception;
8+
use LogicException;
9+
10+
use function sprintf;
11+
12+
/** @internal */
13+
class ExpectedDot extends LogicException implements Exception
14+
{
15+
public static function new(int $position, string $got): self
16+
{
17+
return new self(sprintf('Expected dot at position %d, got "%s".', $position, $got));
18+
}
19+
}

0 commit comments

Comments
 (0)