Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
661 changes: 111 additions & 550 deletions .phpstan-dba-mysqli.cache

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions .phpstan-dba-pdo-mysql.cache

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This extension provides the following features:

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

Expand Down Expand Up @@ -49,6 +50,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
$config = new RuntimeConfiguration();
// $config->debugMode(true);
// $config->stringifyTypes(true);
// $config->analyzeQueryPlans(true);

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

- [Runtime configuration](https://github.com/staabm/phpstan-dba/blob/main/docs/configuration.md)
- [Record and Replay](https://github.com/staabm/phpstan-dba/blob/main/docs/record-and-replay.md)
- [Query Plan Analysis](https://github.com/staabm/phpstan-dba/blob/main/docs/query-plan-analysis.md)
- [Custom Query APIs Support](https://github.com/staabm/phpstan-dba/blob/main/docs/rules.md)
- [MySQL Support](https://github.com/staabm/phpstan-dba/blob/main/docs/mysql.md)
- [PGSQL Support](https://github.com/staabm/phpstan-dba/blob/main/docs/pgsql.md)
Expand Down
30 changes: 30 additions & 0 deletions config/dba.neon
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,33 @@ services:
functionNames:
- 'Deployer\runMysqlQuery#0'
- 'mysqli_query#1'

-
class: staabm\PHPStanDba\Rules\QueryPlanAnalyzerRule
tags: [phpstan.rules.rule]
arguments:
classMethods:
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add mysqli_query and add a testcase

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will be done in a followup

# prepared statement methods
- 'Doctrine\DBAL\Connection::executeQuery#0'
- 'Doctrine\DBAL\Connection::executeCacheQuery#0'
- 'Doctrine\DBAL\Connection::executeStatement#0'
- 'Doctrine\DBAL\Connection::fetchAssociative#0'
- 'Doctrine\DBAL\Connection::fetchNumeric#0'
- 'Doctrine\DBAL\Connection::fetchOne#0'
- 'Doctrine\DBAL\Connection::fetchAllNumeric#0'
- 'Doctrine\DBAL\Connection::fetchAllAssociative#0'
- 'Doctrine\DBAL\Connection::fetchAllKeyValue#0'
- 'Doctrine\DBAL\Connection::fetchAllAssociativeIndexed#0'
- 'Doctrine\DBAL\Connection::fetchFirstColumn#0'
- 'Doctrine\DBAL\Connection::iterateNumeric#0'
- 'Doctrine\DBAL\Connection::iterateAssociative#0'
- 'Doctrine\DBAL\Connection::iterateKeyValue#0'
- 'Doctrine\DBAL\Connection::iterateAssociativeIndexed#0'
- 'Doctrine\DBAL\Connection::iterateColumn#0'
- 'Doctrine\DBAL\Connection::executeUpdate#0' # deprecated in doctrine
# regular statements
- 'PDO::query#0'
- 'PDO::prepare#0'
- 'mysqli::query#0'
- 'Doctrine\DBAL\Connection::query#0' # deprecated in doctrine
- 'Doctrine\DBAL\Connection::exec#0' # deprecated in doctrine
1 change: 1 addition & 0 deletions docs/mysql.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
$config = new RuntimeConfiguration();
// $config->debugMode(true);
// $config->stringifyTypes(true);
// $config->analyzeQueryPlans(true);

// TODO: Put your database credentials here
$mysqli = new mysqli('hostname', 'username', 'password', 'database');
Expand Down
32 changes: 32 additions & 0 deletions docs/query-plan-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Query Plan Analysis

Within your `phpstan-dba-bootstrap.php` file, you can optionally enable query plan analysis.
When enabled, `phpstan-dba` will error when queries are not using indices or queries are inefficient.

Passing `true` will enable the feature:

```php
$config = new RuntimeConfiguration();
$config->analyzeQueryPlans(true);
```

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.
This will only affect queries which already use an index.

```php
$config = new RuntimeConfiguration();
$config->analyzeQueryPlans(100000);
```

To disable the effiency analysis but just check for queries not using indices at all, pass `0`.

```php
$config = new RuntimeConfiguration();
$config->analyzeQueryPlans(0);
```

**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.

**Note:** "Query Plan Analysis" requires an active database connection.

**Note:** ["Query Plan Analysis" is not yet supported on the PGSQL driver](https://github.com/staabm/phpstan-dba/issues/378)
1 change: 1 addition & 0 deletions docs/record-and-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
$config = new RuntimeConfiguration();
// $config->debugMode(true);
// $config->stringifyTypes(true);
// $config->analyzeQueryPlans(true);

QueryReflection::setupReflector(
new ReplayQueryReflector(
Expand Down
100 changes: 100 additions & 0 deletions src/Analyzer/QueryPlanAnalyzerMysql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
<?php

declare(strict_types=1);

namespace staabm\PHPStanDba\Analyzer;

use mysqli;
use PDO;
use PHPStan\ShouldNotHappenException;
use staabm\PHPStanDba\QueryReflection\QueryReflection;

final class QueryPlanAnalyzerMysql
{
/**
* number of unindexed reads allowed before a query is considered inefficient.
*/
public const DEFAULT_UNINDEXED_READS_THRESHOLD = 100000;
/**
* max number of rows in a table, for which we don't report errors, because using a index/table-scan wouldn't improve performance.
*/
public const DEFAULT_SMALL_TABLE_THRESHOLD = 1000;

/**
* @var PDO|mysqli
*/
private $connection;

/**
* @param PDO|mysqli $connection
*/
public function __construct($connection)
{
$this->connection = $connection;
}

/**
* @param non-empty-string $query
*/
public function analyze(string $query): QueryPlanResult
{
if ($this->connection instanceof PDO) {
$stmt = $this->connection->query('EXPLAIN '.$query);

// @phpstan-ignore-next-line
return $this->buildResult($stmt);
} else {
$result = $this->connection->query('EXPLAIN '.$query);
if ($result instanceof \mysqli_result) {
return $this->buildResult($result);
}
}

throw new ShouldNotHappenException();
}

/**
* @param \IteratorAggregate<array-key, array{select_type: string, key: string|null, type: string|null, rows: positive-int, table: ?string}> $it
*/
private function buildResult($it): QueryPlanResult
{
$result = new QueryPlanResult();

$allowedUnindexedReads = QueryReflection::getRuntimeConfiguration()->getNumberOfAllowedUnindexedReads();
if (false === $allowedUnindexedReads) {
throw new ShouldNotHappenException();
}

$allowedRowsNotRequiringIndex = QueryReflection::getRuntimeConfiguration()->getNumberOfRowsNotRequiringIndex();
if (false === $allowedRowsNotRequiringIndex) {
throw new ShouldNotHappenException();
}

foreach ($it as $row) {
// we cannot analyse tables without rows -> mysql will just return 'no matching row in const table'
if (null === $row['table']) {
continue;
}

if (null === $row['key'] && $row['rows'] > $allowedRowsNotRequiringIndex) {
// derived table aka. a expression that generates a table within the scope of a query FROM clause
// is a temporary table, which indexes cannot be created for.
if ('derived' === strtolower($row['select_type'])) {
continue;
}

$result->addRow($row['table'], QueryPlanResult::NO_INDEX);
} else {
if (null !== $row['type'] && 'all' === strtolower($row['type']) && $row['rows'] > $allowedRowsNotRequiringIndex) {
$result->addRow($row['table'], QueryPlanResult::TABLE_SCAN);
} elseif (true === $allowedUnindexedReads && $row['rows'] > self::DEFAULT_UNINDEXED_READS_THRESHOLD) {
$result->addRow($row['table'], QueryPlanResult::UNINDEXED_READS);
} elseif (\is_int($allowedUnindexedReads) && $row['rows'] > $allowedUnindexedReads) {
$result->addRow($row['table'], QueryPlanResult::UNINDEXED_READS);
}
}
}

return $result;
}
}
72 changes: 72 additions & 0 deletions src/Analyzer/QueryPlanResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php

declare(strict_types=1);

namespace staabm\PHPStanDba\Analyzer;

final class QueryPlanResult
{
public const NO_INDEX = 'no-index';
public const TABLE_SCAN = 'table-scan';
public const UNINDEXED_READS = 'unindexed-reads';

/**
* @var array<string, self::*>
*/
private $result = [];

/**
* @param self::* $result
*
* @return void
*/
public function addRow(string $table, string $result)
{
$this->result[$table] = $result;
}

/**
* @return string[]
*/
public function getTablesNotUsingIndex(): array
{
$tables = [];
foreach ($this->result as $table => $result) {
if (self::NO_INDEX === $result) {
$tables[] = $table;
}
}

return $tables;
}

/**
* @return string[]
*/
public function getTablesDoingTableScan(): array
{
$tables = [];
foreach ($this->result as $table => $result) {
if (self::TABLE_SCAN === $result) {
$tables[] = $table;
}
}

return $tables;
}

/**
* @return string[]
*/
public function getTablesDoingUnindexedReads(): array
{
$tables = [];
foreach ($this->result as $table => $result) {
if (self::UNINDEXED_READS === $result) {
$tables[] = $table;
}
}

return $tables;
}
}
7 changes: 6 additions & 1 deletion src/QueryReflection/BasePdoQueryReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
/**
* @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_* }
*/
abstract class BasePdoQueryReflector implements QueryReflector
abstract class BasePdoQueryReflector implements QueryReflector, RecordingReflector
{
private const PSQL_SYNTAX_ERROR = '42601';
private const PSQL_INVALID_TEXT_REPRESENTATION = '22P02';
Expand Down Expand Up @@ -159,6 +159,11 @@ protected function emulateFlags(string $nativeType, string $tableName, string $c
return $this->emulateFlags($nativeType, $tableName, $columnName);
}

public function getDatasource()
{
return $this->pdo;
}

/** @return PDOException|list<ColumnMeta>|null */
abstract protected function simulateQuery(string $queryString);

Expand Down
7 changes: 6 additions & 1 deletion src/QueryReflection/MysqliQueryReflector.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
use staabm\PHPStanDba\Error;
use staabm\PHPStanDba\TypeMapping\MysqliTypeMapper;

final class MysqliQueryReflector implements QueryReflector
final class MysqliQueryReflector implements QueryReflector, RecordingReflector
{
private const MYSQL_SYNTAX_ERROR_CODE = 1064;
private const MYSQL_UNKNOWN_COLUMN_IN_FIELDLIST = 1054;
Expand Down Expand Up @@ -150,4 +150,9 @@ private function simulateQuery(string $queryString)
$this->db->rollback();
}
}

public function getDatasource()
{
return $this->db;
}
}
40 changes: 40 additions & 0 deletions src/QueryReflection/QueryReflection.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use PHPStan\Type\Type;
use PHPStan\Type\TypeUtils;
use PHPStan\Type\UnionType;
use staabm\PHPStanDba\Analyzer\QueryPlanAnalyzerMysql;
use staabm\PHPStanDba\Analyzer\QueryPlanResult;
use staabm\PHPStanDba\DbaException;
use staabm\PHPStanDba\Error;
use staabm\PHPStanDba\UnresolvableQueryException;
Expand Down Expand Up @@ -407,4 +409,42 @@ public function extractNamedPlaceholders(string $queryString): array

return [];
}

/**
* @return iterable<array-key, QueryPlanResult>
*/
public function analyzeQueryPlan(Scope $scope, Expr $queryExpr, ?Type $parameterTypes): iterable
{
$reflector = self::reflector();

if (!$reflector instanceof RecordingReflector) {
throw new DbaException('Query plan analysis is only supported with a recording reflector');
}
if ($reflector instanceof PdoPgSqlQueryReflector) {
throw new DbaException('Query plan analysis is not yet supported with the pdo-pgsql reflector, see https://github.com/staabm/phpstan-dba/issues/378');
}

$ds = $reflector->getDatasource();
if (null === $ds) {
throw new DbaException(sprintf('Unable to create datasource from %s', \get_class($reflector)));
}
$queryPlanAnalyzer = new QueryPlanAnalyzerMysql($ds);

$queryResolver = new QueryResolver();
foreach ($queryResolver->resolve($scope, $queryExpr, $parameterTypes) as $queryString) {
if ('' === $queryString) {
continue;
}

if ('SELECT' !== self::getQueryType($queryString)) {
continue;
}

if ($reflector->validateQueryString($queryString) instanceof Error) {
continue;
}

yield $queryPlanAnalyzer->analyze($queryString);
}
}
}
Loading