Skip to content

Commit 581065b

Browse files
committed
Add WITH support
1 parent e0fb287 commit 581065b

File tree

5 files changed

+284
-3
lines changed

5 files changed

+284
-3
lines changed

src/Components/WithKeyword.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
public string $name;
20+
public array $columns = [];
21+
public Parser $statement;
22+
23+
public function __construct(string $name)
24+
{
25+
$this->name = $name;
26+
}
27+
28+
/**
29+
* @param WithKeyword $component
30+
*
31+
* @return string
32+
*/
33+
public static function build($component, array $options = [])
34+
{
35+
if (! $component instanceof WithKeyword) {
36+
throw new RuntimeException('Can not build a component that is not a WithKeyword');
37+
}
38+
39+
$str = $component->name;
40+
41+
if ($component->columns) {
42+
$str .= ArrayObj::build($component->columns);
43+
}
44+
45+
$str .= ' AS (';
46+
47+
foreach ($component->statement->statements as $statement) {
48+
$str .= $statement->build();
49+
}
50+
51+
$str .= ')';
52+
53+
return $str;
54+
}
55+
}

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: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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\Statement;
14+
use PhpMyAdmin\SqlParser\Parser;
15+
use PhpMyAdmin\SqlParser\Token;
16+
use PhpMyAdmin\SqlParser\TokensList;
17+
use RuntimeException;
18+
19+
use function array_slice;
20+
use function count;
21+
22+
/**
23+
* `WITH` statement.
24+
25+
* WITH [RECURSIVE] query_name [ (column_name [,...]) ] AS (SELECT ...) [, ...]
26+
*/
27+
class WithStatement extends Statement
28+
{
29+
/**
30+
* Options for `WITH` statements and their slot ID.
31+
*
32+
* @var array
33+
*/
34+
public static $OPTIONS = ['RECURSIVE' => 1];
35+
36+
/**
37+
* The clauses of this statement, in order.
38+
*
39+
* @see Statement::$CLAUSES
40+
*
41+
* @var array
42+
*/
43+
public static $CLAUSES = [
44+
'WITH' => [
45+
'WITH',
46+
2,
47+
],
48+
// Used for options.
49+
'_OPTIONS' => [
50+
'_OPTIONS',
51+
1,
52+
],
53+
'AS' => [
54+
'AS',
55+
2,
56+
],
57+
];
58+
59+
/** @var WithKeyword[] */
60+
public $withers = [];
61+
62+
/**
63+
* @param Parser $parser the instance that requests parsing
64+
* @param TokensList $list the list of tokens to be parsed
65+
*/
66+
public function parse(Parser $parser, TokensList $list)
67+
{
68+
++$list->idx; // Skipping `WITH`.
69+
70+
// parse any options if provided
71+
$this->options = OptionsArray::parse(
72+
$parser,
73+
$list,
74+
static::$OPTIONS
75+
);
76+
++$list->idx;
77+
78+
$state = 0;
79+
$wither = null;
80+
81+
for (; $list->idx < $list->count; ++$list->idx) {
82+
/**
83+
* Token parsed at this moment.
84+
*
85+
* @var Token
86+
*/
87+
$token = $list->tokens[$list->idx];
88+
89+
// Skipping whitespaces and comments.
90+
if ($token->type === Token::TYPE_WHITESPACE || $token->type === Token::TYPE_COMMENT) {
91+
continue;
92+
}
93+
94+
if ($token->type === Token::TYPE_NONE) {
95+
$wither = $token->value;
96+
$this->withers[$wither] = new WithKeyword($wither);
97+
$state = 1;
98+
continue;
99+
}
100+
101+
if ($state === 1) {
102+
if ($token->value === '(') {
103+
$this->withers[$wither]->columns = Array2d::parse($parser, $list);
104+
continue;
105+
}
106+
107+
if ($token->keyword === 'AS') {
108+
++$list->idx;
109+
$state = 2;
110+
continue;
111+
}
112+
} elseif ($state === 2) {
113+
if ($token->value === '(') {
114+
++$list->idx;
115+
$subList = $this->getSubTokenList($list);
116+
$subParser = new Parser($subList);
117+
118+
if (count($subParser->errors)) {
119+
foreach ($subParser->errors as $error) {
120+
$parser->error($error);
121+
}
122+
}
123+
124+
$this->withers[$wither]->statement = $subParser;
125+
continue;
126+
}
127+
128+
if ($token->value === ',') {
129+
$list->idx++;
130+
$state = 0;
131+
continue;
132+
}
133+
134+
// nothing else
135+
break;
136+
}
137+
}
138+
139+
--$list->idx;
140+
}
141+
142+
/**
143+
* {@inheritdoc}
144+
*/
145+
public function build()
146+
{
147+
$str = 'WITH ';
148+
149+
foreach ($this->withers as $wither) {
150+
$str .= $str === 'WITH ' ? '' : ', ';
151+
$str .= WithKeyword::build($wither);
152+
}
153+
154+
return $str;
155+
}
156+
157+
private function getSubTokenList(TokensList $list): TokensList
158+
{
159+
$idx = $list->idx;
160+
/** @var Token $token */
161+
$token = $list->tokens[$list->idx];
162+
$openParenthesis = 0;
163+
164+
while ($list->idx < $list->count) {
165+
// var_dump($idx, $token->value);
166+
if ($token->value === '(') {
167+
++$openParenthesis;
168+
} elseif ($token->value === ')') {
169+
if (--$openParenthesis === -1) {
170+
break;
171+
}
172+
}
173+
174+
$token = $list->tokens[++$list->idx];
175+
}
176+
177+
if ($list->idx === $list->count) {
178+
throw new RuntimeException('Syntax error no matching closing parenthesis.');
179+
}
180+
181+
$length = $list->idx - $idx;
182+
183+
return new TokensList(array_slice($list->tokens, $idx, $length), $length);
184+
}
185+
}

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

tests/Parser/WithStatementTest.php

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpMyAdmin\SqlParser\Tests\Parser;
6+
7+
use PhpMyAdmin\SqlParser\Tests\TestCase;
8+
use PhpMyAdmin\SqlParser\Lexer;
9+
use PhpMyAdmin\SqlParser\Parser;
10+
11+
class WithStatementTest extends TestCase
12+
{
13+
public function testWith()
14+
{
15+
$lexer = new Lexer(<<<SQL
16+
WITH categories(identifier, name, parent_id) AS (
17+
SELECT c.identifier, c.name, c.parent_id FROM category c WHERE c.identifier = 'a'
18+
UNION ALL
19+
SELECT c.identifier, c.name, c.parent_id FROM categories, category c WHERE c.identifier = categories.parent_id
20+
), foo AS ( SELECT * FROM test )
21+
SELECT * FROM categories
22+
SQL
23+
);
24+
25+
$lexerErrors = $this->getErrorsAsArray($lexer);
26+
$this->assertCount(0, $lexerErrors);
27+
$parser = new Parser($lexer->list);
28+
$parserErrors = $this->getErrorsAsArray($parser);
29+
$this->assertCount(0, $parserErrors);
30+
$this->assertCount(2, $parser->statements);
31+
32+
// phpcs:disable Generic.Files.LineLength.TooLong
33+
$this->assertEquals(<<<SQL
34+
WITH categories(identifier, name, parent_id) AS (SELECT c.identifier, c.name, c.parent_id FROM category AS `c` WHERE c.identifier = 'a' UNION ALL SELECT c.identifier, c.name, c.parent_id FROM categories, category AS `c` WHERE c.identifier = categories.parent_id), foo AS (SELECT * FROM test)
35+
SQL, $parser->statements[0]->build());
36+
// phpcs:enable
37+
38+
$this->assertEquals(<<<SQL
39+
SELECT * FROM categories
40+
SQL, $parser->statements[1]->build());
41+
}
42+
}

0 commit comments

Comments
 (0)