Skip to content

Commit 2f5da91

Browse files
staabmclxmstaab
andauthored
mysql query plan analyzer (#377)
Co-authored-by: Markus Staab <[email protected]>
1 parent fa34896 commit 2f5da91

26 files changed

+3544
-757
lines changed

.phpstan-dba-mysqli.cache

Lines changed: 111 additions & 550 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.phpstan-dba-pdo-mysql.cache

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This extension provides the following features:
88

99
* result set type-inferrence
1010
* inspect sql queries, detect errors and placeholder/bound value mismatches
11+
* query plan analysis to detect performance issues
1112
* builtin support for `doctrine/dbal`, `mysqli`, and `PDO`
1213
* API to configure the same features for your custom sql based database access layer
1314

@@ -49,6 +50,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
4950
$config = new RuntimeConfiguration();
5051
// $config->debugMode(true);
5152
// $config->stringifyTypes(true);
53+
// $config->analyzeQueryPlans(true);
5254

5355
// TODO: Put your database credentials here
5456
$mysqli = new mysqli('hostname', 'username', 'password', 'database');
@@ -97,6 +99,7 @@ includes:
9799

98100
- [Runtime configuration](https://github.com/staabm/phpstan-dba/blob/main/docs/configuration.md)
99101
- [Record and Replay](https://github.com/staabm/phpstan-dba/blob/main/docs/record-and-replay.md)
102+
- [Query Plan Analysis](https://github.com/staabm/phpstan-dba/blob/main/docs/query-plan-analysis.md)
100103
- [Custom Query APIs Support](https://github.com/staabm/phpstan-dba/blob/main/docs/rules.md)
101104
- [MySQL Support](https://github.com/staabm/phpstan-dba/blob/main/docs/mysql.md)
102105
- [PGSQL Support](https://github.com/staabm/phpstan-dba/blob/main/docs/pgsql.md)

config/dba.neon

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,33 @@ services:
5656
functionNames:
5757
- 'Deployer\runMysqlQuery#0'
5858
- 'mysqli_query#1'
59+
60+
-
61+
class: staabm\PHPStanDba\Rules\QueryPlanAnalyzerRule
62+
tags: [phpstan.rules.rule]
63+
arguments:
64+
classMethods:
65+
# prepared statement methods
66+
- 'Doctrine\DBAL\Connection::executeQuery#0'
67+
- 'Doctrine\DBAL\Connection::executeCacheQuery#0'
68+
- 'Doctrine\DBAL\Connection::executeStatement#0'
69+
- 'Doctrine\DBAL\Connection::fetchAssociative#0'
70+
- 'Doctrine\DBAL\Connection::fetchNumeric#0'
71+
- 'Doctrine\DBAL\Connection::fetchOne#0'
72+
- 'Doctrine\DBAL\Connection::fetchAllNumeric#0'
73+
- 'Doctrine\DBAL\Connection::fetchAllAssociative#0'
74+
- 'Doctrine\DBAL\Connection::fetchAllKeyValue#0'
75+
- 'Doctrine\DBAL\Connection::fetchAllAssociativeIndexed#0'
76+
- 'Doctrine\DBAL\Connection::fetchFirstColumn#0'
77+
- 'Doctrine\DBAL\Connection::iterateNumeric#0'
78+
- 'Doctrine\DBAL\Connection::iterateAssociative#0'
79+
- 'Doctrine\DBAL\Connection::iterateKeyValue#0'
80+
- 'Doctrine\DBAL\Connection::iterateAssociativeIndexed#0'
81+
- 'Doctrine\DBAL\Connection::iterateColumn#0'
82+
- 'Doctrine\DBAL\Connection::executeUpdate#0' # deprecated in doctrine
83+
# regular statements
84+
- 'PDO::query#0'
85+
- 'PDO::prepare#0'
86+
- 'mysqli::query#0'
87+
- 'Doctrine\DBAL\Connection::query#0' # deprecated in doctrine
88+
- 'Doctrine\DBAL\Connection::exec#0' # deprecated in doctrine

docs/mysql.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
2424
$config = new RuntimeConfiguration();
2525
// $config->debugMode(true);
2626
// $config->stringifyTypes(true);
27+
// $config->analyzeQueryPlans(true);
2728

2829
// TODO: Put your database credentials here
2930
$mysqli = new mysqli('hostname', 'username', 'password', 'database');

docs/query-plan-analysis.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Query Plan Analysis
2+
3+
Within your `phpstan-dba-bootstrap.php` file, you can optionally enable query plan analysis.
4+
When enabled, `phpstan-dba` will error when queries are not using indices or queries are inefficient.
5+
6+
Passing `true` will enable the feature:
7+
8+
```php
9+
$config = new RuntimeConfiguration();
10+
$config->analyzeQueryPlans(true);
11+
```
12+
13+
For more fine grained control, you can pass a positive-integer describing the number of unindexed reads a query is allowed to execute before being considered inefficient.
14+
This will only affect queries which already use an index.
15+
16+
```php
17+
$config = new RuntimeConfiguration();
18+
$config->analyzeQueryPlans(100000);
19+
```
20+
21+
To disable the effiency analysis but just check for queries not using indices at all, pass `0`.
22+
23+
```php
24+
$config = new RuntimeConfiguration();
25+
$config->analyzeQueryPlans(0);
26+
```
27+
28+
**Note:** For a meaningful performance analysis it is vital to utilize a database, which containts data and schema as similar as possible to the production database.
29+
30+
**Note:** "Query Plan Analysis" requires an active database connection.
31+
32+
**Note:** ["Query Plan Analysis" is not yet supported on the PGSQL driver](https://github.com/staabm/phpstan-dba/issues/378)

docs/record-and-replay.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
2323
$config = new RuntimeConfiguration();
2424
// $config->debugMode(true);
2525
// $config->stringifyTypes(true);
26+
// $config->analyzeQueryPlans(true);
2627

2728
QueryReflection::setupReflector(
2829
new ReplayQueryReflector(
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace staabm\PHPStanDba\Analyzer;
6+
7+
use mysqli;
8+
use PDO;
9+
use PHPStan\ShouldNotHappenException;
10+
use staabm\PHPStanDba\QueryReflection\QueryReflection;
11+
12+
final class QueryPlanAnalyzerMysql
13+
{
14+
/**
15+
* number of unindexed reads allowed before a query is considered inefficient.
16+
*/
17+
public const DEFAULT_UNINDEXED_READS_THRESHOLD = 100000;
18+
/**
19+
* max number of rows in a table, for which we don't report errors, because using a index/table-scan wouldn't improve performance.
20+
*/
21+
public const DEFAULT_SMALL_TABLE_THRESHOLD = 1000;
22+
23+
/**
24+
* @var PDO|mysqli
25+
*/
26+
private $connection;
27+
28+
/**
29+
* @param PDO|mysqli $connection
30+
*/
31+
public function __construct($connection)
32+
{
33+
$this->connection = $connection;
34+
}
35+
36+
/**
37+
* @param non-empty-string $query
38+
*/
39+
public function analyze(string $query): QueryPlanResult
40+
{
41+
if ($this->connection instanceof PDO) {
42+
$stmt = $this->connection->query('EXPLAIN '.$query);
43+
44+
// @phpstan-ignore-next-line
45+
return $this->buildResult($stmt);
46+
} else {
47+
$result = $this->connection->query('EXPLAIN '.$query);
48+
if ($result instanceof \mysqli_result) {
49+
return $this->buildResult($result);
50+
}
51+
}
52+
53+
throw new ShouldNotHappenException();
54+
}
55+
56+
/**
57+
* @param \IteratorAggregate<array-key, array{select_type: string, key: string|null, type: string|null, rows: positive-int, table: ?string}> $it
58+
*/
59+
private function buildResult($it): QueryPlanResult
60+
{
61+
$result = new QueryPlanResult();
62+
63+
$allowedUnindexedReads = QueryReflection::getRuntimeConfiguration()->getNumberOfAllowedUnindexedReads();
64+
if (false === $allowedUnindexedReads) {
65+
throw new ShouldNotHappenException();
66+
}
67+
68+
$allowedRowsNotRequiringIndex = QueryReflection::getRuntimeConfiguration()->getNumberOfRowsNotRequiringIndex();
69+
if (false === $allowedRowsNotRequiringIndex) {
70+
throw new ShouldNotHappenException();
71+
}
72+
73+
foreach ($it as $row) {
74+
// we cannot analyse tables without rows -> mysql will just return 'no matching row in const table'
75+
if (null === $row['table']) {
76+
continue;
77+
}
78+
79+
if (null === $row['key'] && $row['rows'] > $allowedRowsNotRequiringIndex) {
80+
// derived table aka. a expression that generates a table within the scope of a query FROM clause
81+
// is a temporary table, which indexes cannot be created for.
82+
if ('derived' === strtolower($row['select_type'])) {
83+
continue;
84+
}
85+
86+
$result->addRow($row['table'], QueryPlanResult::NO_INDEX);
87+
} else {
88+
if (null !== $row['type'] && 'all' === strtolower($row['type']) && $row['rows'] > $allowedRowsNotRequiringIndex) {
89+
$result->addRow($row['table'], QueryPlanResult::TABLE_SCAN);
90+
} elseif (true === $allowedUnindexedReads && $row['rows'] > self::DEFAULT_UNINDEXED_READS_THRESHOLD) {
91+
$result->addRow($row['table'], QueryPlanResult::UNINDEXED_READS);
92+
} elseif (\is_int($allowedUnindexedReads) && $row['rows'] > $allowedUnindexedReads) {
93+
$result->addRow($row['table'], QueryPlanResult::UNINDEXED_READS);
94+
}
95+
}
96+
}
97+
98+
return $result;
99+
}
100+
}

src/Analyzer/QueryPlanResult.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace staabm\PHPStanDba\Analyzer;
6+
7+
final class QueryPlanResult
8+
{
9+
public const NO_INDEX = 'no-index';
10+
public const TABLE_SCAN = 'table-scan';
11+
public const UNINDEXED_READS = 'unindexed-reads';
12+
13+
/**
14+
* @var array<string, self::*>
15+
*/
16+
private $result = [];
17+
18+
/**
19+
* @param self::* $result
20+
*
21+
* @return void
22+
*/
23+
public function addRow(string $table, string $result)
24+
{
25+
$this->result[$table] = $result;
26+
}
27+
28+
/**
29+
* @return string[]
30+
*/
31+
public function getTablesNotUsingIndex(): array
32+
{
33+
$tables = [];
34+
foreach ($this->result as $table => $result) {
35+
if (self::NO_INDEX === $result) {
36+
$tables[] = $table;
37+
}
38+
}
39+
40+
return $tables;
41+
}
42+
43+
/**
44+
* @return string[]
45+
*/
46+
public function getTablesDoingTableScan(): array
47+
{
48+
$tables = [];
49+
foreach ($this->result as $table => $result) {
50+
if (self::TABLE_SCAN === $result) {
51+
$tables[] = $table;
52+
}
53+
}
54+
55+
return $tables;
56+
}
57+
58+
/**
59+
* @return string[]
60+
*/
61+
public function getTablesDoingUnindexedReads(): array
62+
{
63+
$tables = [];
64+
foreach ($this->result as $table => $result) {
65+
if (self::UNINDEXED_READS === $result) {
66+
$tables[] = $table;
67+
}
68+
}
69+
70+
return $tables;
71+
}
72+
}

src/QueryReflection/BasePdoQueryReflector.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
/**
1919
* @phpstan-type ColumnMeta array{name: string, table: string, native_type: string, len: int, flags: array<int, string>, precision: int<0, max>, pdo_type: PDO::PARAM_* }
2020
*/
21-
abstract class BasePdoQueryReflector implements QueryReflector
21+
abstract class BasePdoQueryReflector implements QueryReflector, RecordingReflector
2222
{
2323
private const PSQL_SYNTAX_ERROR = '42601';
2424
private const PSQL_INVALID_TEXT_REPRESENTATION = '22P02';
@@ -159,6 +159,11 @@ protected function emulateFlags(string $nativeType, string $tableName, string $c
159159
return $this->emulateFlags($nativeType, $tableName, $columnName);
160160
}
161161

162+
public function getDatasource()
163+
{
164+
return $this->pdo;
165+
}
166+
162167
/** @return PDOException|list<ColumnMeta>|null */
163168
abstract protected function simulateQuery(string $queryString);
164169

0 commit comments

Comments
 (0)