From 4216402e9759307f267a34c756864a979d533f91 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 11 Mar 2021 11:58:04 +0100 Subject: [PATCH] Add WITH support --- phpstan-baseline.neon | 15 +- src/Components/OptionsArray.php | 7 +- src/Components/WithKeyword.php | 65 +++++++++ src/Parser.php | 1 + src/Statements/WithStatement.php | 214 +++++++++++++++++++++++++++++ src/TokensList.php | 6 +- tests/Parser/WithStatementTest.php | 115 ++++++++++++++++ 7 files changed, 411 insertions(+), 12 deletions(-) create mode 100644 src/Components/WithKeyword.php create mode 100644 src/Statements/WithStatement.php create mode 100644 tests/Parser/WithStatementTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index c9cf547b8..097cda816 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -715,11 +715,6 @@ parameters: count: 1 path: src/Components/LockExpression.php - - - message: "#^Cannot access property \\$expr on PhpMyAdmin\\\\SqlParser\\\\Components\\\\Expression\\|null\\.$#" - count: 1 - path: src/Components/OptionsArray.php - - message: "#^Method PhpMyAdmin\\\\SqlParser\\\\Components\\\\OptionsArray\\:\\:__construct\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" count: 1 @@ -1520,6 +1515,11 @@ parameters: count: 1 path: src/Statements/UpdateStatement.php + - + message: "#^Method PhpMyAdmin\\\\SqlParser\\\\Statements\\\\WithStatement\\:\\:parse\\(\\) has no return typehint specified\\.$#" + count: 1 + path: src/Statements/WithStatement.php + - message: "#^Class PhpMyAdmin\\\\SqlParser\\\\TokensList implements generic interface ArrayAccess but does not specify its types\\: TKey, TValue$#" count: 1 @@ -2130,6 +2130,11 @@ parameters: count: 1 path: tests/Parser/UpdateStatementTest.php + - + message: "#^Parameter \\#1 \\$component of static method PhpMyAdmin\\\\SqlParser\\\\Components\\\\WithKeyword\\:\\:build\\(\\) expects PhpMyAdmin\\\\SqlParser\\\\Components\\\\WithKeyword, stdClass given\\.$#" + count: 1 + path: tests/Parser/WithStatementTest.php + - message: "#^Access to an undefined property PhpMyAdmin\\\\SqlParser\\\\Exceptions\\\\LexerException\\|PhpMyAdmin\\\\SqlParser\\\\Exceptions\\\\ParserException\\:\\:\\$ch\\.$#" count: 1 diff --git a/src/Components/OptionsArray.php b/src/Components/OptionsArray.php index 3bec65ae8..9bace82bc 100644 --- a/src/Components/OptionsArray.php +++ b/src/Components/OptionsArray.php @@ -220,8 +220,11 @@ public static function parse(Parser $parser, TokensList $list, array $options = $list, empty($lastOption[2]) ? [] : $lastOption[2] ); - $ret->options[$lastOptionId]['value'] - = $ret->options[$lastOptionId]['expr']->expr; + if ($ret->options[$lastOptionId]['expr'] !== null) { + $ret->options[$lastOptionId]['value'] + = $ret->options[$lastOptionId]['expr']->expr; + } + $lastOption = null; $state = 0; } else { diff --git a/src/Components/WithKeyword.php b/src/Components/WithKeyword.php new file mode 100644 index 000000000..87ca269a8 --- /dev/null +++ b/src/Components/WithKeyword.php @@ -0,0 +1,65 @@ +name = $name; + } + + /** + * @param WithKeyword $component + * @param mixed[] $options + * + * @return string + */ + public static function build($component, array $options = []) + { + if (! $component instanceof WithKeyword) { + throw new RuntimeException('Can not build a component that is not a WithKeyword'); + } + + if (! isset($component->statement)) { + throw new RuntimeException('No statement inside WITH'); + } + + $str = $component->name; + + if ($component->columns) { + $str .= ArrayObj::build($component->columns); + } + + $str .= ' AS ('; + + foreach ($component->statement->statements as $statement) { + $str .= $statement->build(); + } + + $str .= ')'; + + return $str; + } +} diff --git a/src/Parser.php b/src/Parser.php index 0dc3d3065..7fe27d748 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -73,6 +73,7 @@ class Parser extends Core 'REPLACE' => 'PhpMyAdmin\\SqlParser\\Statements\\ReplaceStatement', 'SELECT' => 'PhpMyAdmin\\SqlParser\\Statements\\SelectStatement', 'UPDATE' => 'PhpMyAdmin\\SqlParser\\Statements\\UpdateStatement', + 'WITH' => 'PhpMyAdmin\\SqlParser\\Statements\\WithStatement', // Prepared Statements. // https://dev.mysql.com/doc/refman/5.7/en/sql-syntax-prepared-statements.html diff --git a/src/Statements/WithStatement.php b/src/Statements/WithStatement.php new file mode 100644 index 000000000..92cfb79b0 --- /dev/null +++ b/src/Statements/WithStatement.php @@ -0,0 +1,214 @@ + 1]; + + /** + * The clauses of this statement, in order. + * + * @see Statement::$CLAUSES + * + * @var mixed[] + */ + public static $CLAUSES = [ + 'WITH' => [ + 'WITH', + 2, + ], + // Used for options. + '_OPTIONS' => [ + '_OPTIONS', + 1, + ], + 'AS' => [ + 'AS', + 2, + ], + ]; + + /** @var WithKeyword[] */ + public $withers = []; + + /** + * @param Parser $parser the instance that requests parsing + * @param TokensList $list the list of tokens to be parsed + */ + public function parse(Parser $parser, TokensList $list) + { + ++$list->idx; // Skipping `WITH`. + + // parse any options if provided + $this->options = OptionsArray::parse($parser, $list, static::$OPTIONS); + ++$list->idx; + + /** + * The state of the parser. + * + * Below are the states of the parser. + * + * 0 ---------------- [ name ] -----------------> 1 + * 1 -------------- [( columns )] AS ----------------> 2 + * 2 ------------------ [ , ] --------------------> 0 + * + * @var int + */ + $state = 0; + $wither = null; + + for (; $list->idx < $list->count; ++$list->idx) { + /** + * Token parsed at this moment. + * + * @var Token + */ + $token = $list->tokens[$list->idx]; + + // Skipping whitespaces and comments. + if ($token->type === Token::TYPE_WHITESPACE || $token->type === Token::TYPE_COMMENT) { + continue; + } + + if ($token->type === Token::TYPE_NONE) { + $wither = $token->value; + $this->withers[$wither] = new WithKeyword($wither); + $state = 1; + continue; + } + + if ($state === 1) { + if ($token->value === '(') { + $this->withers[$wither]->columns = Array2d::parse($parser, $list); + continue; + } + + if ($token->keyword === 'AS') { + ++$list->idx; + $state = 2; + continue; + } + } elseif ($state === 2) { + if ($token->value === '(') { + ++$list->idx; + $subList = $this->getSubTokenList($list); + if ($subList instanceof ParserException) { + $parser->errors[] = $subList; + continue; + } + + $subParser = new Parser($subList); + + if (count($subParser->errors)) { + foreach ($subParser->errors as $error) { + $parser->errors[] = $error; + } + } + + $this->withers[$wither]->statement = $subParser; + continue; + } + + // There's another WITH expression to parse, go back to state=0 + if ($token->value === ',') { + $list->idx++; + $state = 0; + continue; + } + + // No more WITH expressions, we're done with this statement + break; + } + } + + --$list->idx; + } + + /** + * {@inheritdoc} + */ + public function build() + { + $str = 'WITH '; + + foreach ($this->withers as $wither) { + $str .= $str === 'WITH ' ? '' : ', '; + $str .= WithKeyword::build($wither); + } + + return $str; + } + + /** + * Get tokens within the WITH expression to use them in another parser + * + * @return ParserException|TokensList + */ + private function getSubTokenList(TokensList $list) + { + $idx = $list->idx; + /** @var Token $token */ + $token = $list->tokens[$list->idx]; + $openParenthesis = 0; + + while ($list->idx < $list->count) { + if ($token->value === '(') { + ++$openParenthesis; + } elseif ($token->value === ')') { + if (--$openParenthesis === -1) { + break; + } + } + + ++$list->idx; + if (! isset($list->tokens[$list->idx])) { + break; + } + + $token = $list->tokens[$list->idx]; + } + + // performance improvement: return the error to avoid a try/catch in the loop + if ($list->idx === $list->count) { + --$list->idx; + + return new ParserException( + Translator::gettext('A closing bracket was expected.'), + $token + ); + } + + $length = $list->idx - $idx; + + return new TokensList(array_slice($list->tokens, $idx, $length), $length); + } +} diff --git a/src/TokensList.php b/src/TokensList.php index 464dcfa5f..486e3aa6f 100644 --- a/src/TokensList.php +++ b/src/TokensList.php @@ -50,11 +50,7 @@ public function __construct(array $tokens = [], $count = -1) } $this->tokens = $tokens; - if ($count !== -1) { - return; - } - - $this->count = count($tokens); + $this->count = $count === -1 ? count($tokens) : $count; } /** diff --git a/tests/Parser/WithStatementTest.php b/tests/Parser/WithStatementTest.php new file mode 100644 index 000000000..e6b0764c4 --- /dev/null +++ b/tests/Parser/WithStatementTest.php @@ -0,0 +1,115 @@ +getErrorsAsArray($lexer); + $this->assertCount(0, $lexerErrors); + $parser = new Parser($lexer->list); + $parserErrors = $this->getErrorsAsArray($parser); + $this->assertCount(0, $parserErrors); + $this->assertCount(2, $parser->statements); + + // phpcs:disable Generic.Files.LineLength.TooLong + $expected = <<assertEquals($expected, $parser->statements[0]->build()); + $this->assertEquals('SELECT * FROM categories', $parser->statements[1]->build()); + } + + public function testWithHasErrors(): void + { + $sql = <<getErrorsAsArray($lexer); + $this->assertCount(0, $lexerErrors); + $parser = new Parser($lexer->list); + $parserErrors = $this->getErrorsAsArray($parser); + $this->assertCount(2, $parserErrors); + } + + public function testWithEmbedParenthesis(): void + { + $sql = <<getErrorsAsArray($lexer); + $this->assertCount(0, $lexerErrors); + $parser = new Parser($lexer->list); + $parserErrors = $this->getErrorsAsArray($parser); + $this->assertCount(0, $parserErrors); + + // phpcs:disable Generic.Files.LineLength.TooLong + $expected = <<assertEquals($expected, $parser->statements[0]->build()); + } + + public function testWithHasUnclosedParenthesis(): void + { + $sql = <<getErrorsAsArray($lexer); + $this->assertCount(0, $lexerErrors); + $parser = new Parser($lexer->list); + $parserErrors = $this->getErrorsAsArray($parser); + $this->assertEquals($parserErrors[0][0], 'A closing bracket was expected.'); + } + + public function testBuildWrongWithKeyword(): void + { + $this->expectExceptionMessage('Can not build a component that is not a WithKeyword'); + WithKeyword::build(new stdClass()); + } + + public function testBuildBadWithKeyword(): void + { + $this->expectExceptionMessage('No statement inside WITH'); + WithKeyword::build(new WithKeyword('test')); + } +}