Skip to content

Commit a285276

Browse files
committed
feature: query depth extractor
- added SelectStatement::from() : From - added sql_query_depth(string $query): int
1 parent 2ce2328 commit a285276

File tree

11 files changed

+625
-9
lines changed

11 files changed

+625
-9
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\AST\Nodes\Exception;
6+
7+
use Flow\PostgreSql\Protobuf\AST\Node;
8+
9+
final class InvalidFromNodeException extends \RuntimeException
10+
{
11+
public static function invalidNode(Node $node) : self
12+
{
13+
return new self(\sprintf(
14+
'Invalid FROM clause node type: %s',
15+
$node->getNode() ?? 'unknown'
16+
));
17+
}
18+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\AST\Nodes;
6+
7+
use Flow\PostgreSql\AST\Nodes\Exception\InvalidFromNodeException;
8+
use Flow\PostgreSql\Protobuf\AST\Node;
9+
10+
final readonly class From implements \Countable
11+
{
12+
/**
13+
* @var array<Node>
14+
*/
15+
private array $nodes;
16+
17+
/**
18+
* @param array<Node> $nodes
19+
*/
20+
public function __construct(array $nodes)
21+
{
22+
foreach ($nodes as $node) {
23+
if (!$this->isValidFromNode($node)) {
24+
throw InvalidFromNodeException::invalidNode($node);
25+
}
26+
}
27+
$this->nodes = $nodes;
28+
}
29+
30+
public function count() : int
31+
{
32+
return \count($this->nodes);
33+
}
34+
35+
public function hasFunction() : bool
36+
{
37+
foreach ($this->nodes as $fromNode) {
38+
if ($fromNode->getRangeFunction() !== null) {
39+
return true;
40+
}
41+
}
42+
43+
return false;
44+
}
45+
46+
public function hasValues() : bool
47+
{
48+
foreach ($this->nodes as $fromNode) {
49+
$rangeSubselect = $fromNode->getRangeSubselect();
50+
51+
if ($rangeSubselect === null) {
52+
continue;
53+
}
54+
55+
$subquery = $rangeSubselect->getSubquery();
56+
57+
if ($subquery === null) {
58+
continue;
59+
}
60+
61+
$selectStmt = $subquery->getSelectStmt();
62+
63+
if ($selectStmt !== null && \count($selectStmt->getValuesLists()) > 0) {
64+
return true;
65+
}
66+
}
67+
68+
return false;
69+
}
70+
71+
public function isEmpty() : bool
72+
{
73+
return $this->count() === 0;
74+
}
75+
76+
private function isValidFromNode(Node $node) : bool
77+
{
78+
return $node->getRangeVar() !== null
79+
|| $node->getRangeSubselect() !== null
80+
|| $node->getJoinExpr() !== null
81+
|| $node->getRangeFunction() !== null
82+
|| $node->getRangeTableFunc() !== null
83+
|| $node->getRangeTableSample() !== null
84+
|| $node->getJsonTable() !== null;
85+
}
86+
}

src/lib/postgresql/src/Flow/PostgreSql/AST/Nodes/Statement/SelectStatement.php

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@
44

55
namespace Flow\PostgreSql\AST\Nodes\Statement;
66

7-
use Flow\PostgreSql\AST\Nodes\{Statement, StatementTrait};
8-
use Flow\PostgreSql\Protobuf\AST\SelectStmt;
9-
use Flow\PostgreSql\QueryBuilder\Select\SelectBuilder;
7+
use Flow\PostgreSql\AST\Nodes\{From, Statement, StatementTrait};
8+
use Flow\PostgreSql\Protobuf\AST\{SelectStmt, SetOperation};
109

1110
/**
1211
* @implements Statement<SelectStmt>
@@ -20,6 +19,11 @@ public function __construct(
2019
) {
2120
}
2221

22+
public function from() : From
23+
{
24+
return new From(\iterator_to_array($this->stmt->getFromClause()));
25+
}
26+
2327
public function hasCte() : bool
2428
{
2529
return $this->stmt->hasWithClause();
@@ -45,13 +49,15 @@ public function hasOffset() : bool
4549
return $this->stmt->hasLimitOffset();
4650
}
4751

48-
public function raw() : SelectStmt
52+
public function hasSetOperation() : bool
4953
{
50-
return $this->stmt;
54+
$op = $this->stmt->getOp();
55+
56+
return $op !== SetOperation::SET_OPERATION_UNDEFINED && $op !== SetOperation::SETOP_NONE;
5157
}
5258

53-
public function toBuilder() : SelectBuilder
59+
public function raw() : SelectStmt
5460
{
55-
return SelectBuilder::fromAst($this->stmt);
61+
return $this->stmt;
5662
}
5763
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\AST\Visitors;
6+
7+
use Flow\PostgreSql\AST\NodeVisitor;
8+
use Flow\PostgreSql\Protobuf\AST\SelectStmt;
9+
10+
/**
11+
* A visitor that tracks the maximum nesting depth of SELECT statements.
12+
*/
13+
final class SelectStmtDepthCollector implements NodeVisitor
14+
{
15+
private int $currentDepth = 0;
16+
17+
private int $maxDepth = 0;
18+
19+
public static function nodeClass() : string
20+
{
21+
return SelectStmt::class;
22+
}
23+
24+
public function enter(object $node) : ?int
25+
{
26+
$this->currentDepth++;
27+
28+
if ($this->currentDepth > $this->maxDepth) {
29+
$this->maxDepth = $this->currentDepth;
30+
}
31+
32+
return null;
33+
}
34+
35+
public function getMaxDepth() : int
36+
{
37+
return $this->maxDepth;
38+
}
39+
40+
public function leave(object $node) : ?int
41+
{
42+
$this->currentDepth--;
43+
44+
return null;
45+
}
46+
47+
public function reset() : void
48+
{
49+
$this->currentDepth = 0;
50+
$this->maxDepth = 0;
51+
}
52+
}

src/lib/postgresql/src/Flow/PostgreSql/DSL/functions.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
use Flow\PostgreSql\Client\RowMapper\ConstructorMapper;
1515
use Flow\PostgreSql\Client\Types\ValueConverters;
1616
use Flow\PostgreSql\{DeparseOptions, ParsedQuery, Parser};
17-
use Flow\PostgreSql\Extractors\{Columns, Functions, Tables};
17+
use Flow\PostgreSql\Extractors\{Columns, Functions, QueryDepth, Tables};
1818
use Flow\PostgreSql\Protobuf\AST\Node;
1919
use Flow\PostgreSql\QueryBuilder\Clause\{
2020
CTE,
@@ -367,6 +367,20 @@ function sql_query_functions(ParsedQuery $query) : Functions
367367
return new Functions($query);
368368
}
369369

370+
/**
371+
* Get the maximum nesting depth of a SQL query.
372+
*
373+
* Example:
374+
* - "SELECT * FROM t" => 1
375+
* - "SELECT * FROM (SELECT * FROM t)" => 2
376+
* - "SELECT * FROM (SELECT * FROM (SELECT * FROM t))" => 3
377+
*/
378+
#[DocumentationDSL(module: Module::PG_QUERY, type: DSLType::HELPER)]
379+
function sql_query_depth(string $sql) : int
380+
{
381+
return (new QueryDepth(sql_parse($sql)))->depth();
382+
}
383+
370384
/**
371385
* Create a new SELECT query builder.
372386
*
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Extractors;
6+
7+
use Flow\PostgreSql\AST\Visitors\SelectStmtDepthCollector;
8+
use Flow\PostgreSql\ParsedQuery;
9+
10+
final readonly class QueryDepth
11+
{
12+
public function __construct(private ParsedQuery $query)
13+
{
14+
}
15+
16+
public function depth() : int
17+
{
18+
$collector = new SelectStmtDepthCollector();
19+
$this->query->traverse($collector);
20+
21+
return $collector->getMaxDepth();
22+
}
23+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Tests\Unit\AST\Nodes;
6+
7+
use function Flow\PostgreSql\DSL\sql_parse;
8+
9+
use Flow\PostgreSql\AST\Nodes\Exception\InvalidFromNodeException;
10+
use Flow\PostgreSql\AST\Nodes\From;
11+
use Flow\PostgreSql\AST\Nodes\Statement\SelectStatement;
12+
use Flow\PostgreSql\Protobuf\AST\Node;
13+
use PHPUnit\Framework\TestCase;
14+
15+
final class FromTest extends TestCase
16+
{
17+
protected function setUp() : void
18+
{
19+
if (!\extension_loaded('pg_query')) {
20+
self::markTestSkipped('pg_query extension is not loaded. For local development use `nix-shell --arg with-pg-query-ext true` to enable it in the shell.');
21+
}
22+
}
23+
24+
public function test_accepts_join_expr_node() : void
25+
{
26+
$statement = sql_parse('SELECT * FROM users JOIN orders ON users.id = orders.user_id')->statements()->first();
27+
self::assertInstanceOf(SelectStatement::class, $statement);
28+
29+
$from = $statement->from();
30+
self::assertFalse($from->isEmpty());
31+
}
32+
33+
public function test_accepts_range_function_node() : void
34+
{
35+
$statement = sql_parse('SELECT * FROM generate_series(1, 10)')->statements()->first();
36+
self::assertInstanceOf(SelectStatement::class, $statement);
37+
38+
$from = $statement->from();
39+
self::assertFalse($from->isEmpty());
40+
}
41+
42+
public function test_accepts_range_subselect_node() : void
43+
{
44+
$statement = sql_parse('SELECT * FROM (SELECT 1) AS t')->statements()->first();
45+
self::assertInstanceOf(SelectStatement::class, $statement);
46+
47+
$from = $statement->from();
48+
self::assertFalse($from->isEmpty());
49+
}
50+
51+
public function test_accepts_range_var_node() : void
52+
{
53+
$statement = sql_parse('SELECT * FROM users')->statements()->first();
54+
self::assertInstanceOf(SelectStatement::class, $statement);
55+
56+
$from = $statement->from();
57+
self::assertFalse($from->isEmpty());
58+
}
59+
60+
public function test_count_returns_number_of_from_nodes() : void
61+
{
62+
$statement = sql_parse('SELECT * FROM users, orders')->statements()->first();
63+
self::assertInstanceOf(SelectStatement::class, $statement);
64+
65+
self::assertCount(2, $statement->from());
66+
}
67+
68+
public function test_count_returns_zero_for_empty_from() : void
69+
{
70+
$from = new From([]);
71+
self::assertCount(0, $from);
72+
}
73+
74+
public function test_empty_nodes_array_creates_empty_from() : void
75+
{
76+
$from = new From([]);
77+
self::assertTrue($from->isEmpty());
78+
}
79+
80+
public function test_has_function_returns_false_for_regular_table() : void
81+
{
82+
$statement = sql_parse('SELECT * FROM users')->statements()->first();
83+
self::assertInstanceOf(SelectStatement::class, $statement);
84+
85+
self::assertFalse($statement->from()->hasFunction());
86+
}
87+
88+
public function test_has_function_returns_true_for_function_in_from() : void
89+
{
90+
$statement = sql_parse('SELECT * FROM generate_series(1, 10)')->statements()->first();
91+
self::assertInstanceOf(SelectStatement::class, $statement);
92+
93+
self::assertTrue($statement->from()->hasFunction());
94+
}
95+
96+
public function test_has_function_returns_true_for_unnest_function() : void
97+
{
98+
$statement = sql_parse('SELECT * FROM unnest(ARRAY[1,2,3])')->statements()->first();
99+
self::assertInstanceOf(SelectStatement::class, $statement);
100+
101+
self::assertTrue($statement->from()->hasFunction());
102+
}
103+
104+
public function test_throws_exception_for_invalid_node() : void
105+
{
106+
$invalidNode = new Node();
107+
108+
$this->expectException(InvalidFromNodeException::class);
109+
$this->expectExceptionMessage('Invalid FROM clause node type');
110+
111+
new From([$invalidNode]);
112+
}
113+
}

0 commit comments

Comments
 (0)