Skip to content

Commit 9c03af1

Browse files
committed
feature: add explain method to postgresql client
- Client::explain() : Plan - Query Plan - Plan Analyzer
1 parent 95421fb commit 9c03af1

File tree

26 files changed

+3332
-2
lines changed

26 files changed

+3332
-2
lines changed

documentation/components/libs/postgresql.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ execute it with the Client, and map results to objects.
3232
| Build SQL queries with type safety | [Query Builder](#query-builder) | `ext-pg_query` |
3333
| Parse and analyze existing SQL | [SQL Parser](#sql-parser) | `ext-pg_query` |
3434
| Add pagination to existing queries | [Query Modification](#query-modification) | `ext-pg_query` |
35+
| Analyze query performance | [Query Plan Analysis](postgresql/client-explain.md) | `ext-pgsql`, `ext-pg_query` |
3536
| Traverse or modify AST directly | [Advanced Features](#advanced-features) | `ext-pg_query` |
3637

3738
## Requirements
@@ -317,6 +318,8 @@ $client->close();
317318
- [Transactions](/documentation/components/libs/postgresql/client-transactions.md) - Transaction callback pattern,
318319
nesting
319320
- [Type System](/documentation/components/libs/postgresql/client-types.md) - Value converters, TypedValue, custom types
321+
- [Query Plan Analysis](/documentation/components/libs/postgresql/client-explain.md) - EXPLAIN ANALYZE, plan insights,
322+
performance debugging
320323

321324
---
322325

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
# Query Plan Analysis
2+
3+
- [⬅️ Back](/documentation/components/libs/postgresql.md)
4+
5+
[TOC]
6+
7+
The client provides built-in support for analyzing PostgreSQL query execution plans. Use `EXPLAIN ANALYZE` to understand
8+
how PostgreSQL executes your queries and identify performance issues.
9+
10+
## Basic Usage
11+
12+
The `explain()` method executes a query with `EXPLAIN ANALYZE` and returns a structured `Plan` object:
13+
14+
```php
15+
<?php
16+
17+
use function Flow\PostgreSql\DSL\{pgsql_client, pgsql_connection, select, col, table, eq, literal};
18+
19+
$client = pgsql_client(pgsql_connection('host=localhost dbname=mydb'));
20+
21+
$query = select(col('id'), col('name'))
22+
->from(table('users'))
23+
->where(eq(col('status'), literal('active')));
24+
25+
$plan = $client->explain($query);
26+
27+
echo "Execution time: {$plan->executionTime()}ms\n";
28+
echo "Planning time: {$plan->planningTime()}ms\n";
29+
echo "Total cost: {$plan->rootNode()->cost()->total()}\n";
30+
```
31+
32+
You can also pass raw SQL strings with parameters:
33+
34+
```php
35+
<?php
36+
37+
$plan = $client->explain(
38+
'SELECT * FROM orders WHERE user_id = $1 AND status = $2',
39+
[42, 'pending']
40+
);
41+
```
42+
43+
## The Plan Object
44+
45+
The `Plan` object provides access to execution statistics and the full node tree:
46+
47+
```php
48+
<?php
49+
50+
$plan = $client->explain($query);
51+
52+
// Execution statistics
53+
$plan->executionTime(); // Total execution time in milliseconds
54+
$plan->planningTime(); // Query planning time in milliseconds
55+
56+
// Root node of the execution plan
57+
$rootNode = $plan->rootNode();
58+
59+
// All nodes as a flat list
60+
foreach ($plan->allNodes() as $node) {
61+
echo "{$node->nodeType()->value}: {$node->cost()->total()}\n";
62+
}
63+
```
64+
65+
## Plan Nodes
66+
67+
Each node in the plan tree represents an operation PostgreSQL performs:
68+
69+
```php
70+
<?php
71+
72+
$node = $plan->rootNode();
73+
74+
// Node type (Seq Scan, Index Scan, Hash Join, etc.)
75+
$node->nodeType(); // PlanNodeType enum
76+
$node->nodeType()->value; // 'Seq Scan', 'Index Scan', etc.
77+
78+
// Cost estimates
79+
$node->cost()->startup(); // Cost to return first row
80+
$node->cost()->total(); // Total cost
81+
82+
// Row estimates vs actual
83+
$node->estimatedRows(); // Planner's row estimate
84+
$node->timing()?->actualRows(); // Actual rows (with ANALYZE)
85+
86+
// Timing information (requires ANALYZE)
87+
$node->timing()?->startupTime(); // Time to first row
88+
$node->timing()?->totalActualTime(); // Total execution time
89+
$node->timing()?->loops(); // Number of iterations
90+
91+
// Buffer statistics (requires BUFFERS option)
92+
$node->buffers()?->sharedHit(); // Blocks from shared cache
93+
$node->buffers()?->sharedRead(); // Blocks read from disk
94+
95+
// Scan-specific info
96+
$node->relationName(); // Table name for scans
97+
$node->alias(); // Table alias
98+
$node->indexName(); // Index name for index scans
99+
100+
// Child nodes
101+
foreach ($node->children() as $child) {
102+
// Process child nodes
103+
}
104+
```
105+
106+
## Plan Analyzer
107+
108+
The `PlanAnalyzer` provides quick insights without manually traversing the plan tree:
109+
110+
```php
111+
<?php
112+
113+
use function Flow\PostgreSql\DSL\{sql_analyze};
114+
115+
$plan = $client->explain($query);
116+
$analyzer = sql_analyze($plan);
117+
118+
// Find performance issues
119+
$insights = $analyzer->allInsights();
120+
121+
foreach ($insights as $insight) {
122+
echo "[{$insight->severity->value}] {$insight->type->value}: {$insight->description}\n";
123+
}
124+
```
125+
126+
### Finding Slow Nodes
127+
128+
Identify the slowest operations in your query:
129+
130+
```php
131+
<?php
132+
133+
// Get top 3 slowest nodes
134+
foreach ($analyzer->slowestNodes(limit: 3) as $insight) {
135+
echo "{$insight->node->nodeType()->value}: {$insight->metrics['time_ms']}ms\n";
136+
}
137+
```
138+
139+
### Sequential Scans
140+
141+
Find full table scans that might benefit from indexes:
142+
143+
```php
144+
<?php
145+
146+
foreach ($analyzer->sequentialScans() as $insight) {
147+
echo "Sequential scan on '{$insight->node->relationName()}'\n";
148+
echo " Estimated rows: {$insight->metrics['estimated_rows']}\n";
149+
// Consider adding an index if the table is large
150+
}
151+
```
152+
153+
### Estimate Mismatches
154+
155+
Detect where the planner's row estimates differ significantly from actual rows:
156+
157+
```php
158+
<?php
159+
160+
// Find nodes where actual rows differ by more than 2x from estimates
161+
foreach ($analyzer->estimateMismatches(threshold: 2.0) as $insight) {
162+
echo "Estimate mismatch on {$insight->node->nodeType()->value}\n";
163+
echo " Expected: {$insight->metrics['estimated_rows']}, Actual: {$insight->metrics['actual_rows']}\n";
164+
echo " Ratio: {$insight->metrics['mismatch_ratio']}x\n";
165+
// Consider running ANALYZE on the table or updating statistics
166+
}
167+
```
168+
169+
### External Sorts
170+
171+
Find sort operations that spill to disk:
172+
173+
```php
174+
<?php
175+
176+
foreach ($analyzer->externalSorts() as $insight) {
177+
echo "Disk-based sort: {$insight->metrics['space_used_kb']}KB used\n";
178+
// Consider increasing work_mem or adding an index
179+
}
180+
```
181+
182+
### Inefficient Filters
183+
184+
Find filters that scan many rows but return few:
185+
186+
```php
187+
<?php
188+
189+
// Find filters removing more than 50% of rows
190+
foreach ($analyzer->inefficientFilters(threshold: 0.5) as $insight) {
191+
echo "Inefficient filter: {$insight->metrics['removal_ratio'] * 100}% of rows discarded\n";
192+
// Consider adding an index to reduce rows scanned
193+
}
194+
```
195+
196+
### Buffer Analysis
197+
198+
Analyze cache efficiency and disk reads:
199+
200+
```php
201+
<?php
202+
203+
// Find nodes reading from disk
204+
foreach ($analyzer->diskReads() as $insight) {
205+
echo "Disk reads: {$insight->metrics['blocks_read']} blocks\n";
206+
}
207+
208+
// Find nodes with poor cache hit ratio
209+
foreach ($analyzer->lowCacheHits(threshold: 0.9) as $insight) {
210+
echo "Low cache hit ratio: {$insight->metrics['hit_ratio'] * 100}%\n";
211+
}
212+
```
213+
214+
### Plan Summary
215+
216+
Get aggregate statistics for the entire plan:
217+
218+
```php
219+
<?php
220+
221+
$summary = $analyzer->summary();
222+
223+
echo "Total cost: {$summary->totalCost}\n";
224+
echo "Execution time: {$summary->executionTime}ms\n";
225+
echo "Planning time: {$summary->planningTime}ms\n";
226+
echo "Node count: {$summary->nodeCount}\n";
227+
echo "Sequential scans: {$summary->sequentialScanCount}\n";
228+
echo "Index scans: {$summary->indexScanCount}\n";
229+
230+
if ($summary->hasExternalSort) {
231+
echo "Warning: Query uses disk-based sorting\n";
232+
}
233+
234+
if ($summary->hasDiskReads) {
235+
echo "Warning: Query reads from disk (not fully cached)\n";
236+
}
237+
238+
if ($summary->overallCacheHitRatio !== null) {
239+
echo "Cache hit ratio: " . round($summary->overallCacheHitRatio * 100, 1) . "%\n";
240+
}
241+
```
242+
243+
## EXPLAIN Configuration
244+
245+
Customize what information is collected:
246+
247+
```php
248+
<?php
249+
250+
use function Flow\PostgreSql\DSL\sql_explain_config;
251+
252+
// Default: ANALYZE with BUFFERS, TIMING, and FORMAT JSON
253+
$plan = $client->explain($query);
254+
255+
// Without ANALYZE (no actual execution, estimates only)
256+
$plan = $client->explain($query, config: sql_explain_config(analyze: false));
257+
258+
// Custom configuration
259+
$plan = $client->explain($query, config: sql_explain_config(
260+
analyze: true,
261+
verbose: false,
262+
buffers: true,
263+
timing: true,
264+
costs: true,
265+
));
266+
```
267+
268+
### Configuration Options
269+
270+
| Option | Description | Default |
271+
|-----------|------------------------------------------|---------|
272+
| `analyze` | Execute query and collect actual timings | `true` |
273+
| `verbose` | Include additional details | `false` |
274+
| `buffers` | Include buffer usage statistics | `true` |
275+
| `timing` | Include timing per node | `true` |
276+
| `costs` | Include cost estimates | `true` |
277+
| `format` | Output format (JSON, TEXT, XML, YAML) | `JSON` |
278+
279+
## Insight Types and Severity
280+
281+
### Insight Types
282+
283+
| Type | Description |
284+
|----------------------|----------------------------------------------------------|
285+
| `SLOW_NODE` | Node with high execution time |
286+
| `ESTIMATE_MISMATCH` | Significant difference between estimated and actual rows |
287+
| `SEQUENTIAL_SCAN` | Full table scan (potential missing index) |
288+
| `EXTERNAL_SORT` | Sort operation spilling to disk |
289+
| `INEFFICIENT_FILTER` | Filter removing large percentage of rows |
290+
| `DISK_READ` | Node reading data from disk |
291+
| `LOW_CACHE_HIT` | Node with poor cache hit ratio |
292+
293+
### Severity Levels
294+
295+
| Severity | Description |
296+
|------------|-------------------------------------------------|
297+
| `INFO` | Informational, may not require action |
298+
| `WARNING` | Potential performance issue worth investigating |
299+
| `CRITICAL` | Significant performance problem |

documentation/components/libs/postgresql/client-fetching.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,35 @@ $exists = $client->fetchScalar(
104104
$name = $client->fetchScalar('SELECT name FROM users WHERE id = $1', [1]);
105105
```
106106

107+
## Typed Scalar Methods
108+
109+
For better static analysis and type safety, use the typed scalar methods that assert the return type:
110+
111+
```php
112+
<?php
113+
114+
// fetchScalarInt() - Returns int
115+
$count = $client->fetchScalarInt('SELECT COUNT(*) FROM users WHERE active = $1', [true]);
116+
// $count is guaranteed to be int
117+
118+
// fetchScalarBool() - Returns bool
119+
$exists = $client->fetchScalarBool(
120+
'SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)',
121+
122+
);
123+
// $exists is guaranteed to be bool
124+
125+
// fetchScalarFloat() - Returns float
126+
$average = $client->fetchScalarFloat('SELECT AVG(price) FROM products');
127+
// $average is guaranteed to be float
128+
129+
// fetchScalarString() - Returns string
130+
$name = $client->fetchScalarString('SELECT name FROM users WHERE id = $1', [1]);
131+
// $name is guaranteed to be string
132+
```
133+
134+
These methods throw `InvalidTypeException` if the value cannot be converted to the expected type.
135+
107136
## execute() - Data Modification
108137

109138
For INSERT, UPDATE, DELETE statements. Returns the number of affected rows:

0 commit comments

Comments
 (0)