Skip to content

Add WITH support #334

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about LogicException ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LogicException are more often used outside of running code, at compile time, here as we would hit that during the "runtime" it's more appropriate. See also https://stackoverflow.com/questions/5586404/logicexception-vs-runtimeexception.

I hesitated here, and I even wanted to pick an exception of the package but ParseException wasn't a good fit.

}

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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe check that statement->statements is an iterable, on line 45 you just check that it's isset

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As component->statement should be typed to Parser and that Parser has a public $statements = []; we're fine :).

$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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You change the behavior, are you sure ? 😃

Before $this->count was equal to 0 as we return earlier

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, there was a bug in the parser actually. count here should always be the value of the array's length.

}

/**
Expand Down
Loading