Skip to content

Commit 1e1a7ff

Browse files
committed
Add WITH support
1 parent e0fb287 commit 1e1a7ff

File tree

7 files changed

+412
-5
lines changed

7 files changed

+412
-5
lines changed

phpstan-baseline.neon

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3270,3 +3270,11 @@ parameters:
32703270
count: 1
32713271
path: tests/Utils/TokensTest.php
32723272

3273+
-
3274+
message: "#^Parameter \\#1 \\$component of static method PhpMyAdmin\\\\SqlParser\\\\Components\\\\WithKeyword\\:\\:build\\(\\) expects PhpMyAdmin\\\\SqlParser\\\\Components\\\\WithKeyword, stdClass given\\.$#"
3275+
path: tests/Parser/WithStatementTest.php
3276+
3277+
-
3278+
message: "#^Method PhpMyAdmin\\\\SqlParser\\\\Statements\\\\WithStatement\\:\\:parse\\(\\) has no return typehint specified\\.$#"
3279+
count: 1
3280+
path: src/Statements/WithStatement.php

src/Components/OptionsArray.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,10 @@ public static function parse(Parser $parser, TokensList $list, array $options =
216216
$list,
217217
empty($lastOption[2]) ? [] : $lastOption[2]
218218
);
219-
$ret->options[$lastOptionId]['value']
220-
= $ret->options[$lastOptionId]['expr']->expr;
219+
if ($ret->options[$lastOptionId]['expr']) {
220+
$ret->options[$lastOptionId]['value']
221+
= $ret->options[$lastOptionId]['expr']->expr;
222+
}
221223
$lastOption = null;
222224
$state = 0;
223225
} 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+
* `UNION` 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
@@ -72,6 +72,7 @@ class Parser extends Core
7272
'REPLACE' => 'PhpMyAdmin\\SqlParser\\Statements\\ReplaceStatement',
7373
'SELECT' => 'PhpMyAdmin\\SqlParser\\Statements\\SelectStatement',
7474
'UPDATE' => 'PhpMyAdmin\\SqlParser\\Statements\\UpdateStatement',
75+
'WITH' => 'PhpMyAdmin\\SqlParser\\Statements\\WithStatement',
7576

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

src/Statements/WithStatement.php

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
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\Statement;
15+
use PhpMyAdmin\SqlParser\Parser;
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(
73+
$parser,
74+
$list,
75+
static::$OPTIONS
76+
);
77+
++$list->idx;
78+
79+
/**
80+
* The state of the parser.
81+
*
82+
* Below are the states of the parser.
83+
*
84+
* 0 ---------------- [ name ] -----------------> 1
85+
* 1 -------------- [( columns )] AS ----------------> 2
86+
* 2 ------------------ [ , ] --------------------> 0
87+
*
88+
* @var int
89+
*/
90+
$state = 0;
91+
$wither = null;
92+
93+
for (; $list->idx < $list->count; ++$list->idx) {
94+
/**
95+
* Token parsed at this moment.
96+
*
97+
* @var Token
98+
*/
99+
$token = $list->tokens[$list->idx];
100+
101+
// Skipping whitespaces and comments.
102+
if ($token->type === Token::TYPE_WHITESPACE || $token->type === Token::TYPE_COMMENT) {
103+
continue;
104+
}
105+
106+
if ($token->type === Token::TYPE_NONE) {
107+
$wither = $token->value;
108+
$this->withers[$wither] = new WithKeyword($wither);
109+
$state = 1;
110+
continue;
111+
}
112+
113+
if ($state === 1) {
114+
if ($token->value === '(') {
115+
$this->withers[$wither]->columns = Array2d::parse($parser, $list);
116+
continue;
117+
}
118+
119+
if ($token->keyword === 'AS') {
120+
++$list->idx;
121+
$state = 2;
122+
continue;
123+
}
124+
} elseif ($state === 2) {
125+
if ($token->value === '(') {
126+
++$list->idx;
127+
$subList = $this->getSubTokenList($list);
128+
if ($subList instanceof ParserException) {
129+
$parser->errors[] = $subList;
130+
continue;
131+
}
132+
133+
$subParser = new Parser($subList);
134+
135+
if (count($subParser->errors)) {
136+
foreach ($subParser->errors as $error) {
137+
$parser->errors[] = $error;
138+
}
139+
}
140+
141+
$this->withers[$wither]->statement = $subParser;
142+
continue;
143+
}
144+
145+
// There's another WITH expression to parse, go back to state=0
146+
if ($token->value === ',') {
147+
$list->idx++;
148+
$state = 0;
149+
continue;
150+
}
151+
152+
// No more WITH expressions, we're done with this statement
153+
break;
154+
}
155+
}
156+
157+
--$list->idx;
158+
}
159+
160+
/**
161+
* {@inheritdoc}
162+
*/
163+
public function build()
164+
{
165+
$str = 'WITH ';
166+
167+
foreach ($this->withers as $wither) {
168+
$str .= $str === 'WITH ' ? '' : ', ';
169+
$str .= WithKeyword::build($wither);
170+
}
171+
172+
return $str;
173+
}
174+
175+
/**
176+
* Get tokens within the WITH expression to use them in another parser
177+
*
178+
* @return ParserException|TokensList
179+
*/
180+
private function getSubTokenList(TokensList $list)
181+
{
182+
$idx = $list->idx;
183+
/** @var Token $token */
184+
$token = $list->tokens[$list->idx];
185+
$openParenthesis = 0;
186+
187+
while ($list->idx < $list->count) {
188+
if ($token->value === '(') {
189+
++$openParenthesis;
190+
} elseif ($token->value === ')') {
191+
if (--$openParenthesis === -1) {
192+
break;
193+
}
194+
}
195+
196+
++$list->idx;
197+
if (! isset($list->tokens[$list->idx])) {
198+
break;
199+
}
200+
201+
$token = $list->tokens[$list->idx];
202+
}
203+
204+
// performance improvement: return the error to avoid a try/catch in the loop
205+
if ($list->idx === $list->count) {
206+
--$list->idx;
207+
208+
return new ParserException(
209+
Translator::gettext('A closing bracket was expected.'),
210+
$token
211+
);
212+
}
213+
214+
$length = $list->idx - $idx;
215+
216+
return new TokensList(array_slice($list->tokens, $idx, $length), $length);
217+
}
218+
}

src/TokensList.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,7 @@ public function __construct(array $tokens = [], $count = -1)
4646
{
4747
if (! empty($tokens)) {
4848
$this->tokens = $tokens;
49-
if ($count === -1) {
50-
$this->count = count($tokens);
51-
}
49+
$this->count = $count === -1 ? count($tokens) : $count;
5250
}
5351
}
5452

0 commit comments

Comments
 (0)