Skip to content

Commit 70bdda6

Browse files
staabmclxmstaab
andauthored
added stringify types RuntimeConfiguration option (#227)
Co-authored-by: Markus Staab <[email protected]>
1 parent 5227c73 commit 70bdda6

20 files changed

+1618
-4149
lines changed

.github/workflows/phpstan.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ jobs:
5555
mysql -uroot -h127.0.0.1 -proot -e 'create database phpstan_dba;'
5656
mysql -uroot -h127.0.0.1 -proot phpstan_dba < tests/schema.sql
5757
58-
- run: vendor/bin/phpstan
58+
- run: composer phpstan
5959

6060
replay:
6161
name: PHPStan (reflection replay)
@@ -84,4 +84,4 @@ jobs:
8484
with:
8585
composer-options: "--prefer-dist --no-progress"
8686

87-
- run: vendor/bin/phpstan
87+
- run: composer phpstan -- --debug

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ jobs:
5757
mysql -uroot -h127.0.0.1 -proot -e 'create database phpstan_dba;'
5858
mysql -uroot -h127.0.0.1 -proot phpstan_dba < tests/schema.sql
5959
60-
- run: vendor/bin/phpunit
60+
- run: composer phpunit
6161

6262
replay:
6363
name: PHPUnit (reflection replay)
@@ -88,4 +88,4 @@ jobs:
8888
- name: Setup Problem Matchers for PHPUnit
8989
run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
9090

91-
- run: vendor/bin/phpunit
91+
- run: composer phpunit -- --debug

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ In case you are using Doctrine ORM, you might use phpstan-dba in tandem with [ph
1616
**Note:**
1717
At the moment only mysql/mariadb databases are supported. Technically it's not a big problem to support other databases though.
1818

19-
[see the unit-testsuite](https://github.com/staabm/phpstan-dba/tree/main/tests/data) to get a feeling about the current featureset.
19+
[see the unit-testsuite](https://github.com/staabm/phpstan-dba/tree/main/tests/default/data) to get a feeling about the current featureset.
2020

2121

2222
## DEMO
@@ -49,6 +49,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
4949

5050
$config = new RuntimeConfiguration();
5151
// $config->debugMode(true);
52+
// $config->stringifyTypes(true);
5253

5354
QueryReflection::setupReflector(
5455
new RecordingQueryReflector(
@@ -92,6 +93,7 @@ Within your `phpstan-dba-bootstrap.php` file you can configure `phpstan-dba` so
9293
Use the [`RuntimeConfiguration`](https://github.com/staabm/phpstan-dba/tree/main/src/QueryReflection/RuntimeConfiguration.php) builder-object and pass it as a second argument to `QueryReflection::setupReflector()`.
9394

9495
If not configured otherwise, the following defaults are used:
96+
- type-inference works as precise as possible. In case your database access layer returns strings instead of integers and floats, use the [`stringifyTypes`](https://github.com/staabm/phpstan-dba/tree/main/src/QueryReflection/RuntimeConfiguration.php) option.
9597
- when analyzing a php8+ codebase, [`PDO::ERRMODE_EXCEPTION` error handling](https://www.php.net/manual/en/pdo.error-handling.php) is assumed.
9698
- when analyzing a php8.1+ codebase, [`mysqli_report(\MYSQLI_REPORT_ERROR | \MYSQLI_REPORT_STRICT);` error handling](https://www.php.net/mysqli_report) is assumed.
9799

@@ -116,6 +118,7 @@ $cacheFile = __DIR__.'/.phpstan-dba.cache';
116118

117119
$config = new RuntimeConfiguration();
118120
// $config->debugMode(true);
121+
// $config->stringifyTypes(true);
119122

120123
QueryReflection::setupReflector(
121124
new ReplayQueryReflector(

composer.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@
4343
"@csfix"
4444
],
4545
"phpstan": [
46-
"phpstan analyse -c phpstan.neon.dist"
46+
"phpstan analyse -c phpstan.neon.dist",
47+
"phpstan analyse -c tests/default/config/phpstan.neon.dist",
48+
"phpstan analyse -c tests/stringify/config/phpstan.neon.dist"
4749
],
4850
"phpunit": [
49-
"phpunit"
51+
"phpunit -c tests/default/config/phpunit.xml",
52+
"phpunit -c tests/stringify/config/phpunit.xml"
5053
]
5154
},
5255
"config": {

phpstan.neon.dist

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,28 +7,6 @@ parameters:
77

88
paths:
99
- src/
10-
- tests/
1110

1211
bootstrapFiles:
1312
- bootstrap.php
14-
15-
ignoreErrors:
16-
-
17-
message: '#Function Deployer\\runMysqlQuery\(\) should return array<int, array<int, string>>\|null but return statement is missing.#'
18-
path: tests/default/data/runMysqlQuery.php
19-
-
20-
message: '#.*has no return type specified.#'
21-
path: tests/*
22-
-
23-
message: '#.*with no type specified.#'
24-
path: tests/*
25-
-
26-
message: '#.*return type has no value type specified in iterable type iterable.#'
27-
path: tests/*
28-
-
29-
message: '#.*with no value type specified in iterable type array.#'
30-
path: tests/*
31-
32-
excludePaths:
33-
analyseAndScan:
34-
- *Fixture/**

src/QueryReflection/MysqliQueryReflector.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,18 @@
88
use mysqli_result;
99
use mysqli_sql_exception;
1010
use PHPStan\ShouldNotHappenException;
11+
use PHPStan\Type\Accessory\AccessoryNumericStringType;
1112
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
1213
use PHPStan\Type\Constant\ConstantIntegerType;
1314
use PHPStan\Type\Constant\ConstantStringType;
1415
use PHPStan\Type\FloatType;
1516
use PHPStan\Type\IntegerType;
17+
use PHPStan\Type\IntersectionType;
1618
use PHPStan\Type\MixedType;
1719
use PHPStan\Type\StringType;
1820
use PHPStan\Type\Type;
1921
use PHPStan\Type\TypeCombinator;
22+
use PHPStan\Type\UnionType;
2023
use staabm\PHPStanDba\Error;
2124
use staabm\PHPStanDba\Types\MysqlIntegerRanges;
2225

@@ -295,6 +298,18 @@ private function mapMysqlToPHPStanType(int $mysqlType, int $mysqlFlags, int $len
295298
}
296299
}
297300

301+
if (QueryReflection::getRuntimeConfiguration()->isStringifyTypes()) {
302+
$numberType = new UnionType([new IntegerType(), new FloatType()]);
303+
$isNumber = $numberType->isSuperTypeOf($phpstanType)->yes();
304+
305+
if ($isNumber) {
306+
$phpstanType = new IntersectionType([
307+
new StringType(),
308+
new AccessoryNumericStringType(),
309+
]);
310+
}
311+
}
312+
298313
if (false === $notNull) {
299314
$phpstanType = TypeCombinator::addNull($phpstanType);
300315
}

src/QueryReflection/ReflectionCache.php

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@
55
namespace staabm\PHPStanDba\QueryReflection;
66

77
use const LOCK_EX;
8+
use PHPStan\ShouldNotHappenException;
89
use PHPStan\Type\Type;
910
use staabm\PHPStanDba\DbaException;
1011
use staabm\PHPStanDba\Error;
1112

1213
final class ReflectionCache
1314
{
14-
public const SCHEMA_VERSION = 'v3-rename-props';
15+
public const SCHEMA_VERSION = 'v4-runtime-config';
1516

1617
/**
1718
* @var string
@@ -28,6 +29,11 @@ final class ReflectionCache
2829
*/
2930
private $changes = [];
3031

32+
/**
33+
* @var bool
34+
*/
35+
private $initialized = false;
36+
3137
/**
3238
* @var resource
3339
*/
@@ -55,19 +61,40 @@ public static function create(string $cacheFile): self
5561
return new self($cacheFile);
5662
}
5763

64+
/**
65+
* @deprecated use create() instead
66+
*/
5867
public static function load(string $cacheFile): self
5968
{
60-
$reflectionCache = new self($cacheFile);
61-
$cachedRecords = $reflectionCache->readCache(true);
62-
if (null !== $cachedRecords) {
63-
$reflectionCache->records = $cachedRecords;
69+
return new self($cacheFile);
70+
}
71+
72+
/**
73+
* @return array<string, array{error?: ?Error, result?: array<QueryReflector::FETCH_TYPE*, ?Type>}>
74+
*/
75+
private function lazyReadRecords()
76+
{
77+
if ($this->initialized) {
78+
return $this->records;
79+
}
80+
81+
$cache = $this->readCache(true);
82+
if (null !== $cache) {
83+
$this->records = $cache['records'];
84+
} else {
85+
$this->records = [];
6486
}
87+
$this->initialized = true;
6588

66-
return $reflectionCache;
89+
return $this->records;
6790
}
6891

6992
/**
70-
* @return array<string, array{error?: ?Error, result?: array<QueryReflector::FETCH_TYPE*, ?Type>}>|null
93+
* @return array{
94+
* records: array<string, array{error?: ?Error, result?: array<QueryReflector::FETCH_TYPE*, ?Type>}>,
95+
* runtimeConfig: array<string, scalar>,
96+
* schemaVersion: string
97+
* }|null
7198
*/
7299
private function readCache(bool $useReadLock): ?array
73100
{
@@ -88,11 +115,20 @@ private function readCache(bool $useReadLock): ?array
88115
}
89116
}
90117

91-
if (\is_array($cache) && \array_key_exists('schemaVersion', $cache) && self::SCHEMA_VERSION === $cache['schemaVersion']) {
92-
return $cache['records'];
118+
if (!\is_array($cache) || !\array_key_exists('schemaVersion', $cache) || self::SCHEMA_VERSION !== $cache['schemaVersion']) {
119+
return null;
120+
}
121+
122+
if ($cache['runtimeConfig'] !== QueryReflection::getRuntimeConfiguration()->toArray()) {
123+
return null;
93124
}
94125

95-
return null;
126+
if (!\is_array($cache['records'])) {
127+
throw new ShouldNotHappenException();
128+
}
129+
130+
// @phpstan-ignore-next-line
131+
return $cache;
96132
}
97133

98134
public function persist(): void
@@ -122,6 +158,7 @@ public function persist(): void
122158
$cacheContent = '<?php return '.var_export([
123159
'schemaVersion' => self::SCHEMA_VERSION,
124160
'records' => $newRecords,
161+
'runtimeConfig' => QueryReflection::getRuntimeConfiguration()->toArray(),
125162
], true).';';
126163

127164
if (false === file_put_contents($this->cacheFile, $cacheContent)) {
@@ -139,7 +176,9 @@ public function persist(): void
139176

140177
public function hasValidationError(string $queryString): bool
141178
{
142-
if (!\array_key_exists($queryString, $this->records)) {
179+
$records = $this->lazyReadRecords();
180+
181+
if (!\array_key_exists($queryString, $records)) {
143182
return false;
144183
}
145184

@@ -150,7 +189,9 @@ public function hasValidationError(string $queryString): bool
150189

151190
public function getValidationError(string $queryString): ?Error
152191
{
153-
if (!\array_key_exists($queryString, $this->records)) {
192+
$records = $this->lazyReadRecords();
193+
194+
if (!\array_key_exists($queryString, $records)) {
154195
throw new DbaException(sprintf('Cache not populated for query "%s"', $queryString));
155196
}
156197

@@ -164,7 +205,9 @@ public function getValidationError(string $queryString): ?Error
164205

165206
public function putValidationError(string $queryString, ?Error $error): void
166207
{
167-
if (!\array_key_exists($queryString, $this->records)) {
208+
$records = $this->lazyReadRecords();
209+
210+
if (!\array_key_exists($queryString, $records)) {
168211
$this->changes[$queryString] = $this->records[$queryString] = [];
169212
}
170213

@@ -178,7 +221,9 @@ public function putValidationError(string $queryString, ?Error $error): void
178221
*/
179222
public function hasResultType(string $queryString, int $fetchType): bool
180223
{
181-
if (!\array_key_exists($queryString, $this->records)) {
224+
$records = $this->lazyReadRecords();
225+
226+
if (!\array_key_exists($queryString, $records)) {
182227
return false;
183228
}
184229

@@ -195,7 +240,9 @@ public function hasResultType(string $queryString, int $fetchType): bool
195240
*/
196241
public function getResultType(string $queryString, int $fetchType): ?Type
197242
{
198-
if (!\array_key_exists($queryString, $this->records)) {
243+
$records = $this->lazyReadRecords();
244+
245+
if (!\array_key_exists($queryString, $records)) {
199246
throw new DbaException(sprintf('Cache not populated for query "%s"', $queryString));
200247
}
201248

@@ -216,7 +263,9 @@ public function getResultType(string $queryString, int $fetchType): ?Type
216263
*/
217264
public function putResultType(string $queryString, int $fetchType, ?Type $resultType): void
218265
{
219-
if (!\array_key_exists($queryString, $this->records)) {
266+
$records = $this->lazyReadRecords();
267+
268+
if (!\array_key_exists($queryString, $records)) {
220269
$this->changes[$queryString] = $this->records[$queryString] = [];
221270
}
222271

src/QueryReflection/RuntimeConfiguration.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,19 @@ final class RuntimeConfiguration
3131
* @var bool
3232
*/
3333
private $debugMode = false;
34+
/**
35+
* @var bool
36+
*/
37+
private $stringifyTypes = false;
3438

3539
public static function create(): self
3640
{
3741
return new self();
3842
}
3943

4044
/**
45+
* Defines whether the database access returns `false` on error or throws exceptions.
46+
*
4147
* @param self::ERROR_MODE* $mode
4248
*/
4349
public function errorMode(string $mode): self
@@ -54,11 +60,27 @@ public function debugMode(bool $mode): self
5460
return $this;
5561
}
5662

63+
/**
64+
* Infer string-types instead of more precise types.
65+
* This might be necessary in case your are using `\PDO::ATTR_EMULATE_PREPARES` or `\PDO::ATTR_STRINGIFY_FETCHES`.
66+
*/
67+
public function stringifyTypes(bool $stringify): self
68+
{
69+
$this->stringifyTypes = $stringify;
70+
71+
return $this;
72+
}
73+
5774
public function isDebugEnabled(): bool
5875
{
5976
return $this->debugMode;
6077
}
6178

79+
public function isStringifyTypes(): bool
80+
{
81+
return $this->stringifyTypes;
82+
}
83+
6284
public function throwsPdoExceptions(PhpVersion $phpVersion): bool
6385
{
6486
if (self::ERROR_MODE_EXCEPTION === $this->errorMode) {
@@ -84,4 +106,16 @@ public function throwsMysqliExceptions(PhpVersion $phpVersion): bool
84106
// since php8.1 the mysqli php-src default error mode changed to exception
85107
return $phpVersion->getVersionId() >= 80100;
86108
}
109+
110+
/**
111+
* @return array<string, scalar>
112+
*/
113+
public function toArray(): array
114+
{
115+
return [
116+
'errorMode' => $this->errorMode,
117+
'debugMode' => $this->debugMode,
118+
'stringifyTypes' => $this->stringifyTypes,
119+
];
120+
}
87121
}

0 commit comments

Comments
 (0)