Skip to content

Commit 8bf38db

Browse files
staabmclxmstaab
andauthored
Support prepared statements type inference (#44)
Co-authored-by: Markus Staab <m.staab@complex-it.de>
1 parent eb654fd commit 8bf38db

File tree

7 files changed

+390
-6
lines changed

7 files changed

+390
-6
lines changed

.phpstan-dba.cache

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,99 @@
430430
)),
431431
),
432432
),
433+
'SELECT email, adaid FROM ada WHERE adaid = 1' =>
434+
array (
435+
'error' => NULL,
436+
'result' =>
437+
array (
438+
3 =>
439+
PHPStan\Type\Constant\ConstantArrayType::__set_state(array(
440+
'keyType' =>
441+
PHPStan\Type\UnionType::__set_state(array(
442+
'types' =>
443+
array (
444+
0 =>
445+
PHPStan\Type\Constant\ConstantIntegerType::__set_state(array(
446+
'value' => 0,
447+
)),
448+
1 =>
449+
PHPStan\Type\Constant\ConstantIntegerType::__set_state(array(
450+
'value' => 1,
451+
)),
452+
2 =>
453+
PHPStan\Type\Constant\ConstantStringType::__set_state(array(
454+
'value' => 'adaid',
455+
'isClassString' => false,
456+
)),
457+
3 =>
458+
PHPStan\Type\Constant\ConstantStringType::__set_state(array(
459+
'value' => 'email',
460+
'isClassString' => false,
461+
)),
462+
),
463+
)),
464+
'itemType' =>
465+
PHPStan\Type\UnionType::__set_state(array(
466+
'types' =>
467+
array (
468+
0 =>
469+
PHPStan\Type\IntegerRangeType::__set_state(array(
470+
'min' => 0,
471+
'max' => 4294967295,
472+
)),
473+
1 =>
474+
PHPStan\Type\StringType::__set_state(array(
475+
)),
476+
),
477+
)),
478+
'keyTypes' =>
479+
array (
480+
0 =>
481+
PHPStan\Type\Constant\ConstantStringType::__set_state(array(
482+
'value' => 'email',
483+
'isClassString' => false,
484+
)),
485+
1 =>
486+
PHPStan\Type\Constant\ConstantIntegerType::__set_state(array(
487+
'value' => 0,
488+
)),
489+
2 =>
490+
PHPStan\Type\Constant\ConstantStringType::__set_state(array(
491+
'value' => 'adaid',
492+
'isClassString' => false,
493+
)),
494+
3 =>
495+
PHPStan\Type\Constant\ConstantIntegerType::__set_state(array(
496+
'value' => 1,
497+
)),
498+
),
499+
'valueTypes' =>
500+
array (
501+
0 =>
502+
PHPStan\Type\StringType::__set_state(array(
503+
)),
504+
1 =>
505+
PHPStan\Type\StringType::__set_state(array(
506+
)),
507+
2 =>
508+
PHPStan\Type\IntegerRangeType::__set_state(array(
509+
'min' => 0,
510+
'max' => 4294967295,
511+
)),
512+
3 =>
513+
PHPStan\Type\IntegerRangeType::__set_state(array(
514+
'min' => 0,
515+
'max' => 4294967295,
516+
)),
517+
),
518+
'nextAutoIndex' => 2,
519+
'optionalKeys' =>
520+
array (
521+
),
522+
'allArrays' => NULL,
523+
)),
524+
),
525+
),
433526
'SELECT email, adaid FROM ada WHERE adaid=1' =>
434527
array (
435528
'error' => NULL,

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,6 @@ composer require --dev staabm/phpstan-dba
137137

138138
## Todos
139139

140-
- support placeholders in queries
141-
- support named placeholders in queries
142140
- support the [phpstan/extension-installer](https://github.com/phpstan/extension-installer)
143141
- support [more mysql to PHPStan type mappings](https://github.com/staabm/phpstan-dba/blob/b868f40c80afcecd3de408df3801b5a24e220dd8/src/QueryReflection/MysqliQueryReflector.php#L111)
144142
- cover more real world examples and fine tune the [QueryReflection classes](https://github.com/staabm/phpstan-dba/tree/main/src/QueryReflection)

composer.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
"require": {
55
"php": "^8.0",
66
"ext-mysqli": "*",
7-
"phpstan/phpstan": "^1.2"
7+
"phpstan/phpstan": "^1.2",
8+
"rector/rector": "^0.12.11"
89
},
910
"require-dev": {
1011
"ext-pdo": "*",
1112
"friendsofphp/php-cs-fixer": "3.4.0",
12-
"phpunit/phpunit": "^9",
13-
"phpstan/phpstan-php-parser": "^1.0",
14-
"phpstan/phpstan-strict-rules": "^1.1",
13+
"phpstan/phpstan-php-parser": "^1.1",
1514
"phpstan/phpstan-phpunit": "^1.0",
15+
"phpstan/phpstan-strict-rules": "^1.1",
16+
"phpunit/phpunit": "^9",
1617
"symplify/phpstan-extensions": "^10.0"
1718
},
1819
"autoload": {

config/extensions.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ services:
99
tags:
1010
- phpstan.broker.dynamicMethodReturnTypeExtension
1111

12+
-
13+
class: staabm\PHPStanDba\Extensions\PdoExecuteTypeSpecifyingExtension
14+
tags:
15+
- phpstan.typeSpecifier.methodTypeSpecifyingExtension
16+
1217
-
1318
class: staabm\PHPStanDba\Extensions\PdoStatementFetchDynamicReturnTypeExtension
1419
tags:
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace staabm\PHPStanDba\Extensions;
6+
7+
use PDOStatement;
8+
use PhpParser\Node;
9+
use PhpParser\Node\Expr;
10+
use PhpParser\Node\Expr\Assign;
11+
use PhpParser\Node\Expr\MethodCall;
12+
use PhpParser\Node\FunctionLike;
13+
use PhpParser\NodeFinder;
14+
use PHPStan\Analyser\Scope;
15+
use PHPStan\Analyser\SpecifiedTypes;
16+
use PHPStan\Analyser\TypeSpecifier;
17+
use PHPStan\Analyser\TypeSpecifierAwareExtension;
18+
use PHPStan\Analyser\TypeSpecifierContext;
19+
use PHPStan\Reflection\MethodReflection;
20+
use PHPStan\ShouldNotHappenException;
21+
use PHPStan\Type\Constant\ConstantArrayType;
22+
use PHPStan\Type\Constant\ConstantIntegerType;
23+
use PHPStan\Type\Constant\ConstantStringType;
24+
use PHPStan\Type\ConstantScalarType;
25+
use PHPStan\Type\Generic\GenericObjectType;
26+
use PHPStan\Type\MethodTypeSpecifyingExtension;
27+
use PHPStan\Type\Type;
28+
use staabm\PHPStanDba\QueryReflection\QueryReflection;
29+
use staabm\PHPStanDba\QueryReflection\QueryReflector;
30+
use Symplify\Astral\ValueObject\AttributeKey;
31+
32+
final class PdoExecuteTypeSpecifyingExtension implements MethodTypeSpecifyingExtension, TypeSpecifierAwareExtension
33+
{
34+
private TypeSpecifier $typeSpecifier;
35+
private NodeFinder $nodeFinder;
36+
37+
public function __construct()
38+
{
39+
$this->nodeFinder = new NodeFinder();
40+
}
41+
42+
public function getClass(): string
43+
{
44+
return PDOStatement::class;
45+
}
46+
47+
public function isMethodSupported(MethodReflection $methodReflection, MethodCall $node, TypeSpecifierContext $context): bool
48+
{
49+
return 'execute' === $methodReflection->getName();
50+
}
51+
52+
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
53+
{
54+
$this->typeSpecifier = $typeSpecifier;
55+
}
56+
57+
public function specifyTypes(MethodReflection $methodReflection, MethodCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes
58+
{
59+
// keep original param name because named-parameters
60+
$methodCall = $node;
61+
$stmtType = $scope->getType($methodCall->var);
62+
63+
$inferedType = $this->inferStatementType($methodCall, $scope);
64+
if (null !== $inferedType) {
65+
return $this->typeSpecifier->create($methodCall->var, $inferedType, TypeSpecifierContext::createTruthy(), true);
66+
}
67+
68+
return $this->typeSpecifier->create($methodCall->var, $stmtType, TypeSpecifierContext::createTruthy());
69+
}
70+
71+
private function inferStatementType(MethodCall $methodCall, Scope $scope): ?Type
72+
{
73+
$args = $methodCall->getArgs();
74+
75+
if (0 === \count($args)) {
76+
return null;
77+
}
78+
79+
$parameterTypes = $scope->getType($args[0]->value);
80+
$parameters = $this->resolveParameters($parameterTypes);
81+
$queryExpr = $this->findQueryStringExpression($methodCall);
82+
if (null === $queryExpr) {
83+
return null;
84+
}
85+
86+
// resolve query parameter from "prepare"
87+
if ($queryExpr instanceof MethodCall) {
88+
$args = $queryExpr->getArgs();
89+
$queryExpr = $args[0]->value;
90+
}
91+
92+
$queryReflection = new QueryReflection();
93+
$queryString = $queryReflection->resolveQueryString($queryExpr, $scope);
94+
if (null === $queryString) {
95+
return null;
96+
}
97+
98+
$reflectionFetchType = QueryReflector::FETCH_TYPE_BOTH;
99+
$queryString = $this->replaceParameters($queryString, $parameters);
100+
$resultType = $queryReflection->getResultType($queryString, $reflectionFetchType);
101+
102+
if ($resultType) {
103+
return new GenericObjectType(PDOStatement::class, [$resultType]);
104+
}
105+
106+
return null;
107+
}
108+
109+
/**
110+
* @param array<string|int, string> $parameters
111+
*/
112+
private function replaceParameters(string $queryString, array $parameters): string
113+
{
114+
$replaceFirst = function (string $haystack, string $needle, string $replace) {
115+
$pos = strpos($haystack, $needle);
116+
if (false !== $pos) {
117+
return substr_replace($haystack, $replace, $pos, \strlen($needle));
118+
}
119+
120+
return $haystack;
121+
};
122+
123+
foreach ($parameters as $placeholderKey => $value) {
124+
if (\is_int($placeholderKey)) {
125+
$queryString = $replaceFirst($queryString, '?', $value);
126+
} else {
127+
$queryString = str_replace($placeholderKey, $value, $queryString);
128+
}
129+
}
130+
131+
return $queryString;
132+
}
133+
134+
/**
135+
* @return array<string|int, string>
136+
*/
137+
private function resolveParameters(Type $parameterTypes): array
138+
{
139+
$parameters = [];
140+
141+
if ($parameterTypes instanceof ConstantArrayType) {
142+
$keyTypes = $parameterTypes->getKeyTypes();
143+
$valueTypes = $parameterTypes->getValueTypes();
144+
145+
foreach ($keyTypes as $i => $keyType) {
146+
if ($keyType instanceof ConstantStringType) {
147+
$placeholderName = $keyType->getValue();
148+
149+
if (!str_starts_with($placeholderName, ':')) {
150+
$placeholderName = ':'.$placeholderName;
151+
}
152+
153+
if ($valueTypes[$i] instanceof ConstantScalarType) {
154+
$parameters[$placeholderName] = (string) ($valueTypes[$i]->getValue());
155+
}
156+
} elseif ($keyType instanceof ConstantIntegerType) {
157+
if ($valueTypes[$i] instanceof ConstantScalarType) {
158+
$parameters[$keyType->getValue()] = (string) ($valueTypes[$i]->getValue());
159+
}
160+
}
161+
}
162+
}
163+
164+
return $parameters;
165+
}
166+
167+
private function findQueryStringExpression(MethodCall $methodCall): ?Expr
168+
{
169+
// todo: use astral simpleNameResolver
170+
$nameResolver = function ($node) {
171+
if (\is_string($node->name)) {
172+
return $node->name;
173+
}
174+
if ($node->name instanceof Node\Identifier) {
175+
return $node->name->toString();
176+
}
177+
};
178+
179+
$current = $methodCall;
180+
while (null !== $current) {
181+
/** @var Assign|null $assign */
182+
$assign = $this->findFirstPreviousOfNode($current, function ($node) {
183+
return $node instanceof Assign;
184+
});
185+
186+
if (null !== $assign && $nameResolver($assign->var) === $nameResolver($methodCall->var)) {
187+
return $assign->expr;
188+
}
189+
190+
$current = $assign;
191+
}
192+
193+
return null;
194+
}
195+
196+
/**
197+
* @param callable(Node $node):bool $filter
198+
*/
199+
private function findFirstPreviousOfNode(Node $node, callable $filter): ?Node
200+
{
201+
// move to previous expression
202+
$previousStatement = $node->getAttribute(AttributeKey::PREVIOUS);
203+
if (null !== $previousStatement) {
204+
if (!$previousStatement instanceof Node) {
205+
throw new ShouldNotHappenException();
206+
}
207+
$foundNode = $this->findFirst([$previousStatement], $filter);
208+
// we found what we need
209+
if (null !== $foundNode) {
210+
return $foundNode;
211+
}
212+
213+
return $this->findFirstPreviousOfNode($previousStatement, $filter);
214+
}
215+
216+
$parent = $node->getAttribute(AttributeKey::PARENT);
217+
if ($parent instanceof FunctionLike) {
218+
return null;
219+
}
220+
221+
if ($parent instanceof Node) {
222+
return $this->findFirstPreviousOfNode($parent, $filter);
223+
}
224+
225+
return null;
226+
}
227+
228+
/**
229+
* @param Node|Node[] $nodes
230+
* @param callable(Node $node):bool $filter
231+
*/
232+
private function findFirst(Node|array $nodes, callable $filter): ?Node
233+
{
234+
return $this->nodeFinder->findFirst($nodes, $filter);
235+
}
236+
}

tests/DbaInferenceTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public function dataFileAsserts(): iterable
2929
yield from $this->gatherAssertTypes(__DIR__.'/data/pdo-fetch-types.php');
3030

3131
yield from $this->gatherAssertTypes(__DIR__.'/data/pdo-column-count.php');
32+
yield from $this->gatherAssertTypes(__DIR__.'/data/pdo-stmt-execute.php');
3233

3334
yield from $this->gatherAssertTypes(__DIR__.'/data/mysqli.php');
3435
yield from $this->gatherAssertTypes(__DIR__.'/data/mysqli-escape.php');

0 commit comments

Comments
 (0)