Skip to content

Commit

Permalink
improve result type of mysqli_result::fetch_object using object shapes
Browse files Browse the repository at this point in the history
  • Loading branch information
schlndh committed Apr 14, 2023
1 parent f949f52 commit edff3ca
Show file tree
Hide file tree
Showing 10 changed files with 221 additions and 2 deletions.
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@ cs-fix:

phpstan:
php -d memory_limit=256M vendor/bin/phpstan analyse

phpstan-baseline:
php -d memory_limit=256M vendor/bin/phpstan analyse --generate-baseline
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"require": {
"php": "^8.1",
"ext-mbstring": "*",
"phpstan/phpstan": "^1.10"
"phpstan/phpstan": "^1.10.12"
},
"require-dev": {
"ext-mysqli": "*",
Expand Down
6 changes: 5 additions & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
parameters:
ignoreErrors: []
ignoreErrors:
-
message: "#^Doing instanceof PHPStan\\\\Type\\\\Constant\\\\ConstantStringType is error\\-prone and deprecated\\. Use Type\\:\\:getConstantStrings\\(\\) instead\\.$#"
count: 1
path: src/PHPStan/Helper/MySQLi/PHPStanMySQLiHelper.php
58 changes: 58 additions & 0 deletions src/PHPStan/Helper/MySQLi/PHPStanMySQLiHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
use MariaStan\PHPStan\Helper\PHPStanReturnTypeHelper;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ErrorType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\NeverType;
use PHPStan\Type\NullType;
use PHPStan\Type\ObjectShapeType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\ObjectWithoutClassType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\VerbosityLevel;
Expand Down Expand Up @@ -193,6 +198,59 @@ public function fetchArray(AnalyserResultPHPStanParams $params, ?int $mode): Typ
return TypeCombinator::union($this->getRowType($params, $mode), new NullType(), new ConstantBooleanType(false));
}

public function fetchObject(AnalyserResultPHPStanParams $params, ?string $class): Type
{
$rowType = $this->getRowType($params, MYSQLI_ASSOC);
$baseObjectType = $class !== null
? new ObjectType($class)
: new ObjectWithoutClassType();

// Modified from
// https://github.com/phpstan/phpstan-src/blob/8cdb5c95fd7a7c1a8f47d07637d5ebbbff0bfb86/src/Analyser/MutatingScope.php#L1375
if (count($rowType->getConstantArrays()) > 0) {
$objects = [];

foreach ($rowType->getConstantArrays() as $constantArray) {
$properties = [];
$optionalProperties = [];

foreach ($constantArray->getKeyTypes() as $i => $keyType) {
if (! $keyType instanceof ConstantStringType) {
// an object with integer properties is >weird<
continue;
}

$valueType = $constantArray->getValueTypes()[$i];
$optional = $constantArray->isOptionalKey($i);

if ($optional) {
$optionalProperties[] = $keyType->getValue();
}

$properties[$keyType->getValue()] = $valueType;
}

$intersectedObject = TypeCombinator::intersect(
new ObjectShapeType($properties, $optionalProperties),
$baseObjectType,
);

// This happens if the class is not registered in universalObjectCratesClasses.
if ($intersectedObject instanceof NeverType) {
$intersectedObject = $baseObjectType;
}

$objects[] = $intersectedObject;
}

$objectType = TypeCombinator::union(...$objects);
} else {
$objectType = $baseObjectType;
}

return TypeCombinator::union($objectType, new NullType(), new ConstantBooleanType(false));
}

public function fetchAll(AnalyserResultPHPStanParams $params, ?int $mode): Type
{
return new ArrayType(new IntegerType(), $this->getRowType($params, $mode));
Expand Down
21 changes: 21 additions & 0 deletions src/PHPStan/Type/MySQLi/MySQLiResultDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
'fetch_row',
'fetch_array',
'fetch_column',
'fetch_object',
],
true,
);
Expand Down Expand Up @@ -75,6 +76,10 @@ public function getTypeFromMethodCall(
$this->extractModeFromFirstArg($methodCall, $scope),
),
'fetch_row' => $this->phpstanMysqliHelper->fetchArray($params, MYSQLI_NUM),
'fetch_object' => $this->phpstanMysqliHelper->fetchObject(
$params,
$this->extractClassFromFirstArg($methodCall, $scope),
),
'fetch_array' => $this->phpstanMysqliHelper->fetchArray(
$params,
$this->extractModeFromFirstArg($methodCall, $scope),
Expand Down Expand Up @@ -117,6 +122,22 @@ private function extractModeFromFirstArg(MethodCall $methodCall, Scope $scope):
return null;
}

private function extractClassFromFirstArg(MethodCall $methodCall, Scope $scope): ?string
{
if (count($methodCall->getArgs()) === 0) {
return \stdClass::class;
}

$firstArgType = $scope->getType($methodCall->getArgs()[0]->value);
$constantStrings = $firstArgType->getConstantStrings();

if (count($constantStrings) === 1) {
return $constantStrings[0]->getValue();
}

return null;
}

private function getFallbackReturnType(
MethodReflection $methodReflection,
MethodCall $methodCall,
Expand Down
9 changes: 9 additions & 0 deletions tests/PHPStan/Type/MySQLi/CustomResultClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace MariaStan\PHPStan\Type\MySQLi;

class CustomResultClass
{
}
9 changes: 9 additions & 0 deletions tests/PHPStan/Type/MySQLi/CustomUniversalObjectCrate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace MariaStan\PHPStan\Type\MySQLi;

class CustomUniversalObjectCrate
{
}
111 changes: 111 additions & 0 deletions tests/PHPStan/Type/MySQLi/data/MySQLiTypeInferenceDataTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace MariaStan\PHPStan\Type\MySQLi\data;

use MariaStan\PHPStan\Type\MySQLi\CustomResultClass;
use MariaStan\PHPStan\Type\MySQLi\CustomUniversalObjectCrate;
use MariaStan\TestCaseHelper;
use mysqli;
use mysqli_result;
Expand All @@ -14,6 +16,7 @@
use function array_keys;
use function assert;
use function function_exists;
use function get_object_vars;
use function gettype;
use function implode;
use function in_array;
Expand Down Expand Up @@ -658,6 +661,114 @@ public function testFetchArray(): void
} while (true);
}

public function testFetchObject(): void
{
$db = TestCaseHelper::getDefaultSharedConnection();
$result = $db->query('SELECT id FROM mysqli_test');

do {
$row = $result->fetch_object();

if (function_exists('assertType')) {
assertType('(object{id: int}&stdClass)|false|null', $row);
}

if ($row === null) {
break;
}

$row = get_object_vars($row);
$this->assertSame(['id'], array_keys($row));
$this->assertIsInt($row['id']);
} while (true);

$result = $db->query('SELECT id, 5 val, "aa" id FROM mysqli_test');

do {
$row = $result->fetch_object();

if (function_exists('assertType')) {
assertType('(object{id: string, val: int}&stdClass)|false|null', $row);
}

if ($row === null) {
break;
}

$row = get_object_vars($row);
$this->assertSame(['id', 'val'], array_keys($row));
$this->assertIsString($row['id']);
$this->assertIsInt($row['val']);
} while (true);

$result = $db->query('SELECT id, 5 val, "aa" id FROM mysqli_test');

do {
$row = $result->fetch_object(CustomUniversalObjectCrate::class);

if (function_exists('assertType')) {
// The class needs to be registered in universalObjectCratesClasses
assertType('(' . CustomUniversalObjectCrate::class . '&object{id: string, val: int})|false|null', $row);
}

if ($row === null) {
break;
}

$this->assertInstanceOf(CustomUniversalObjectCrate::class, $row);
$row = get_object_vars($row);
$this->assertSame(['id', 'val'], array_keys($row));
$this->assertIsString($row['id']);
$this->assertIsInt($row['val']);
} while (true);

$result = $db->query('SELECT id, 5 val, "aa" id FROM mysqli_test');

do {
$row = $result->fetch_object(CustomResultClass::class);

if (function_exists('assertType')) {
// This class is not registered in universalObjectCratesClasses, so we can't intersect it with object
// shape.
assertType(CustomResultClass::class . '|false|null', $row);
}

if ($row === null) {
break;
}

$this->assertInstanceOf(CustomResultClass::class, $row);
$row = get_object_vars($row);
$this->assertSame(['id', 'val'], array_keys($row));
$this->assertIsString($row['id']);
$this->assertIsInt($row['val']);
} while (true);

$result = $db->query('SELECT id, 5 val, "aa" id FROM mysqli_test');

do {
$row = $result->fetch_object(
$this->hideValueFromPhpstan(true)
? CustomUniversalObjectCrate::class
: \stdClass::class,
);

if (function_exists('assertType')) {
assertType('object{id: string, val: int}|false|null', $row);
}

if ($row === null) {
break;
}

$this->assertInstanceOf(CustomUniversalObjectCrate::class, $row);
$row = get_object_vars($row);
$this->assertSame(['id', 'val'], array_keys($row));
$this->assertIsString($row['id']);
$this->assertIsInt($row['val']);
} while (true);
}

public function testFetchColumn(): void
{
$db = TestCaseHelper::getDefaultSharedConnection();
Expand Down
2 changes: 2 additions & 0 deletions tests/PHPStan/Type/MySQLi/test.file-reflection.neon
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@ parameters:
maria-stan:
reflection:
file: %rootDir%/../../../tests/PHPStan/Type/MySQLi/schema.dump
universalObjectCratesClasses:
- MariaStan\PHPStan\Type\MySQLi\CustomUniversalObjectCrate
services:
mariaDbReflection: @mariaDbFileDbReflection
2 changes: 2 additions & 0 deletions tests/PHPStan/test.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ parameters:
user: 'root'
password: 'root'
database: 'mariastan_test'
universalObjectCratesClasses:
- MariaStan\PHPStan\Type\MySQLi\CustomUniversalObjectCrate

services:
-
Expand Down

0 comments on commit edff3ca

Please sign in to comment.