Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Infer getArrayResult #593

Draft
wants to merge 2 commits into
base: 1.4.x
Choose a base branch
from
Draft
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
48 changes: 48 additions & 0 deletions src/Type/Doctrine/HydrationModeReturnTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
use PHPStan\Type\IntegerRangeType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\IterableType;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectWithoutClassType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\TypeUtils;
use PHPStan\Type\TypeWithClassName;
use PHPStan\Type\VoidType;

class HydrationModeReturnTypeResolver
Expand Down Expand Up @@ -44,6 +47,9 @@ public function getMethodReturnTypeForHydrationMode(
switch ($hydrationMode) {
case AbstractQuery::HYDRATE_OBJECT:
break;
case AbstractQuery::HYDRATE_ARRAY:
$queryResultType = $this->getArrayHydratedReturnType($queryResultType, $objectManager);
break;
case AbstractQuery::HYDRATE_SIMPLEOBJECT:
$queryResultType = $this->getSimpleObjectHydratedReturnType($queryResultType);
break;
Expand Down Expand Up @@ -84,6 +90,48 @@ public function getMethodReturnTypeForHydrationMode(
}
}

/**
* When we're array-hydrating object, we're not sure of the shape of the array.
* We could return `new ArrayType(new MixedType(), new MixedType())`
* but the lack of precision in the array keys/values would give false positive.
*
* @see https://github.com/phpstan/phpstan-doctrine/pull/412#issuecomment-1497092934
*/
private function getArrayHydratedReturnType(Type $queryResultType, ?ObjectManager $objectManager): ?Type
{
$mixedFound = false;
$queryResultType = TypeTraverser::map(
$queryResultType,
static function (Type $type, callable $traverse) use ($objectManager, &$mixedFound): Type {
$isObject = (new ObjectWithoutClassType())->isSuperTypeOf($type);
if ($isObject->no()) {
return $traverse($type);
}
if (
$isObject->maybe()
|| !$type instanceof TypeWithClassName
|| $objectManager === null
) {
$mixedFound = true;

return new MixedType();
}

/** @var class-string $className */
$className = $type->getClassName();
if (!$objectManager->getMetadataFactory()->hasMetadataFor($className)) {
return $traverse($type);
}

$mixedFound = true;

return new MixedType();
}
);

return $mixedFound ? null : $queryResultType;
}

private function getSimpleObjectHydratedReturnType(Type $queryResultType): ?Type
{
if ((new ObjectWithoutClassType())->isSuperTypeOf($queryResultType)->yes()) {
Expand Down
37 changes: 22 additions & 15 deletions src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturn
'getSingleResult' => 0,
];

private const METHOD_HYDRATION_MODE = [
'getArrayResult' => AbstractQuery::HYDRATE_ARRAY,
];

/** @var ObjectMetadataResolver */
private $objectMetadataResolver;

Expand All @@ -50,7 +54,8 @@ public function getClass(): string

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()]);
return isset(self::METHOD_HYDRATION_MODE_ARG[$methodReflection->getName()])
|| isset(self::METHOD_HYDRATION_MODE[$methodReflection->getName()]);
}

public function getTypeFromMethodCall(
Expand All @@ -61,21 +66,23 @@ public function getTypeFromMethodCall(
{
$methodName = $methodReflection->getName();

if (!isset(self::METHOD_HYDRATION_MODE_ARG[$methodName])) {
throw new ShouldNotHappenException();
}

$argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName];
$args = $methodCall->getArgs();

if (isset($args[$argIndex])) {
$hydrationMode = $scope->getType($args[$argIndex]->value);
if (isset(self::METHOD_HYDRATION_MODE[$methodName])) {
$hydrationMode = new ConstantIntegerType(self::METHOD_HYDRATION_MODE[$methodName]);
} elseif (isset(self::METHOD_HYDRATION_MODE_ARG[$methodName])) {
$argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName];
$args = $methodCall->getArgs();

if (isset($args[$argIndex])) {
$hydrationMode = $scope->getType($args[$argIndex]->value);
} else {
$parametersAcceptor = ParametersAcceptorSelector::selectSingle(
$methodReflection->getVariants()
);
$parameter = $parametersAcceptor->getParameters()[$argIndex];
$hydrationMode = $parameter->getDefaultValue() ?? new NullType();
}
} else {
$parametersAcceptor = ParametersAcceptorSelector::selectSingle(
$methodReflection->getVariants()
);
$parameter = $parametersAcceptor->getParameters()[$argIndex];
$hydrationMode = $parameter->getDefaultValue() ?? new NullType();
throw new ShouldNotHappenException();
}

$queryType = $scope->getType($methodCall->var);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,42 @@ public static function getTestData(): iterable
Query::HYDRATE_SIMPLEOBJECT,
];

yield 'getResult(array), full entity' => [
new MixedType(),
'
SELECT s
FROM QueryResult\Entities\Simple s
',
'getResult',
Query::HYDRATE_ARRAY,
];

yield 'getResult(array), fields' => [
self::list(self::constantArray([
[new ConstantStringType('decimalColumn'), self::numericString()],
[new ConstantStringType('floatColumn'), new FloatType()],
])),
'
SELECT s.decimalColumn, s.floatColumn
FROM QueryResult\Entities\Simple s
',
'getResult',
Query::HYDRATE_ARRAY,
];

yield 'getResult(array), expressions' => [
self::list(self::constantArray([
[new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()],
[new ConstantStringType('floatColumn'), self::floatOrStringified()],
])),
'
SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn
FROM QueryResult\Entities\Simple s
',
'getResult',
Query::HYDRATE_ARRAY,
];

yield 'getResult(object), fields' => [
self::list(self::constantArray([
[new ConstantStringType('decimalColumn'), self::numericString()],
Expand Down Expand Up @@ -210,6 +246,16 @@ public static function getTestData(): iterable
Query::HYDRATE_SIMPLEOBJECT,
];

yield 'toIterable(array), full entity' => [
new MixedType(),
'
SELECT s
FROM QueryResult\Entities\Simple s
',
'toIterable',
Query::HYDRATE_ARRAY,
];

yield 'getArrayResult(), full entity' => [
new MixedType(),
'
Expand All @@ -219,6 +265,30 @@ public static function getTestData(): iterable
'getArrayResult',
];

yield 'getArrayResult(), fields' => [
self::list(self::constantArray([
[new ConstantStringType('decimalColumn'), self::numericString()],
[new ConstantStringType('floatColumn'), new FloatType()],
])),
'
SELECT s.decimalColumn, s.floatColumn
FROM QueryResult\Entities\Simple s
',
'getArrayResult',
];

yield 'getArrayResult(), expressions' => [
self::list(self::constantArray([
[new ConstantStringType('decimalColumn'), self::floatOrIntOrStringified()],
[new ConstantStringType('floatColumn'), self::floatOrStringified()],
])),
'
SELECT -s.decimalColumn as decimalColumn, -s.floatColumn as floatColumn
FROM QueryResult\Entities\Simple s
',
'getArrayResult',
];

yield 'getResult(single_scalar), decimal field' => [
new MixedType(),
'
Expand Down
38 changes: 38 additions & 0 deletions tests/Type/Doctrine/QueryBuilderReproductionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Doctrine;

use PHPStan\Testing\TypeInferenceTestCase;

class QueryBuilderReproductionsTest extends TypeInferenceTestCase
{

/**
* @return iterable<mixed>
*/
public function dataFileAsserts(): iterable
{
yield from $this->gatherAssertTypes(__DIR__ . '/data/queryBuilderReproductions.php');
}

/**
* @dataProvider dataFileAsserts
* @param mixed ...$args
*/
public function testFileAsserts(
string $assertType,
string $file,
...$args
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

public static function getAdditionalConfigFiles(): array
{
return [
__DIR__ . '/data/QueryResult/config.neon',
];
}

}
20 changes: 20 additions & 0 deletions tests/Type/Doctrine/data/QueryResult/Entities/OrderItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php declare(strict_types=1);

namespace QueryResult\Entities;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class OrderItem
{

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;

#[ORM\ManyToOne]
private Product $product;

}
17 changes: 17 additions & 0 deletions tests/Type/Doctrine/data/QueryResult/Entities/Product.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php declare(strict_types=1);

namespace QueryResult\Entities;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\Table(name: 'products')]
class Product
{

#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(options: ['unsigned' => true])]
private ?int $id = null;

}
11 changes: 11 additions & 0 deletions tests/Type/Doctrine/data/QueryResult/entity-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,22 @@
[__DIR__ . '/Entities']
), 'QueryResult\Entities\\');

if (PHP_VERSION_ID >= 80100) {
$metadataDriver->addDriver(
new AttributeDriver([__DIR__ . '/Entities']),
'QueryResult\Entities\\'
);
}

if (property_exists(Column::class, 'enumType') && PHP_VERSION_ID >= 80100) {
$metadataDriver->addDriver(new AnnotationDriver(
new AnnotationReader(),
[__DIR__ . '/EntitiesEnum']
), 'QueryResult\EntitiesEnum\\');
$metadataDriver->addDriver(
new AttributeDriver([__DIR__ . '/EntitiesEnum']),
'QueryResult\EntitiesEnum\\'
);
}

$config->setMetadataDriverImpl($metadataDriver);
Expand Down
35 changes: 35 additions & 0 deletions tests/Type/Doctrine/data/QueryResult/queryResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,41 @@ public function testReturnTypeOfQueryMethodsWithExplicitArrayHydrationMode(Entit
'mixed',
$query->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY)
);


$query = $em->createQuery('
SELECT m.intColumn, m.stringNullColumn, m.datetimeColumn
FROM QueryResult\Entities\Many m
');

assertType(
'list<array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}>',
$query->getResult(AbstractQuery::HYDRATE_ARRAY)
);
assertType(
'list<array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}>',
$query->getArrayResult()
);
assertType(
'list<array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}>',
$query->execute(null, AbstractQuery::HYDRATE_ARRAY)
);
assertType(
'list<array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}>',
$query->executeIgnoreQueryCache(null, AbstractQuery::HYDRATE_ARRAY)
);
assertType(
'list<array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}>',
$query->executeUsingQueryCache(null, AbstractQuery::HYDRATE_ARRAY)
);
assertType(
'array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}',
$query->getSingleResult(AbstractQuery::HYDRATE_ARRAY)
);
assertType(
'array{intColumn: int, stringNullColumn: string|null, datetimeColumn: DateTime}|null',
$query->getOneOrNullResult(AbstractQuery::HYDRATE_ARRAY)
);
}


Expand Down
31 changes: 31 additions & 0 deletions tests/Type/Doctrine/data/queryBuilderReproductions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace QueryBuilderReproductions;

use Doctrine\ORM\EntityManager;
use QueryResult\Entities\OrderItem;
use function PHPStan\Testing\assertType;

class Foo
{

/** @var EntityManager */
private $entityManager;

public function __construct(EntityManager $entityManager)
{
$this->entityManager = $entityManager;
}

public function doFoo(): void
{
$result = $this->entityManager->createQueryBuilder()
->select('DISTINCT IDENTITY(oi.product) AS id')
->from(OrderItem::class, 'oi')
->join('oi.product', 'p')
->getQuery()
->getArrayResult();
assertType('list<array{id: int}>', $result);
}

}
Loading