Skip to content

Commit 4216402

Browse files
soyukawilliamdes
authored andcommitted
Add WITH support
1 parent 8d8336d commit 4216402

File tree

7 files changed

+411
-12
lines changed

7 files changed

+411
-12
lines changed

phpstan-baseline.neon

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -715,11 +715,6 @@ parameters:
715715
count: 1
716716
path: src/Components/LockExpression.php
717717

718-
-
719-
message: "#^Cannot access property \\$expr on PhpMyAdmin\\\\SqlParser\\\\Components\\\\Expression\\|null\\.$#"
720-
count: 1
721-
path: src/Components/OptionsArray.php
722-
723718
-
724719
message: "#^Method PhpMyAdmin\\\\SqlParser\\\\Components\\\\OptionsArray\\:\\:__construct\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#"
725720
count: 1
@@ -1520,6 +1515,11 @@ parameters:
15201515
count: 1
15211516
path: src/Statements/UpdateStatement.php
15221517

1518+
-
1519+
message: "#^Method PhpMyAdmin\\\\SqlParser\\\\Statements\\\\WithStatement\\:\\:parse\\(\\) has no return typehint specified\\.$#"
1520+
count: 1
1521+
path: src/Statements/WithStatement.php
1522+
15231523
-
15241524
message: "#^Class PhpMyAdmin\\\\SqlParser\\\\TokensList implements generic interface ArrayAccess but does not specify its types\\: TKey, TValue$#"
15251525
count: 1
@@ -2130,6 +2130,11 @@ parameters:
21302130
count: 1
21312131
path: tests/Parser/UpdateStatementTest.php
21322132

2133+
-
2134+
message: "#^Parameter \\#1 \\$component of static method PhpMyAdmin\\\\SqlParser\\\\Components\\\\WithKeyword\\:\\:build\\(\\) expects PhpMyAdmin\\\\SqlParser\\\\Components\\\\WithKeyword, stdClass given\\.$#"
2135+
count: 1
2136+
path: tests/Parser/WithStatementTest.php
2137+
21332138
-
21342139
message: "#^Access to an undefined property PhpMyAdmin\\\\SqlParser\\\\Exceptions\\\\LexerException\\|PhpMyAdmin\\\\SqlParser\\\\Exceptions\\\\ParserException\\:\\:\\$ch\\.$#"
21352140
count: 1

src/Components/OptionsArray.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,11 @@ public static function parse(Parser $parser, TokensList $list, array $options =
220220
$list,
221221
empty($lastOption[2]) ? [] : $lastOption[2]
222222
);
223-
$ret->options[$lastOptionId]['value']
224-
= $ret->options[$lastOptionId]['expr']->expr;
223+
if ($ret->options[$lastOptionId]['expr'] !== null) {
224+
$ret->options[$lastOptionId]['value']
225+
= $ret->options[$lastOptionId]['expr']->expr;
226+
}
227+
225228
$lastOption = null;
226229
$state = 0;
227230
} else {

src/Components/WithKeyword.php

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<?php
2+
/**
3+
* `WITH` keyword builder.
4+
*/
5+
6+
declare(strict_types=1);
7+
8+
namespace PhpMyAdmin\SqlParser\Components;
9+
10+
use PhpMyAdmin\SqlParser\Component;
11+
use PhpMyAdmin\SqlParser\Parser;
12+
use RuntimeException;
13+
14+
/**
15+
* `WITH` keyword builder.
16+
*/
17+
final class WithKeyword extends Component
18+
{
19+
/** @var string */
20+
public $name;
21+
22+
/** @var ArrayObj[] */
23+
public $columns = [];
24+
25+
/** @var Parser */
26+
public $statement;
27+
28+
public function __construct(string $name)
29+
{
30+
$this->name = $name;
31+
}
32+
33+
/**
34+
* @param WithKeyword $component
35+
* @param mixed[] $options
36+
*
37+
* @return string
38+
*/
39+
public static function build($component, array $options = [])
40+
{
41+
if (! $component instanceof WithKeyword) {
42+
throw new RuntimeException('Can not build a component that is not a WithKeyword');
43+
}
44+
45+
if (! isset($component->statement)) {
46+
throw new RuntimeException('No statement inside WITH');
47+
}
48+
49+
$str = $component->name;
50+
51+
if ($component->columns) {
52+
$str .= ArrayObj::build($component->columns);
53+
}
54+
55+
$str .= ' AS (';
56+
57+
foreach ($component->statement->statements as $statement) {
58+
$str .= $statement->build();
59+
}
60+
61+
$str .= ')';
62+
63+
return $str;
64+
}
65+
}

src/Parser.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ class Parser extends Core
7373
'REPLACE' => 'PhpMyAdmin\\SqlParser\\Statements\\ReplaceStatement',
7474
'SELECT' => 'PhpMyAdmin\\SqlParser\\Statements\\SelectStatement',
7575
'UPDATE' => 'PhpMyAdmin\\SqlParser\\Statements\\UpdateStatement',
76+
'WITH' => 'PhpMyAdmin\\SqlParser\\Statements\\WithStatement',
7677

7778
// Prepared Statements.
7879
// https://dev.mysql.com/doc/refman/5.7/en/sql-syntax-prepared-statements.html

src/Statements/WithStatement.php

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
<?php
2+
/**
3+
* `WITH` statement.
4+
*/
5+
6+
declare(strict_types=1);
7+
8+
namespace PhpMyAdmin\SqlParser\Statements;
9+
10+
use PhpMyAdmin\SqlParser\Components\Array2d;
11+
use PhpMyAdmin\SqlParser\Components\OptionsArray;
12+
use PhpMyAdmin\SqlParser\Components\WithKeyword;
13+
use PhpMyAdmin\SqlParser\Exceptions\ParserException;
14+
use PhpMyAdmin\SqlParser\Parser;
15+
use PhpMyAdmin\SqlParser\Statement;
16+
use PhpMyAdmin\SqlParser\Token;
17+
use PhpMyAdmin\SqlParser\TokensList;
18+
use PhpMyAdmin\SqlParser\Translator;
19+
20+
use function array_slice;
21+
use function count;
22+
23+
/**
24+
* `WITH` statement.
25+
26+
* WITH [RECURSIVE] query_name [ (column_name [,...]) ] AS (SELECT ...) [, ...]
27+
*/
28+
final class WithStatement extends Statement
29+
{
30+
/**
31+
* Options for `WITH` statements and their slot ID.
32+
*
33+
* @var mixed[]
34+
*/
35+
public static $OPTIONS = ['RECURSIVE' => 1];
36+
37+
/**
38+
* The clauses of this statement, in order.
39+
*
40+
* @see Statement::$CLAUSES
41+
*
42+
* @var mixed[]
43+
*/
44+
public static $CLAUSES = [
45+
'WITH' => [
46+
'WITH',
47+
2,
48+
],
49+
// Used for options.
50+
'_OPTIONS' => [
51+
'_OPTIONS',
52+
1,
53+
],
54+
'AS' => [
55+
'AS',
56+
2,
57+
],
58+
];
59+
60+
/** @var WithKeyword[] */
61+
public $withers = [];
62+
63+
/**
64+
* @param Parser $parser the instance that requests parsing
65+
* @param TokensList $list the list of tokens to be parsed
66+
*/
67+
public function parse(Parser $parser, TokensList $list)
68+
{
69+
++$list->idx; // Skipping `WITH`.
70+
71+
// parse any options if provided
72+
$this->options = OptionsArray::parse($parser, $list, static::$OPTIONS);
73+
++$list->idx;
74+
75+
/**
76+
* The state of the parser.
77+
*
78+
* Below are the states of the parser.
79+
*
80+
* 0 ---------------- [ name ] -----------------> 1
81+
* 1 -------------- [( columns )] AS ----------------> 2
82+
* 2 ------------------ [ , ] --------------------> 0
83+
*
84+
* @var int
85+
*/
86+
$state = 0;
87+
$wither = null;
88+
89+
for (; $list->idx < $list->count; ++$list->idx) {
90+
/**
91+
* Token parsed at this moment.
92+
*
93+
* @var Token
94+
*/
95+
$token = $list->tokens[$list->idx];
96+
97+
// Skipping whitespaces and comments.
98+
if ($token->type === Token::TYPE_WHITESPACE || $token->type === Token::TYPE_COMMENT) {
99+
continue;
100+
}
101+
102+
if ($token->type === Token::TYPE_NONE) {
103+
$wither = $token->value;
104+
$this->withers[$wither] = new WithKeyword($wither);
105+
$state = 1;
106+
continue;
107+
}
108+
109+
if ($state === 1) {
110+
if ($token->value === '(') {
111+
$this->withers[$wither]->columns = Array2d::parse($parser, $list);
112+
continue;
113+
}
114+
115+
if ($token->keyword === 'AS') {
116+
++$list->idx;
117+
$state = 2;
118+
continue;
119+
}
120+
} elseif ($state === 2) {
121+
if ($token->value === '(') {
122+
++$list->idx;
123+
$subList = $this->getSubTokenList($list);
124+
if ($subList instanceof ParserException) {
125+
$parser->errors[] = $subList;
126+
continue;
127+
}
128+
129+
$subParser = new Parser($subList);
130+
131+
if (count($subParser->errors)) {
132+
foreach ($subParser->errors as $error) {
133+
$parser->errors[] = $error;
134+
}
135+
}
136+
137+
$this->withers[$wither]->statement = $subParser;
138+
continue;
139+
}
140+
141+
// There's another WITH expression to parse, go back to state=0
142+
if ($token->value === ',') {
143+
$list->idx++;
144+
$state = 0;
145+
continue;
146+
}
147+
148+
// No more WITH expressions, we're done with this statement
149+
break;
150+
}
151+
}
152+
153+
--$list->idx;
154+
}
155+
156+
/**
157+
* {@inheritdoc}
158+
*/
159+
public function build()
160+
{
161+
$str = 'WITH ';
162+
163+
foreach ($this->withers as $wither) {
164+
$str .= $str === 'WITH ' ? '' : ', ';
165+
$str .= WithKeyword::build($wither);
166+
}
167+
168+
return $str;
169+
}
170+
171+
/**
172+
* Get tokens within the WITH expression to use them in another parser
173+
*
174+
* @return ParserException|TokensList
175+
*/
176+
private function getSubTokenList(TokensList $list)
177+
{
178+
$idx = $list->idx;
179+
/** @var Token $token */
180+
$token = $list->tokens[$list->idx];
181+
$openParenthesis = 0;
182+
183+
while ($list->idx < $list->count) {
184+
if ($token->value === '(') {
185+
++$openParenthesis;
186+
} elseif ($token->value === ')') {
187+
if (--$openParenthesis === -1) {
188+
break;
189+
}
190+
}
191+
192+
++$list->idx;
193+
if (! isset($list->tokens[$list->idx])) {
194+
break;
195+
}
196+
197+
$token = $list->tokens[$list->idx];
198+
}
199+
200+
// performance improvement: return the error to avoid a try/catch in the loop
201+
if ($list->idx === $list->count) {
202+
--$list->idx;
203+
204+
return new ParserException(
205+
Translator::gettext('A closing bracket was expected.'),
206+
$token
207+
);
208+
}
209+
210+
$length = $list->idx - $idx;
211+
212+
return new TokensList(array_slice($list->tokens, $idx, $length), $length);
213+
}
214+
}

src/TokensList.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,7 @@ public function __construct(array $tokens = [], $count = -1)
5050
}
5151

5252
$this->tokens = $tokens;
53-
if ($count !== -1) {
54-
return;
55-
}
56-
57-
$this->count = count($tokens);
53+
$this->count = $count === -1 ? count($tokens) : $count;
5854
}
5955

6056
/**

0 commit comments

Comments
 (0)