Skip to content

Commit f173935

Browse files
author
Pavel Karfík
committed
feat: pgsql query plan analyzer support
1 parent c28ca17 commit f173935

10 files changed

+202
-41
lines changed

src/Analyzer/Analyzer.php

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace staabm\PHPStanDba\Analyzer;
6+
7+
interface Analyzer
8+
{
9+
public function analyze(string $query): QueryPlanResult;
10+
}

src/Analyzer/QueryPlanAnalyzerMysql.php

+5-5
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
use PHPStan\ShouldNotHappenException;
1010
use staabm\PHPStanDba\QueryReflection\QueryReflection;
1111

12-
final class QueryPlanAnalyzerMysql
12+
final class QueryPlanAnalyzerMysql implements Analyzer
1313
{
1414
/**
1515
* @deprecated use QueryPlanAnalyzer::DEFAULT_UNINDEXED_READS_THRESHOLD instead
@@ -89,19 +89,19 @@ private function buildResult(string $simulatedQuery, $it): QueryPlanResult
8989
continue;
9090
}
9191

92-
$result->addRow($row['table'], QueryPlanResult::NO_INDEX);
92+
$result->addRow($row['table'], QueryPlanResult::ROW_NO_INDEX);
9393
} else {
9494
// don't analyse maybe existing data, to make the result consistent with empty db schemas
9595
if (QueryPlanAnalyzer::TABLES_WITHOUT_DATA === $allowedRowsNotRequiringIndex) {
9696
continue;
9797
}
9898

9999
if (null !== $row['type'] && 'all' === strtolower($row['type']) && $row['rows'] >= $allowedRowsNotRequiringIndex) {
100-
$result->addRow($row['table'], QueryPlanResult::TABLE_SCAN);
100+
$result->addRow($row['table'], QueryPlanResult::ROW_TABLE_SCAN);
101101
} elseif (true === $allowedUnindexedReads && $row['rows'] >= QueryPlanAnalyzer::DEFAULT_UNINDEXED_READS_THRESHOLD) {
102-
$result->addRow($row['table'], QueryPlanResult::UNINDEXED_READS);
102+
$result->addRow($row['table'], QueryPlanResult::ROW_UNINDEXED_READS);
103103
} elseif (\is_int($allowedUnindexedReads) && $row['rows'] >= $allowedUnindexedReads) {
104-
$result->addRow($row['table'], QueryPlanResult::UNINDEXED_READS);
104+
$result->addRow($row['table'], QueryPlanResult::ROW_UNINDEXED_READS);
105105
}
106106
}
107107
}
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace staabm\PHPStanDba\Analyzer;
6+
7+
use PDO;
8+
use PHPStan\ShouldNotHappenException;
9+
use staabm\PHPStanDba\QueryReflection\QueryReflection;
10+
11+
final class QueryPlanAnalyzerPgSql implements Analyzer
12+
{
13+
/** @var PDO */
14+
private $connection;
15+
16+
public function __construct(PDO $connection)
17+
{
18+
$this->connection = $connection;
19+
}
20+
21+
public function analyze(string $query): QueryPlanResult
22+
{
23+
$simulatedQuery = 'EXPLAIN (FORMAT JSON) '.$query;
24+
25+
$stmt = $this->connection->query($simulatedQuery);
26+
27+
return $this->buildResult($simulatedQuery, $stmt);
28+
}
29+
30+
/** @param \PDOStatement<array<float|int|string|null>> $stmt */
31+
private static function buildResult(string $simulatedQuery, $stmt): QueryPlanResult
32+
{
33+
$allowedUnindexedReads = QueryReflection::getRuntimeConfiguration()->getNumberOfAllowedUnindexedReads();
34+
if (false === $allowedUnindexedReads) {
35+
throw new ShouldNotHappenException();
36+
}
37+
38+
$allowedRowsNotRequiringIndex = QueryReflection::getRuntimeConfiguration()->getNumberOfRowsNotRequiringIndex();
39+
if (false === $allowedRowsNotRequiringIndex) {
40+
throw new ShouldNotHappenException();
41+
}
42+
43+
$queryPlanResult = new QueryPlanResult($simulatedQuery);
44+
$result = $stmt->fetch();
45+
$queryPlans = \is_array($result) && \array_key_exists('QUERY PLAN', $result)
46+
? json_decode($result['QUERY PLAN'], true)
47+
: [];
48+
\assert(\is_array($queryPlans));
49+
foreach ($queryPlans as $queryPlan) {
50+
$plan = $queryPlan['Plan'];
51+
$table = $plan['Alias'] ?? null;
52+
53+
if (null === $table) {
54+
continue;
55+
}
56+
57+
$rowReads = $plan['Plan Rows'];
58+
$nodeType = $plan['Node Type'];
59+
if ('Seq Scan' === $nodeType && $rowReads >= $allowedRowsNotRequiringIndex) {
60+
$queryPlanResult->addRow($table, QueryPlanResult::ROW_SEQ_SCAN);
61+
}
62+
if ('Bitmap Heap Scan' === $nodeType) {
63+
$queryPlanResult->addRow($table, QueryPlanResult::ROW_BITMAP_HEAP_SCAN);
64+
}
65+
if ('Bitmap Index Scan' === $nodeType) {
66+
$queryPlanResult->addRow($table, QueryPlanResult::ROW_BITMAP_INDEX_SCAN);
67+
}
68+
if ('Index Scan' === $nodeType) {
69+
$queryPlanResult->addRow($table, QueryPlanResult::ROW_INDEX_SCAN);
70+
}
71+
if ('Aggregate' === $nodeType) {
72+
$queryPlanResult->addRow($table, QueryPlanResult::ROW_AGGREGATE);
73+
}
74+
if ('Hash Aggregate' === $nodeType) {
75+
$queryPlanResult->addRow($table, QueryPlanResult::ROW_HASH_AGGREGATE);
76+
}
77+
}
78+
79+
return $queryPlanResult;
80+
}
81+
}

src/Analyzer/QueryPlanResult.php

+36-8
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,31 @@
66

77
final class QueryPlanResult
88
{
9-
public const NO_INDEX = 'no-index';
10-
public const TABLE_SCAN = 'table-scan';
11-
public const UNINDEXED_READS = 'unindexed-reads';
9+
public const ROW_NO_INDEX = 'no-index';
10+
public const ROW_TABLE_SCAN = 'table-scan';
11+
public const ROW_UNINDEXED_READS = 'unindexed-reads';
1212

1313
/**
14-
* @var array<string, self::*>
14+
* @see https://www.postgresql.org/docs/current/sql-explain.html
15+
* @see https://www.postgresql.org/docs/current/using-explain.html
16+
*/
17+
public const ROW_AGGREGATE = 'aggregate';
18+
public const ROW_BITMAP_HEAP_SCAN = 'bitmap-heap-scan';
19+
public const ROW_BITMAP_INDEX_SCAN = 'bitmap-index-scan';
20+
public const ROW_HASH_AGGREGATE = 'hash-aggregate';
21+
public const ROW_INDEX_SCAN = 'index-scan';
22+
public const ROW_SEQ_SCAN = 'seq-scan';
23+
24+
private const MYSQL_TIPS = [
25+
self::ROW_NO_INDEX => 'see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/select-optimization.html',
26+
self::ROW_TABLE_SCAN => 'see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/table-scan-avoidance.html',
27+
self::ROW_UNINDEXED_READS => 'see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/select-optimization.html',
28+
];
29+
30+
public const PGSQL_TIP = 'see PostgreSQL Docs https://www.postgresql.org/docs/8.1/performance-tips.html';
31+
32+
/**
33+
* @var array<string, self::ROW_*>
1534
*/
1635
private $result = [];
1736

@@ -31,7 +50,7 @@ public function getSimulatedQuery(): string
3150
}
3251

3352
/**
34-
* @param self::* $result
53+
* @param self::ROW_* $result
3554
*
3655
* @return void
3756
*/
@@ -47,7 +66,7 @@ public function getTablesNotUsingIndex(): array
4766
{
4867
$tables = [];
4968
foreach ($this->result as $table => $result) {
50-
if (self::NO_INDEX === $result) {
69+
if (self::ROW_NO_INDEX === $result || self::ROW_INDEX_SCAN !== $result) {
5170
$tables[] = $table;
5271
}
5372
}
@@ -62,7 +81,7 @@ public function getTablesDoingTableScan(): array
6281
{
6382
$tables = [];
6483
foreach ($this->result as $table => $result) {
65-
if (self::TABLE_SCAN === $result) {
84+
if (self::ROW_TABLE_SCAN === $result || self::ROW_SEQ_SCAN === $result) {
6685
$tables[] = $table;
6786
}
6887
}
@@ -77,11 +96,20 @@ public function getTablesDoingUnindexedReads(): array
7796
{
7897
$tables = [];
7998
foreach ($this->result as $table => $result) {
80-
if (self::UNINDEXED_READS === $result) {
99+
if (self::ROW_UNINDEXED_READS === $result || self::ROW_INDEX_SCAN !== $result) {
81100
$tables[] = $table;
82101
}
83102
}
84103

85104
return $tables;
86105
}
106+
107+
public function getTipForTable(string $table): string
108+
{
109+
$result = $this->result[$table];
110+
111+
return \in_array($result, array_keys(self::MYSQL_TIPS), true)
112+
? self::MYSQL_TIPS[$this->result[$table]]
113+
: self::PGSQL_TIP;
114+
}
87115
}

src/QueryReflection/QueryReflection.php

+9-4
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use PHPStan\Type\TypeUtils;
1818
use PHPStan\Type\UnionType;
1919
use staabm\PHPStanDba\Analyzer\QueryPlanAnalyzerMysql;
20+
use staabm\PHPStanDba\Analyzer\QueryPlanAnalyzerPgSql;
2021
use staabm\PHPStanDba\Analyzer\QueryPlanQueryResolver;
2122
use staabm\PHPStanDba\Analyzer\QueryPlanResult;
2223
use staabm\PHPStanDba\Ast\ExpressionFinder;
@@ -465,15 +466,19 @@ public function analyzeQueryPlan(Scope $scope, Expr $queryExpr, ?Type $parameter
465466
if (!$reflector instanceof RecordingReflector) {
466467
throw new DbaException('Query plan analysis is only supported with a recording reflector');
467468
}
468-
if ($reflector instanceof PdoPgSqlQueryReflector) {
469-
throw new DbaException('Query plan analysis is not yet supported with the pdo-pgsql reflector, see https://github.com/staabm/phpstan-dba/issues/378');
470-
}
471469

472470
$ds = $reflector->getDatasource();
473471
if (null === $ds) {
474472
throw new DbaException(sprintf('Unable to create datasource from %s', \get_class($reflector)));
475473
}
476-
$queryPlanAnalyzer = new QueryPlanAnalyzerMysql($ds);
474+
475+
$innerReflector = method_exists($reflector, 'getReflector') ? $reflector->getReflector() : null;
476+
if ($innerReflector instanceof PdoPgSqlQueryReflector) {
477+
\assert($ds instanceof \PDO);
478+
$queryPlanAnalyzer = new QueryPlanAnalyzerPgSql($ds);
479+
} else {
480+
$queryPlanAnalyzer = new QueryPlanAnalyzerMysql($ds);
481+
}
477482

478483
$queryResolver = new QueryPlanQueryResolver();
479484
foreach ($queryResolver->resolve($scope, $queryExpr, $parameterTypes) as $queryString) {

src/QueryReflection/RecordingQueryReflector.php

+5
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,9 @@ public function getDatasource()
6363

6464
return null;
6565
}
66+
67+
public function getReflector(): QueryReflector
68+
{
69+
return $this->reflector;
70+
}
6671
}

src/Rules/QueryPlanAnalyzerRule.php

+3-3
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ private function analyze(CallLike $callLike, Scope $scope): array
154154
$table
155155
))
156156
->line($callLike->getLine())
157-
->tip('see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/select-optimization.html')
157+
->tip($queryPlanResult->getTipForTable($table))
158158
->build();
159159
}
160160
} else {
@@ -165,7 +165,7 @@ private function analyze(CallLike $callLike, Scope $scope): array
165165
$table
166166
))
167167
->line($callLike->getLine())
168-
->tip('see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/table-scan-avoidance.html')
168+
->tip($queryPlanResult->getTipForTable($table))
169169
->build();
170170
}
171171

@@ -176,7 +176,7 @@ private function analyze(CallLike $callLike, Scope $scope): array
176176
$table
177177
))
178178
->line($callLike->getLine())
179-
->tip('see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/select-optimization.html')
179+
->tip($queryPlanResult->getTipForTable($table))
180180
->build();
181181
}
182182
}

tests/rules/QueryPlanAnalyzerRuleTest.php

+43-11
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PHPStan\Rules\Rule;
66
use PHPStan\Testing\RuleTestCase;
7+
use staabm\PHPStanDba\Analyzer\QueryPlanResult;
78
use staabm\PHPStanDba\QueryReflection\QueryReflection;
89
use staabm\PHPStanDba\Rules\QueryPlanAnalyzerRule;
910

@@ -48,10 +49,6 @@ public static function getAdditionalConfigFiles(): array
4849

4950
public function testNotUsingIndex(): void
5051
{
51-
if ('pdo-pgsql' === getenv('DBA_REFLECTOR')) {
52-
$this->markTestSkipped('query plan analyzer is not yet implemented for pgsql');
53-
}
54-
5552
if ('recording' !== getenv('DBA_MODE')) {
5653
$this->markTestSkipped('query plan analyzer requires a active database connection');
5754
}
@@ -60,7 +57,9 @@ public function testNotUsingIndex(): void
6057
$this->numberOfRowsNotRequiringIndex = 2;
6158

6259
$proposal = "\n\nConsider optimizing the query.\nIn some cases this is not a problem and this error should be ignored.";
63-
$tip = 'see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/select-optimization.html';
60+
$tip = 'pdo-pgsql' === getenv('DBA_REFLECTOR')
61+
? QueryPlanResult::PGSQL_TIP
62+
: 'see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/select-optimization.html';
6463

6564
$this->analyse([__DIR__.'/data/query-plan-analyzer.php'], [
6665
[
@@ -93,10 +92,6 @@ public function testNotUsingIndex(): void
9392

9493
public function testNotUsingIndexInDebugMode(): void
9594
{
96-
if ('pdo-pgsql' === getenv('DBA_REFLECTOR')) {
97-
$this->markTestSkipped('query plan analyzer is not yet implemented for pgsql');
98-
}
99-
10095
if ('recording' !== getenv('DBA_MODE')) {
10196
$this->markTestSkipped('query plan analyzer requires a active database connection');
10297
}
@@ -106,16 +101,53 @@ public function testNotUsingIndexInDebugMode(): void
106101
$this->numberOfRowsNotRequiringIndex = 2;
107102

108103
$proposal = "\n\nConsider optimizing the query.\nIn some cases this is not a problem and this error should be ignored.";
104+
if ('pdo-pgsql' === getenv('DBA_REFLECTOR')) {
105+
$this->analyse([__DIR__.'/data/query-plan-analyzer.php'], [
106+
[
107+
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN (FORMAT JSON) SELECT * FROM ada WHERE email = 'test@example.com'",
108+
12,
109+
QueryPlanResult::PGSQL_TIP,
110+
],
111+
[
112+
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN (FORMAT JSON) SELECT *,adaid FROM ada WHERE email = 'test@example.com'",
113+
17,
114+
QueryPlanResult::PGSQL_TIP,
115+
],
116+
[
117+
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN (FORMAT JSON) SELECT * FROM ada WHERE email = '1970-01-01'",
118+
22,
119+
QueryPlanResult::PGSQL_TIP,
120+
],
121+
[
122+
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN (FORMAT JSON) SELECT * FROM ada WHERE email = '1970-01-01'",
123+
23,
124+
QueryPlanResult::PGSQL_TIP,
125+
],
126+
[
127+
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN (FORMAT JSON) SELECT * FROM ada WHERE email = '1970-01-01'",
128+
28,
129+
QueryPlanResult::PGSQL_TIP,
130+
],
131+
[
132+
'Unresolvable Query: Cannot simulate parameter value for type: mixed.',
133+
61,
134+
'Make sure all variables involved have a non-mixed type and array-types are specified.',
135+
],
136+
]);
137+
138+
return;
139+
}
140+
109141
$tip = 'see Mysql Docs https://dev.mysql.com/doc/refman/8.0/en/select-optimization.html';
110142

111143
$this->analyse([__DIR__.'/data/query-plan-analyzer.php'], [
112144
[
113-
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN SELECT * FROM `ada` WHERE email = 'test@example.com'",
145+
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN SELECT * FROM ada WHERE email = 'test@example.com'",
114146
12,
115147
$tip,
116148
],
117149
[
118-
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN SELECT *,adaid FROM `ada` WHERE email = 'test@example.com'",
150+
"Query is not using an index on table 'ada'.".$proposal."\n\nSimulated query: EXPLAIN SELECT *,adaid FROM ada WHERE email = 'test@example.com'",
119151
17,
120152
$tip,
121153
],

tests/rules/config/.phpunit-phpstan-dba-pdo-mysql.cache

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)