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

Deprecate relying on the current implementation of the database object name parser #6592

Merged
merged 3 commits into from
Nov 13, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,28 @@ awareness about deprecated code.

# Upgrade to 4.3

## Deprecated relying on the current implementation of the database object name parser

The current object name parser implicitly quotes identifiers in the following cases:

1. If the object name is a reserved keyword (e.g., `select`).
2. If an unquoted identifier is preceded by a quoted identifier (e.g., `"inventory".product`).

As a result, the original case of such identifiers is preserved on platforms that respect the SQL-92 standard (i.e.,
identifiers are not upper-cased on Oracle and IBM DB2, and not lower-cased on PostgreSQL). This behavior is deprecated.

If preserving the original case of an identifier is required, please explicitly quote it (e.g., `select` → `"select"`).

Additionally, the current parser exhibits the following defects:

1. It ignores a missing closing quote in a quoted identifier (e.g., `"inventory`).
2. It allows names with more than two identifiers (e.g., `warehouse.inventory.product`) but only uses the first two,
ignoring the remaining ones.
3. If a quoted identifier contains a dot, it incorrectly treats the part before the dot as a qualifier, despite the
identifier being quoted.

Relying on the above behaviors is deprecated.

## Deprecated `AbstractPlatform::quoteIdentifier()` and `Connection::quoteIdentifier()`

The `AbstractPlatform::quoteIdentifier()` and `Connection::quoteIdentifier()` methods have been deprecated.
Expand Down
5 changes: 5 additions & 0 deletions src/Platforms/AbstractMySQLPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -875,4 +875,9 @@ public function fetchTableOptionsByTable(bool $includeTableName): string

return $sql . ' WHERE ' . implode(' AND ', $conditions);
}

public function normalizeUnquotedIdentifier(string $identifier): string
{
return $identifier;
}
}
12 changes: 12 additions & 0 deletions src/Platforms/AbstractPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -2293,6 +2293,18 @@ public function getUnionDistinctSQL(): string
return 'UNION';
}

/**
* Changes the case of unquoted identifier in the same way as the given platform would change it if it was specified
* in an SQL statement.
*
* Even though the default behavior is not the most common across supported platforms, it is part of the SQL92
* standard.
*/
public function normalizeUnquotedIdentifier(string $identifier): string
{
return strtoupper($identifier);
}

/**
* Creates the schema manager that can be used to inspect and change the underlying
* database schema according to the dialect of the platform.
Expand Down
5 changes: 5 additions & 0 deletions src/Platforms/PostgreSQLPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -786,4 +786,9 @@ public function createSchemaManager(Connection $connection): PostgreSQLSchemaMan
{
return new PostgreSQLSchemaManager($connection, $this);
}

public function normalizeUnquotedIdentifier(string $identifier): string
{
return strtolower($identifier);
}
}
5 changes: 5 additions & 0 deletions src/Platforms/SQLServerPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -1241,4 +1241,9 @@ public function createSchemaManager(Connection $connection): SQLServerSchemaMana
{
return new SQLServerSchemaManager($connection, $this);
}

public function normalizeUnquotedIdentifier(string $identifier): string
{
return $identifier;
}
}
5 changes: 5 additions & 0 deletions src/Platforms/SQLitePlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -973,4 +973,9 @@ public function getUnionSelectPartSQL(string $subQuery): string
{
return $subQuery;
}

public function normalizeUnquotedIdentifier(string $identifier): string
{
return $identifier;
}
}
130 changes: 126 additions & 4 deletions src/Schema/AbstractAsset.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@
namespace Doctrine\DBAL\Schema;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Schema\Name\Parser;
use Doctrine\DBAL\Schema\Name\Parser\Identifier;
use Doctrine\Deprecations\Deprecation;

use function array_map;
use function count;
use function crc32;
use function dechex;
use function explode;
use function implode;
use function sprintf;
use function str_contains;
use function str_replace;
use function strtolower;
Expand All @@ -35,11 +40,18 @@ abstract class AbstractAsset

protected bool $_quoted = false;

/** @var list<Identifier> */
private array $identifiers = [];

private bool $validateFuture = false;

/**
* Sets the name of this asset.
*/
protected function _setName(string $name): void
{
$input = $name;

if ($this->isIdentifierQuoted($name)) {
$this->_quoted = true;
$name = $this->trimQuotes($name);
Expand All @@ -52,6 +64,81 @@ protected function _setName(string $name): void
}

$this->_name = $name;

$this->validateFuture = false;

if ($input !== '') {
$parser = new Parser();

try {
$identifiers = $parser->parse($input);
} catch (Parser\Exception $e) {
Deprecation::trigger(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/6592',
'Unable to parse object name: %s.',
$e->getMessage(),
);

return;
}
} else {
$identifiers = [];
}

switch (count($identifiers)) {
case 0:
$this->identifiers = [];

return;
case 1:
$namespace = null;
$name = $identifiers[0];
break;

case 2:
/** @psalm-suppress PossiblyUndefinedArrayOffset */
[$namespace, $name] = $identifiers;
break;

default:
Deprecation::trigger(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/6592',
'An object name may consist of at most 2 identifiers (<namespace>.<name>), %d given.',
count($identifiers),
);

return;
}

$this->identifiers = $identifiers;
$this->validateFuture = true;

$futureName = $name->getValue();
$futureNamespace = $namespace?->getValue();

if ($this->_name !== $futureName) {
Deprecation::trigger(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/6592',
'Instead of "%s", this name will be interpreted as "%s" in 5.0',
$this->_name,
$futureName,
);
}

if ($this->_namespace === $futureNamespace) {
return;
}

Deprecation::trigger(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/6592',
'Instead of %s, the namespace in this name will be interpreted as %s in 5.0.',
$this->_namespace !== null ? sprintf('"%s"', $this->_namespace) : 'null',
$futureNamespace !== null ? sprintf('"%s"', $futureNamespace) : 'null',
);
}

/**
Expand Down Expand Up @@ -129,12 +216,47 @@ public function getName(): string
public function getQuotedName(AbstractPlatform $platform): string
{
$keywords = $platform->getReservedKeywordsList();
$parts = explode('.', $this->getName());
foreach ($parts as $k => $v) {
$parts[$k] = $this->_quoted || $keywords->isKeyword($v) ? $platform->quoteSingleIdentifier($v) : $v;
$parts = $normalizedParts = [];

foreach (explode('.', $this->getName()) as $identifier) {
$isQuoted = $this->_quoted || $keywords->isKeyword($identifier);

if (! $isQuoted) {
$parts[] = $identifier;
$normalizedParts[] = $platform->normalizeUnquotedIdentifier($identifier);
} else {
$parts[] = $platform->quoteSingleIdentifier($identifier);
$normalizedParts[] = $identifier;
}
}

$name = implode('.', $parts);

if ($this->validateFuture) {
$futureParts = array_map(static function (Identifier $identifier) use ($platform): string {
$value = $identifier->getValue();

if (! $identifier->isQuoted()) {
$value = $platform->normalizeUnquotedIdentifier($value);
}

return $value;
}, $this->identifiers);

if ($normalizedParts !== $futureParts) {
Deprecation::trigger(
'doctrine/dbal',
'https://github.com/doctrine/dbal/pull/6592',
'Relying on implicitly quoted identifiers preserving their original case is deprecated. '
. 'The current name %s will become %s in 5.0. '
. 'Please quote the name if the case needs to be preserved.',
$name,
implode('.', array_map([$platform, 'quoteSingleIdentifier'], $futureParts)),
);
}
}

return implode('.', $parts);
return $name;
}

/**
Expand Down
103 changes: 103 additions & 0 deletions src/Schema/Name/Parser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Schema\Name;

use Doctrine\DBAL\Schema\Name\Parser\Exception;
use Doctrine\DBAL\Schema\Name\Parser\Exception\ExpectedDot;
use Doctrine\DBAL\Schema\Name\Parser\Exception\ExpectedNextIdentifier;
use Doctrine\DBAL\Schema\Name\Parser\Exception\UnableToParseIdentifier;
use Doctrine\DBAL\Schema\Name\Parser\Identifier;

use function assert;
use function preg_match;
use function str_replace;
use function strlen;

/**
* Parses a qualified or unqualified SQL-like name.
*
* A name can be either unqualified or qualified:
* - An unqualified name consists of a single identifier.
* - A qualified name is a sequence of two or more identifiers separated by dots.
*
* An identifier can be quoted or unquoted:
* - A quoted identifier is enclosed in double quotes ("), backticks (`), or square brackets ([]).
* The closing quote character can be escaped by doubling it.
* - An unquoted identifier may contain any character except whitespace, dots, or any of the quote characters.
*
* Differences from SQL:
* 1. Identifiers that are reserved keywords or start with a digit do not need to be quoted.
* 2. Whitespace is not allowed between identifiers.
*
* @internal
*/
final class Parser
{
private const IDENTIFIER_PATTERN = <<<'PATTERN'
/\G
(?:
"(?<ansi>[^"]*(?:""[^"]*)*)" # ANSI SQL double-quoted
| `(?<mysql>[^`]*(?:``[^`]*)*)` # MySQL-style backtick-quoted
| \[(?<sqlserver>[^]]*(?:]][^]]*)*)] # SQL Server-style square-bracket-quoted
| (?<unquoted>[^\s."`\[\]]+) # Unquoted
)
/x
PATTERN;

/**
* @return list<Identifier>
*
* @throws Exception
*/
public function parse(string $input): array
{
if ($input === '') {
return [];
}

$offset = 0;
$identifiers = [];
$length = strlen($input);

while (true) {
if ($offset >= $length) {
throw ExpectedNextIdentifier::new();
}

if (preg_match(self::IDENTIFIER_PATTERN, $input, $matches, 0, $offset) === 0) {
throw UnableToParseIdentifier::new($offset);
}

if (isset($matches['ansi']) && strlen($matches['ansi']) > 0) {
$identifier = Identifier::quoted(str_replace('""', '"', $matches['ansi']));
} elseif (isset($matches['mysql']) && strlen($matches['mysql']) > 0) {
$identifier = Identifier::quoted(str_replace('``', '`', $matches['mysql']));
} elseif (isset($matches['sqlserver']) && strlen($matches['sqlserver']) > 0) {
$identifier = Identifier::quoted(str_replace(']]', ']', $matches['sqlserver']));
} else {
assert(isset($matches['unquoted']) && strlen($matches['unquoted']) > 0);
$identifier = Identifier::unquoted($matches['unquoted']);
}

$identifiers[] = $identifier;

$offset += strlen($matches[0]);

if ($offset >= $length) {
break;
}

$character = $input[$offset];

if ($character !== '.') {
throw ExpectedDot::new($offset, $character);
}

$offset++;
}

return $identifiers;
}
}
11 changes: 11 additions & 0 deletions src/Schema/Name/Parser/Exception.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Schema\Name\Parser;

use Throwable;

interface Exception extends Throwable
{
}
19 changes: 19 additions & 0 deletions src/Schema/Name/Parser/Exception/ExpectedDot.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Schema\Name\Parser\Exception;

use Doctrine\DBAL\Schema\Name\Parser\Exception;
use LogicException;

use function sprintf;

/** @internal */
class ExpectedDot extends LogicException implements Exception
{
public static function new(int $position, string $got): self
{
return new self(sprintf('Expected dot at position %d, got "%s".', $position, $got));
}
}
Loading
Loading