Skip to content

Commit 9b4faca

Browse files
authored
feature: postgresql plan summary (#2133)
- added: sql_to_limited_query(string $sql, int $limit) DSL function - adds LIMIT clause to queries without adding OFFSET - added: PlanSummary::normalize() method - returns all plan summary fields as snake_case keyed array - added: PlanSummary memory statistics: memoryUsed, memoryPeak - added: PlanSummary join statistics: hashJoinCount, nestedLoopCount, mergeJoinCount - added: PlanSummary buffer statistics: totalSharedHit, totalSharedRead, hasTempSpill - added: PlanSummary row statistics: estimatedRows, actualRows - added: LimitedQueryTest - 7 test cases for sql_to_limited_query function - added: PlanSummaryTest - 3 test cases for PlanSummary::normalize() method - changed: PlanAnalyzer::summary() now computes and passes join counts, buffer statistics, and row data to PlanSummary
1 parent f6fd81a commit 9b4faca

File tree

6 files changed

+327
-1
lines changed

6 files changed

+327
-1
lines changed

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,23 @@ function sql_to_paginated_query(string $sql, int $limit, int $offset = 0) : stri
299299
return $query->deparse();
300300
}
301301

302+
/**
303+
* Transform a SQL query to limit results to a specific number of rows.
304+
*
305+
* @param string $sql The SQL query to limit
306+
* @param int $limit Maximum number of rows to return
307+
*
308+
* @return string The limited SQL query
309+
*/
310+
#[DocumentationDSL(module: Module::PG_QUERY, type: DSLType::HELPER)]
311+
function sql_to_limited_query(string $sql, int $limit) : string
312+
{
313+
$query = (new Parser())->parse($sql);
314+
$query->traverse(new PaginationModifier(new PaginationConfig($limit)));
315+
316+
return $query->deparse();
317+
}
318+
302319
/**
303320
* Transform a SQL query into a COUNT query for pagination.
304321
*

src/lib/postgresql/src/Flow/PostgreSql/Explain/Analyzer/PlanAnalyzer.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,10 @@ public function summary() : PlanSummary
417417
$hasDiskReads = false;
418418
$totalSharedHit = 0;
419419
$totalSharedRead = 0;
420+
$hashJoinCount = 0;
421+
$nestedLoopCount = 0;
422+
$mergeJoinCount = 0;
423+
$hasTempSpill = false;
420424

421425
foreach ($nodes as $node) {
422426
if ($node->isSequentialScan()) {
@@ -431,12 +435,29 @@ public function summary() : PlanSummary
431435
$hasExternalSort = true;
432436
}
433437

438+
if ($node->isHashJoin()) {
439+
$hashJoinCount++;
440+
}
441+
442+
if ($node->isNestedLoop()) {
443+
$nestedLoopCount++;
444+
}
445+
446+
if ($node->isMergeJoin()) {
447+
$mergeJoinCount++;
448+
}
449+
434450
$buffers = $node->buffers();
435451

436452
if ($buffers !== null) {
437453
if ($buffers->sharedRead() > 0) {
438454
$hasDiskReads = true;
439455
}
456+
457+
if ($buffers->hasDiskSpill()) {
458+
$hasTempSpill = true;
459+
}
460+
440461
$totalSharedHit += $buffers->sharedHit();
441462
$totalSharedRead += $buffers->sharedRead();
442463
}
@@ -455,6 +476,16 @@ public function summary() : PlanSummary
455476
hasExternalSort: $hasExternalSort,
456477
hasDiskReads: $hasDiskReads,
457478
overallCacheHitRatio: $overallCacheHitRatio,
479+
memoryUsed: $this->plan->memoryUsed(),
480+
memoryPeak: $this->plan->memoryPeak(),
481+
hashJoinCount: $hashJoinCount,
482+
nestedLoopCount: $nestedLoopCount,
483+
mergeJoinCount: $mergeJoinCount,
484+
totalSharedHit: $totalSharedHit,
485+
totalSharedRead: $totalSharedRead,
486+
hasTempSpill: $hasTempSpill,
487+
estimatedRows: $this->plan->rootNode()->estimatedRows(),
488+
actualRows: $this->plan->rootNode()->actualRows(),
458489
);
459490
}
460491

src/lib/postgresql/src/Flow/PostgreSql/Explain/Analyzer/PlanSummary.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,64 @@ public function __construct(
1616
public bool $hasExternalSort,
1717
public bool $hasDiskReads,
1818
public ?float $overallCacheHitRatio,
19+
public ?int $memoryUsed,
20+
public ?int $memoryPeak,
21+
public int $hashJoinCount,
22+
public int $nestedLoopCount,
23+
public int $mergeJoinCount,
24+
public int $totalSharedHit,
25+
public int $totalSharedRead,
26+
public bool $hasTempSpill,
27+
public int $estimatedRows,
28+
public ?int $actualRows,
1929
) {
2030
}
31+
32+
/**
33+
* @return array{
34+
* total_cost: float,
35+
* execution_time: ?float,
36+
* planning_time: ?float,
37+
* node_count: int,
38+
* sequential_scan_count: int,
39+
* index_scan_count: int,
40+
* has_external_sort: bool,
41+
* has_disk_reads: bool,
42+
* overall_cache_hit_ratio: ?float,
43+
* memory_used: ?int,
44+
* memory_peak: ?int,
45+
* hash_join_count: int,
46+
* nested_loop_count: int,
47+
* merge_join_count: int,
48+
* total_shared_hit: int,
49+
* total_shared_read: int,
50+
* has_temp_spill: bool,
51+
* estimated_rows: int,
52+
* actual_rows: ?int
53+
* }
54+
*/
55+
public function normalize() : array
56+
{
57+
return [
58+
'total_cost' => $this->totalCost,
59+
'execution_time' => $this->executionTime,
60+
'planning_time' => $this->planningTime,
61+
'node_count' => $this->nodeCount,
62+
'sequential_scan_count' => $this->sequentialScanCount,
63+
'index_scan_count' => $this->indexScanCount,
64+
'has_external_sort' => $this->hasExternalSort,
65+
'has_disk_reads' => $this->hasDiskReads,
66+
'overall_cache_hit_ratio' => $this->overallCacheHitRatio,
67+
'memory_used' => $this->memoryUsed,
68+
'memory_peak' => $this->memoryPeak,
69+
'hash_join_count' => $this->hashJoinCount,
70+
'nested_loop_count' => $this->nestedLoopCount,
71+
'merge_join_count' => $this->mergeJoinCount,
72+
'total_shared_hit' => $this->totalSharedHit,
73+
'total_shared_read' => $this->totalSharedRead,
74+
'has_temp_spill' => $this->hasTempSpill,
75+
'estimated_rows' => $this->estimatedRows,
76+
'actual_rows' => $this->actualRows,
77+
];
78+
}
2179
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Tests\Unit\AST\Transformers;
6+
7+
use function Flow\PostgreSql\DSL\sql_to_limited_query;
8+
9+
use PHPUnit\Framework\TestCase;
10+
11+
final class LimitedQueryTest extends TestCase
12+
{
13+
protected function setUp() : void
14+
{
15+
if (!\extension_loaded('pg_query')) {
16+
self::markTestSkipped('pg_query extension is not loaded. For local development use `nix-shell --arg with-pg-query-ext true` to enable it.');
17+
}
18+
}
19+
20+
public function test_limit_does_not_add_offset() : void
21+
{
22+
$result = sql_to_limited_query('SELECT id, name FROM products ORDER BY name', 25);
23+
24+
self::assertSame('SELECT id, name FROM products ORDER BY name LIMIT 25', $result);
25+
self::assertStringNotContainsString('OFFSET', $result);
26+
}
27+
28+
public function test_limit_overrides_existing_limit() : void
29+
{
30+
$result = sql_to_limited_query('SELECT * FROM users LIMIT 100', 10);
31+
32+
self::assertSame('SELECT * FROM users LIMIT 10', $result);
33+
}
34+
35+
public function test_limit_removes_existing_offset() : void
36+
{
37+
$result = sql_to_limited_query('SELECT * FROM users ORDER BY id LIMIT 100 OFFSET 50', 10);
38+
39+
self::assertSame('SELECT * FROM users ORDER BY id LIMIT 10', $result);
40+
self::assertStringNotContainsString('OFFSET', $result);
41+
}
42+
43+
public function test_limit_union_wraps_in_subquery() : void
44+
{
45+
$sql = 'SELECT id FROM users UNION SELECT id FROM admins ORDER BY id';
46+
47+
$result = sql_to_limited_query($sql, 10);
48+
49+
self::assertSame('SELECT * FROM (SELECT id FROM users UNION SELECT id FROM admins ORDER BY id) _pagination_subq LIMIT 10', $result);
50+
self::assertStringNotContainsString('OFFSET', $result);
51+
}
52+
53+
public function test_limit_with_joins() : void
54+
{
55+
$sql = 'SELECT u.id, o.total FROM users u JOIN orders o ON u.id = o.user_id';
56+
57+
$result = sql_to_limited_query($sql, 50);
58+
59+
self::assertSame('SELECT u.id, o.total FROM users u JOIN orders o ON u.id = o.user_id LIMIT 50', $result);
60+
}
61+
62+
public function test_limit_with_where_clause() : void
63+
{
64+
$result = sql_to_limited_query('SELECT * FROM users WHERE active = true', 5);
65+
66+
self::assertSame('SELECT * FROM users WHERE active = true LIMIT 5', $result);
67+
}
68+
69+
public function test_simple_select_with_limit() : void
70+
{
71+
$result = sql_to_limited_query('SELECT * FROM users', 10);
72+
73+
self::assertSame('SELECT * FROM users LIMIT 10', $result);
74+
}
75+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Flow\PostgreSql\Tests\Unit\Explain\Analyzer;
6+
7+
use Flow\PostgreSql\Explain\Analyzer\PlanSummary;
8+
use PHPUnit\Framework\TestCase;
9+
10+
final class PlanSummaryTest extends TestCase
11+
{
12+
public function test_normalize_returns_all_fields() : void
13+
{
14+
$summary = new PlanSummary(
15+
totalCost: 150.5,
16+
executionTime: 25.0,
17+
planningTime: 0.5,
18+
nodeCount: 5,
19+
sequentialScanCount: 2,
20+
indexScanCount: 3,
21+
hasExternalSort: true,
22+
hasDiskReads: true,
23+
overallCacheHitRatio: 0.95,
24+
memoryUsed: 1024,
25+
memoryPeak: 2048,
26+
hashJoinCount: 1,
27+
nestedLoopCount: 2,
28+
mergeJoinCount: 0,
29+
totalSharedHit: 100,
30+
totalSharedRead: 5,
31+
hasTempSpill: false,
32+
estimatedRows: 1000,
33+
actualRows: 950,
34+
);
35+
36+
$normalized = $summary->normalize();
37+
38+
self::assertSame(150.5, $normalized['total_cost']);
39+
self::assertSame(25.0, $normalized['execution_time']);
40+
self::assertSame(0.5, $normalized['planning_time']);
41+
self::assertSame(5, $normalized['node_count']);
42+
self::assertSame(2, $normalized['sequential_scan_count']);
43+
self::assertSame(3, $normalized['index_scan_count']);
44+
self::assertTrue($normalized['has_external_sort']);
45+
self::assertTrue($normalized['has_disk_reads']);
46+
self::assertSame(0.95, $normalized['overall_cache_hit_ratio']);
47+
self::assertSame(1024, $normalized['memory_used']);
48+
self::assertSame(2048, $normalized['memory_peak']);
49+
self::assertSame(1, $normalized['hash_join_count']);
50+
self::assertSame(2, $normalized['nested_loop_count']);
51+
self::assertSame(0, $normalized['merge_join_count']);
52+
self::assertSame(100, $normalized['total_shared_hit']);
53+
self::assertSame(5, $normalized['total_shared_read']);
54+
self::assertFalse($normalized['has_temp_spill']);
55+
self::assertSame(1000, $normalized['estimated_rows']);
56+
self::assertSame(950, $normalized['actual_rows']);
57+
}
58+
59+
public function test_normalize_returns_expected_keys() : void
60+
{
61+
$summary = new PlanSummary(
62+
totalCost: 50.0,
63+
executionTime: 10.0,
64+
planningTime: 0.1,
65+
nodeCount: 2,
66+
sequentialScanCount: 1,
67+
indexScanCount: 1,
68+
hasExternalSort: false,
69+
hasDiskReads: false,
70+
overallCacheHitRatio: 1.0,
71+
memoryUsed: 512,
72+
memoryPeak: 1024,
73+
hashJoinCount: 0,
74+
nestedLoopCount: 0,
75+
mergeJoinCount: 0,
76+
totalSharedHit: 50,
77+
totalSharedRead: 0,
78+
hasTempSpill: false,
79+
estimatedRows: 100,
80+
actualRows: 100,
81+
);
82+
83+
$normalized = $summary->normalize();
84+
85+
$expectedKeys = [
86+
'total_cost',
87+
'execution_time',
88+
'planning_time',
89+
'node_count',
90+
'sequential_scan_count',
91+
'index_scan_count',
92+
'has_external_sort',
93+
'has_disk_reads',
94+
'overall_cache_hit_ratio',
95+
'memory_used',
96+
'memory_peak',
97+
'hash_join_count',
98+
'nested_loop_count',
99+
'merge_join_count',
100+
'total_shared_hit',
101+
'total_shared_read',
102+
'has_temp_spill',
103+
'estimated_rows',
104+
'actual_rows',
105+
];
106+
107+
self::assertSame($expectedKeys, \array_keys($normalized));
108+
}
109+
110+
public function test_normalize_with_null_values() : void
111+
{
112+
$summary = new PlanSummary(
113+
totalCost: 100.0,
114+
executionTime: null,
115+
planningTime: null,
116+
nodeCount: 1,
117+
sequentialScanCount: 0,
118+
indexScanCount: 0,
119+
hasExternalSort: false,
120+
hasDiskReads: false,
121+
overallCacheHitRatio: null,
122+
memoryUsed: null,
123+
memoryPeak: null,
124+
hashJoinCount: 0,
125+
nestedLoopCount: 0,
126+
mergeJoinCount: 0,
127+
totalSharedHit: 0,
128+
totalSharedRead: 0,
129+
hasTempSpill: false,
130+
estimatedRows: 50,
131+
actualRows: null,
132+
);
133+
134+
$normalized = $summary->normalize();
135+
136+
self::assertSame(100.0, $normalized['total_cost']);
137+
self::assertNull($normalized['execution_time']);
138+
self::assertNull($normalized['planning_time']);
139+
self::assertNull($normalized['overall_cache_hit_ratio']);
140+
self::assertNull($normalized['memory_used']);
141+
self::assertNull($normalized['memory_peak']);
142+
self::assertSame(50, $normalized['estimated_rows']);
143+
self::assertNull($normalized['actual_rows']);
144+
}
145+
}

web/landing/resources/dsl.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)