Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

- name: Generate changelog
id: changelog
uses: metcalfc/changelog-generator@v1.0.0
uses: metcalfc/changelog-generator@v2.0.0
with:
myToken: ${{ secrets.GITHUB_TOKEN }}

Expand Down
11 changes: 11 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ parameters:
- stubs/Collections/Selectable.stub
- stubs/ORM/QueryBuilder.stub
- stubs/ORM/AbstractQuery.stub
- stubs/ORM/Query.stub
- stubs/ServiceDocumentRepository.stub

parametersSchema:
Expand Down Expand Up @@ -111,6 +112,16 @@ services:
class: PHPStan\Type\Doctrine\Query\QueryGetDqlDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
-
class: PHPStan\Type\Doctrine\CreateQueryDynamicReturnTypeExtension
arguments:
objectMetadataResolver: @PHPStan\Type\Doctrine\ObjectMetadataResolver
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
-
class: PHPStan\Type\Doctrine\Query\QueryResultDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
-
class: PHPStan\Type\Doctrine\QueryBuilder\Expr\ExpressionBuilderDynamicReturnTypeExtension
arguments:
Expand Down
98 changes: 98 additions & 0 deletions src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Doctrine;

use Doctrine\Common\CommonException;
use Doctrine\DBAL\DBALException;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\ResultSetMapping;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\Doctrine\ObjectMetadataResolver;
use PHPStan\Type\Doctrine\Query\ResultSetMappingTypeResolver;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;

final class CreateQueryDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
private ObjectMetadataResolver $objectMetadataResolver;

public function __construct(ObjectMetadataResolver $objectMetadataResolver)
{
$this->objectMetadataResolver = $objectMetadataResolver;
}

public function getClass(): string
{
return EntityManagerInterface::class;
}

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'createQuery';
}

public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$queryStringArgIndex = 0;

if (!isset($methodCall->args[$queryStringArgIndex])) {
return $this->fallbackType();
}

$argType = $scope->getType($methodCall->args[$queryStringArgIndex]->value);
if (!$argType instanceof ConstantStringType) {
return $this->fallbackType();
}

$queryString = $argType->getValue();

$em = $this->objectMetadataResolver->getObjectManager();

if (!$em instanceof EntityManagerInterface) {
return $this->fallbackType();
}

$getResultSetMapping = new \ReflectionMethod(
Query::class,
'getResultSetMapping',
);

$getResultSetMapping->setAccessible(true);

try {
$query = $em->createQuery($queryString);
$resultSetMapping = $getResultSetMapping->invoke($query);
} catch (ORMException | DBALException | CommonException $e) {
return $this->fallbackType();
}

if (!$resultSetMapping instanceof ResultSetMapping) {
return $this->fallbackType();
}

$resolver = new ResultSetMappingTypeResolver($resultSetMapping);

return new GenericObjectType(
Query::class,
[$resolver->resolveType()],
);
}

private function fallbackType(): GenericObjectType
{
return new GenericObjectType(
Query::class,
[new MixedType(true)],
);
}
}
119 changes: 119 additions & 0 deletions src/Type/Doctrine/Query/QueryResultDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Doctrine\Query;

use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\Query;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\ArrayType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\VoidType;

final class QueryResultDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
private const METHOD_HYDRATION_MODE_ARG = [
'getResult' => 0,
'execute' => 1,
'executeIgnoreQueryCache' => 1,
'executeUsingQueryCache' => 1,
'getSingleResult' => 0,
];

private const METHOD_RETURNS_SINGLE = [
'getSingleResult' => true,
'getOneOrNullResult' => true,
];

private const METHOD_RETURNS_NULL = [
'getOneOrNullResult' => true,
];

public function getClass(): string
{
return AbstractQuery::class;
}

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

public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$methodName = $methodReflection->getName();
$argIndex = self::METHOD_HYDRATION_MODE_ARG[$methodName] ?? 0;

$isHydrationModeObject = $this->isHydrationModeObject($methodCall, $scope, $argIndex);

if (!$isHydrationModeObject) {
return $this->fallbackType($methodReflection);
}

$queryType = $scope->getType($methodCall->var);

if (!$queryType instanceof GenericObjectType) {
return $this->fallbackType($methodReflection);
}

$types = $queryType->getTypes();

if (!isset($types[0])) {
return $this->fallbackType($methodReflection);
}

$resultType = $types[0];

if ($resultType instanceof VoidType) {
return $this->fallbackType($methodReflection);
}

if ($methodName === 'getOneOrNullResult') {
return TypeCombinator::addNull($resultType);
}

if ($methodName === 'getSingleResult') {
return $resultType;
}

return new ArrayType(
new IntegerType(),
$resultType,
);
}

private function isHydrationModeObject(
MethodCall $methodCall,
Scope $scope,
int $argIndex
): bool {
if (!isset($methodCall->args[$argIndex])) {
return true;
}

$argType = $scope->getType($methodCall->args[$argIndex]->value);
if (!$argType instanceof ConstantIntegerType) {
return false;
}

return $argType->getValue() === Query::HYDRATE_OBJECT;
}

private function fallbackType(MethodReflection $methodReflection): Type
{
$parametersAcceptor = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants());

return $parametersAcceptor->getReturnType();
}
}
77 changes: 77 additions & 0 deletions src/Type/Doctrine/Query/ResultSetMappingTypeResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Doctrine\Query;

use Doctrine\ORM\Query\ResultSetMapping;
use Mention\Kebab\Json\JsonUtils;
use PHPStan\Type\ArrayType;
use PHPStan\Type\MixedType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\VoidType;

final class ResultSetMappingTypeResolver
{
private ResultSetMapping $rsm;

public function __construct(ResultSetMapping $rsm)
{
$this->rsm = $rsm;
}

public function resolveType(): Type
{
if (!$this->rsm->isSelect) {
return new VoidType();
}

// Mixed results are arrays containing more than one value.
//
// ResultSetMapping has no info about nullability of elements in a mixed
// result, so we just return array<mixed> for now.
//
// Example queries that trigger a mixed result:
//
// In the following example query, result has type array{alert: Alert, id: int}:
// "SELECT a, a.id FROM Alert"
//
// In the following example query, result has type array{alert1: Alert, alert2: Alert}:
// "SELECT a1 AS alert1, a2 AS alert2 FROM Alert a1 JOIN Alert a2 WITH a1.id = a2.id"
//
// Example query that does not trigger a mixed result:
//
// In the following example query, result has type Alert:
// "SELECT a, o FROM Alert a JOIN a.owner o"
if ($this->rsm->isMixed || count($this->rsm->aliasMap) === 0) {
return new ArrayType(
new MixedType(true),
new MixedType(true),
);
}

$rootClassName = null;

foreach ($this->rsm->aliasMap as $alias => $className) {
if (isset($this->rsm->parentAliasMap[$alias])) {
continue;
}
if ($rootClassName === null) {
$rootClassName = $className;
continue;
}
throw new \Exception(sprintf(
'Unexpectedly found more than 1 root in a non-mixed ResultSetMapping (aliasMap: %s)',
JsonUtils::encode($this->rsm->aliasMap,
)));
}

if ($rootClassName === null) {
throw new \Exception(sprintf(
'Unexpectedly did not found any root in a non-mixed ResultSetMapping (aliasMap: %s)',
JsonUtils::encode($this->rsm->aliasMap,
)));
}

return new ObjectType($rootClassName);
}
}
3 changes: 3 additions & 0 deletions stubs/ORM/AbstractQuery.stub
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace Doctrine\ORM;

use Doctrine\Common\Collections\ArrayCollection;

/**
* @template ResultT
*/
abstract class AbstractQuery
{

Expand Down
12 changes: 12 additions & 0 deletions stubs/ORM/Query.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Doctrine\ORM;

/**
* @template ResultT
*
* @extends AbstractQuery<ResultT>
*/
final class Query extends AbstractQuery
{
}