Skip to content

Commit 91d2de5

Browse files
committed
Add WITH support
1 parent e0fb287 commit 91d2de5

File tree

7 files changed

+389
-6
lines changed

7 files changed

+389
-6
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: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,15 @@ public static function parse(Parser $parser, TokensList $list, array $options =
211211
// change this iteration.
212212
if ($state === 2) {
213213
if ($lastOption[1] === 'expr' || $lastOption[1] === 'expr=') {
214-
$ret->options[$lastOptionId]['expr'] = Expression::parse(
214+
$expr = $ret->options[$lastOptionId]['expr'] = Expression::parse(
215215
$parser,
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: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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+
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+
$str = $component->name;
46+
47+
if ($component->columns) {
48+
$str .= ArrayObj::build($component->columns);
49+
}
50+
51+
$str .= ' AS (';
52+
53+
foreach ($component->statement->statements as $statement) {
54+
$str .= $statement->build();
55+
}
56+
57+
$str .= ')';
58+
59+
return $str;
60+
}
61+
}

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

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)