diff --git a/README.md b/README.md index 346c2020..6812173c 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,9 @@ Basic usage: use ... $platform = Platform::get(Platform::MYSQL, '8.0'); // version defaults to x.x.99 when no patch number is given -$settings = new ParserSettings($platform); +$config = new ParserConfig($platform); $session = new Session($platform); -$parser = new Parser($settings, $session); +$parser = new Parser($config, $session); // returns a Generator. will not parse anything if you don't iterate over it $commands = $parser->parse('SELECT foo FROM ...'); diff --git a/composer.json b/composer.json index bc3dd68c..4cdd1718 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ }, "require-dev": { "dogma/dogma-dev": "0.1.29", - "phpstan/phpstan": "1.9.15", + "phpstan/phpstan": "1.12.3", "phpstan/phpstan-strict-rules": "^1.0", "amphp/parallel-functions": "1.1.0", "rector/rector": "0.14.8", @@ -26,7 +26,7 @@ "classmap": ["sources"] }, "autoload-dev": { - "classmap": ["tests", "build"] + "classmap": ["tests", "build", "vendor/dogma/dogma-debug"] }, "minimum-stability": "dev", "prefer-stable": true, @@ -90,7 +90,10 @@ "tests:mysql": "php tests/Mysql/test.php", "tests:mysql-s": "php tests/Mysql/test.php --single", - "phpstan:run": "php vendor/phpstan/phpstan/phpstan analyse -c build/phpstan/phpstan.neon --memory-limit=512M", + "phpstan:run": [ + "Composer\\Config::disableProcessTimeout", + "php vendor/phpstan/phpstan/phpstan analyse -vvv -c build/phpstan/phpstan.neon --memory-limit=512M" + ], "phpstan:all": [ "php84 vendor/phpstan/phpstan/phpstan analyse -c build/phpstan/phpstan.neon --memory-limit=512M", "php83 vendor/phpstan/phpstan/phpstan analyse -c build/phpstan/phpstan.neon --memory-limit=512M", diff --git a/sources/Formatter/Formatter.php b/sources/Formatter/Formatter.php index 86deae88..7621e927 100644 --- a/sources/Formatter/Formatter.php +++ b/sources/Formatter/Formatter.php @@ -38,6 +38,7 @@ use function preg_match; use function str_replace; use function strpos; +use function strtoupper; class Formatter { @@ -124,12 +125,13 @@ public function formatName(string $name): string $sqlMode = $this->session->getMode(); $quote = $sqlMode->containsAny(SqlMode::ANSI_QUOTES) ? '"' : '`'; $name = str_replace($quote, $quote . $quote, $name); + $upper = strtoupper($name); $needsQuoting = $this->quoteAllNames + || isset($this->platform->reserved[$upper]) || strpos($name, $quote) !== false // contains quote || preg_match('~[\pL_]~u', $name) === 0 // does not contain letters - || preg_match('~[\pC\pM\pS\pZ\p{Pd}\p{Pe}\p{Pf}\p{Pi}\p{Po}\p{Ps}]~u', ltrim($name, '@')) !== 0 // contains control, mark, symbols, whitespace, punctuation except _ - || $this->platform->isReserved($name); + || preg_match('~[\pC\pM\pS\pZ\p{Pd}\p{Pe}\p{Pf}\p{Pi}\p{Po}\p{Ps}]~u', ltrim($name, '@')) !== 0; // contains control, mark, symbols, whitespace, punctuation except _ if ($needsQuoting && !$sqlMode->containsAny(SqlMode::NO_BACKSLASH_ESCAPES)) { $name = str_replace($this->escapeKeys, $this->escapeValues, $name); diff --git a/sources/Parser/Dal/ReplicationCommandsParser.php b/sources/Parser/Dal/ReplicationCommandsParser.php index 80cf75dc..7c9d76ab 100644 --- a/sources/Parser/Dal/ReplicationCommandsParser.php +++ b/sources/Parser/Dal/ReplicationCommandsParser.php @@ -13,9 +13,12 @@ use LogicException; use SqlFtw\Parser\ExpressionParser; use SqlFtw\Parser\InvalidValueException; +use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\TokenList; use SqlFtw\Parser\TokenType; +use SqlFtw\Platform\Features\Feature; +use SqlFtw\Platform\Platform; use SqlFtw\Sql\Dal\Replication\ChangeMasterToCommand; use SqlFtw\Sql\Dal\Replication\ChangeReplicationFilterCommand; use SqlFtw\Sql\Dal\Replication\ChangeReplicationSourceToCommand; @@ -55,10 +58,13 @@ class ReplicationCommandsParser { + private Platform $platform; + private ExpressionParser $expressionParser; - public function __construct(ExpressionParser $expressionParser) + public function __construct(Platform $platform, ExpressionParser $expressionParser) { + $this->platform = $platform; $this->expressionParser = $expressionParser; } @@ -511,7 +517,9 @@ public function parseStartGroupReplication(TokenList $tokenList): StartGroupRepl $keywords = [Keyword::USER, Keyword::PASSWORD, Keyword::DEFAULT_AUTH]; $keyword = $tokenList->getAnyKeyword(...$keywords); while ($keyword !== null) { - $tokenList->check('group replication credentials', 80021); + if (!isset($this->platform->features[Feature::GROUP_REPLICATION_CREDENTIALS])) { + throw new InvalidVersionException(Feature::GROUP_REPLICATION_CREDENTIALS, $this->platform, $tokenList); + } $tokenList->passSymbol('='); if ($keyword === Keyword::USER) { $user = $tokenList->expectString(); diff --git a/sources/Parser/Dal/UserCommandsParser.php b/sources/Parser/Dal/UserCommandsParser.php index 78684356..db5e7710 100644 --- a/sources/Parser/Dal/UserCommandsParser.php +++ b/sources/Parser/Dal/UserCommandsParser.php @@ -9,8 +9,10 @@ namespace SqlFtw\Parser\Dal; +use LogicException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\TokenList; +use SqlFtw\Platform\Features\Feature; use SqlFtw\Platform\Platform; use SqlFtw\Sql\Dal\User\AddAuthFactor; use SqlFtw\Sql\Dal\User\AlterAuthOption; @@ -55,7 +57,6 @@ use SqlFtw\Sql\Dal\User\UserPrivilege; use SqlFtw\Sql\Dal\User\UserPrivilegeResource; use SqlFtw\Sql\Dal\User\UserPrivilegeResourceType; -use SqlFtw\Sql\Dal\User\UserPrivilegeType; use SqlFtw\Sql\Dal\User\UserResourceOption; use SqlFtw\Sql\Dal\User\UserResourceOptionType; use SqlFtw\Sql\Dal\User\UserTlsOption; @@ -74,6 +75,13 @@ class UserCommandsParser { + private Platform $platform; + + public function __construct(Platform $platform) + { + $this->platform = $platform; + } + private const RESOURCE_PRIVILEGES = [ UserPrivilegeResourceType::TABLE => [ StaticUserPrivilege::ALL, @@ -368,7 +376,7 @@ private function parseAuthOptionParts(TokenList $tokenList, bool $currentUser = if (!$currentUser && $authPlugin !== null && $tokenList->hasKeyword(Keyword::AS)) { $as = $tokenList->expectStringValue(); } elseif ($tokenList->hasKeyword(Keyword::BY)) { - if ($tokenList->using(Platform::MYSQL, null, 50799) && $tokenList->hasKeyword(Keyword::PASSWORD)) { + if (isset($this->platform->features[Feature::DEPRECATED_IDENTIFIED_BY_PASSWORD]) && $tokenList->hasKeyword(Keyword::PASSWORD)) { $oldHashedPassword = true; $password = $tokenList->expectStringValue(); } elseif ($tokenList->hasKeywords(Keyword::RANDOM, Keyword::PASSWORD)) { @@ -755,8 +763,9 @@ private function parsePrivilegesList(TokenList $tokenList, bool $ifExists = fals } } } - /** @var UserPrivilegeType $type */ - $type = $type; + if ($type === null) { + throw new LogicException('Type cannot be null here.'); + } $columns = null; if ($tokenList->hasSymbol('(')) { @@ -976,9 +985,9 @@ public function parseSetPassword(TokenList $tokenList): SetPasswordCommand $passwordFunction = $password = $replace = null; if ($tokenList->hasOperator(Operator::EQUAL)) { - $passwordFunction = $tokenList->using(null, 50700) - ? $tokenList->getAnyKeyword(Keyword::PASSWORD) - : $tokenList->getAnyKeyword(Keyword::PASSWORD, Keyword::OLD_PASSWORD); + $passwordFunction = isset($this->platform->functions[BuiltInFunction::OLD_PASSWORD]) + ? $tokenList->getAnyKeyword(Keyword::PASSWORD, Keyword::OLD_PASSWORD) + : $tokenList->getAnyKeyword(Keyword::PASSWORD); if ($passwordFunction !== null) { $tokenList->expectSymbol('('); } diff --git a/sources/Parser/Ddl/IndexCommandsParser.php b/sources/Parser/Ddl/IndexCommandsParser.php index 591cd4e1..a7b3c923 100644 --- a/sources/Parser/Ddl/IndexCommandsParser.php +++ b/sources/Parser/Ddl/IndexCommandsParser.php @@ -11,8 +11,10 @@ use Dogma\Re; use SqlFtw\Parser\ExpressionParser; +use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\TokenList; +use SqlFtw\Platform\Features\Feature; use SqlFtw\Platform\Platform; use SqlFtw\Sql\Ddl\Index\CreateIndexCommand; use SqlFtw\Sql\Ddl\Index\DropIndexCommand; @@ -28,8 +30,8 @@ use SqlFtw\Sql\Order; use SqlFtw\Sql\SqlMode; use function count; +use function strcasecmp; use function strlen; -use function strtoupper; class IndexCommandsParser { @@ -124,7 +126,7 @@ public function parseIndexDefinition(TokenList $tokenList, bool $inTable = false $name = $tokenList->expectName(EntityType::INDEX); } - if ($name !== null && strtoupper($name) === Keyword::PRIMARY) { + if ($name !== null && strcasecmp($name, Keyword::PRIMARY) === 0) { throw new ParserException('Invalid index name.', $tokenList); } @@ -169,7 +171,7 @@ public function parseIndexDefinition(TokenList $tokenList, bool $inTable = false $withParser = $tokenList->expectName(EntityType::INDEX_PARSER); } elseif ($keyword === Keyword::COMMENT) { $commentString = $tokenList->expectString(); - $limit = $this->platform->getMaxLengths()[EntityType::INDEX_COMMENT]; + $limit = $this->platform->maxLengths[EntityType::INDEX_COMMENT]; if (strlen($commentString) > $limit && $tokenList->getSession()->getMode()->containsAny(SqlMode::STRICT_ALL_TABLES)) { throw new ParserException("Index comment length exceeds limit of {$limit} bytes.", $tokenList); } @@ -185,11 +187,15 @@ public function parseIndexDefinition(TokenList $tokenList, bool $inTable = false } elseif ($keyword === Keyword::INVISIBLE) { $visible = false; } elseif ($keyword === Keyword::ENGINE_ATTRIBUTE) { - $tokenList->check(Keyword::ENGINE_ATTRIBUTE, 80021); + if (!isset($this->platform->features[Feature::ENGINE_ATTRIBUTE])) { + throw new InvalidVersionException(Feature::ENGINE_ATTRIBUTE, $this->platform, $tokenList); + } $tokenList->passSymbol('='); $engineAttribute = $tokenList->expectString(); } elseif ($keyword === Keyword::SECONDARY_ENGINE_ATTRIBUTE) { - $tokenList->check(Keyword::SECONDARY_ENGINE_ATTRIBUTE, 80021); + if (!isset($this->platform->features[Feature::SECONDARY_ENGINE_ATTRIBUTE])) { + throw new InvalidVersionException(Feature::SECONDARY_ENGINE_ATTRIBUTE, $this->platform, $tokenList); + } $tokenList->passSymbol('='); $secondaryEngineAttribute = $tokenList->expectString(); } @@ -219,7 +225,9 @@ private function parseIndexParts(TokenList $tokenList): array $parts = []; do { if ($tokenList->hasSymbol('(')) { - $tokenList->check('functional indexes', 80013); + if (!isset($this->platform->features[Feature::FUNCTIONAL_INDEXES])) { + throw new InvalidVersionException(Feature::FUNCTIONAL_INDEXES, $this->platform, $tokenList); + } $expression = $this->expressionParser->parseExpression($tokenList); $tokenList->expectSymbol(')'); diff --git a/sources/Parser/Ddl/InstanceCommandParser.php b/sources/Parser/Ddl/InstanceCommandParser.php index 7162338e..12e93034 100644 --- a/sources/Parser/Ddl/InstanceCommandParser.php +++ b/sources/Parser/Ddl/InstanceCommandParser.php @@ -12,10 +12,10 @@ use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\TokenList; +use SqlFtw\Platform\Features\Feature; use SqlFtw\Platform\Platform; use SqlFtw\Sql\Ddl\Instance\AlterInstanceAction; use SqlFtw\Sql\Ddl\Instance\AlterInstanceCommand; -use SqlFtw\Sql\EntityType; use SqlFtw\Sql\Keyword; use function strtolower; @@ -48,33 +48,32 @@ public function __construct(Platform $platform) */ public function parseAlterInstance(TokenList $tokenList): AlterInstanceCommand { - if ($tokenList->using(null, 80000)) { - $tokenList->expectKeywords(Keyword::ALTER, Keyword::INSTANCE); + if (!isset($this->platform->features[Feature::ALTER_INSTANCE])) { + throw new InvalidVersionException(Feature::ALTER_INSTANCE, $this->platform, $tokenList); + } + + $tokenList->expectKeywords(Keyword::ALTER, Keyword::INSTANCE); - $action = $tokenList->expectMultiNameEnum(AlterInstanceAction::class); + $action = $tokenList->expectMultiNameEnum(AlterInstanceAction::class); + if (!$action->equalsValue(AlterInstanceAction::ROTATE_INNODB_MASTER_KEY) + && !isset($this->platform->features[Feature::ALTER_INSTANCE_2]) + ) { + throw new InvalidVersionException(Feature::ALTER_INSTANCE_2, $this->platform, $tokenList); + } - $forChannel = null; - $noRollbackOnError = false; - if ($action->equalsValue(AlterInstanceAction::RELOAD_TLS)) { - if ($tokenList->hasKeywords(Keyword::FOR, Keyword::CHANNEL)) { - $forChannel = strtolower($tokenList->expectNonReservedNameOrString()); - if ($forChannel !== 'mysql_main' && $forChannel !== 'mysql_admin') { - throw new ParserException('Invalid channel name.', $tokenList); - } + $forChannel = null; + $noRollbackOnError = false; + if ($action->equalsValue(AlterInstanceAction::RELOAD_TLS)) { + if ($tokenList->hasKeywords(Keyword::FOR, Keyword::CHANNEL)) { + $forChannel = strtolower($tokenList->expectNonReservedNameOrString()); + if ($forChannel !== 'mysql_main' && $forChannel !== 'mysql_admin') { + throw new ParserException('Invalid channel name.', $tokenList); } - $noRollbackOnError = $tokenList->hasKeywords(Keyword::NO, Keyword::ROLLBACK, Keyword::ON, Keyword::ERROR); } - - return new AlterInstanceCommand($action, $forChannel, $noRollbackOnError); - } elseif ($tokenList->using(null, 50700)) { - $tokenList->expectKeywords(Keyword::ALTER, Keyword::INSTANCE, Keyword::ROTATE); - $tokenList->expectAnyName('INNODB'); - $tokenList->expectKeywords(Keyword::MASTER, Keyword::KEY); - - return new AlterInstanceCommand(new AlterInstanceAction(AlterInstanceAction::ROTATE_INNODB_MASTER_KEY)); - } else { - throw new InvalidVersionException('ALTER INSTANCE is implemented since 5.7', $this->platform, $tokenList); + $noRollbackOnError = $tokenList->hasKeywords(Keyword::NO, Keyword::ROLLBACK, Keyword::ON, Keyword::ERROR); } + + return new AlterInstanceCommand($action, $forChannel, $noRollbackOnError); } } diff --git a/sources/Parser/Ddl/RoutineCommandsParser.php b/sources/Parser/Ddl/RoutineCommandsParser.php index 8b5e6298..ab5f2ecd 100644 --- a/sources/Parser/Ddl/RoutineCommandsParser.php +++ b/sources/Parser/Ddl/RoutineCommandsParser.php @@ -10,9 +10,12 @@ namespace SqlFtw\Parser\Ddl; use SqlFtw\Parser\ExpressionParser; +use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\RoutineBodyParser; use SqlFtw\Parser\TokenList; +use SqlFtw\Platform\Features\Feature; +use SqlFtw\Platform\Platform; use SqlFtw\Sql\Collation; use SqlFtw\Sql\Ddl\Routine\AlterFunctionCommand; use SqlFtw\Sql\Ddl\Routine\AlterProcedureCommand; @@ -32,12 +35,15 @@ class RoutineCommandsParser { + private Platform $platform; + private ExpressionParser $expressionParser; private RoutineBodyParser $routineBodyParser; - public function __construct(ExpressionParser $expressionParser, RoutineBodyParser $routineBodyParser) + public function __construct(Platform $platform, ExpressionParser $expressionParser, RoutineBodyParser $routineBodyParser) { + $this->platform = $platform; $this->expressionParser = $expressionParser; $this->routineBodyParser = $routineBodyParser; } @@ -158,7 +164,10 @@ public function parseCreateFunction(TokenList $tokenList): CreateFunctionCommand } $tokenList->expectKeyword(Keyword::FUNCTION); - $ifNotExists = $tokenList->using(null, 80000) && $tokenList->hasKeywords(Keyword::IF, Keyword::NOT, Keyword::EXISTS); + $ifNotExists = $tokenList->hasKeywords(Keyword::IF, Keyword::NOT, Keyword::EXISTS); + if ($ifNotExists && !isset($this->platform->features[Feature::CREATE_ROUTINE_IF_NOT_EXISTS])) { + throw new InvalidVersionException(Feature::CREATE_ROUTINE_IF_NOT_EXISTS, $this->platform, $tokenList); + } $name = $tokenList->expectObjectIdentifier(); @@ -228,7 +237,10 @@ public function parseCreateProcedure(TokenList $tokenList): CreateProcedureComma } $tokenList->expectKeyword(Keyword::PROCEDURE); - $ifNotExists = $tokenList->using(null, 80000) && $tokenList->hasKeywords(Keyword::IF, Keyword::NOT, Keyword::EXISTS); + $ifNotExists = $tokenList->hasKeywords(Keyword::IF, Keyword::NOT, Keyword::EXISTS); + if ($ifNotExists && !isset($this->platform->features[Feature::CREATE_ROUTINE_IF_NOT_EXISTS])) { + throw new InvalidVersionException(Feature::CREATE_ROUTINE_IF_NOT_EXISTS, $this->platform, $tokenList); + } $name = $tokenList->expectObjectIdentifier(); diff --git a/sources/Parser/Ddl/SchemaCommandsParser.php b/sources/Parser/Ddl/SchemaCommandsParser.php index 77be3219..6ce641e3 100644 --- a/sources/Parser/Ddl/SchemaCommandsParser.php +++ b/sources/Parser/Ddl/SchemaCommandsParser.php @@ -9,8 +9,11 @@ namespace SqlFtw\Parser\Ddl; +use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\TokenList; +use SqlFtw\Platform\Features\Feature; +use SqlFtw\Platform\Platform; use SqlFtw\Sql\Ddl\Schema\AlterSchemaCommand; use SqlFtw\Sql\Ddl\Schema\CreateSchemaCommand; use SqlFtw\Sql\Ddl\Schema\DropSchemaCommand; @@ -23,6 +26,13 @@ class SchemaCommandsParser { + private Platform $platform; + + public function __construct(Platform $platform) + { + $this->platform = $platform; + } + /** * ALTER {DATABASE | SCHEMA} [db_name] * alter_option ... @@ -107,11 +117,15 @@ private function parseOptions(TokenList $tokenList): ?SchemaOptions $tokenList->passSymbol('='); $collation = $tokenList->expectCollationName(); } elseif ($keyword === Keyword::ENCRYPTION) { - $tokenList->check('schema encryption', 80016); + if (!isset($this->platform->features[Feature::SCHEMA_ENCRYPTION])) { + throw new InvalidVersionException(Feature::SCHEMA_ENCRYPTION, $this->platform, $tokenList); + } $tokenList->passSymbol('='); $encryption = $tokenList->expectBool(); } else { - $tokenList->check('schema read only', 80022); + if (!isset($this->platform->features[Feature::SCHEMA_READ_ONLY])) { + throw new InvalidVersionException(Feature::SCHEMA_READ_ONLY, $this->platform, $tokenList); + } $tokenList->expectKeyword(Keyword::ONLY); $tokenList->passSymbol('='); if ($tokenList->hasKeyword(Keyword::DEFAULT)) { diff --git a/sources/Parser/Ddl/TableCommandsParser.php b/sources/Parser/Ddl/TableCommandsParser.php index ee509d84..f05554a2 100644 --- a/sources/Parser/Ddl/TableCommandsParser.php +++ b/sources/Parser/Ddl/TableCommandsParser.php @@ -13,9 +13,11 @@ use SqlFtw\Parser\Dml\QueryParser; use SqlFtw\Parser\ExpressionParser; use SqlFtw\Parser\InvalidValueException; +use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\TokenList; use SqlFtw\Parser\TokenType; +use SqlFtw\Platform\Features\Feature; use SqlFtw\Platform\Platform; use SqlFtw\Sql\Charset; use SqlFtw\Sql\Ddl\StorageType; @@ -119,6 +121,7 @@ use SqlFtw\Sql\SubqueryType; use function array_values; use function count; +use function strcasecmp; use function strlen; use function strtoupper; @@ -596,12 +599,12 @@ public function parseAlterTable(TokenList $tokenList): AlterTableCommand } elseif ($tokenList->hasAnyKeyword(Keyword::INDEX, Keyword::KEY)) { // RENAME {INDEX|KEY} old_index_name TO new_index_name $oldName = $tokenList->expectName(EntityType::INDEX); - if (strtoupper($oldName) === Keyword::PRIMARY) { + if (strcasecmp($oldName, Keyword::PRIMARY) === 0) { throw new ParserException('Cannot rename key PRIMARY.', $tokenList); } $tokenList->expectKeyword(Keyword::TO); $newName = $tokenList->expectName(EntityType::INDEX); - if ($newName === '' || strtoupper($newName) === Keyword::PRIMARY) { + if ($newName === '' || strcasecmp($newName, Keyword::PRIMARY) === 0) { throw new ParserException('Invalid index name.', $tokenList); } $actions[] = new RenameIndexAction($oldName, $newName); @@ -945,12 +948,16 @@ private function parseOrdinaryColumn(string $name, ColumnType $type, TokenList $ break; case Keyword::VISIBLE: // [VISIBLE | INVISIBLE] - $tokenList->check('column visibility', 80023); + if (!isset($this->platform->features[Feature::COLUMN_VISIBILITY])) { + throw new InvalidVersionException(Feature::COLUMN_VISIBILITY, $this->platform, $tokenList); + } $visible = true; break; case Keyword::INVISIBLE: // [VISIBLE | INVISIBLE] - $tokenList->check('column visibility', 80023); + if (!isset($this->platform->features[Feature::COLUMN_VISIBILITY])) { + throw new InvalidVersionException(Feature::COLUMN_VISIBILITY, $this->platform, $tokenList); + } $visible = false; break; case Keyword::AUTO_INCREMENT: @@ -985,7 +992,7 @@ private function parseOrdinaryColumn(string $name, ColumnType $type, TokenList $ case Keyword::COMMENT: // [COMMENT 'string'] $comment = $tokenList->expectString(); - $limit = $this->platform->getMaxLengths()[EntityType::FIELD_COMMENT]; + $limit = $this->platform->maxLengths[EntityType::FIELD_COMMENT]; if (strlen($comment) > $limit && $tokenList->getSession()->getMode()->containsAny(SqlMode::STRICT_ALL_TABLES)) { throw new ParserException("Column comment length exceeds limit of {$limit} bytes.", $tokenList); } @@ -1102,12 +1109,16 @@ private function parseGeneratedColumn(string $name, ColumnType $type, TokenList break; case Keyword::VISIBLE: // [VISIBLE | INVISIBLE] - $tokenList->check('column visibility', 80023); + if (!isset($this->platform->features[Feature::COLUMN_VISIBILITY])) { + throw new InvalidVersionException(Feature::COLUMN_VISIBILITY, $this->platform, $tokenList); + } $visible = true; break; case Keyword::INVISIBLE: // [VISIBLE | INVISIBLE] - $tokenList->check('column visibility', 80023); + if (!isset($this->platform->features[Feature::COLUMN_VISIBILITY])) { + throw new InvalidVersionException(Feature::COLUMN_VISIBILITY, $this->platform, $tokenList); + } $visible = false; break; case Keyword::UNIQUE: @@ -1125,7 +1136,7 @@ private function parseGeneratedColumn(string $name, ColumnType $type, TokenList case Keyword::COMMENT: // [COMMENT 'string'] $comment = $tokenList->expectString(); - $limit = $this->platform->getMaxLengths()[EntityType::FIELD_COMMENT]; + $limit = $this->platform->maxLengths[EntityType::FIELD_COMMENT]; if (strlen($comment) > $limit && $tokenList->getSession()->getMode()->containsAny(SqlMode::STRICT_ALL_TABLES)) { throw new ParserException("Column comment length exceeds limit of {$limit} bytes.", $tokenList); } @@ -1383,7 +1394,7 @@ private function parseTableOption(TokenList $tokenList, array $options): array case Keyword::COMMENT: $tokenList->passSymbol('='); $comment = $tokenList->expectString(); - $limit = $this->platform->getMaxLengths()[EntityType::TABLE_COMMENT]; + $limit = $this->platform->maxLengths[EntityType::TABLE_COMMENT]; if (strlen($comment) > $limit && $tokenList->getSession()->getMode()->containsAny(SqlMode::STRICT_ALL_TABLES)) { throw new ParserException("Table comment length exceeds limit of {$limit} bytes.", $tokenList); } @@ -1753,7 +1764,7 @@ private function parsePartitionDefinition(TokenList $tokenList, ?PartitioningCon do { $values[] = $value = $this->expressionParser->parseExpression($tokenList); - if ($value instanceof SimpleName && strtoupper($value->getName()) === Keyword::MAXVALUE) { + if ($value instanceof SimpleName && strcasecmp($value->getName(), Keyword::MAXVALUE) === 0) { // check MAXVALUE throw new ParserException('MAXVALUE is not allowed in values list.', $tokenList); } elseif ($value instanceof Parentheses) { @@ -1766,7 +1777,7 @@ private function parsePartitionDefinition(TokenList $tokenList, ?PartitioningCon $items = $list->getItems(); // check MAXVALUE foreach ($items as $item) { - if ($item instanceof SimpleName && strtoupper($item->getName()) === Keyword::MAXVALUE) { + if ($item instanceof SimpleName && strcasecmp($item->getName(), Keyword::MAXVALUE) === 0) { throw new ParserException('MAXVALUE is not allowed in values list.', $tokenList); } } diff --git a/sources/Parser/Ddl/TriggerCommandsParser.php b/sources/Parser/Ddl/TriggerCommandsParser.php index 78c8bb22..b02f4b08 100644 --- a/sources/Parser/Ddl/TriggerCommandsParser.php +++ b/sources/Parser/Ddl/TriggerCommandsParser.php @@ -10,8 +10,11 @@ namespace SqlFtw\Parser\Ddl; use SqlFtw\Parser\ExpressionParser; +use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\RoutineBodyParser; use SqlFtw\Parser\TokenList; +use SqlFtw\Platform\Features\Feature; +use SqlFtw\Platform\Platform; use SqlFtw\Sql\Ddl\Trigger\CreateTriggerCommand; use SqlFtw\Sql\Ddl\Trigger\DropTriggerCommand; use SqlFtw\Sql\Ddl\Trigger\TriggerEvent; @@ -25,12 +28,15 @@ class TriggerCommandsParser { + private Platform $platform; + private ExpressionParser $expressionParser; private RoutineBodyParser $routineBodyParser; - public function __construct(ExpressionParser $expressionParser, RoutineBodyParser $routineBodyParser) + public function __construct(Platform $platform, ExpressionParser $expressionParser, RoutineBodyParser $routineBodyParser) { + $this->platform = $platform; $this->expressionParser = $expressionParser; $this->routineBodyParser = $routineBodyParser; } @@ -60,7 +66,10 @@ public function parseCreateTrigger(TokenList $tokenList): CreateTriggerCommand } $tokenList->expectKeyword(Keyword::TRIGGER); - $ifNotExists = $tokenList->using(null, 80000) && $tokenList->hasKeywords(Keyword::IF, Keyword::NOT, Keyword::EXISTS); + $ifNotExists = $tokenList->hasKeywords(Keyword::IF, Keyword::NOT, Keyword::EXISTS); + if ($ifNotExists && !isset($this->platform->features[Feature::CREATE_ROUTINE_IF_NOT_EXISTS])) { + throw new InvalidVersionException(Feature::CREATE_ROUTINE_IF_NOT_EXISTS, $this->platform, $tokenList); + } $name = $tokenList->expectObjectIdentifier(); diff --git a/sources/Parser/Dml/PreparedCommandsParser.php b/sources/Parser/Dml/PreparedCommandsParser.php index 01d3a717..e1b99a98 100644 --- a/sources/Parser/Dml/PreparedCommandsParser.php +++ b/sources/Parser/Dml/PreparedCommandsParser.php @@ -25,7 +25,6 @@ use SqlFtw\Sql\Routine\RoutineType; use function count; use function get_class; -use function in_array; use function iterator_to_array; class PreparedCommandsParser @@ -98,7 +97,7 @@ public function parsePrepare(TokenList $tokenList): PrepareCommand $class = get_class($statement); if ($statement instanceof StoredProcedureCommand && $tokenList->inRoutine() === RoutineType::PROCEDURE) { // ok - } elseif (!in_array($class, $this->platform->getPreparableCommands(), true)) { + } elseif (!isset($this->platform->preparableCommands[$class])) { throw new ParserException('Non-preparable statement in PREPARE: ' . $class, $tokenList); } } diff --git a/sources/Parser/Dml/QueryParser.php b/sources/Parser/Dml/QueryParser.php index 7fc772b6..98d128f8 100644 --- a/sources/Parser/Dml/QueryParser.php +++ b/sources/Parser/Dml/QueryParser.php @@ -10,10 +10,12 @@ namespace SqlFtw\Parser\Dml; use SqlFtw\Parser\ExpressionParser; +use SqlFtw\Parser\InvalidVersionException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\ParserFactory; use SqlFtw\Parser\TokenList; use SqlFtw\Parser\TokenType; +use SqlFtw\Platform\Features\Feature; use SqlFtw\Platform\Platform; use SqlFtw\Sql\Command; use SqlFtw\Sql\CommonTableExpressionType; @@ -74,6 +76,8 @@ class QueryParser { + private Platform $platform; + private ParserFactory $parserFactory; private ExpressionParser $expressionParser; @@ -83,11 +87,13 @@ class QueryParser private OptimizerHintParser $optimizerHintParser; public function __construct( + Platform $platform, ParserFactory $parserFactory, ExpressionParser $expressionParser, TableReferenceParser $tableReferenceParser, OptimizerHintParser $optimizerHintParser ) { + $this->platform = $platform; $this->parserFactory = $parserFactory; $this->expressionParser = $expressionParser; $this->tableReferenceParser = $tableReferenceParser; @@ -504,9 +510,9 @@ public function parseSelect(TokenList $tokenList, ?WithClause $with = null): Que $groupBy = []; do { $expression = $this->expressionParser->parseAssignExpression($tokenList); - $order = null; - if ($tokenList->using(Platform::MYSQL, null, 50799)) { - $order = $tokenList->getKeywordEnum(Order::class); + $order = $tokenList->getKeywordEnum(Order::class); + if ($order !== null && !isset($this->platform->features[Feature::DEPRECATED_GROUP_BY_ORDERING])) { + throw new InvalidVersionException(Feature::DEPRECATED_GROUP_BY_ORDERING, $this->platform, $tokenList); } $groupBy[] = new GroupByExpression($expression, $order); } while ($tokenList->hasSymbol(',')); diff --git a/sources/Parser/ExpressionParser.php b/sources/Parser/ExpressionParser.php index a4ef3176..b20e8875 100644 --- a/sources/Parser/ExpressionParser.php +++ b/sources/Parser/ExpressionParser.php @@ -90,6 +90,7 @@ use function in_array; use function ltrim; use function sprintf; +use function strcasecmp; use function strlen; use function strtolower; use function strtoupper; @@ -176,20 +177,25 @@ public function parseExpression(TokenList $tokenList): RootNode return new BinaryOperator($left, new Operator($operator), $right); } elseif ($tokenList->hasKeyword(Keyword::IS)) { $not = $tokenList->hasKeyword(Keyword::NOT); - $keyword = $tokenList->expectAnyKeyword(Keyword::NULL, Keyword::TRUE, Keyword::FALSE, Keyword::UNKNOWN); + $keyword = $tokenList->getAnyKeyword(Keyword::NULL, Keyword::TRUE, Keyword::FALSE, Keyword::UNKNOWN); switch ($keyword) { + case Keyword::NULL: + $right = new NullLiteral(); + break; case Keyword::TRUE: $right = new BoolLiteral(true); break; case Keyword::FALSE: $right = new BoolLiteral(false); break; - case Keyword::NULL: - $right = new NullLiteral(); - break; - default: + case Keyword::UNKNOWN: $right = new UnknownLiteral(); break; + default: // null + $right = $this->parsePlaceholder($tokenList, true); + if ($right === null) { + $tokenList->missingAnyKeyword(Keyword::NULL, Keyword::TRUE, Keyword::FALSE, Keyword::UNKNOWN); + } } $operator = new Operator($not ? Operator::IS_NOT : Operator::IS); @@ -262,20 +268,25 @@ private function parseBooleanPrimary(TokenList $tokenList): RootNode while ($tokenList->hasKeyword(Keyword::IS)) { $not = $tokenList->hasKeyword(Keyword::NOT); - $keyword = $tokenList->expectAnyKeyword(Keyword::NULL, Keyword::TRUE, Keyword::FALSE, Keyword::UNKNOWN); + $keyword = $tokenList->getAnyKeyword(Keyword::NULL, Keyword::TRUE, Keyword::FALSE, Keyword::UNKNOWN); switch ($keyword) { + case Keyword::NULL: + $right = new NullLiteral(); + break; case Keyword::TRUE: $right = new BoolLiteral(true); break; case Keyword::FALSE: $right = new BoolLiteral(false); break; - case Keyword::NULL: - $right = new NullLiteral(); - break; - default: + case Keyword::UNKNOWN: $right = new UnknownLiteral(); break; + default: + $right = $this->parsePlaceholder($tokenList, true); + if ($right === null) { + $tokenList->missingAnyKeyword(Keyword::NULL, Keyword::TRUE, Keyword::FALSE, Keyword::UNKNOWN); + } } $operator = new Operator($not ? Operator::IS_NOT : Operator::IS); @@ -721,7 +732,7 @@ public function parseAtVariable(TokenList $tokenList, string $atVariable, bool $ if (in_array(strtoupper($atVariable), ['@@LOCAL', '@@SESSION', '@@GLOBAL', '@@PERSIST', '@@PERSIST_ONLY'], true)) { // @@global.foo $tokenList->expectSymbol('.'); - if (strtoupper($atVariable) === '@@LOCAL') { + if (strcasecmp($atVariable, '@@LOCAL') === 0) { $atVariable = '@@SESSION'; } $scope = new Scope(substr($atVariable, 2)); @@ -993,7 +1004,7 @@ private function parseTimeValue(TokenList $tokenList): ?TimeValue return null; } - private function parsePlaceholder(TokenList $tokenList): ?Placeholder + private function parsePlaceholder(TokenList $tokenList, bool $ifAllowedAnywhere = false): ?Placeholder { $token = $tokenList->get(TokenType::PLACEHOLDER); if ($token === null) { @@ -1001,6 +1012,10 @@ private function parsePlaceholder(TokenList $tokenList): ?Placeholder } $extensions = $this->config->getClientSideExtensions(); + if ($ifAllowedAnywhere && ($extensions & ClientSideExtension::ALLOW_PLACEHOLDERS_ANYWHERE) === 0) { + throw new ParserException("Placeholder {$token->value} is not allowed here.", $tokenList); + } + if (($token->type & TokenType::QUESTION_MARK_PLACEHOLDER) !== 0 && (($extensions & ClientSideExtension::ALLOW_QUESTION_MARK_PLACEHOLDERS_OUTSIDE_PREPARED_STATEMENTS) !== 0 || $tokenList->inPrepared())) { // param_marker return new QuestionMarkPlaceholder(); @@ -1088,7 +1103,7 @@ public function parseAlias(TokenList $tokenList, bool $required = false): ?strin if ($alias !== null) { return $alias; } else { - return $tokenList->expectNonReservedName(EntityType::ALIAS, TokenType::AT_VARIABLE); + return $tokenList->expectNonReservedName(EntityType::ALIAS, TokenType::AT_VARIABLE); } } else { $alias = $tokenList->getNonReservedName(EntityType::ALIAS, TokenType::AT_VARIABLE); @@ -1366,7 +1381,7 @@ private function parseTypeOptions(BaseType $type, TokenList $tokenList, bool $fo $values = []; do { $value = $tokenList->expectStringValue(); - $limit = $this->config->getPlatform()->getMaxLengths()[EntityType::ENUM_VALUE]; + $limit = $this->config->getPlatform()->maxLengths[EntityType::ENUM_VALUE]; if (strlen($value->asString()) > $limit) { throw new ParserException("Enum value '{$value->getValue()}' exceeds limit of {$limit} bytes.", $tokenList); } diff --git a/sources/Parser/Lexer.php b/sources/Parser/Lexer.php index 236c55a1..1e3e0268 100644 --- a/sources/Parser/Lexer.php +++ b/sources/Parser/Lexer.php @@ -9,6 +9,8 @@ // phpcs:disable SlevomatCodingStandard.ControlStructures.JumpStatementsSpacing // phpcs:disable SlevomatCodingStandard.ControlStructures.AssignmentInCondition +// phpcs:disable SlevomatCodingStandard.ControlStructures.NewWithParentheses.MissingParentheses +// phpcs:disable Generic.Formatting.DisallowMultipleStatements.SameLine namespace SqlFtw\Parser; @@ -34,12 +36,13 @@ use function ord; use function preg_match; use function str_replace; +use function strcasecmp; use function strlen; use function strpos; use function strtolower; use function strtoupper; use function substr; -use function trim; +use const PREG_UNMATCHED_AS_NULL; /** * SQL lexer - breaks input string into `Token` objects, resolves delimiters and returns `TokenList` objects @@ -68,6 +71,9 @@ class Lexer '\\\\' => '\\', ]; + public const ANCHORED_NUMBER_REGEXP = '~\G([+-]*)(\d*\.\d+|\d+\.?)(?:([eE])([+-]?)(\d*))?~'; + public const ANCHORED_UUID_REGEXP = '~\G[\dA-F]{8}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{12}~i'; + public const ANCHORED_IP_V4_REGEXP = '~\G((?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d))~'; public const UUID_REGEXP = '~^[\dA-F]{8}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{4}-[\dA-F]{12}$~i'; public const IP_V4_REGEXP = '~^((?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d))~'; @@ -96,15 +102,6 @@ class Lexer private bool $withWhitespace; - /** @var array */ - private array $reservedKey; - - /** @var array */ - private array $keywordsKey; - - /** @var array */ - private array $operatorsKey; - /** @var list */ private array $escapeKeys; @@ -127,9 +124,6 @@ public function __construct(ParserConfig $config, Session $session) $this->withComments = $config->tokenizeComments(); $this->withWhitespace = $config->tokenizeWhitespace(); - $this->reservedKey = array_flip($this->platform->getReserved()); - $this->keywordsKey = array_flip($this->platform->getNonReserved()); - $this->operatorsKey = array_flip($this->platform->getOperators()); $this->escapeKeys = array_keys(self::MYSQL_ESCAPES); $this->escapeValues = array_values(self::MYSQL_ESCAPES); } @@ -138,18 +132,18 @@ public function __construct(ParserConfig $config, Session $session) * Tokenize SQL code and return a generator of TokenList objects (terminated by DELIMITER or DELIMITER_DEFINITION tokens) * @return Generator */ - public function tokenize(string $string): Generator + public function tokenize(string $source): Generator { // this allows TokenList to not have to call doAutoSkip() million times when there are no skippable tokens produced $autoSkip = ($this->withWhitespace ? T::WHITESPACE : 0) | ($this->withComments ? T::COMMENT : 0); $extensions = $this->config->getClientSideExtensions(); - $parseOldNullLiteral = $this->platform->hasFeature(Feature::OLD_NULL_LITERAL); - $parseOptimizerHints = $this->platform->hasFeature(Feature::OPTIMIZER_HINTS); + $parseOldNullLiteral = isset($this->platform->features[Feature::DEPRECATED_OLD_NULL_LITERAL]); + $parseOptimizerHints = isset($this->platform->features[Feature::OPTIMIZER_HINTS]); $allowDelimiterDefinition = ($extensions & ClientSideExtension::ALLOW_DELIMITER_DEFINITION) !== 0; // last significant token parsed (comments and whitespace are skipped here) - $previous = new Token(TokenType::END, 0, 0, ''); + $previous = $p = new Token; $p->type = TokenType::END; $p->start = 0; $p->value = ''; // reset $tokens = []; @@ -159,21 +153,18 @@ public function tokenize(string $string): Generator $delimiter = $this->session->getDelimiter(); $commentDepth = 0; $position = 0; - $row = 1; - $column = 1; - $length = strlen($string); + $length = strlen($source); continue_tokenizing: while ($position < $length) { - $char = $string[$position]; + $char = $source[$position]; $start = $position; $position++; - $column++; if ($char === $delimiter[0]) { - if (substr($string, $position - 1, strlen($delimiter)) === $delimiter) { + if (substr($source, $position - 1, strlen($delimiter)) === $delimiter) { $position += strlen($delimiter) - 1; - $tokens[] = new Token(T::DELIMITER, $start, $row, $delimiter); + $tokens[] = $t = new Token; $t->type = T::DELIMITER; $t->start = $start; $t->value = $delimiter; goto yield_token_list; } } @@ -184,27 +175,20 @@ public function tokenize(string $string): Generator case "\r": case "\n": $ws = $char; - if ($char === "\n") { - $column = 1; - $row++; - } while ($position < $length) { - $next = $string[$position]; + $next = $source[$position]; if ($next === ' ' || $next === "\t" || $next === "\r") { $ws .= $next; $position++; - $column++; } elseif ($next === "\n") { $ws .= $next; $position++; - $column = 1; - $row++; } else { break; } } if ($this->withWhitespace) { - $tokens[] = new Token(T::WHITESPACE, $start, $row, $ws); + $tokens[] = $t = new Token; $t->type = T::WHITESPACE; $t->start = $start; $t->value = $ws; } break; case '(': @@ -215,82 +199,77 @@ public function tokenize(string $string): Generator case '}': case ',': case ';': - $tokens[] = $previous = new Token(T::SYMBOL, $start, $row, $char); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL; $t->start = $start; $t->value = $char; break; case ':': if (($extensions & ClientSideExtension::ALLOW_NAMED_DOUBLE_COLON_PLACEHOLDERS) !== 0) { $name = ''; while ($position < $length) { - $nextDc = $string[$position]; + $nextDc = $source[$position]; if ($nextDc === '_' || ctype_alpha($nextDc) || (strlen($name) > 0 && ctype_digit($nextDc))) { $name .= $nextDc; $position++; - $column++; } else { break; } } if ($name !== '') { - $tokens[] = $previous = new Token(T::PLACEHOLDER | T::DOUBLE_COLON_PLACEHOLDER, $start, $row, ':' . $name); + $tokens[] = $previous = $t = new Token; $t->type = T::PLACEHOLDER | T::DOUBLE_COLON_PLACEHOLDER; $t->start = $start; $t->value = ':' . $name; break; } } $operator = $char; while ($position < $length) { - $next2 = $string[$position]; - if (!isset($this->operatorsKey[$operator . $next2])) { - if ($operator !== ':') { - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator); + $next2 = $source[$position]; + if (!isset($this->platform->operators[$operator . $next2])) { + if ($operator === ':') { + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL; $t->start = $start; $t->value = $char; } else { - $tokens[] = $previous = new Token(T::SYMBOL, $start, $row, $char); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator; } break 2; } if (isset(self::$operatorSymbolsKey[$next2])) { $operator .= $next2; $position++; - $column++; } else { break; } } - if ($operator !== ':') { - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator); + if ($operator === ':') { + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL; $t->start = $start; $t->value = $char; } else { - $tokens[] = $previous = new Token(T::SYMBOL, $start, $row, $char); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator; } break; case '*': // /*!12345 ... */ - if ($position < $length && $string[$position] === '/') { + if ($position < $length && $source[$position] === '/') { if ($condition !== null) { // end of optional comment - $afterComment = $string[$position + 1]; + $afterComment = $source[$position + 1]; if ($this->withWhitespace && $afterComment !== ' ' && $afterComment !== "\t" && $afterComment !== "\n") { // insert a space in case that optional comment is immediately followed by a non-whitespace token // (resulting token list would serialize into invalid code) - $tokens[] = new Token(T::WHITESPACE, $position + 1, $row, ' '); + $tokens[] = $t = new Token; $t->type = T::WHITESPACE; $t->start = $position + 1; $t->value = ' '; } $condition = null; $position++; - $column++; break; } elseif ($hint) { // end of optimizer hint - $tokens[] = new Token(T::OPTIMIZER_HINT_END, $position - 1, $row, '*/'); + $tokens[] = $t = new Token; $t->type = T::OPTIMIZER_HINT_END; $t->start = $position - 1; $t->value = '*/'; $hint = false; $position++; - $column++; break; } } // continue case '\\': - if ($parseOldNullLiteral && $char === '\\' && $position < $length && $string[$position] === 'N') { + if ($parseOldNullLiteral && $char === '\\' && $position < $length && $source[$position] === 'N') { $position++; - $column++; - $tokens[] = $previous = new Token(T::SYMBOL | T::VALUE, $start, $row, '\\N'); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::VALUE; $t->start = $start; $t->value = '\\N'; break; } // continue @@ -305,77 +284,72 @@ public function tokenize(string $string): Generator case '~': $operator2 = $char; while ($position < $length) { - $next3 = $string[$position]; - if (!isset($this->operatorsKey[$operator2 . $next3])) { - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator2); + $next3 = $source[$position]; + if (!isset($this->platform->operators[$operator2 . $next3])) { + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator2; break 2; } if (isset(self::$operatorSymbolsKey[$next3])) { $operator2 .= $next3; $position++; - $column++; } else { break; } } - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator2); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator2; break; case '?': if (($extensions & ClientSideExtension::ALLOW_NUMBERED_QUESTION_MARK_PLACEHOLDERS) !== 0) { $number = ''; while ($position < $length) { - $nextQm = $string[$position]; + $nextQm = $source[$position]; if (ctype_digit($nextQm)) { $number .= $nextQm; $position++; - $column++; } else { break; } } if ($number !== '') { - $tokens[] = $previous = new Token(T::PLACEHOLDER | T::NUMBERED_QUESTION_MARK_PLACEHOLDER, $start, $row, '?' . $number); + $tokens[] = $previous = $t = new Token; $t->type = T::PLACEHOLDER | T::NUMBERED_QUESTION_MARK_PLACEHOLDER; $t->start = $start; $t->value = '?' . $number; break; } } - if ($position < $length && ctype_alnum($string[$position])) { - $exception = new LexerException("Invalid character after placeholder $string[$position].", $position, $string); + if ($position < $length && ctype_alnum($source[$position])) { + $exception = new LexerException("Invalid character after placeholder $source[$position].", $position, $source); - $tokens[] = new Token(T::PLACEHOLDER | T::QUESTION_MARK_PLACEHOLDER | T::INVALID, $start, $row, '?', null, $exception); + $tokens[] = $t = new Token; $t->type = T::PLACEHOLDER | T::QUESTION_MARK_PLACEHOLDER | T::INVALID; $t->start = $start; $t->value = '?'; $t->exception = $exception; $invalid = true; break; } - if ($position > 1 && ctype_alnum($string[$position - 2])) { - $exception = new LexerException("Invalid character before placeholder {$string[$position - 2]}.", $position, $string); + if ($position > 1 && ctype_alnum($source[$position - 2])) { + $exception = new LexerException("Invalid character before placeholder {$source[$position - 2]}.", $position, $source); - $tokens[] = new Token(T::PLACEHOLDER | T::QUESTION_MARK_PLACEHOLDER | T::INVALID, $start, $row, '?', null, $exception); + $tokens[] = $t = new Token; $t->type = T::PLACEHOLDER | T::QUESTION_MARK_PLACEHOLDER | T::INVALID; $t->start = $start; $t->value = '?'; $t->exception = $exception; $invalid = true; break; } - $tokens[] = $previous = new Token(T::PLACEHOLDER | T::QUESTION_MARK_PLACEHOLDER, $start, $row, $char); + $tokens[] = $previous = $t = new Token; $t->type = T::PLACEHOLDER | T::QUESTION_MARK_PLACEHOLDER; $t->start = $start; $t->value = $char; break; case '@': $var = $char; - $second = $string[$position]; + $second = $source[$position]; if ($second === '@') { // @@variable $var .= $second; $position++; - $column++; - if ($string[$position] === '`') { + if ($source[$position] === '`') { // @@`variable` $position++; - $column++; - $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::BACKTICK_QUOTED_STRING, $string, $position, $column, $row, '`', '@@'); + $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::BACKTICK_QUOTED_STRING, $source, $position, '`', '@@'); break; } while ($position < $length) { - $next4 = $string[$position]; + $next4 = $source[$position]; if ($next4 === '@' || isset(self::$nameCharsKey[$next4]) || ord($next4) > 127) { $var .= $next4; $position++; - $column++; } else { break; } @@ -387,45 +361,39 @@ public function tokenize(string $string): Generator $var = substr($var, 0, -strlen($delimiter)); $yieldDelimiter = true; } - $upper = strtoupper(substr($var, 2)); - if ($upper === 'DEFAULT') { + if (strcasecmp(substr($var, 2), 'DEFAULT') === 0) { // todo: probably all magic functions? - $exception = new LexerException("Invalid variable name $var.", $position, $string); + $exception = new LexerException("Invalid variable name $var.", $position, $source); - $tokens[] = new Token(T::NAME | T::AT_VARIABLE | T::INVALID, $start, $row, $var, null, $exception); + $tokens[] = $t = new Token; $t->type = T::NAME | T::AT_VARIABLE | T::INVALID; $t->start = $start; $t->value = $var; $t->exception = $exception; $invalid = true; break; } - $tokens[] = $previous = new Token(T::NAME | T::AT_VARIABLE, $start, $row, $var); + $tokens[] = $previous = $t = new Token; $t->type = T::NAME | T::AT_VARIABLE; $t->start = $start; $t->value = $var; if ($yieldDelimiter) { - $tokens[] = new Token(T::DELIMITER, $start, $row, $delimiter); + $tokens[] = $t = new Token; $t->type = T::DELIMITER; $t->start = $start; $t->value = $delimiter; goto yield_token_list; } } elseif ($second === '`') { $position++; - $column++; - $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::BACKTICK_QUOTED_STRING, $string, $position, $column, $row, $second, '@'); + $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::BACKTICK_QUOTED_STRING, $source, $position, $second, '@'); } elseif ($second === "'") { $position++; - $column++; - $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::SINGLE_QUOTED_STRING, $string, $position, $column, $row, $second, '@'); + $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::SINGLE_QUOTED_STRING, $source, $position, $second, '@'); } elseif ($second === '"') { $position++; - $column++; - $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::DOUBLE_QUOTED_STRING, $string, $position, $column, $row, $second, '@'); + $tokens[] = $previous = $this->parseString(T::NAME | T::AT_VARIABLE | T::DOUBLE_QUOTED_STRING, $source, $position, $second, '@'); } elseif (isset(self::$userVariableNameCharsKey[$second]) || ord($second) > 127) { // @variable $var .= $second; $position++; - $column++; while ($position < $length) { - $next5 = $string[$position]; + $next5 = $source[$position]; if (isset(self::$userVariableNameCharsKey[$next5]) || ord($next5) > 127) { $var .= $next5; $position++; - $column++; } else { break; } @@ -437,25 +405,24 @@ public function tokenize(string $string): Generator $var = substr($var, 0, -strlen($delimiter)); $yieldDelimiter = true; } - $upper = strtoupper(substr($var, 1)); - if ($upper === 'DEFAULT') { + if (strcasecmp(substr($var, 1), 'DEFAULT') === 0) { // todo: probably all magic functions? - $exception = new LexerException("Invalid variable name $var.", $position, $string); + $exception = new LexerException("Invalid variable name $var.", $position, $source); - $tokens[] = new Token(T::NAME | T::AT_VARIABLE | T::INVALID, $start, $row, $var, null, $exception); + $tokens[] = $t = new Token; $t->type = T::NAME | T::AT_VARIABLE | T::INVALID; $t->start = $start; $t->value = $var; $t->exception = $exception; $invalid = true; break; } - $tokens[] = $previous = new Token(T::NAME | T::AT_VARIABLE, $start, $row, $var); + $tokens[] = $previous = $t = new Token; $t->type = T::NAME | T::AT_VARIABLE; $t->start = $start; $t->value = $var; if ($yieldDelimiter) { - $tokens[] = new Token(T::DELIMITER, $start, $row, $delimiter); + $tokens[] = $t = new Token; $t->type = T::DELIMITER; $t->start = $start; $t->value = $delimiter; goto yield_token_list; } } else { // simple @ (valid as empty host name) - $tokens[] = $previous = new Token(T::NAME | T::AT_VARIABLE, $start, $row, $var); + $tokens[] = $previous = $t = new Token; $t->type = T::NAME | T::AT_VARIABLE; $t->start = $start; $t->value = $var; break; } break; @@ -463,65 +430,58 @@ public function tokenize(string $string): Generator // # comment $hashComment = $char; while ($position < $length) { - $next6 = $string[$position]; + $next6 = $source[$position]; $hashComment .= $next6; $position++; if ($next6 === "\n") { - $column = 0; - $row++; break; } } if ($this->withComments) { - $tokens[] = $previous = new Token(T::COMMENT | T::HASH_COMMENT, $start, $row, $hashComment); + $tokens[] = $previous = $t = new Token; $t->type = T::COMMENT | T::HASH_COMMENT; $t->start = $start; $t->value = $hashComment; } break; case '/': - $next7 = $position < $length ? $string[$position] : ''; + $next7 = $position < $length ? $source[$position] : ''; if ($next7 === '/') { // // comment $position++; $slashComment = $char . $next7; while ($position < $length) { - $next7 = $string[$position]; + $next7 = $source[$position]; $slashComment .= $next7; $position++; if ($next7 === "\n") { - $column = 0; - $row++; break; } } if ($this->withComments) { - $tokens[] = $previous = new Token(T::COMMENT | T::DOUBLE_SLASH_COMMENT, $start, $row, $slashComment); + $tokens[] = $previous = $t = new Token; $t->type = T::COMMENT | T::DOUBLE_SLASH_COMMENT; $t->start = $start; $t->value = $slashComment; } } elseif ($next7 === '*') { $position++; - $column++; - $optional = $string[$position] === '!'; - $beforeComment = $string[$position - 3]; + $optional = $source[$position] === '!'; + $beforeComment = $source[$position - 3]; // todo: Maria $validOptional = true; if ($optional) { - if (strlen($string) > $position + 1 && $string[$position + 1] === '*' && $string[$position + 2] === '/') { + if (strlen($source) > $position + 1 && $source[$position + 1] === '*' && $source[$position + 2] === '/') { // /*!*/ $position += 3; - $column += 3; break; } - $validOptional = preg_match('~^([Mm]?!(?:00000|[1-9]\d{4,5})?)\D~', substr($string, $position, 10), $m) === 1; + $validOptional = preg_match('~^([Mm]?!(?:00000|[1-9]\d{4,5})?)\D~', substr($source, $position, 10), $m) === 1; if ($validOptional) { $versionId = strtoupper(str_replace('!', '', $m[1])); if ($this->platform->interpretOptionalComment($versionId)) { if ($this->withWhitespace && $beforeComment !== ' ' && $beforeComment !== "\t" && $beforeComment !== "\n") { // insert a space in case that optional comment was immediately following a non-whitespace token // (resulting token list would serialize into invalid code) - $tokens[] = new Token(T::WHITESPACE, $position - 3, $row, ' '); + $tokens[] = $t = new Token; $t->type = T::WHITESPACE; $t->start = $position - 3; $t->value = ' '; } $condition = $versionId; $position += strlen($versionId) + 1; - $column += strlen($versionId) + 1; // continue parsing as conditional code break; @@ -529,7 +489,7 @@ public function tokenize(string $string): Generator } } - $isHint = $string[$position] === '+'; + $isHint = $source[$position] === '+'; if ($isHint && $parseOptimizerHints) { $optimizerHintCanFollow = ($previous->type & TokenType::RESERVED) !== 0 && in_array(strtoupper($previous->value), [Keyword::SELECT, Keyword::INSERT, Keyword::REPLACE, Keyword::UPDATE, Keyword::DELETE], true); @@ -537,8 +497,7 @@ public function tokenize(string $string): Generator if ($optimizerHintCanFollow) { $hint = true; $position++; - $column++; - $tokens[] = new Token(T::OPTIMIZER_HINT_START, $start, $row, '/*+'); + $tokens[] = $t = new Token; $t->type = T::OPTIMIZER_HINT_START; $t->start = $start; $t->value = '/*+'; break; } } @@ -548,16 +507,14 @@ public function tokenize(string $string): Generator $comment = $char . $next7; $terminated = false; while ($position < $length) { - $next8 = $string[$position]; - if ($next8 === '/' && ($position + 1 < $length) && $string[$position + 1] === '*') { - $comment .= $next8 . $string[$position + 1]; + $next8 = $source[$position]; + if ($next8 === '/' && ($position + 1 < $length) && $source[$position + 1] === '*') { + $comment .= $next8 . $source[$position + 1]; $position += 2; - $column += 2; $commentDepth++; - } elseif ($next8 === '*' && ($position + 1 < $length) && $string[$position + 1] === '/') { - $comment .= $next8 . $string[$position + 1]; + } elseif ($next8 === '*' && ($position + 1 < $length) && $source[$position + 1] === '/') { + $comment .= $next8 . $source[$position + 1]; $position += 2; - $column += 2; $commentDepth--; if ($commentDepth === 0) { $terminated = true; @@ -566,25 +523,22 @@ public function tokenize(string $string): Generator } elseif ($next8 === "\n") { $comment .= $next8; $position++; - $column = 0; - $row++; } else { $comment .= $next8; $position++; - $column++; } } if (!$terminated) { - $exception = new LexerException('End of comment not found.', $position, $string); + $exception = new LexerException('End of comment not found.', $position, $source); - $tokens[] = new Token(T::COMMENT | T::BLOCK_COMMENT | T::INVALID, $start, $row, $comment, null, $exception); + $tokens[] = $t = new Token; $t->type = T::COMMENT | T::BLOCK_COMMENT | T::INVALID; $t->start = $start; $t->value = $comment; $t->exception = $exception; $invalid = true; break; } elseif (!$validOptional) { $condition = null; - $exception = new LexerException('Invalid optional comment: ' . $comment, $position, $string); + $exception = new LexerException('Invalid optional comment: ' . $comment, $position, $source); - $tokens[] = new Token(T::COMMENT | T::BLOCK_COMMENT | T::OPTIONAL_COMMENT | T::INVALID, $start, $row, $comment, null, $exception); + $tokens[] = $t = new Token; $t->type = T::COMMENT | T::BLOCK_COMMENT | T::OPTIONAL_COMMENT | T::INVALID; $t->start = $start; $t->value = $comment; $t->exception = $exception; $invalid = true; break; } @@ -592,17 +546,17 @@ public function tokenize(string $string): Generator if ($this->withComments) { if ($optional) { // /*!12345 comment (when not interpreted as code) */ - $tokens[] = new Token(T::COMMENT | T::BLOCK_COMMENT | T::OPTIONAL_COMMENT, $start, $row, $comment); + $tokens[] = $t = new Token; $t->type = T::COMMENT | T::BLOCK_COMMENT | T::OPTIONAL_COMMENT; $t->start = $start; $t->value = $comment; } elseif ($hint) { // /*+ comment */ (when not interpreted as code) - $tokens[] = new Token(T::COMMENT | T::BLOCK_COMMENT | T::OPTIMIZER_HINT_COMMENT, $start, $row, $comment); + $tokens[] = $t = new Token; $t->type = T::COMMENT | T::BLOCK_COMMENT | T::OPTIMIZER_HINT_COMMENT; $t->start = $start; $t->value = $comment; } else { // /* comment */ - $tokens[] = new Token(T::COMMENT | T::BLOCK_COMMENT, $start, $row, $comment); + $tokens[] = $t = new Token; $t->type = T::COMMENT | T::BLOCK_COMMENT; $t->start = $start; $t->value = $comment; } } } else { - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $char); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $char; } break; case '"': @@ -610,160 +564,176 @@ public function tokenize(string $string): Generator ? T::NAME | T::DOUBLE_QUOTED_STRING : T::VALUE | T::STRING | T::DOUBLE_QUOTED_STRING; - $tokens[] = $previous = $this->parseString($type, $string, $position, $column, $row, '"'); + $tokens[] = $previous = $this->parseString($type, $source, $position, '"'); break; case "'": - $tokens[] = $previous = $this->parseString(T::VALUE | T::STRING | T::SINGLE_QUOTED_STRING, $string, $position, $column, $row, "'"); + $tokens[] = $previous = $this->parseString(T::VALUE | T::STRING | T::SINGLE_QUOTED_STRING, $source, $position, "'"); break; case '`': - $tokens[] = $previous = $this->parseString(T::NAME | T::BACKTICK_QUOTED_STRING, $string, $position, $column, $row, '`'); + $tokens[] = $previous = $this->parseString(T::NAME | T::BACKTICK_QUOTED_STRING, $source, $position, '`'); break; case '.': - $next9 = $position < $length ? $string[$position] : ''; + $afterDot = $position < $length ? $source[$position] : ''; // .123 cannot follow a name, e.g.: "select 1ea10.1a20, ...", but can follow a keyword, e.g.: "INTERVAL .4 SECOND" - if (isset(self::$numbersKey[$next9]) && (($previous->type & T::NAME) === 0 || ($previous->type & T::KEYWORD) !== 0)) { - $token = $this->parseNumber($string, $position, $column, $row, '.'); - if ($token !== null) { - $tokens[] = $previous = $token; - break; + if (isset(self::$numbersKey[$afterDot]) && (($previous->type & T::NAME) === 0 || ($previous->type & T::KEYWORD) !== 0)) { + if (preg_match(self::ANCHORED_NUMBER_REGEXP, $source, $m, PREG_UNMATCHED_AS_NULL, $position - 1) !== 0) { + $token = $this->numberToken($source, $position, $m); // @phpstan-ignore argument.type + if ($token !== null) { + $tokens[] = $previous = $token; + break; + } } } - $tokens[] = $previous = new Token(T::SYMBOL, $start, $row, $char); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL; $t->start = $start; $t->value = $char; break; case '-': - $second = $position < $length ? $string[$position] : ''; - $numberCanFollow = ($previous->type & T::END) !== 0 - || (($previous->type & T::SYMBOL) !== 0 && $previous->value !== ')' && $previous->value !== '?') - || (($previous->type & T::KEYWORD) !== 0 && strtoupper($previous->value) === Keyword::DEFAULT); - if ($numberCanFollow) { - $token = $this->parseNumber($string, $position, $column, $row, '-'); - if ($token !== null) { - $tokens[] = $previous = $token; - break; - } - } + $second = $position < $length ? $source[$position] : ''; if ($second === '-') { - $third = $position + 1 < $length ? $string[$position + 1] : ''; + $third = $position + 1 < $length ? $source[$position + 1] : ''; + if ($third === "\n") { + // --\n + $position += 2; + if ($this->withComments) { + $tokens[] = $previous = $t = new Token; $t->type = T::COMMENT | T::DOUBLE_HYPHEN_COMMENT; $t->start = $start; $t->value = "--\n"; + } + break; + } + if ($third === "\r") { + $fourth = $position + 2 < $length ? $source[$position + 2] : ''; + if ($fourth === "\n") { + // --\r\n + $position += 3; + if ($this->withComments) { + $tokens[] = $previous = $t = new Token; $t->type = T::COMMENT | T::DOUBLE_HYPHEN_COMMENT; $t->start = $start; $t->value = "--\r\n"; + } + break; + } + } if ($third === ' ') { // -- comment - $endOfLine = strpos($string, "\n", $position); + $endOfLine = strpos($source, "\n", $position); if ($endOfLine === false) { - $endOfLine = strlen($string); + $endOfLine = strlen($source); } - $line = substr($string, $position - 1, $endOfLine - $position + 2); + $line = substr($source, $position - 1, $endOfLine - $position + 2); $position += strlen($line) - 1; - $column = 0; - $row++; if ($this->withComments) { - $tokens[] = $previous = new Token(T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, $start, $row, $line); + $tokens[] = $previous = $t = new Token; $t->type = T::COMMENT | T::DOUBLE_HYPHEN_COMMENT; $t->start = $start; $t->value = $line; } break; } - $tokens[] = new Token(T::SYMBOL | T::OPERATOR, $start, $row, '-'); + $tokens[] = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = '-'; $position++; - $column++; - $token = $this->parseNumber($string, $position, $column, $row, '-'); - if ($token !== null) { - $tokens[] = $previous = $token; - break; + if (preg_match(self::ANCHORED_NUMBER_REGEXP, $source, $m, PREG_UNMATCHED_AS_NULL, $position - 1) !== 0) { + $token = $this->numberToken($source, $position, $m); // @phpstan-ignore argument.type + if ($token !== null) { + $tokens[] = $previous = $token; + break; + } + } + } + + $numberCanFollow = ($previous->type & T::END) !== 0 + || (($previous->type & T::SYMBOL) !== 0 && $previous->value !== ')' && $previous->value !== '?') + || (($previous->type & T::KEYWORD) !== 0 && strcasecmp($previous->value, Keyword::DEFAULT) === 0); + if ($numberCanFollow) { + if (preg_match(self::ANCHORED_NUMBER_REGEXP, $source, $m, PREG_UNMATCHED_AS_NULL, $position - 1) !== 0) { + $token = $this->numberToken($source, $position, $m); // @phpstan-ignore argument.type + if ($token !== null) { + $tokens[] = $previous = $token; + break; + } } } $operator3 = $char; while ($position < $length) { - $next10 = $string[$position]; - if (!isset($this->operatorsKey[$operator3 . $next10])) { - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator3); + $next10 = $source[$position]; + if (!isset($this->platform->operators[$operator3 . $next10])) { + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator3; break 2; } if (isset(self::$operatorSymbolsKey[$next10])) { $operator3 .= $next10; $position++; - $column++; } else { break; } } - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator3); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator3; break; case '+': - $next11 = $position < $length ? $string[$position] : ''; + $afterPlus = $position < $length ? $source[$position] : ''; $numberCanFollow = ($previous->type & T::END) !== 0 || (($previous->type & T::SYMBOL) !== 0 && $previous->value !== ')' && $previous->value !== '?') || (($previous->type & T::KEYWORD) !== 0 && $previous->value === Keyword::DEFAULT); - if ($numberCanFollow && ($next11 === '.' || isset(self::$numbersKey[$next11]))) { - $token = $this->parseNumber($string, $position, $column, $row, '+'); - if ($token !== null) { - $tokens[] = $previous = $token; - break; + + if ($numberCanFollow && ($afterPlus === '.' || isset(self::$numbersKey[$afterPlus]))) { + if (preg_match(self::ANCHORED_NUMBER_REGEXP, $source, $m, PREG_UNMATCHED_AS_NULL, $position - 1) !== 0) { + $token = $this->numberToken($source, $position, $m); // @phpstan-ignore argument.type + if ($token !== null) { + $tokens[] = $previous = $token; + break; + } } } $operator4 = $char; while ($position < $length) { - $next12 = $string[$position]; - if (!isset($this->operatorsKey[$operator4 . $next12])) { - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator4); + $next12 = $source[$position]; + if (!isset($this->platform->operators[$operator4 . $next12])) { + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator4; break 2; } if (isset(self::$operatorSymbolsKey[$next12])) { $operator4 .= $next12; $position++; - $column++; } else { break; } } - $tokens[] = $previous = new Token(T::SYMBOL | T::OPERATOR, $start, $row, $operator4); + $tokens[] = $previous = $t = new Token; $t->type = T::SYMBOL | T::OPERATOR; $t->start = $start; $t->value = $operator4; break; case '0': - $next13 = $position < $length ? $string[$position] : ''; + $next13 = $position < $length ? $source[$position] : ''; if ($next13 === 'b') { // 0b00100011 $position++; - $column++; $bits = ''; while ($position < $length) { - $next13 = $string[$position]; + $next13 = $source[$position]; if ($next13 === '0' || $next13 === '1') { $bits .= $next13; $position++; - $column++; } elseif (isset(self::$nameCharsKey[$next13])) { // name pretending to be a binary literal :E $position -= strlen($bits) + 1; - $column -= strlen($bits) + 1; break; } else { - $orig = $char . 'b' . $bits; - $tokens[] = $previous = new Token(T::VALUE | T::BINARY_LITERAL, $start, $row, $bits, $orig); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::BINARY_LITERAL; $t->start = $start; $t->value = $bits; break 2; } } } elseif ($next13 === 'x') { // 0x001f $position++; - $column++; $bits = ''; while ($position < $length) { - $next13 = $string[$position]; + $next13 = $source[$position]; if (isset(self::$hexadecKey[$next13])) { $bits .= $next13; $position++; - $column++; } elseif (isset(self::$nameCharsKey[$next13])) { // name pretending to be a hexadecimal literal :E $position -= strlen($bits) + 1; - $column -= strlen($bits) + 1; break; } else { - $orig = $char . 'x' . $bits; - $tokens[] = $previous = new Token(T::VALUE | T::HEXADECIMAL_LITERAL, $start, $row, strtolower($bits), $orig); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::HEXADECIMAL_LITERAL; $t->start = $start; $t->value = strtolower($bits); break 2; } } @@ -778,56 +748,53 @@ public function tokenize(string $string): Generator case '7': case '8': case '9': - $uuid = substr($string, $position - 1, 36); // UUID - if (strlen($uuid) === 36 && preg_match(self::UUID_REGEXP, $uuid) !== 0) { + if ($length >= $position + 35 && preg_match(self::ANCHORED_UUID_REGEXP, $source, $m, 0, $position - 1) !== 0) { + $uuid = $m[0]; // @phpstan-ignore offsetAccess.notFound $position += 35; - $column += 35; - $tokens[] = $previous = new Token(T::VALUE | T::UUID, $start, $row, $uuid); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::UUID; $t->start = $start; $t->value = $uuid; break; } // IPv4 - if (preg_match(self::IP_V4_REGEXP, $uuid, $m) !== 0) { - $position += strlen($m[0]) - 1; - $column += strlen($m[0]) - 1; - $tokens[] = $previous = new Token(T::VALUE | T::STRING, $start, $row, $m[0]); + if ($length >= $position + 6 && preg_match(self::ANCHORED_IP_V4_REGEXP, $source, $m, 0, $position - 1) !== 0) { + $ipv4 = $m[0]; // @phpstan-ignore offsetAccess.notFound + $position += strlen($ipv4) - 1; + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::STRING; $t->start = $start; $t->value = $ipv4; break; } - $token = $this->parseNumber($string, $position, $column, $row, $char); - if ($token !== null) { - $tokens[] = $previous = $token; - break; + // number + if (preg_match(self::ANCHORED_NUMBER_REGEXP, $source, $m, PREG_UNMATCHED_AS_NULL, $position - 1) !== 0) { + $token = $this->numberToken($source, $position, $m); // @phpstan-ignore argument.type + if ($token !== null) { + $tokens[] = $previous = $token; + break; + } } // continue case 'B': case 'b': // b'01' // B'01' - if (($char === 'B' || $char === 'b') && $position < $length && $string[$position] === '\'') { + if (($char === 'B' || $char === 'b') && $position < $length && $source[$position] === '\'') { $position++; - $column++; $bits = $next14 = ''; while ($position < $length) { - $next14 = $string[$position]; + $next14 = $source[$position]; if ($next14 === '\'') { $position++; - $column++; break; } else { $bits .= $next14; $position++; - $column++; } } if (ltrim($bits, '01') === '') { - $orig = $char . '\'' . $bits . '\''; - - $tokens[] = $previous = new Token(T::VALUE | T::BINARY_LITERAL, $start, $row, $bits, $orig); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::BINARY_LITERAL; $t->start = $start; $t->value = $bits; } else { - $exception = new LexerException('Invalid binary literal', $position, $string); - $orig = $char . '\'' . $bits . $next14; + $exception = new LexerException('Invalid binary literal', $position, $source); + $value = $char . '\'' . $bits . $next14; - $tokens[] = $previous = new Token(T::VALUE | T::BINARY_LITERAL | T::INVALID, $start, $row, $orig, $orig, $exception); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::BINARY_LITERAL | T::INVALID; $t->start = $start; $t->value = $value; $t->exception = $exception; // todo why orig? $invalid = true; break; } @@ -844,43 +811,37 @@ public function tokenize(string $string): Generator case 'e': case 'F': case 'f': - $uuid2 = substr($string, $position - 1, 36); // UUID - if (strlen($uuid2) === 36 && preg_match(self::UUID_REGEXP, $uuid2) !== 0) { + if ($length >= $position + 35 && preg_match(self::ANCHORED_UUID_REGEXP, $source, $m, 0, $position - 1) !== 0) { + $uuid2 = $m[0]; // @phpstan-ignore offsetAccess.notFound $position += 35; - $column += 35; - $tokens[] = $previous = new Token(T::VALUE | T::UUID, $start, $row, $uuid2); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::UUID; $t->start = $start; $t->value = $uuid2; break; } // continue case 'X': case 'x': - if (($char === 'X' || $char === 'x') && $position < $length && $string[$position] === '\'') { + if (($char === 'X' || $char === 'x') && $position < $length && $source[$position] === '\'') { $position++; - $column++; $bits = $next15 = ''; while ($position < $length) { - $next15 = $string[$position]; + $next15 = $source[$position]; if ($next15 === '\'') { $position++; - $column++; break; } else { $bits .= $next15; $position++; - $column++; } } $bits = strtolower($bits); if (ltrim($bits, '0123456789abcdef') === '') { - $orig = $char . '\'' . $bits . '\''; - - $tokens[] = $previous = new Token(T::VALUE | T::HEXADECIMAL_LITERAL, $start, $row, $bits, $orig); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::HEXADECIMAL_LITERAL; $t->start = $start; $t->value = $bits; } else { - $exception = new LexerException('Invalid hexadecimal literal', $position, $string); - $orig = $char . '\'' . $bits . $next15; + $exception = new LexerException('Invalid hexadecimal literal', $position, $source); + $value = $char . '\'' . $bits . $next15; - $tokens[] = $previous = new Token(T::VALUE | T::HEXADECIMAL_LITERAL | T::INVALID, $start, $row, $orig, $orig, $exception); + $tokens[] = $previous = $t = new Token; $t->type = T::VALUE | T::HEXADECIMAL_LITERAL | T::INVALID; $t->start = $start; $t->value = $value; $t->exception = $exception; // todo why orig? $invalid = true; break; } @@ -888,25 +849,22 @@ public function tokenize(string $string): Generator } // continue case 'N': - $next16 = $position < $length ? $string[$position] : null; - if ($char === 'N' && $next16 === '"') { + $afterN = $position < $length ? $source[$position] : null; + if ($char === 'N' && $afterN === '"') { $position++; - $column++; $type = $this->session->getMode()->containsAny(SqlMode::ANSI_QUOTES) ? T::NAME | T::DOUBLE_QUOTED_STRING : T::VALUE | T::STRING | T::DOUBLE_QUOTED_STRING; - $tokens[] = $previous = $this->parseString($type, $string, $position, $column, $row, '"', 'N'); + $tokens[] = $previous = $this->parseString($type, $source, $position, '"', 'N'); break; - } elseif ($char === 'N' && $next16 === "'") { + } elseif ($char === 'N' && $afterN === "'") { $position++; - $column++; - $tokens[] = $previous = $this->parseString(T::VALUE | T::STRING | T::SINGLE_QUOTED_STRING, $string, $position, $column, $row, "'", 'N'); + $tokens[] = $previous = $this->parseString(T::VALUE | T::STRING | T::SINGLE_QUOTED_STRING, $source, $position, "'", 'N'); break; - } elseif ($char === 'N' && $next16 === '`') { + } elseif ($char === 'N' && $afterN === '`') { $position++; - $column++; - $tokens[] = $previous = $this->parseString(T::NAME | T::BACKTICK_QUOTED_STRING, $string, $position, $column, $row, "`", 'N'); + $tokens[] = $previous = $this->parseString(T::NAME | T::BACKTICK_QUOTED_STRING, $source, $position, "`", 'N'); break; } case 'n': @@ -950,11 +908,10 @@ public function tokenize(string $string): Generator case '$': $name = $char; while ($position < $length) { - $next17 = $string[$position]; + $next17 = $source[$position]; if (isset(self::$nameCharsKey[$next17]) || ord($next17) > 127) { $name .= $next17; $position++; - $column++; } else { break; } @@ -967,44 +924,43 @@ public function tokenize(string $string): Generator } $upper = strtoupper($name); - if (isset($this->reservedKey[$upper])) { - if (isset($this->operatorsKey[$upper])) { - $tokens[] = $previous = new Token(T::KEYWORD | T::RESERVED | T::NAME | T::UNQUOTED_NAME | T::OPERATOR, $start, $row, $name); + if (isset($this->platform->reserved[$upper])) { + if (isset($this->platform->operators[$upper])) { + $tokens[] = $previous = $t = new Token; $t->type = T::KEYWORD | T::RESERVED | T::NAME | T::UNQUOTED_NAME | T::OPERATOR; $t->start = $start; $t->value = $name; } else { - $tokens[] = $previous = new Token(T::KEYWORD | T::RESERVED | T::NAME | T::UNQUOTED_NAME, $start, $row, $name); + $tokens[] = $previous = $t = new Token; $t->type = T::KEYWORD | T::RESERVED | T::NAME | T::UNQUOTED_NAME; $t->start = $start; $t->value = $name; } - } elseif (isset($this->keywordsKey[$upper])) { - $tokens[] = $previous = new Token(T::KEYWORD | T::NAME | T::UNQUOTED_NAME, $start, $row, $name); + } elseif (isset($this->platform->nonReserved[$upper])) { + $tokens[] = $previous = $t = new Token; $t->type = T::KEYWORD | T::NAME | T::UNQUOTED_NAME; $t->start = $start; $t->value = $name; } elseif ($upper === Keyword::DELIMITER && $allowDelimiterDefinition) { - $tokens[] = new Token(T::KEYWORD | T::NAME | T::UNQUOTED_NAME, $start, $row, $name); + $tokens[] = $t = new Token; $t->type = T::KEYWORD | T::NAME | T::UNQUOTED_NAME; $t->start = $start; $t->value = $name; $start = $position; - $whitespace = $this->parseWhitespace($string, $position, $column, $row); + $whitespace = $this->parseWhitespace($source, $position); if ($this->withWhitespace) { - $tokens[] = new Token(T::WHITESPACE, $start, $row, $whitespace); + $tokens[] = $t = new Token; $t->type = T::WHITESPACE; $t->start = $start; $t->value = $whitespace; } $start = $position; $del = ''; while ($position < $length) { - $next18 = $string[$position]; + $next18 = $source[$position]; if ($next18 === "\n" || $next18 === "\r" || $next18 === "\t" || $next18 === ' ') { break; } else { $del .= $next18; $position++; - $column++; } } if ($del === '') { - $exception = new LexerException('Delimiter not found', $position, $string); + $exception = new LexerException('Delimiter not found', $position, $source); - $tokens[] = $previous = new Token(T::INVALID, $start, $row, $del, null, $exception); + $tokens[] = $previous = $t = new Token; $t->type = T::INVALID; $t->start = $start; $t->value = $del; $t->exception = $exception; $invalid = true; break; } - if ($this->platform->isReserved(strtoupper($del))) { - $exception = new LexerException('Delimiter can not be a reserved word', $position, $string); + if (isset($this->platform->reserved[strtoupper($del)])) { + $exception = new LexerException('Delimiter can not be a reserved word', $position, $source); - $tokens[] = $previous = new Token(T::DELIMITER_DEFINITION | T::INVALID, $start, $row, $del, null, $exception); + $tokens[] = $previous = $t = new Token; $t->type = T::DELIMITER_DEFINITION | T::INVALID; $t->start = $start; $t->value = $del; $t->exception = $exception; $invalid = true; break; } @@ -1019,12 +975,12 @@ public function tokenize(string $string): Generator */ $delimiter = $del; $this->session->setDelimiter($delimiter); - $tokens[] = $previous = new Token(T::DELIMITER_DEFINITION, $start, $row, $delimiter); + $tokens[] = $previous = $t = new Token; $t->type = T::DELIMITER_DEFINITION; $t->start = $start; $t->value = $delimiter; } else { - $tokens[] = $previous = new Token(T::NAME | T::UNQUOTED_NAME, $start, $row, $name); + $tokens[] = $previous = $t = new Token; $t->type = T::NAME | T::UNQUOTED_NAME; $t->start = $start; $t->value = $name; } if ($yieldDelimiter) { - $tokens[] = new Token(T::DELIMITER, $start, $row, $delimiter); + $tokens[] = $t = new Token; $t->type = T::DELIMITER; $t->start = $start; $t->value = $delimiter; goto yield_token_list; } elseif (($previous->type & T::DELIMITER_DEFINITION) !== 0) { goto yield_token_list; @@ -1032,24 +988,23 @@ public function tokenize(string $string): Generator break; default: if (ord($char) < 32) { - $exception = new LexerException('Invalid ASCII control character', $position, $string); + $exception = new LexerException('Invalid ASCII control character', $position, $source); - $tokens[] = $previous = new Token(T::INVALID, $start, $row, $char, null, $exception); + $tokens[] = $previous = $t = new Token; $t->type = T::INVALID; $t->start = $start; $t->value = $char; $t->exception = $exception; $invalid = true; break; } $name2 = $char; while ($position < $length) { - $next19 = $string[$position]; + $next19 = $source[$position]; if (isset(self::$nameCharsKey[$next19]) || ord($next19) > 127) { $name2 .= $next19; $position++; - $column++; } else { break; } } - $tokens[] = $previous = new Token(T::NAME | T::UNQUOTED_NAME, $start, $row, $name2); + $tokens[] = $previous = $t = new Token; $t->type = T::NAME | T::UNQUOTED_NAME; $t->start = $start; $t->value = $name2; } } @@ -1058,19 +1013,19 @@ public function tokenize(string $string): Generator if ($condition !== null) { $lastToken = end($tokens); $condition = null; - $exception = new LexerException("End of optional comment not found.", $lastToken->position, ''); - $tokens[] = new Token(T::END + T::INVALID, 0, 0, '', '', $exception); + $exception = new LexerException("End of optional comment not found.", $lastToken->start, ''); + $tokens[] = $t = new Token; $t->type = T::END + T::INVALID; $t->start = 0; $t->value = ''; $t->exception = $exception; $invalid = true; } if ($hint) { $lastToken = end($tokens); $hint = false; - $exception = new LexerException("End of optimizer hint not found.", $lastToken->position, ''); - $tokens[] = new Token(T::END + T::INVALID, 0, 0, '', '', $exception); + $exception = new LexerException("End of optimizer hint not found.", $lastToken->start, ''); + $tokens[] = $t = new Token; $t->type = T::END + T::INVALID; $t->start = 0; $t->value = ''; $t->exception = $exception; $invalid = true; } - yield new TokenList($tokens, $this->config->getPlatform(), $this->session, $autoSkip, $invalid); + yield new TokenList($source, $tokens, $this->platform, $this->session, $autoSkip, $invalid); $tokens = []; $invalid = false; @@ -1081,21 +1036,15 @@ public function tokenize(string $string): Generator } } - private function parseWhitespace(string $string, int &$position, int &$column, int &$row): string + private function parseWhitespace(string $source, int &$position): string { - $length = strlen($string); + $length = strlen($source); $whitespace = ''; while ($position < $length) { - $next = $string[$position]; - if ($next === ' ' || $next === "\t" || $next === "\r") { + $next = $source[$position]; + if ($next === ' ' || $next === "\t" || $next === "\r" || $next === "\n") { $whitespace .= $next; $position++; - $column++; - } elseif ($next === "\n") { - $whitespace .= $next; - $position++; - $column = 1; - $row++; } else { break; } @@ -1104,10 +1053,10 @@ private function parseWhitespace(string $string, int &$position, int &$column, i return $whitespace; } - private function parseString(int $tokenType, string $string, int &$position, int &$column, int &$row, string $quote, string $prefix = ''): Token + private function parseString(int $tokenType, string $source, int &$position, string $quote, string $prefix = ''): Token { $startAt = $position - 1 - strlen($prefix); - $length = strlen($string); + $length = strlen($source); $mode = $this->session->getMode(); $ansi = $mode->containsAny(SqlMode::ANSI_QUOTES); @@ -1119,15 +1068,14 @@ private function parseString(int $tokenType, string $string, int &$position, int $escaped = false; $finished = false; while ($position < $length) { - $next = $string[$position]; + $next = $source[$position]; // todo: check for \0 in names? if ($next === $quote) { $orig[] = $next; $position++; - $column++; if ($escaped) { $escaped = false; - } elseif ($position < $length && $string[$position] === $quote) { + } elseif ($position < $length && $source[$position] === $quote) { $escaped = true; } else { $finished = true; @@ -1136,31 +1084,28 @@ private function parseString(int $tokenType, string $string, int &$position, int } elseif ($next === "\n") { $orig[] = $next; $position++; - $column = 1; - $row++; } elseif ($backslashes && $next === '\\') { $escaped = !$escaped; $orig[] = $next; $position++; - $column++; } elseif ($escaped && $next !== '\\' && $next !== $quote) { $escaped = false; $orig[] = $next; $position++; - $column++; } else { $orig[] = $next; $position++; - $column++; } } $orig = implode('', $orig); if (!$finished) { - $exception = new LexerException("End of string not found. Starts with " . substr($string, $startAt - 1, 100), $position, $string); + $exception = new LexerException("End of string not found. Starts with " . substr($source, $startAt - 1, 100), $position, $source); + + $t = new Token; $t->type = $tokenType | T::INVALID; $t->start = $startAt; $t->value = $prefix . $orig; $t->exception = $exception; - return new Token($tokenType | T::INVALID, $startAt, $row, $prefix . $orig, $prefix . $orig, $exception); + return $t; } // remove quotes @@ -1172,152 +1117,58 @@ private function parseString(int $tokenType, string $string, int &$position, int $value = str_replace($this->escapeKeys, $this->escapeValues, $value); } - return new Token($tokenType, $startAt, $row, ($isAtVariable ? $prefix : '') . $value, $prefix . $orig); + $t = new Token; $t->type = $tokenType; $t->start = $startAt; $t->value = ($isAtVariable ? $prefix : '') . $value; + + return $t; } - private function parseNumber(string $string, int &$position, int &$column, int $row, string $start): ?Token + /** + * @param array{string, ?string, string, ?string, ?string, ?string} $m + */ + private function numberToken(string $source, int &$position, array $m): ?Token { + [$value, $sign, $base, $e, $expSign, $exponent] = $m; + $startAt = $position - 1; - $type = T::VALUE | T::NUMBER; - $length = strlen($string); - $offset = 0; - $isFloat = $start === '.'; - $isNumeric = isset(self::$numbersKey[$start]); - $base = $start; - $minusAllowed = $start === '-'; - $exp = ''; - do { - // integer (prefixed by any number of "-") - $next = ''; - while ($position + $offset < $length) { - $next = $string[$position + $offset]; - if (isset(self::$numbersKey[$next]) || ($minusAllowed && ($next === '-' || $next === ' '))) { - $base .= $next; - $offset++; - if ($next !== '-' && $next !== ' ') { - $isNumeric = true; - $minusAllowed = false; - } - } else { - break; - } - } - if ($position + $offset >= $length) { - break; - } + $len = strlen($value) - 1; - // decimal part - if ($next === '.') { - $isFloat = true; - if ($start !== '.') { - $base .= $next; - $offset++; - while ($position + $offset < $length) { - $next = $string[$position + $offset]; - if (isset(self::$numbersKey[$next])) { - $base .= $next; - $offset++; - $isNumeric = true; - } else { - break; - } - } - } else { - break; - } - } - if (!$isNumeric) { - return null; - } - if ($position + $offset >= $length) { - break; - } + $intBase = ctype_digit($base); + $nextChar = $source[$position + $len] ?? ''; + if ($e === null && $intBase && isset(self::$nameCharsKey[$nextChar])) { + // followed by a name character while not having '.' or exponent - this is a prefix of a name, not a number + return null; + } - // exponent - $next = $string[$position + $offset]; - do { - if ($next === 'e' || $next === 'E') { - $exp = $next; - $offset++; - $next = $position + $offset < $length ? $string[$position + $offset] : ''; - $expComplete = false; - if ($next === '+' || $next === '-' || isset(self::$numbersKey[$next])) { - $exp .= $next; - $offset++; - if (isset(self::$numbersKey[$next])) { - $expComplete = true; - } - } - while ($position + $offset < $length) { - $next = $string[$position + $offset]; - if (isset(self::$numbersKey[$next])) { - $exp .= $next; - $offset++; - $expComplete = true; - } else { - if (trim($exp, 'e+-') === '' && strpos($base, '.') !== false) { - $len = strlen($base . $exp) - 1; - $position += $len; - $column += $len; - $exception = new LexerException('Invalid number exponent ' . $exp, $position, $string); + $type = T::VALUE | T::NUMBER; + $position += $len; - return new Token($type | T::INVALID, $startAt, $row, $base . $exp, $base . $exp, $exception); - } - break; - } - } - if (!$expComplete) { - if (strpos($base, '.') !== false) { - $len = strlen($base . $exp) - 1; - $position += $len; - $column += $len; - $exception = new LexerException('Invalid number exponent ' . $exp, $position, $string); - - return new Token($type | T::INVALID, $startAt, $row, $base . $exp, $base . $exp, $exception); - } else { - return null; - } - } - } elseif (isset(self::$nameCharsKey[$next]) || ord($next) > 127) { - if (!$isFloat) { - $isNumeric = false; - } - break 2; - } - } while (false); // @phpstan-ignore-line - } while (false); // @phpstan-ignore-line + if ($e !== null && $exponent === '') { + $exception = new LexerException('Invalid number exponent ' . $value, $position, $source); - if (!$isNumeric) { - return null; - } + $t = new Token; $t->type = $type | T::INVALID; $t->start = $startAt; $t->value = $value; $t->exception = $exception; - $orig = $base . $exp; - $value = $base . str_replace(' ', '', strtolower($exp)); - if (strpos($orig, '-- ') === 0) { - return null; + return $t; } - $len = strlen($orig) - 1; - $position += $len; - $column += $len; - // todo: is "+42" considered uint? - if (ctype_digit($value)) { - $type |= T::INT | T::UINT; + if ($intBase && $sign === '' && $e === null) { + $t = new Token; $t->type = $type | T::INT | T::UINT; $t->start = $startAt; $t->value = $value; - return new Token($type, $startAt, $row, $value, $orig); + return $t; } - // value clean-up: --+.123E+2 => +0.123e+2 - while ($value[0] === '-' && $value[1] === '-') { - $value = substr($value, 2); + while (strlen($sign) > 1 && $sign[0] === '-' && $sign[1] === '-') { // @phpstan-ignore argument.type, offsetAccess.notFound, offsetAccess.notFound + $sign = substr($sign, 2); // @phpstan-ignore argument.type } - if (preg_match('~^(?:0|[+-]?[1-9]\\d*)$~', $value) !== 0) { - $type |= TokenType::INT; + if ($intBase && $e === null) { + $type |= T::INT; } - return new Token($type, $startAt, $row, $value, $orig); + $v = ($sign === '-' ? '-' : '') . $base . ($e !== null ? 'e' : '') . $expSign . $exponent; + $t = new Token; $t->type = $type; $t->start = $startAt; $t->value = $v; + + return $t; } } diff --git a/sources/Parser/Parser.php b/sources/Parser/Parser.php index ea8fefb4..3f102e79 100644 --- a/sources/Parser/Parser.php +++ b/sources/Parser/Parser.php @@ -23,7 +23,9 @@ use SqlFtw\Sql\Command; use SqlFtw\Sql\Keyword; use Throwable; +use function array_values; use function count; +use function iterator_to_array; use function strtoupper; /** @@ -108,7 +110,33 @@ public function getNextTokenList(): ?TokenList } /** - * @return Generator + * @return list + */ + public function parseAll(string $sql): array + { + return array_values(iterator_to_array($this->parse($sql))); + } + + /** + * @throws SingleStatementExpectedException when given SQL with multiple or no statements + */ + public function parseSingle(string $sql): Command + { + $commands = array_values(iterator_to_array($this->parse($sql))); + if (count($commands) === 0) { + throw new SingleStatementExpectedException($commands); + } + if (count($commands) > 1) { + if (count($commands) !== 2 || !$commands[1] instanceof EmptyCommand) { + throw new SingleStatementExpectedException($commands); + } + } + + return $commands[0]; + } + + /** + * @return Generator */ public function parse(string $sql, bool $prepared = false): Generator { @@ -257,7 +285,8 @@ public function parseTokenList(TokenList $tokenList): Command } return $command; - } catch (ParsingException | Throwable $e) { // todo: remove Throwable + } catch (ParsingException | Throwable $e) { // @phpstan-ignore catch.neverThrown + // todo: remove Throwable // Throwable should not be here $tokenList->finish(); diff --git a/sources/Parser/ParserFactory.php b/sources/Parser/ParserFactory.php index 99623e17..67b863d2 100644 --- a/sources/Parser/ParserFactory.php +++ b/sources/Parser/ParserFactory.php @@ -94,7 +94,7 @@ public function __construct(Parser $parser, ParserConfig $config, Session $sessi $this->expressionParser = new ExpressionParser($config, $queryParserProxy); $this->optimizerHintParser = new OptimizerHintParser($this->expressionParser); $this->tableReferenceParser = new TableReferenceParser($this->expressionParser, $queryParserProxy); - $this->queryParser = new QueryParser($this, $this->expressionParser, $this->tableReferenceParser, $this->optimizerHintParser); + $this->queryParser = new QueryParser($this->platform, $this, $this->expressionParser, $this->tableReferenceParser, $this->optimizerHintParser); $this->routineBodyParser = new RoutineBodyParser($this->platform, $this->parser, $this->expressionParser, $this->queryParser, $sessionUpdater); } @@ -142,7 +142,7 @@ public function getCreateFunctionCommandParser(): CreateFunctionCommandParser public function getSchemaCommandsParser(): SchemaCommandsParser { - return new SchemaCommandsParser(); + return new SchemaCommandsParser($this->platform); } public function getDeleteCommandParser(): DeleteCommandParser @@ -242,7 +242,7 @@ public function getPreparedCommandsParser(): PreparedCommandsParser public function getReplicationCommandsParser(): ReplicationCommandsParser { - return new ReplicationCommandsParser($this->expressionParser); + return new ReplicationCommandsParser($this->platform, $this->expressionParser); } public function getResetCommandParser(): ResetCommandParser @@ -262,7 +262,7 @@ public function getRestartCommandParser(): RestartCommandParser public function getRoutineCommandsParser(): RoutineCommandsParser { - return new RoutineCommandsParser($this->expressionParser, $this->routineBodyParser); + return new RoutineCommandsParser($this->platform, $this->expressionParser, $this->routineBodyParser); } public function getServerCommandsParser(): ServerCommandsParser @@ -312,7 +312,7 @@ public function getTransactionCommandsParser(): TransactionCommandsParser public function getTriggerCommandsParser(): TriggerCommandsParser { - return new TriggerCommandsParser($this->expressionParser, $this->routineBodyParser); + return new TriggerCommandsParser($this->platform, $this->expressionParser, $this->routineBodyParser); } public function getUpdateCommandParser(): UpdateCommandParser @@ -327,7 +327,7 @@ public function getUseCommandParser(): UseCommandParser public function getUserCommandsParser(): UserCommandsParser { - return new UserCommandsParser(); + return new UserCommandsParser($this->platform); } public function getViewCommandsParser(): ViewCommandsParser diff --git a/sources/Parser/RoutineBodyParser.php b/sources/Parser/RoutineBodyParser.php index 3548c062..984c1efe 100644 --- a/sources/Parser/RoutineBodyParser.php +++ b/sources/Parser/RoutineBodyParser.php @@ -71,7 +71,6 @@ use SqlFtw\Sql\SubqueryType; use function array_values; use function get_class; -use function in_array; class RoutineBodyParser { @@ -301,7 +300,7 @@ private function parseCommand(TokenList $tokenList, bool $topLevel): Command // ok } else { $class = get_class($statement); - if (!in_array($class, $this->platform->getPreparableCommands(), true)) { + if (!isset($this->platform->preparableCommands[$class])) { throw new ParserException('Non-preparable statement in routine body: ' . $class, $tokenList); } } diff --git a/sources/Parser/Token.php b/sources/Parser/Token.php index 35fb4523..eaefefa8 100644 --- a/sources/Parser/Token.php +++ b/sources/Parser/Token.php @@ -7,46 +7,43 @@ * For the full copyright and license information read the file 'license.md', distributed with this source code */ +// phpcs:disable Squiz.WhiteSpace.MemberVarSpacing.Incorrect + namespace SqlFtw\Parser; +use function substr; + /** * Represents atomic part of SQL syntax */ final class Token { - /** @readonly */ - public int $type; - - /** @readonly */ - public int $position; - - /** @readonly */ - public int $row; - - /** @readonly */ - public string $value; - - /** @readonly */ - public ?string $original; - - /** @readonly */ - public ?LexerException $exception; - - public function __construct( - int $type, - int $position, - int $row, - string $value, - ?string $original = null, - ?LexerException $exception = null - ) { - $this->type = $type; - $this->position = $position; - $this->row = $row; - $this->value = $value; - $this->original = $original; - $this->exception = $exception; + public const NORMALIZED_TYPES = TokenType::BINARY_LITERAL + | TokenType::HEXADECIMAL_LITERAL + | TokenType::SINGLE_QUOTED_STRING + | TokenType::DOUBLE_QUOTED_STRING + | TokenType::BACKTICK_QUOTED_STRING + | TokenType::NUMBER; + + // union of TokenType constants + public int $type; // @phpstan-ignore property.uninitialized + + // length can be calculated using position of next token + public int $start; // @phpstan-ignore property.uninitialized + + // normalized value. original value can be retrieved by position and length from parsed string + public string $value; // @phpstan-ignore property.uninitialized + + public ?LexerException $exception = null; + + public function getSourceValue(string $source, ?Token $next): string + { + if ($next !== null) { + return substr($source, $this->start, $next->start - $this->start); + } else { + return substr($source, $this->start); + } } } diff --git a/sources/Parser/TokenList.php b/sources/Parser/TokenList.php index 6bc89c24..15cea065 100644 --- a/sources/Parser/TokenList.php +++ b/sources/Parser/TokenList.php @@ -7,12 +7,11 @@ * For the full copyright and license information read the file 'license.md', distributed with this source code */ -// phpcs-disable Squiz.WhiteSpace.MemberVarSpacing.AfterComment +// phpcs:disable Squiz.WhiteSpace.MemberVarSpacing.AfterComment namespace SqlFtw\Parser; use Dogma\Language\Encoding; -use Dogma\Str; use InvalidArgumentException; use LogicException; use SqlFtw\Parser\TokenType as T; @@ -44,6 +43,7 @@ use SqlFtw\Sql\SqlEnum; use SqlFtw\Sql\SubqueryType; use SqlFtw\Sql\UserName; +use SqlFtw\Util\Str; use function array_merge; use function array_pop; use function array_slice; @@ -60,10 +60,12 @@ use function min; use function preg_match; use function rtrim; +use function strcasecmp; use function strlen; use function strtolower; use function strtoupper; use function substr; +use function trim; use function ucfirst; /** @@ -90,6 +92,8 @@ class TokenList { + private string $source; + /** @var non-empty-list */ private array $tokens; @@ -97,9 +101,6 @@ class TokenList private Session $session; - /** @var array */ - private array $maxLengths; - private bool $invalid; // parser state ---------------------------------------------------------------------------------------------------- @@ -131,12 +132,12 @@ class TokenList /** * @param non-empty-list $tokens */ - public function __construct(array $tokens, Platform $platform, Session $session, int $autoSkip = 0, bool $invalid = false) + public function __construct(string &$source, array $tokens, Platform $platform, Session $session, int $autoSkip = 0, bool $invalid = false) { + $this->source = &$source; $this->tokens = $tokens; $this->platform = $platform; $this->session = $session; - $this->maxLengths = $platform->getMaxLengths(); $this->autoSkip = $autoSkip; $this->invalid = $invalid; } @@ -146,18 +147,6 @@ public function getSession(): Session return $this->session; } - public function using(?string $platform = null, ?int $minVersion = null, ?int $maxVersion = null): bool - { - return $this->platform->matches($platform, $minVersion, $maxVersion); - } - - public function check(string $feature, ?int $minVersion = null, ?int $maxVersion = null, ?string $platform = null): void - { - if (!$this->platform->matches($platform, $minVersion, $maxVersion)) { - throw new InvalidVersionException($feature, $this->platform, $this); - } - } - // state ----------------------------------------------------------------------------------------------------------- public function invalid(): bool @@ -280,7 +269,12 @@ public function finish(): void public function isFinished(): bool { if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + $token = $this->tokens[$this->position] ?? null; + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } if ($this->position >= count($this->tokens)) { @@ -357,7 +351,7 @@ public function setAutoSkip(int $autoSkip): void $this->autoSkip = $autoSkip; } - private function doAutoSkip(): void + private function doAutoSkip(): void // @phpstan-ignore method.unused { $token = $this->tokens[$this->position] ?? null; while ($token !== null && ($this->autoSkip & $token->type) !== 0) { @@ -373,6 +367,7 @@ public function extractRawExpression(int $start): string } $end = $this->position - 1; + $endIndex = $this->position; $beginning = true; $position = $start; $tokens = []; @@ -391,6 +386,7 @@ public function extractRawExpression(int $start): string for ($i = count($tokens) - 1; $i >= 0; $i--) { if (($tokens[$i]->type & $this->autoSkip) !== 0) { unset($tokens[$i]); + $endIndex--; } else { break; } @@ -398,11 +394,13 @@ public function extractRawExpression(int $start): string $expression = ''; /** @var Token $token */ - foreach ($tokens as $token) { - $expression .= $token->original ?? $token->value; + foreach ($tokens as $i => $token) { + $expression .= ($token->type & Token::NORMALIZED_TYPES) !== 0 + ? $token->getSourceValue($this->source, $tokens[$i + 1] ?? $this->tokens[$endIndex] ?? null) + : $token->value; } - return $expression; + return trim($expression); } // contents -------------------------------------------------------------------------------------------------------- @@ -417,15 +415,15 @@ public function getTokens(): array public function getStartOffset(): int { - return $this->tokens[0]->position; + return $this->tokens[0]->start; } public function getEndOffset(): int { $token = end($this->tokens); - $value = $token->original ?? $token->value; + $value = $token->value; // todo: should get source value end - return $token->position + strlen($value); + return $token->start + strlen($value); } public function append(self $tokenList): void @@ -441,7 +439,7 @@ public function slice(int $startOffset, int $endOffset): self /** @var non-empty-list $tokens */ $tokens = array_slice($this->tokens, $startOffset, $endOffset - $startOffset + 1); - return new self($tokens, $this->platform, $this->session, $this->autoSkip); + return new self($this->source, $tokens, $this->platform, $this->session, $this->autoSkip); } /** @@ -457,7 +455,7 @@ public function filter(callable $filter): self } } - return new self($tokens, $this->platform, $this->session, $this->autoSkip); + return new self($this->source, $tokens, $this->platform, $this->session, $this->autoSkip); } /** @@ -471,7 +469,7 @@ public function map(callable $mapper): self $tokens[] = $mapper($token); } - return new self($tokens, $this->platform, $this->session, $this->autoSkip); + return new self($this->source, $tokens, $this->platform, $this->session, $this->autoSkip); } public function getLast(): Token @@ -503,7 +501,9 @@ public function serialize(): string $result = ''; foreach ($this->tokens as $i => $token) { - $result .= $token->original ?? $token->value; + $result .= ($token->type & Token::NORMALIZED_TYPES) !== 0 + ? $token->getSourceValue($this->source, $this->tokens[$i + 1] ?? null) + : $token->value; if (($this->autoSkip & T::WHITESPACE) !== 0) { continue; @@ -534,10 +534,16 @@ public function seek(int $type, ?string $value = null, int $maxOffset = 1000): ? { $position = $this->position; for ($n = 0; $n < $maxOffset; $n++) { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null) { break; } @@ -557,15 +563,21 @@ public function seekKeyword(string $keyword, int $maxOffset): bool { $position = $this->position; for ($n = 0; $n < $maxOffset; $n++) { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null) { break; } $this->position++; - if (($token->type & T::KEYWORD) !== 0 && strtoupper($token->value) === $keyword) { + if (($token->type & T::KEYWORD) !== 0 && strcasecmp($token->value, $keyword) === 0) { $this->position = $position; return true; @@ -580,20 +592,26 @@ public function seekKeywordBefore(string $keyword, string $beforeKeyword, int $m { $position = $this->position; for ($n = 0; $n < $maxOffset; $n++) { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null) { break; } $this->position++; if (($token->type & T::KEYWORD) !== 0) { - if (strtoupper($token->value) === $keyword) { + if (strcasecmp($token->value, $keyword) === 0) { $this->position = $position; return true; - } elseif (strtoupper($token->value) === $beforeKeyword) { + } elseif (strcasecmp($token->value, $beforeKeyword) === 0) { $this->position = $position; return false; @@ -617,10 +635,16 @@ public function missing(string $description): void public function expect(int $tokenType, int $tokenMask = 0): Token { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null || ($token->type & $tokenType) === 0 || ($token->type & $tokenMask) !== 0) { throw InvalidTokenException::tokens($tokenType, $tokenMask, null, $token, $this); } @@ -634,17 +658,23 @@ public function expect(int $tokenType, int $tokenMask = 0): Token */ public function get(?int $tokenType = null, int $tokenMask = 0, ?string $value = null): ?Token { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null) { return null; } if ($tokenType !== null && (($token->type & $tokenType) === 0 || ($token->type & $tokenMask) !== 0)) { return null; } - if ($value !== null && strtolower($token->value) !== strtolower($value)) { + if ($value !== null && strcasecmp($token->value, $value) !== 0) { return null; } @@ -670,10 +700,16 @@ public function pass(int $tokenType, ?string $value = null): void public function expectSymbol(string $symbol): Token { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null || ($token->type & T::SYMBOL) === 0) { throw InvalidTokenException::tokens(T::SYMBOL, 0, $symbol, $token, $this); } @@ -690,10 +726,16 @@ public function expectSymbol(string $symbol): Token */ public function hasSymbol(string $symbol): bool { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token !== null && ($token->type & T::SYMBOL) !== 0 && $token->value === $symbol) { $this->position++; @@ -708,10 +750,16 @@ public function hasSymbol(string $symbol): bool */ public function passSymbol(string $symbol): void { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token !== null && ($token->type & T::SYMBOL) !== 0 && $token->value === $symbol) { $this->position++; } @@ -742,7 +790,7 @@ public function hasOperator(string $operator): bool $token = $this->get(T::OPERATOR); if ($token === null) { return false; - } elseif (strtoupper($token->value) === $operator) { + } elseif (strcasecmp($token->value, $operator) === 0) { return true; } else { $this->position = $position; @@ -758,7 +806,7 @@ public function expectAnyOperator(string ...$operators): string if (!in_array($upper, $operators, true)) { $this->position--; - throw InvalidTokenException::tokens(T::OPERATOR, 0, array_values($operators), $token, $this); + throw InvalidTokenException::tokens(T::OPERATOR, 0, $operators, $token, $this); } return $token->value; @@ -769,13 +817,19 @@ public function getAnyOperator(string ...$operators): ?string $position = $this->position; $token = $this->get(T::OPERATOR); - if ($token === null || !in_array(strtoupper($token->value), $operators, true)) { + if ($token === null) { + $this->position = $position; + + return null; + } + $upper = strtoupper($token->value); + if (!in_array($upper, $operators, true)) { $this->position = $position; return null; } - return strtoupper($token->value); + return $upper; } // numbers --------------------------------------------------------------------------------------------------------- @@ -1023,8 +1077,7 @@ public function expectNameEnum(string $className): SqlEnum return $enum; } catch (InvalidEnumValueException $e) { $this->position--; - /** @var list $values */ - $values = $className::getAllowedValues(); + $values = array_values($className::getAllowedValues()); throw InvalidTokenException::tokens(T::NAME, 0, $values, $this->tokens[$this->position - 1], $this); } @@ -1049,8 +1102,7 @@ public function getNameEnum(string $className): ?SqlEnum return $enum; } catch (InvalidEnumValueException $e) { $this->position--; - /** @var list $values */ - $values = $className::getAllowedValues(); + $values = array_values($className::getAllowedValues()); throw InvalidTokenException::tokens(T::NAME, 0, $values, $this->tokens[$this->position - 1], $this); } @@ -1072,8 +1124,7 @@ public function expectNameOrStringEnum(string $className): SqlEnum return $enum; } catch (InvalidEnumValueException $e) { $this->position--; - /** @var list $values */ - $values = $className::getAllowedValues(); + $values = array_values($className::getAllowedValues()); throw InvalidTokenException::tokens(T::NAME | T::STRING, 0, $values, $this->tokens[$this->position - 1], $this); } @@ -1087,11 +1138,16 @@ public function expectNameOrStringEnum(string $className): SqlEnum public function expectMultiNameEnum(string $className): SqlEnum { if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + $token = $this->tokens[$this->position] ?? null; + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } + $start = $this->position; - /** @var list $values */ - $values = $className::getAllowedValues(); + $values = array_values($className::getAllowedValues()); foreach ($values as $value) { $this->position = $start; $keywords = explode(' ', $value); @@ -1307,10 +1363,10 @@ public function validateName(string $entity, string $name): void if (in_array($entity, $trailingWhitespaceNotAllowed, true) && rtrim($name, " \t\r\n") !== $name) { throw new ParserException(ucfirst($entity) . ' name must not contain right side white space.', $this); } - if (isset($this->maxLengths[$entity]) && Str::length($name) > $this->maxLengths[$entity]) { // todo: chars or bytes? - throw new ParserException(ucfirst($entity) . " name must be at most {$this->maxLengths[$entity]} characters long.", $this); + if (isset($this->platform->maxLengths[$entity]) && Str::length($name) > $this->platform->maxLengths[$entity]) { // todo: chars or bytes? + throw new ParserException(ucfirst($entity) . " name must be at most {$this->platform->maxLengths[$entity]} characters long.", $this); } - if ($entity === EntityType::INDEX && strtoupper($name) === 'GEN_CLUST_INDEX') { + if ($entity === EntityType::INDEX && strcasecmp($name, 'GEN_CLUST_INDEX') === 0) { throw new ParserException('GEN_CLUST_INDEX is a reserved name for primary index.', $this); } } @@ -1330,38 +1386,50 @@ public function missingAnyKeyword(string ...$keywords): void public function expectKeyword(?string $keyword = null): string { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null || ($token->type & T::KEYWORD) === 0) { throw InvalidTokenException::tokens(T::KEYWORD, 0, $keyword, $token, $this); } - $value = strtoupper($token->value); - if ($keyword !== null && $value !== $keyword) { + $upper = strtoupper($token->value); + if ($keyword !== null && $upper !== $keyword) { throw InvalidTokenException::tokens(T::KEYWORD, 0, $keyword, $token, $this); } $this->position++; - return $value; + return $upper; } public function getKeyword(?string $keyword = null): ?string { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null || ($token->type & T::KEYWORD) === 0) { return null; } - $value = strtoupper($token->value); - if ($keyword !== null && $value !== $keyword) { + $upper = strtoupper($token->value); + if ($keyword !== null && $upper !== $keyword) { return null; } $this->position++; - return $value; + return $upper; } /** @@ -1369,15 +1437,19 @@ public function getKeyword(?string $keyword = null): ?string */ public function hasKeyword(string $keyword): bool { + $token = $this->tokens[$this->position] ?? null; if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null || ($token->type & T::KEYWORD) === 0) { return false; } - $value = strtoupper($token->value); - if ($value !== $keyword) { + if (strcasecmp($token->value, $keyword) !== 0) { return false; } $this->position++; @@ -1428,10 +1500,16 @@ public function expectAnyKeyword(string ...$keywords): string public function getAnyKeyword(string ...$keywords): ?string { + $token = $this->tokens[$this->position] ?? null; + if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } - $token = $this->tokens[$this->position] ?? null; + if ($token === null || ($token->type & T::KEYWORD) === 0) { return null; } @@ -1470,7 +1548,6 @@ public function hasAnyKeyword(string ...$keywords): bool */ public function expectKeywordEnum(string $className): SqlEnum { - /** @var array $values */ $values = $className::getAllowedValues(); /** @var T $enum */ @@ -1486,7 +1563,6 @@ public function expectKeywordEnum(string $className): SqlEnum */ public function getKeywordEnum(string $className): ?SqlEnum { - /** @var array $values */ $values = $className::getAllowedValues(); $token = $this->getAnyKeyword(...array_values($values)); if ($token === null) { @@ -1507,10 +1583,15 @@ public function getKeywordEnum(string $className): ?SqlEnum public function expectMultiKeywordsEnum(string $className): SqlEnum { if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + $token = $this->tokens[$this->position] ?? null; + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } + $start = $this->position; - /** @var list $values */ $values = $className::getAllowedValues(); foreach ($values as $value) { $this->position = $start; @@ -1539,7 +1620,6 @@ public function expectMultiKeywordsEnum(string $className): SqlEnum public function getMultiKeywordsEnum(string $className): ?SqlEnum { $start = $this->position; - /** @var array $values */ $values = $className::getAllowedValues(); foreach ($values as $value) { $this->position = $start; @@ -1614,7 +1694,7 @@ public function expectUserName(bool $forRole = false): UserName $name = $token->value; // characters, not bytes // todo: encoding - if (mb_strlen($name) > $this->maxLengths[EntityType::USER]) { + if (mb_strlen($name) > $this->platform->maxLengths[EntityType::USER]) { throw new ParserException('Too long user name.', $this); } elseif ($forRole && ($token->type & T::UNQUOTED_NAME) !== 0 && in_array(strtoupper($name), $notAllowed, true)) { throw new ParserException('User name not allowed.', $this); @@ -1623,7 +1703,7 @@ public function expectUserName(bool $forRole = false): UserName $token = $this->get(T::AT_VARIABLE); if ($token !== null) { $host = ltrim($token->value, '@'); - if (strlen($host) > $this->maxLengths[EntityType::HOST]) { + if (strlen($host) > $this->platform->maxLengths[EntityType::HOST]) { throw new ParserException('Too long host name.', $this); } } @@ -1708,13 +1788,24 @@ public function expectStorageEngineName(): StorageEngine public function expectEnd(): void { if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + $token = $this->tokens[$this->position] ?? null; + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } + // pass trailing ; when delimiter is something else while ($this->hasSymbol(';')) { $this->trailingDelimiter .= ';'; if ($this->autoSkip !== 0) { - $this->doAutoSkip(); + // doAutoSkip() inlined + $token = $this->tokens[$this->position] ?? null; + while ($token !== null && ($this->autoSkip & $token->type) !== 0) { + $this->position++; + $token = $this->tokens[$this->position] ?? null; + } } } diff --git a/sources/Parser/exceptions/InvalidTokenException.php b/sources/Parser/exceptions/InvalidTokenException.php index 9926069b..b5024492 100644 --- a/sources/Parser/exceptions/InvalidTokenException.php +++ b/sources/Parser/exceptions/InvalidTokenException.php @@ -10,7 +10,7 @@ namespace SqlFtw\Parser; use Dogma\ExceptionValueFormatter; -use Dogma\Str; +use SqlFtw\Util\Str; use Throwable; use function implode; use function is_array; @@ -20,7 +20,7 @@ class InvalidTokenException extends ParserException { /** - * @param string|list|null $expectedValue + * @param string|array|null $expectedValue */ public static function tokens( int $expectedToken, diff --git a/sources/Parser/exceptions/InvalidValueException.php b/sources/Parser/exceptions/InvalidValueException.php index c50d0945..a3dc10d7 100644 --- a/sources/Parser/exceptions/InvalidValueException.php +++ b/sources/Parser/exceptions/InvalidValueException.php @@ -24,7 +24,7 @@ public function __construct(string $expectedType, TokenList $tokenList, ?Throwab $context = self::formatContext($tokenList); parent::__construct( - sprintf("Invalid value $value->original for type $expectedType at position %d in:\n%s", $tokenList->getPosition(), $context), + sprintf("Invalid value {$value->value} for type {$expectedType} at position %d in:\n%s", $tokenList->getPosition(), $context), $tokenList, $previous ); diff --git a/sources/Parser/exceptions/ParserException.php b/sources/Parser/exceptions/ParserException.php index c285d2d0..bcb8369b 100644 --- a/sources/Parser/exceptions/ParserException.php +++ b/sources/Parser/exceptions/ParserException.php @@ -43,13 +43,13 @@ protected static function formatContext(TokenList $tokenList): string $separator = ($tokenList->getAutoSkip() & TokenType::WHITESPACE) === 0 ? ' ' : ''; $context = '"…' . implode($separator, array_map(static function (Token $token) { - return $token->original ?? $token->value; + return $token->value; // todo: should fetch source value }, array_slice($tokens, 0, $prefix))); if (isset($tokens[$prefix])) { - $context .= '»' . ($tokens[$prefix]->original ?? $tokens[$prefix]->value) . '«'; + $context .= '»' . ($tokens[$prefix]->value) . '«'; // todo: should fetch source value $context .= implode($separator, array_map(static function (Token $token) { - return $token->original ?? $token->value; + return $token->value; // todo: should fetch source value }, array_slice($tokens, $prefix + 1))) . '…"'; } else { $context .= '»«"'; diff --git a/sources/Parser/exceptions/SingleStatementExpectedException.php b/sources/Parser/exceptions/SingleStatementExpectedException.php new file mode 100644 index 00000000..51803f65 --- /dev/null +++ b/sources/Parser/exceptions/SingleStatementExpectedException.php @@ -0,0 +1,22 @@ + $commands + */ + public function __construct(array $commands, ?Throwable $previous = null) + { + $count = count($commands); + + parent::__construct("Single statement was expected, but {$count} statements was parsed.", $previous); + } + +} diff --git a/sources/Platform/ClientSideExtension.php b/sources/Platform/ClientSideExtension.php index b2ae41d7..be7635c9 100644 --- a/sources/Platform/ClientSideExtension.php +++ b/sources/Platform/ClientSideExtension.php @@ -45,4 +45,7 @@ class ClientSideExtension // "UPDATE [tbl2] SET [a] = 1 WHERE [b] = 2" (Doctrine, Dibi) //public const CONVERT_SQUARE_BRACKET_IDENTIFIERS = 16; + // placeholders in places not allowed by MySQL, but used by DBALs and other tools (LIMIT ?, foo IS ? etc.) + public const ALLOW_PLACEHOLDERS_ANYWHERE = 32; + } diff --git a/sources/Platform/Features/Feature.php b/sources/Platform/Features/Feature.php index d50e0376..d8cf1892 100644 --- a/sources/Platform/Features/Feature.php +++ b/sources/Platform/Features/Feature.php @@ -12,12 +12,24 @@ class Feature { + public const ALTER_INSTANCE = 'alter-instance'; + public const ALTER_INSTANCE_2 = 'alter-instance-2'; + public const COLUMN_VISIBILITY = 'column-visibility'; + public const CREATE_ROUTINE_IF_NOT_EXISTS = 'create-routine-if-not-exists'; + public const ENGINE_ATTRIBUTE = 'engine-attributes'; + public const FUNCTIONAL_INDEXES = 'functional-indexes'; + public const GROUP_REPLICATION_CREDENTIALS = 'group-replication-credentials'; public const OPTIMIZER_HINTS = 'optimizer-hints'; // /*+ ... */ public const REQUIRE_TABLE_PRIMARY_KEY_CHECK_GENERATE = 'require-table-primary-key-check-generate'; // >=8.0.32 + public const SCHEMA_ENCRYPTION = 'schema-encryption'; + public const SCHEMA_READ_ONLY = 'schema-read-only'; + public const SECONDARY_ENGINE_ATTRIBUTE = 'secondary-engine-attributes'; // deprecation of old features - public const OLD_NULL_LITERAL = 'old-null-literal'; // \N - public const UNQUOTED_NAMES_CAN_START_WITH_DOLLAR_SIGN = 'unquoted-names-can-start-with-dollar-sign'; // depr. 8.0.32 - public const FULL_IS_VALID_NAME = 'full-is-valid-name'; // depr. 8.0.32 + public const DEPRECATED_FULL_IS_VALID_NAME = 'full-is-valid-name'; // depr. 8.0.32 + public const DEPRECATED_GROUP_BY_ORDERING = 'group-by-ordering'; + public const DEPRECATED_IDENTIFIED_BY_PASSWORD = 'identified-by-password'; + public const DEPRECATED_OLD_NULL_LITERAL = 'old-null-literal'; // \N + public const DEPRECATED_UNQUOTED_NAMES_CAN_START_WITH_DOLLAR_SIGN = 'unquoted-names-can-start-with-dollar-sign'; // depr. 8.0.32 } diff --git a/sources/Platform/Features/MysqlFeatures.php b/sources/Platform/Features/MysqlFeatures.php index 0a6d0e47..057d033e 100644 --- a/sources/Platform/Features/MysqlFeatures.php +++ b/sources/Platform/Features/MysqlFeatures.php @@ -119,9 +119,23 @@ class MysqlFeatures extends FeaturesList /** @var list */ public array $features = [ + [Feature::ALTER_INSTANCE, 50700, self::MAX], + [Feature::ALTER_INSTANCE, 80000, self::MAX], + [Feature::COLUMN_VISIBILITY, 80023, self::MAX], + [Feature::CREATE_ROUTINE_IF_NOT_EXISTS, 80000, self::MAX], + [Feature::ENGINE_ATTRIBUTE, 80021, self::MAX], + [Feature::FUNCTIONAL_INDEXES, 80013, self::MAX], + [Feature::GROUP_REPLICATION_CREDENTIALS, 80021, self::MAX], [Feature::OPTIMIZER_HINTS, self::MIN, self::MAX], - [Feature::OLD_NULL_LITERAL, self::MIN, 79999], - [Feature::UNQUOTED_NAMES_CAN_START_WITH_DOLLAR_SIGN, self::MIN, 80031], + [Feature::SCHEMA_ENCRYPTION, 80016, self::MAX], + [Feature::SCHEMA_READ_ONLY, 80022, self::MAX], + [Feature::SECONDARY_ENGINE_ATTRIBUTE, 80021, self::MAX], + + [Feature::DEPRECATED_GROUP_BY_ORDERING, self::MIN, 50799], + [Feature::DEPRECATED_IDENTIFIED_BY_PASSWORD, self::MIN, 50799], + [Feature::DEPRECATED_OLD_NULL_LITERAL, self::MIN, 79999], + [Feature::DEPRECATED_UNQUOTED_NAMES_CAN_START_WITH_DOLLAR_SIGN, self::MIN, 80031], + ]; /** @var list */ @@ -1308,6 +1322,7 @@ class MysqlFeatures extends FeaturesList [BuiltInFunction::ENCODE, self::MIN, self::MAX, 50700], [BuiltInFunction::ENCRYPT, self::MIN, 80000, 50700], [BuiltInFunction::MD5, self::MIN, self::MAX], + [BuiltInFunction::OLD_PASSWORD, self::MIN, 50704], [BuiltInFunction::PASSWORD, self::MIN, 80001, 50700], [BuiltInFunction::RANDOM_BYTES, 50617, self::MAX], [BuiltInFunction::SHA, self::MIN, self::MAX], diff --git a/sources/Platform/Platform.php b/sources/Platform/Platform.php index cd6788b8..409c5f37 100644 --- a/sources/Platform/Platform.php +++ b/sources/Platform/Platform.php @@ -11,7 +11,6 @@ use LogicException; use SqlFtw\Platform\Features\Feature; -use SqlFtw\Platform\Features\FeaturesList; use SqlFtw\Platform\Features\MysqlFeatures; use SqlFtw\Platform\Naming\NamingStrategy; use SqlFtw\Sql\Command; @@ -22,13 +21,13 @@ use SqlFtw\Sql\Keyword; use SqlFtw\Sql\MysqlVariable; use SqlFtw\Sql\SqlMode; +use function array_combine; use function assert; use function end; use function explode; use function in_array; use function is_string; use function ltrim; -use function strtoupper; use function ucfirst; class Platform @@ -94,31 +93,59 @@ class Platform private Version $version; - private FeaturesList $featuresList; + /** + * @readonly + * @var array + */ + public array $features; - /** @var list */ - private ?array $features = null; + /** + * @readonly + * @var array + */ + public array $reserved; - /** @var list */ - private ?array $reserved = null; + /** + * @readonly + * @var array + */ + public array $nonReserved; - /** @var list */ - private ?array $nonReserved = null; + /** + * @readonly + * @var array + */ + public array $operators; - /** @var list */ - private ?array $operators = null; + /** + * @readonly + * @var array + */ + public array $types; - /** @var list */ - private ?array $types = null; + /** + * @readonly + * @var array + */ + public array $functions; - /** @var list */ - private ?array $functions = null; + /** + * @readonly + * @var array + */ + public array $variables; - /** @var list */ - private ?array $variables = null; + /** + * @readonly + * @var array, class-string> + */ + public array $preparableCommands; - /** @var list> */ - private ?array $preparableCommands = null; + /** + * @readonly + * @var array + */ + public array $maxLengths; /** * @param self::* $name @@ -129,11 +156,47 @@ final private function __construct(string $name, Version $version) $this->version = $version; switch ($name) { case self::MYSQL: - $this->featuresList = new MysqlFeatures(); + $featuresList = new MysqlFeatures(); break; default: throw new LogicException('Only MySQL platform is supported for now.'); } + + $versionId = $this->version->getId(); + + /** @var list $filtered */ + $filtered = $this->filterForVersion($featuresList->features, $versionId); + $this->features = array_combine($filtered, $filtered); + + /** @var list $filtered */ + $filtered = $this->filterForVersion($featuresList->reserved, $versionId); + $this->reserved = array_combine($filtered, $filtered); + + /** @var list $filtered */ + $filtered = $this->filterForVersion($featuresList->nonReserved, $versionId); + $this->nonReserved = array_combine($filtered, $filtered); + + /** @var list $filtered */ + $filtered = $this->filterForVersion($featuresList->operators, $versionId); + $this->operators = array_combine($filtered, $filtered); + + /** @var list $filtered */ + $filtered = $this->filterForVersion($featuresList->types, $versionId); + $this->types = array_combine($filtered, $filtered); + + /** @var list $filtered */ + $filtered = $this->filterForVersion($featuresList->functions, $versionId); + $this->functions = array_combine($filtered, $filtered); + + /** @var list $filtered */ + $filtered = $this->filterForVersion($featuresList->variables, $versionId); + $this->variables = array_combine($filtered, $filtered); + + /** @var list> $filtered */ + $filtered = $this->filterForVersion($featuresList->preparableCommands, $versionId); + $this->preparableCommands = array_combine($filtered, $filtered); + + $this->maxLengths = $featuresList->maxLengths; } /** @@ -228,7 +291,7 @@ public function matches(?string $name, ?int $minVersion = null, ?int $maxVersion public function interpretOptionalComment(string $versionId): bool { - $maria = $versionId !== '' && strtoupper($versionId[0]) === 'M'; + $maria = $versionId !== '' && ($versionId[0] === 'M' || $versionId[0] === 'm'); $versionId = (int) ltrim($versionId, 'Mm'); if ($this->name !== self::MYSQL && $this->name !== self::MARIA) { @@ -286,152 +349,6 @@ public function getNamingStrategy(): NamingStrategy return new $class(); } - // features -------------------------------------------------------------------------------------------------------- - - public function hasFeature(string $feature): bool - { - return in_array($feature, $this->getFeatures(), true); - } - - /** - * @return list - */ - public function getFeatures(): array - { - if ($this->features === null) { - /** @var list $filtered */ - $filtered = $this->filterForVersion($this->featuresList->features, $this->version->getId()); - $this->features = $filtered; - } - - return $this->features; - } - - public function isReserved(string $word): bool - { - $word = strtoupper($word); - - return in_array($word, $this->getReserved(), true); - } - - /** - * @return list - */ - public function getReserved(): array - { - if ($this->reserved === null) { - /** @var list $filtered */ - $filtered = $this->filterForVersion($this->featuresList->reserved, $this->version->getId()); - $this->reserved = $filtered; - } - - return $this->reserved; - } - - public function isKeyword(string $word, int $version): bool - { - $word = strtoupper($word); - - return in_array($word, $this->getReserved(), true) || in_array($word, $this->getNonReserved(), true); - } - - /** - * @return list - */ - public function getNonReserved(): array - { - if ($this->nonReserved === null) { - /** @var list $filtered */ - $filtered = $this->filterForVersion($this->featuresList->nonReserved, $this->version->getId()); - $this->nonReserved = $filtered; - } - - return $this->nonReserved; - } - - /** - * @return list - */ - public function getOperators(): array - { - if ($this->operators === null) { - /** @var list $filtered */ - $filtered = $this->filterForVersion($this->featuresList->operators, $this->version->getId()); - $this->operators = $filtered; - } - - return $this->operators; - } - - public function isType(string $word): bool - { - return in_array($word, $this->getTypes(), true); - } - - /** - * @return list - */ - public function getTypes(): array - { - if ($this->types === null) { - /** @var list $filtered */ - $filtered = $this->filterForVersion($this->featuresList->types, $this->version->getId()); - $this->types = $filtered; - } - - return $this->types; - } - - /** - * @return list - */ - public function getBuiltInFunctions(): array - { - if ($this->functions === null) { - /** @var list $filtered */ - $filtered = $this->filterForVersion($this->featuresList->functions, $this->version->getId()); - $this->functions = $filtered; - } - - return $this->functions; - } - - /** - * @return list - */ - public function getSystemVariables(): array - { - if ($this->variables === null) { - /** @var list $filtered */ - $filtered = $this->filterForVersion($this->featuresList->variables, $this->version->getId()); - $this->variables = $filtered; - } - - return $this->variables; - } - - /** - * @return list> - */ - public function getPreparableCommands(): array - { - if ($this->preparableCommands === null) { - /** @var list> $filtered */ - $filtered = $this->filterForVersion($this->featuresList->preparableCommands, $this->version->getId()); - $this->preparableCommands = $filtered; - } - - return $this->preparableCommands; - } - - /** - * @return array - */ - public function getMaxLengths(): array - { - return $this->featuresList->maxLengths; - } - /** * @template T * @param list $values diff --git a/sources/Resolver/ExpressionResolver.php b/sources/Resolver/ExpressionResolver.php index 280a3ac2..ad64cbbd 100644 --- a/sources/Resolver/ExpressionResolver.php +++ b/sources/Resolver/ExpressionResolver.php @@ -69,7 +69,7 @@ use function is_string; use function method_exists; use function round; -use function strtoupper; +use function strcasecmp; /** * Simplifies or resolves SQL expressions @@ -499,7 +499,7 @@ public function isSimpleSelect(SelectCommand $select): bool $from = $select->getFrom(); while ($from !== null) { // FROM DUAL allowed - if ($from instanceof TableReferenceTable && strtoupper($from->getTable()->getFullName()) === Keyword::DUAL) { + if ($from instanceof TableReferenceTable && strcasecmp($from->getTable()->getFullName(), Keyword::DUAL) === 0) { break; } // todo: check other allowed states (simple subselect...) diff --git a/sources/Resolver/Functions/FunctionsCast.php b/sources/Resolver/Functions/FunctionsCast.php index 811e13bd..627cc08c 100644 --- a/sources/Resolver/Functions/FunctionsCast.php +++ b/sources/Resolver/Functions/FunctionsCast.php @@ -9,12 +9,12 @@ namespace SqlFtw\Resolver\Functions; -use Dogma\Str; use LogicException; use SqlFtw\Resolver\UnresolvableException; use SqlFtw\Sql\Expression\BaseType; use SqlFtw\Sql\Expression\CastType; use SqlFtw\Sql\Expression\Value; +use SqlFtw\Util\Str; use function is_int; use function str_pad; use function substr; diff --git a/sources/Resolver/Functions/FunctionsMatching.php b/sources/Resolver/Functions/FunctionsMatching.php index 2563ec9d..caa33f76 100644 --- a/sources/Resolver/Functions/FunctionsMatching.php +++ b/sources/Resolver/Functions/FunctionsMatching.php @@ -9,8 +9,8 @@ namespace SqlFtw\Resolver\Functions; -use Dogma\Str; use SqlFtw\Sql\Expression\Value; +use SqlFtw\Util\Str; use function preg_match; use function preg_replace; use function soundex; diff --git a/sources/Resolver/Functions/FunctionsString.php b/sources/Resolver/Functions/FunctionsString.php index 0c864516..6e5a3f62 100644 --- a/sources/Resolver/Functions/FunctionsString.php +++ b/sources/Resolver/Functions/FunctionsString.php @@ -10,9 +10,9 @@ namespace SqlFtw\Resolver\Functions; use Dogma\InvalidValueException; -use Dogma\Str; use SqlFtw\Resolver\UnresolvableException; use SqlFtw\Sql\Expression\Value; +use SqlFtw\Util\Str; use function array_reverse; use function array_slice; use function assert; @@ -251,11 +251,11 @@ public function field($find, ...$strings): int return 0; } - /** @var int|string $i */ + /** @var int $i */ foreach ($strings as $i => $string) { $string = $this->cast->toString($string); if ($find === $string) { - return ((int) $i) + 1; + return $i + 1; } } @@ -549,9 +549,8 @@ public function make_set($bits, ...$values): ?string return null; } $result = []; - /** @var int|string $i */ foreach ($values as $i => $value) { - if ($value !== null && ($bits & (2 ** (int) $i)) !== 0) { + if ($value !== null && ($bits & (2 ** $i)) !== 0) { $result[] = $this->cast->toString($value); } } diff --git a/sources/Session/SessionUpdater.php b/sources/Session/SessionUpdater.php index f83732f3..2965e3dc 100644 --- a/sources/Session/SessionUpdater.php +++ b/sources/Session/SessionUpdater.php @@ -9,6 +9,7 @@ namespace SqlFtw\Session; +use LogicException; use SqlFtw\Parser\ParserException; use SqlFtw\Parser\TokenList; use SqlFtw\Resolver\ExpressionHelper; @@ -23,6 +24,7 @@ use SqlFtw\Sql\Ddl\Routine\CreateRoutineCommand; use SqlFtw\Sql\Dml\Query\SelectCommand; use SqlFtw\Sql\Dml\Query\SelectIntoVariables; +use SqlFtw\Sql\Expression\BoolValue; use SqlFtw\Sql\Expression\DefaultLiteral; use SqlFtw\Sql\Expression\NoneLiteral; use SqlFtw\Sql\Expression\Scope; @@ -38,10 +40,14 @@ use SqlFtw\Sql\SqlMode; use SqlFtw\Sql\Statement; use function count; +use function get_class; +use function gettype; use function is_array; use function is_bool; use function is_float; use function is_int; +use function is_object; +use function is_scalar; use function is_string; use function trim; @@ -113,9 +119,11 @@ public function processSet(SetCommand $command, TokenList $tokenList): void $value = MysqlVariable::getDefault($name); } elseif (!ExpressionHelper::isValue($value)) { $value = new UnresolvedExpression($value); + } elseif ($value instanceof BoolValue) { + $value = $value->asBool(); + } elseif ($value !== null && !is_scalar($value)) { + throw new LogicException('Should be scalar at this point: ' . (is_object($value) ? get_class($value) : gettype($value))); } - /** @var UnresolvedExpression|Value|scalar|null $value */ - $value = $value; if ($variable instanceof SystemVariable) { $scope = $variable->getScope(); diff --git a/sources/Sql/Collation.php b/sources/Sql/Collation.php index cb0eeeee..8d6f29d9 100644 --- a/sources/Sql/Collation.php +++ b/sources/Sql/Collation.php @@ -9,7 +9,7 @@ namespace SqlFtw\Sql; -use Dogma\Str; +use SqlFtw\Util\Str; use function array_search; class Collation extends SqlEnum diff --git a/sources/Sql/Dal/Replication/ChangeMasterToCommand.php b/sources/Sql/Dal/Replication/ChangeMasterToCommand.php index 3c9d4c40..857f6aeb 100644 --- a/sources/Sql/Dal/Replication/ChangeMasterToCommand.php +++ b/sources/Sql/Dal/Replication/ChangeMasterToCommand.php @@ -84,7 +84,7 @@ public function getChannel(): ?string public function serialize(Formatter $formatter): string { - $result = "CHANGE MASTER TO \n " . implode(",\n ", array_filter(Arr::mapPairs( + $result = "CHANGE MASTER TO \n " . implode(",\n ", array_filter(Arr::mapPairs( // @phpstan-ignore arrayFilter.strict $this->options, static function (string $option, $value) use ($formatter): ?string { if ($value === null) { diff --git a/sources/Sql/Dal/Replication/ChangeReplicationSourceToCommand.php b/sources/Sql/Dal/Replication/ChangeReplicationSourceToCommand.php index 8508051b..72bd74a7 100644 --- a/sources/Sql/Dal/Replication/ChangeReplicationSourceToCommand.php +++ b/sources/Sql/Dal/Replication/ChangeReplicationSourceToCommand.php @@ -78,7 +78,7 @@ public function getChannel(): ?string public function serialize(Formatter $formatter): string { - $result = "CHANGE REPLICATION SOURCE TO \n " . implode(",\n ", array_filter(Arr::mapPairs( + $result = "CHANGE REPLICATION SOURCE TO \n " . implode(",\n ", array_filter(Arr::mapPairs( // @phpstan-ignore arrayFilter.strict $this->options, static function (string $option, $value) use ($formatter): ?string { if ($value === null) { diff --git a/sources/Sql/Ddl/Table/Constraint/ConstraintList.php b/sources/Sql/Ddl/Table/Constraint/ConstraintList.php index 1fae1132..f6b3b6ca 100644 --- a/sources/Sql/Ddl/Table/Constraint/ConstraintList.php +++ b/sources/Sql/Ddl/Table/Constraint/ConstraintList.php @@ -7,6 +7,8 @@ * For the full copyright and license information read the file 'license.md', distributed with this source code */ +// phpcs:disable Squiz.Functions.MultiLineFunctionDeclaration.ContentAfterBrace + namespace SqlFtw\Sql\Ddl\Table\Constraint; use function array_filter; @@ -74,8 +76,8 @@ public function getDroppedConstraints(): array */ public function getForeignKeys(): array { - /** @var list $result */ - $result = array_filter($this->constraints, static function (ConstraintDefinition $constraint) { + /** @var array $result */ + $result = array_filter($this->constraints, static function (ConstraintDefinition $constraint): bool { // @phpstan-ignore varTag.type return $constraint->getBody() instanceof ForeignKeyDefinition; }); @@ -87,8 +89,8 @@ public function getForeignKeys(): array */ public function getDroppedForeignKeys(): array { - /** @var list $result */ - $result = array_filter($this->droppedConstraints, static function (ConstraintDefinition $constraint) { + /** @var array $result */ + $result = array_filter($this->droppedConstraints, static function (ConstraintDefinition $constraint): bool { // @phpstan-ignore varTag.type return $constraint->getBody() instanceof ForeignKeyDefinition; }); diff --git a/sources/Sql/Ddl/Table/Option/StorageEngine.php b/sources/Sql/Ddl/Table/Option/StorageEngine.php index e782033f..853b330a 100644 --- a/sources/Sql/Ddl/Table/Option/StorageEngine.php +++ b/sources/Sql/Ddl/Table/Option/StorageEngine.php @@ -27,10 +27,11 @@ class StorageEngine implements SqlSerializable public function __construct(string $value) { - if (!isset(self::$map[strtolower($value)])) { - throw new InvalidDefinitionException("Invalid storage engine name: $value."); + $lower = strtolower($value); + if (!isset(self::$map[$lower])) { + throw new InvalidDefinitionException("Invalid storage engine name: {$value}."); } - $this->value = self::$map[strtolower($value)]; + $this->value = self::$map[$lower]; } // standard diff --git a/sources/Sql/Ddl/Table/Option/TableOptionsList.php b/sources/Sql/Ddl/Table/Option/TableOptionsList.php index 6365e655..a4c7e9e9 100644 --- a/sources/Sql/Ddl/Table/Option/TableOptionsList.php +++ b/sources/Sql/Ddl/Table/Option/TableOptionsList.php @@ -141,7 +141,7 @@ public function serialize(Formatter $formatter, string $itemSeparator, string $v return ''; } - return implode($itemSeparator, array_filter(Arr::mapPairs( + return implode($itemSeparator, array_filter(Arr::mapPairs( // @phpstan-ignore arrayFilter.strict $this->options, static function (string $option, $value) use ($formatter, $valueSeparator): ?string { if ($value === null) { diff --git a/sources/Sql/Dml/TableReference/ConditionalJoin.php b/sources/Sql/Dml/TableReference/ConditionalJoin.php new file mode 100644 index 00000000..023ab68f --- /dev/null +++ b/sources/Sql/Dml/TableReference/ConditionalJoin.php @@ -0,0 +1,17 @@ +getPlatform()->isReserved($part) ? '`' . $part . '`' : $part; + $platform = $formatter->getPlatform(); + + return isset($platform->reserved[strtoupper($part)]) ? '`' . $part . '`' : $part; }, explode('.', $this->name)); return ($this->scope !== null ? '@@' . $this->scope->getValue() . '.' : '@@') . implode('.', $parts); diff --git a/sources/Sql/Expression/time/DatetimeLiteral.php b/sources/Sql/Expression/time/DatetimeLiteral.php index b0fb8ccb..ddaf1a25 100644 --- a/sources/Sql/Expression/time/DatetimeLiteral.php +++ b/sources/Sql/Expression/time/DatetimeLiteral.php @@ -43,7 +43,7 @@ public function __construct(string $value) throw new InvalidDefinitionException("Invalid datetime literal format: '$value'."); } - $this->normalized = self::checkAndNormalize((string) $year, (string) $month, (string) $day, (string) $hours, $minutes, $seconds, $fraction, $offsetSign, $offsetHours, $offsetMinutes); + $this->normalized = self::checkAndNormalize($year, $month, $day, $hours, $minutes, $seconds, $fraction, $offsetSign, $offsetHours, $offsetMinutes); $this->value = $value; } diff --git a/sources/Sql/Expression/time/TimestampLiteral.php b/sources/Sql/Expression/time/TimestampLiteral.php index 304007dc..40153ead 100644 --- a/sources/Sql/Expression/time/TimestampLiteral.php +++ b/sources/Sql/Expression/time/TimestampLiteral.php @@ -37,7 +37,7 @@ public function __construct(string $value) throw new InvalidDefinitionException("Invalid timestamp literal format: '$value'."); } - $this->normalized = DatetimeLiteral::checkAndNormalize((string) $year, (string) $month, (string) $day, (string) $hours, $minutes, $seconds, $fraction, $offsetSign, $offsetHours, $offsetMinutes); + $this->normalized = DatetimeLiteral::checkAndNormalize($year, $month, $day, $hours, $minutes, $seconds, $fraction, $offsetSign, $offsetHours, $offsetMinutes); $this->value = $value; } diff --git a/sources/Sql/SqlMode.php b/sources/Sql/SqlMode.php index 2b363806..fc928c5c 100644 --- a/sources/Sql/SqlMode.php +++ b/sources/Sql/SqlMode.php @@ -188,7 +188,7 @@ public static function getFromString(string $string): self $string = trim($string); /** @var list $parts */ $parts = explode(',', strtoupper($string)); - $parts = array_filter($parts); + $parts = array_filter($parts); // @phpstan-ignore arrayFilter.strict try { self::checkValues($parts); } catch (InvalidValueException $e) { diff --git a/sources/Util/Str.php b/sources/Util/Str.php new file mode 100644 index 00000000..81a198ae --- /dev/null +++ b/sources/Util/Str.php @@ -0,0 +1,45 @@ +value) { parent::fail(sprintf('Token value is "%s" (%s) and should be "%s" (%s).', $token->value, gettype($token->value), $value, gettype($value))); } - if ($position !== null && $position !== $token->position) { - parent::fail(sprintf('Token starting position is %s and should be %s.', $token->position, $position)); + if ($position !== null && $position !== $token->start) { + parent::fail(sprintf('Token starting position is %s and should be %s.', $token->start, $position)); } } @@ -95,8 +95,8 @@ public static function invalidToken(Token $token, int $type, string $messageRege parent::fail(sprintf('Token exception message is "%s" and should match "%s".', $message, $messageRegexp)); } } - if ($position !== null && $position !== $token->position) { - parent::fail(sprintf('Token starting position is %s and should be %s.', $token->position, $position)); + if ($position !== null && $position !== $token->start) { + parent::fail(sprintf('Token starting position is %s and should be %s.', $token->start, $position)); } } @@ -186,7 +186,7 @@ public static function parseSerialize( Debugger::dump($results); foreach ($results as $command) { if ($command instanceof InvalidCommand) { - Debugger::dumpException($command->getException()); + Debugger::dumpException($command->getException()); // @phpstan-ignore staticMethod.notFound (WTF?) } } } diff --git a/tests/Mysql/Data/KnownFailures.php b/tests/Mysql/Data/KnownFailures.php index 54614c54..2aa0a51a 100644 --- a/tests/Mysql/Data/KnownFailures.php +++ b/tests/Mysql/Data/KnownFailures.php @@ -1139,7 +1139,7 @@ trait KnownFailures "SET PERSIST test_component.bool_sys_var = OFF;" => Valid::YES, "SELECT @@test_component.str_sys_var_default;" => Valid::YES, "SET GLOBAL test_component.str_sys_var_default=something;" => Valid::YES, - "SET GLOBAL test_component.str_sys_var_default=\"dictionary.txt\";", + "SET GLOBAL test_component.str_sys_var_default=\"dictionary.txt\";" => Valid::YES, "SET GLOBAL test_server_telemetry_traces.callsite_context_keys=';source_file;source_line;;';" => Valid::YES, "SET GLOBAL test_server_telemetry_traces.application_context_keys='client_id;root_id;parent_id;id';" => Valid::YES, diff --git a/tests/Mysql/MysqlTest.php b/tests/Mysql/MysqlTest.php index 2734886a..412d20ab 100644 --- a/tests/Mysql/MysqlTest.php +++ b/tests/Mysql/MysqlTest.php @@ -3,7 +3,6 @@ namespace SqlFtw\Tests\Mysql; use Dogma\Application\Colors; -use Dogma\Str; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; use SplFileInfo; @@ -14,6 +13,7 @@ use SqlFtw\Tests\Mysql\Data\TestSuites; use SqlFtw\Tests\Mysql\Data\VersionTags; use SqlFtw\Tests\ResultRenderer; +use SqlFtw\Util\Str; use function Amp\ParallelFunctions\parallelMap; use function Amp\Promise\wait; use function chdir; diff --git a/tests/Mysql/MysqlTestFilter.php b/tests/Mysql/MysqlTestFilter.php index ffc2e324..e38f9e8c 100644 --- a/tests/Mysql/MysqlTestFilter.php +++ b/tests/Mysql/MysqlTestFilter.php @@ -11,8 +11,8 @@ namespace SqlFtw\Tests\Mysql; -use Dogma\Str; use SqlFtw\Platform\Features\MysqlError; +use SqlFtw\Util\Str; use function array_flip; use function array_splice; use function explode; @@ -71,14 +71,12 @@ public function filter(string $text): string $quotedDelimiter = ';'; while ($i < count($rows)) { $row = $rows[$i]; - if ($row === 'my $escaped_query = <translateErrorCodes($m[1]); + $rows[$i] = '-- error ' . $this->translateErrorCodes($m[1]); // @phpstan-ignore offsetAccess.notFound } elseif (preg_match('~^\s*error ((?:\d|ER_).*)~i', $row, $m) !== 0) { // error code - $rows[$i] = '-- error ' . $this->translateErrorCodes($m[1]); + $rows[$i] = '-- error ' . $this->translateErrorCodes($m[1]); // @phpstan-ignore offsetAccess.notFound } elseif (preg_match('~^--disable_abort_on_error~i', $row, $m) !== 0) { // error code - $rows[$i] = '-- error DISABLED (from "' . $m[0] . '")'; + $rows[$i] = '-- error DISABLED (from "' . $m[0] . '")'; // @phpstan-ignore offsetAccess.notFound } elseif (preg_match('~^--(query_vertical|send) (.*)~i', $row, $m) !== 0) { // query - $rows[$i] = $m[2] . '; -- (from "--' . $m[1] . '")'; + $rows[$i] = $m[2] . '; -- (from "--' . $m[1] . '")'; // @phpstan-ignore offsetAccess.notFound, offsetAccess.notFound } elseif (preg_match('~^\s*(query_vertical|send)(?:\s+(.*)|$)~i', $row, $m) !== 0) { // query - $rows[$i] = ($m[2] ?? '') . ' -- (from "' . $m[1] . '")'; + $rows[$i] = ($m[2] ?? '') . ' -- (from "' . $m[1] . '")'; // @phpstan-ignore offsetAccess.notFound } elseif (preg_match('~^--disable_testcase~', $row) !== 0) { // skipped $j = $i + 1; @@ -161,13 +159,13 @@ public function filter(string $text): string if (preg_match('~^\s*if\s*\(!?`(select.*)~i', $row, $m) !== 0) { // processing selects in conditions if (preg_match('~`\)\s*$~', $row) !== 0) { - $rows[$i] = substr($m[1], 0, -2) . ' -- XB5'; + $rows[$i] = substr($m[1], 0, -2) . ' -- XB5'; // @phpstan-ignore offsetAccess.notFound } else { $j = $i; - $rows[$i] = $m[1] . ' -- XB6'; + $rows[$i] = $m[1] . ' -- XB6'; // @phpstan-ignore offsetAccess.notFound while ($j < count($rows)) { if (preg_match('~(.*)`\)\s*{?$~', $rows[$j], $m) !== 0) { - $rows[$j] = $m[1] . $delimiter . ' -- XB7'; + $rows[$j] = $m[1] . $delimiter . ' -- XB7'; // @phpstan-ignore offsetAccess.notFound break; } $j++; diff --git a/tests/Mysql/MysqlTestJob.php b/tests/Mysql/MysqlTestJob.php index 9f9eaf96..fd217e64 100644 --- a/tests/Mysql/MysqlTestJob.php +++ b/tests/Mysql/MysqlTestJob.php @@ -1,16 +1,16 @@ filter($sql); + // phpcs:disable SlevomatCodingStandard.Functions.RequireSingleLineCall.RequiredSingleLineCall $platform = Platform::get(Platform::MYSQL, $version); $config = new ParserConfig( $platform, @@ -242,9 +243,13 @@ private function checkSerialisation(Command $command, Formatter $formatter, Sess public function normalizeOriginalSql(TokenList $tokenList, bool $debug = false): array { $original = $tokenList->map(static function (Token $token): Token { - return ($token->type & TokenType::COMMENT) !== 0 - ? new Token(TokenType::WHITESPACE, $token->position, $token->row, ' ') - : $token; + if (($token->type & TokenType::COMMENT) !== 0) { + $t = new Token; $t->type = TokenType::WHITESPACE; $t->start = $token->start; $t->value = ' '; + + return $t; + } else { + return $token; + } })->serialize(); $original = trim($original); diff --git a/tests/Mysql/Result.php b/tests/Mysql/Result.php index c377655d..556f0043 100644 --- a/tests/Mysql/Result.php +++ b/tests/Mysql/Result.php @@ -2,7 +2,6 @@ namespace SqlFtw\Tests\Mysql; -use SqlFtw\Parser\TokenList; use SqlFtw\Sql\Command; use SqlFtw\Sql\SqlMode; diff --git a/tests/Mysql/test.php b/tests/Mysql/test.php index b53ddfd3..cdb8f8f1 100644 --- a/tests/Mysql/test.php +++ b/tests/Mysql/test.php @@ -5,13 +5,17 @@ use Dogma\Application\Colors; use Dogma\Debug\Dumper; use function class_exists; +use function dirname; use function in_array; -require_once __DIR__ . '/../../vendor/autoload.php'; -if (class_exists(Dumper::class)) { - require_once __DIR__ . '/../debugger.php'; +if (!class_exists(Dumper::class)) { + require_once dirname(__DIR__, 2) . '/vendor/dogma/dogma-debug/shortcuts.php'; } +require_once __DIR__ . '/../../vendor/autoload.php'; +require_once __DIR__ . '/../../vendor/dogma/dogma-debug/shortcuts.php'; +require_once __DIR__ . '/../debugger.php'; + rd($argv); $help = in_array('help', $argv, true); diff --git a/tests/Parser/Lexer.comments.phpt b/tests/Parser/Lexer.comments.phpt index 8c74a359..53fb5740 100644 --- a/tests/Parser/Lexer.comments.phpt +++ b/tests/Parser/Lexer.comments.phpt @@ -68,6 +68,18 @@ Assert::token($tokens[0], T::WHITESPACE, ' ', 0); Assert::token($tokens[1], T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, "-- comment\n", 1); Assert::token($tokens[2], T::WHITESPACE, " ", 12); +$tokens = Assert::tokens(" -- comment\n-- comment2\n-- comment3 ", 4); +Assert::token($tokens[0], T::WHITESPACE, ' ', 0); +Assert::token($tokens[1], T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, "-- comment\n", 1); +Assert::token($tokens[2], T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, "-- comment2\n", 12); +Assert::token($tokens[3], T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, "-- comment3 ", 24); + +$tokens = Assert::tokens(" -- comment\n--\n-- comment3 ", 4); +Assert::token($tokens[0], T::WHITESPACE, ' ', 0); +Assert::token($tokens[1], T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, "-- comment\n", 1); +Assert::token($tokens[2], T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, "--\n", 12); +Assert::token($tokens[3], T::COMMENT | T::DOUBLE_HYPHEN_COMMENT, "-- comment3 ", 15); + // DOUBLE_SLASH_COMMENT $tokens = Assert::tokens(' // comment', 2); Assert::token($tokens[0], T::WHITESPACE, ' ', 0); diff --git a/tests/Parser/Lexer.numbers.phpt b/tests/Parser/Lexer.numbers.phpt index 5f689f40..072604e4 100644 --- a/tests/Parser/Lexer.numbers.phpt +++ b/tests/Parser/Lexer.numbers.phpt @@ -20,7 +20,7 @@ Assert::token($tokens[2], T::WHITESPACE, ' ', 4); $tokens = Assert::tokens(' +123 ', 3); Assert::token($tokens[0], T::WHITESPACE, ' ', 0); -Assert::token($tokens[1], T::VALUE | T::NUMBER | T::INT, '+123', 1); +Assert::token($tokens[1], T::VALUE | T::NUMBER | T::INT, '123', 1); Assert::token($tokens[2], T::WHITESPACE, ' ', 5); $tokens = Assert::tokens(' -123 ', 3); diff --git a/tests/Parser/TokenList.phpt b/tests/Parser/TokenList.phpt index fc15e273..c1504002 100644 --- a/tests/Parser/TokenList.phpt +++ b/tests/Parser/TokenList.phpt @@ -1,5 +1,8 @@ type = TokenType::WHITESPACE; $ws->start = 0; $ws->value = 'ws'; +$comment = new Token; $comment->type = TokenType::COMMENT; $comment->start = 1; $comment->value = 'comment'; +$value = new Token; $value->type = TokenType::VALUE; $value->start = 2; $value->value = 'value'; +$name = new Token; $name->type = TokenType::NAME; $name->start = 2; $name->value = 'name'; -$tokenList = new TokenList([$ws, $comment, $value, $ws, $comment, $name, $ws, $comment], $platform, $session); +$source = '----------------------------------------------------'; +$tokenList = new TokenList($source, [$ws, $comment, $value, $ws, $comment, $name, $ws, $comment], $platform, $session); $tokenList->setAutoSkip(TokenType::WHITESPACE | TokenType::COMMENT); getLast: diff --git a/tests/ParserHelper.php b/tests/ParserHelper.php index 69136160..1ff45d41 100644 --- a/tests/ParserHelper.php +++ b/tests/ParserHelper.php @@ -9,7 +9,6 @@ namespace SqlFtw\Tests; -use SqlFtw\Parser\Lexer; use SqlFtw\Parser\Parser; use SqlFtw\Parser\ParserConfig; use SqlFtw\Platform\ClientSideExtension; diff --git a/tests/ResultRenderer.php b/tests/ResultRenderer.php index 4f4afe67..8c9cda60 100644 --- a/tests/ResultRenderer.php +++ b/tests/ResultRenderer.php @@ -6,14 +6,13 @@ use Dogma\Debug\Debugger; use Dogma\Debug\Dumper; use Dogma\Debug\Units; -use Dogma\Str; use SqlFtw\Formatter\Formatter; use SqlFtw\Parser\InvalidCommand; -use SqlFtw\Parser\TokenList; use SqlFtw\Sql\Command; use SqlFtw\Sql\SqlMode; use SqlFtw\Tests\Mysql\MysqlTestJob; use SqlFtw\Tests\Mysql\Result; +use SqlFtw\Util\Str; use function count; use function rd; use function rdf; @@ -196,7 +195,7 @@ public function renderSerialisationErrors(array $serialisationErrors): void { $job = new MysqlTestJob(); foreach ($serialisationErrors as $path => $serialisationError) { - foreach ($serialisationError as [$command, $tokenList, $mode]) { + foreach ($serialisationError as [$command, $mode]) { $this->renderTestPath($path); $this->renderSerialisationError($command, $mode, $job); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index bf4e9967..0d597e66 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -9,14 +9,15 @@ use function header; use const PHP_SAPI; +if (!class_exists(Dumper::class)) { + require_once dirname(__DIR__) . '/vendor/dogma/dogma-debug/shortcuts.php'; +} + require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../vendor/nette/tester/src/bootstrap.php'; require_once __DIR__ . '/ParserHelper.php'; require_once __DIR__ . '/Assert.php'; - -if (class_exists(Dumper::class)) { - require_once __DIR__ . '/debugger.php'; -} +require_once __DIR__ . '/debugger.php'; // phpcs:ignore SlevomatCodingStandard.Variables.DisallowSuperGlobalVariable if (!empty($_SERVER['argv'])) { // @phpstan-ignore-line ❤ empty() diff --git a/tests/debugger.php b/tests/debugger.php index 7279dde0..55532489 100644 --- a/tests/debugger.php +++ b/tests/debugger.php @@ -70,10 +70,9 @@ Dumper::$escapeWhiteSpace = $oldEscapeWhiteSpace; $type = implode('|', TokenType::getByValue($token->type)->getConstantNames()); - $orig = $token->original !== null && $token->original !== $token->value ? ' / ' . Dumper::value($token->original) : ''; - return Dumper::class(get_class($token)) . Dumper::bracket('(') . $value . $orig . ' / ' - . Dumper::value2($type) . ' ' . Dumper::info('at row') . ' ' . $token->row + return Dumper::class(get_class($token)) . Dumper::bracket('(') . $value . ' / ' + . Dumper::value2($type) . ' ' . Dumper::info('at position') . ' ' . $token->start . Dumper::bracket(')') . Dumper::objectInfo($token); }; Dumper::$objectFormatters[Token::class] = $tokenFormatter;