Skip to content

Commit

Permalink
Merge #334 - Fix #165, Fix: #331 - Add WITH support
Browse files Browse the repository at this point in the history
Fixes: #165
Fixes: #331

Signed-off-by: William Desportes <[email protected]>
  • Loading branch information
williamdes committed May 16, 2021
2 parents 8d8336d + 4216402 commit 1b330ca
Show file tree
Hide file tree
Showing 7 changed files with 411 additions and 12 deletions.
15 changes: 10 additions & 5 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions src/Components/OptionsArray.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions src/Components/WithKeyword.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php
/**
* `WITH` keyword builder.
*/

declare(strict_types=1);

namespace PhpMyAdmin\SqlParser\Components;

use PhpMyAdmin\SqlParser\Component;
use PhpMyAdmin\SqlParser\Parser;
use RuntimeException;

/**
* `WITH` keyword builder.
*/
final class WithKeyword extends Component
{
/** @var string */
public $name;

/** @var ArrayObj[] */
public $columns = [];

/** @var Parser */
public $statement;

public function __construct(string $name)
{
$this->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;
}
}
1 change: 1 addition & 0 deletions src/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
214 changes: 214 additions & 0 deletions src/Statements/WithStatement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<?php
/**
* `WITH` statement.
*/

declare(strict_types=1);

namespace PhpMyAdmin\SqlParser\Statements;

use PhpMyAdmin\SqlParser\Components\Array2d;
use PhpMyAdmin\SqlParser\Components\OptionsArray;
use PhpMyAdmin\SqlParser\Components\WithKeyword;
use PhpMyAdmin\SqlParser\Exceptions\ParserException;
use PhpMyAdmin\SqlParser\Parser;
use PhpMyAdmin\SqlParser\Statement;
use PhpMyAdmin\SqlParser\Token;
use PhpMyAdmin\SqlParser\TokensList;
use PhpMyAdmin\SqlParser\Translator;

use function array_slice;
use function count;

/**
* `WITH` statement.
* WITH [RECURSIVE] query_name [ (column_name [,...]) ] AS (SELECT ...) [, ...]
*/
final class WithStatement extends Statement
{
/**
* Options for `WITH` statements and their slot ID.
*
* @var mixed[]
*/
public static $OPTIONS = ['RECURSIVE' => 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);
}
}
6 changes: 1 addition & 5 deletions src/TokensList.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading

0 comments on commit 1b330ca

Please sign in to comment.