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

Fix relation types #1285

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0fb1fee
Added annotations to `Relation.stub`
nagmat84 Jun 13, 2022
36412d2
Added annotations to `HasOneOrMany.stub`
nagmat84 Jun 18, 2022
c69ac76
Added annotations to `HasOne.stub`
nagmat84 Jun 18, 2022
d53ff78
Added annotations to `HasMany.stub`
nagmat84 Jun 18, 2022
39d6649
Added annotations to `BelongsTo.stub`
nagmat84 Jun 18, 2022
68a94f2
Added annotations to `BelongsToMany.stub`
nagmat84 Jun 18, 2022
c3fed73
Added annotations to `HasManyThrough.stub`
nagmat84 Jun 18, 2022
4244519
Added annotations to `HasOneThrough.stub`
nagmat84 Jun 18, 2022
ed3802c
Added annotations to `MorphOneOrMany.stub`
nagmat84 Jun 18, 2022
3801301
Added annotations to `MorphMany.stub`
nagmat84 Jun 18, 2022
04e8dfb
Added annotations to `MorphOne.stub`
nagmat84 Jun 18, 2022
da1fa35
Added annotations to `MorphTo.stub`
nagmat84 Jun 18, 2022
6ac7227
Added annotations to `MorphToMany.stub`
nagmat84 Jun 18, 2022
537ce04
Adopted Eloquent Builder to changed relation classes
nagmat84 Jun 18, 2022
689029c
Added template parameter to ModelNotFoundException
nagmat84 Jun 19, 2022
6942b7d
Adopted application tests to new `HasMany` relation
nagmat84 Jun 18, 2022
fcad3dd
Adopted feature tests to new relation classes
nagmat84 Jun 18, 2022
2643c29
Forward calls on relation with all template parameters
nagmat84 Jun 18, 2022
8cfc43a
Added method to find intermediate model type
nagmat84 Jun 18, 2022
427c38b
Return relations with all template parameters
nagmat84 Jun 18, 2022
d271a32
Return relations on models with all template parameters
nagmat84 Jun 19, 2022
fc0d35f
Make formatter happy.
nagmat84 Jun 19, 2022
83cd5f1
Updated CHANGELOG.md
nagmat84 Jul 2, 2022
84f3a9b
Fixed wrong typed annotation
nagmat84 Jul 2, 2022
1a66127
Used FQCN in PHPDoc comments as requested by review
nagmat84 Jul 2, 2022
066eb25
Added test for relations
nagmat84 Jul 2, 2022
b8331c1
Make Eloquent Builder work with template parameters named TRelatedMod…
nagmat84 Jul 3, 2022
f660f26
Also forward methods on Eloquent Buidler if bound to unspecific Model
nagmat84 Jul 3, 2022
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]

* added: The `Relation`-related classes now required up to four template parameters instead of one: `TRelatedModel` (required as before), `TDeclaringModel` (required), `TResult` (required by base classes like `HasOneOrMany`, not required by user-facing classes like `HasMany`) and `TIntermediateModel` (only required by `...Through...`-relations) https://github.com/nunomaduro/larastan/pull/1285
* fix: Resolve correct model factory instance when application namespace is empty

### Fixed
Expand Down
10 changes: 5 additions & 5 deletions src/Methods/BuilderHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@ public function dynamicWhere(
$returnClassReflection = $returnObject->getClassReflection();

if ($returnClassReflection !== null) {
$modelType = $returnClassReflection->getActiveTemplateTypeMap()->getType('TModelClass');

if ($modelType === null) {
$modelType = $returnClassReflection->getActiveTemplateTypeMap()->getType('TRelatedModel');
}
$templateTypeMap = $returnClassReflection->getActiveTemplateTypeMap();
$modelType =
$templateTypeMap->getType('TModelClass') ??
$templateTypeMap->getType('TRelatedModel') ??
$templateTypeMap->getType('TDeclaringModel');

if ($modelType !== null) {
$finder = substr($methodName, 5);
Expand Down
16 changes: 5 additions & 11 deletions src/Methods/EloquentBuilderForwardsCallsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\Generic\TemplateMixedType;
use PHPStan\Type\Generic\TemplateObjectType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeWithClassName;

Expand Down Expand Up @@ -77,22 +75,18 @@ private function findMethod(ClassReflection $classReflection, string $methodName
return null;
}

$templateTypeMap = $classReflection->getActiveTemplateTypeMap();
/** @var Type|TemplateMixedType|null $modelType */
$modelType = $classReflection->getActiveTemplateTypeMap()->getType('TModelClass');
$modelType =
$templateTypeMap->getType('TModelClass') ??
$templateTypeMap->getType('TRelatedModel') ??
$templateTypeMap->getType('TDeclaringModel');

// Generic type is not specified
if ($modelType === null) {
return null;
}

if ($modelType instanceof TemplateObjectType) {
$modelType = $modelType->getBound();

if ($modelType->equals(new ObjectType(Model::class))) {
return null;
}
}

if ($modelType instanceof TypeWithClassName) {
$modelReflection = $modelType->getClassReflection();
} else {
Expand Down
15 changes: 6 additions & 9 deletions src/Methods/RelationForwardsCallsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use NunoMaduro\Larastan\Reflection\EloquentBuilderMethodReflection;
use PHPStan\Reflection\ClassReflection;
Expand Down Expand Up @@ -109,14 +108,12 @@ private function findMethod(ClassReflection $classReflection, string $methodName
$parametersAcceptor = ParametersAcceptorSelector::selectSingle($reflection->getVariants());
$returnType = $parametersAcceptor->getReturnType();

$types = [$relatedModel];

// BelongsTo relation needs second generic type
if ((new ObjectType(BelongsTo::class))->isSuperTypeOf(new ObjectType($classReflection->getName()))->yes()) {
$childType = $classReflection->getActiveTemplateTypeMap()->getType('TChildModel');

if ($childType !== null) {
$types[] = $childType;
// Copy template parameters with generic types of relation
$types = [];
foreach (['TRelatedModel', 'TDeclaringModel', 'TIntermediateModel', 'TResult'] as $templateParameter) {
$type = $classReflection->getActiveTemplateTypeMap()->getType($templateParameter);
if ($type !== null) {
$types[] = $type;
}
}

Expand Down
14 changes: 11 additions & 3 deletions src/ReturnTypes/BuilderModelFindExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,13 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
return false;
}

$model = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TModelClass');
$templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap();
$model =
$templateTypeMap->getType('TModelClass') ??
$templateTypeMap->getType('TRelatedModel') ??
$templateTypeMap->getType('TDeclaringModel');

if ($model === null || ! $model instanceof ObjectType) {
if (! $model instanceof ObjectType) {
return false;
}

Expand All @@ -81,8 +85,12 @@ public function getTypeFromMethodCall(
MethodCall $methodCall,
Scope $scope
): Type {
$templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap();
/** @var ObjectType $model */
$model = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TModelClass');
$model =
$templateTypeMap->getType('TModelClass') ??
$templateTypeMap->getType('TRelatedModel') ??
$templateTypeMap->getType('TDeclaringModel');
$returnType = $methodReflection->getVariants()[0]->getReturnType();
$argType = $scope->getType($methodCall->getArgs()[0]->value);

Expand Down
11 changes: 9 additions & 2 deletions src/ReturnTypes/EloquentBuilderExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,12 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
}

$templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap();
$model =
$templateTypeMap->getType('TModelClass') ??
$templateTypeMap->getType('TRelatedModel') ??
$templateTypeMap->getType('TDeclaringModel');

if (! $templateTypeMap->getType('TModelClass') instanceof ObjectType) {
if (! $model instanceof ObjectType) {
return false;
}

Expand All @@ -74,7 +78,10 @@ public function getTypeFromMethodCall(
$templateTypeMap = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap();

/** @var Type|ObjectType|TemplateMixedType $modelType */
$modelType = $templateTypeMap->getType('TModelClass');
$modelType =
$templateTypeMap->getType('TModelClass') ??
$templateTypeMap->getType('TRelatedModel') ??
$templateTypeMap->getType('TDeclaringModel');

if ($modelType instanceof ObjectType && in_array(Collection::class, $returnType->getReferencedClasses(), true)) {
$collectionClassName = $this->builderHelper->determineCollectionClassName($modelType->getClassName());
Expand Down
51 changes: 41 additions & 10 deletions src/Types/ModelRelationsDynamicMethodReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,37 @@
namespace NunoMaduro\Larastan\Types;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\Relation;
use NunoMaduro\Larastan\Concerns\HasContainer;
use NunoMaduro\Larastan\Methods\BuilderHelper;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;

class ModelRelationsDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
use HasContainer;

/** @var BuilderHelper */
private $builderHelper;

/** @var RelationParserHelper */
private $relationParserHelper;

public function __construct(RelationParserHelper $relationParserHelper)
{
public function __construct(
RelationParserHelper $relationParserHelper,
BuilderHelper $builderHelper
) {
$this->relationParserHelper = $relationParserHelper;
$this->builderHelper = $builderHelper;
}

public function getClass(): string
Expand Down Expand Up @@ -94,15 +102,38 @@ public function getTypeFromMethodCall(
->relationParserHelper
->findRelatedModelInRelationMethod($methodReflection);

$classReflection = $methodReflection->getDeclaringClass();
$declaringModelClassName = $methodReflection->getDeclaringClass()->getName();

$templateTypes = [
new ObjectType($relatedModelClassName),
new ObjectType($declaringModelClassName),
];

$hasManyThroughType = new ObjectType(HasManyThrough::class);
if ($hasManyThroughType->isSuperTypeOf($returnType)->yes()) {
/** @var string $intermediateModelClassName */
$intermediateModelClassName = $this
->relationParserHelper
->findIntermediateModelInRelationMethod($methodReflection);
$templateTypes[] = new ObjectType($intermediateModelClassName);
}

if ($returnType->isInstanceOf(BelongsTo::class)->yes()) {
return new GenericObjectType($returnType->getClassName(), [
new ObjectType($relatedModelClassName),
new ObjectType($classReflection->getName()),
]);
// Work-around for a Laravel bug
// Opposed to other `HasOne...` and `HasMany...` methods,
// `HasOneThrough` and `HasManyThrough` do not extend a common
// `HasOneOrManyThrough` base class, but `HasOneThrough` directly
// extends `HasManyThrough`.
// This does not only violate Liskov's Substitution Principle but also
// has the unfortunate side effect that `HasManyThrough` cannot
// bind the template parameter `TResult` to a Collection, but needs
// to keep it unbound for `HasOneThrough` to overwrite it.
// Hence, if `HasManyTrough` is used directly, we must bind the
// fourth template parameter `TResult` here.
if ($hasManyThroughType->equals($returnType)) {
$collectionClassName = $this->builderHelper->determineCollectionClassName($relatedModelClassName);
$templateTypes[] = new GenericObjectType($collectionClassName, [new IntegerType(), new ObjectType($relatedModelClassName)]);
}

return new GenericObjectType($returnType->getClassName(), [new ObjectType($relatedModelClassName)]);
return new GenericObjectType($returnType->getClassName(), $templateTypes);
}
}
83 changes: 58 additions & 25 deletions src/Types/RelationDynamicMethodReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
namespace NunoMaduro\Larastan\Types;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use NunoMaduro\Larastan\Methods\BuilderHelper;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionVariant;
Expand All @@ -17,16 +17,19 @@
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\IntegerType;
use PHPStan\Type\ObjectType;
use PHPStan\Type\StaticType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeWithClassName;

class RelationDynamicMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
private BuilderHelper $builderHelper;
private ReflectionProvider $provider;

public function __construct(ReflectionProvider $provider)
public function __construct(BuilderHelper $builderHelper, ReflectionProvider $provider)
{
$this->builderHelper = $builderHelper;
$this->provider = $provider;
}

Expand All @@ -53,6 +56,9 @@ public function getTypeFromMethodCall(
MethodCall $methodCall,
Scope $scope
): Type {
$methodName = $methodReflection->getName();
$methodArgs = $methodCall->getArgs();
$numArgs = count($methodArgs);
/** @var FunctionVariant $functionVariant */
$functionVariant = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants());
$returnType = $functionVariant->getReturnType();
Expand All @@ -61,38 +67,65 @@ public function getTypeFromMethodCall(
return $returnType;
}

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

if ($calledOnType instanceof StaticType) {
$calledOnType = new ObjectType($calledOnType->getClassName());
}

if (count($methodCall->getArgs()) === 0) {
if (
// Special case for MorphTo. `morphTo` can be called without arguments.
if ($methodReflection->getName() === 'morphTo') {
return new GenericObjectType($returnType->getClassName(), [new ObjectType(Model::class), $calledOnType]);
}

($methodName !== 'morphTo' && $numArgs < 1) ||
// Special case for "...Through". `has...Through` must be called with a 2nd parameter for the intermediate model
(in_array($methodName, ['hasOneThrough', 'hasManyThrough']) && $numArgs < 2)
) {
return $returnType;
}

$argType = $scope->getType($methodCall->getArgs()[0]->value);
$templateTypes = [];

if (! $argType instanceof ConstantStringType) {
// Determine TRelatedModel; this is the 1st parameter, if given, or `Eloquent\Model`
$relatedModelClassArgType = $numArgs === 0 ?
new ConstantStringType(Model::class, true) :
$scope->getType($methodCall->getArgs()[0]->value);
if (! $relatedModelClassArgType instanceof ConstantStringType) {
return $returnType;
}
$relatedModelClassName = $relatedModelClassArgType->getValue();
if (! $this->provider->hasClass($relatedModelClassName)) {
$relatedModelClassName = Model::class;
}
$templateTypes[] = new ObjectType($relatedModelClassName);

$argClassName = $argType->getValue();

if (! $this->provider->hasClass($argClassName)) {
$argClassName = Model::class;
// Determine TDeclaringModel; this is the model on whose instance the method is called
$calledOnType = $scope->getType($methodCall->var);
if (! $calledOnType instanceof TypeWithClassName) {
return $returnType;
}
$declaringModelClassName = $calledOnType->getClassName();
$templateTypes[] = new ObjectType($declaringModelClassName);

// Determine TIntermediateModel for "Through" types; this is the 2nd parameter
$hasManyThroughType = new ObjectType(HasManyThrough::class);
if ($hasManyThroughType->isSuperTypeOf($returnType)->yes()) {
$intermediateModelClassArgType = $scope->getType($methodCall->getArgs()[1]->value);
if (! $intermediateModelClassArgType instanceof ConstantStringType) {
return $returnType;
}
$intermediateModelClassName = $intermediateModelClassArgType->getValue();
$templateTypes[] = new ObjectType($intermediateModelClassName);
}

// Special case for BelongsTo. We need to add the child model as a generic type also.
if ((new ObjectType(BelongsTo::class))->isSuperTypeOf($returnType)->yes()) {
return new GenericObjectType($returnType->getClassName(), [new ObjectType($argClassName), $calledOnType]);
// Work-around for a Laravel bug
// Opposed to other `HasOne...` and `HasMany...` methods,
// `HasOneThrough` and `HasManyThrough` do not extend a common
// `HasOneOrManyThrough` base class, but `HasOneThrough` directly
// extends `HasManyThrough`.
// This does not only violate Liskov's Substitution Principle but also
// has the unfortunate side effect that `HasManyThrough` cannot
// bind the template parameter `TResult` to a Collection, but needs
// to keep it unbound for `HasOneThrough` to overwrite it.
// Hence, if `HasManyTrough` is used directly, we must bind the
// fourth template parameter `TResult` here.
szepeviktor marked this conversation as resolved.
Show resolved Hide resolved
if ($hasManyThroughType->equals($returnType)) {
$collectionClassName = $this->builderHelper->determineCollectionClassName($relatedModelClassName);
$templateTypes[] = new GenericObjectType($collectionClassName, [new IntegerType(), new ObjectType($relatedModelClassName)]);
}

return new GenericObjectType($returnType->getClassName(), [new ObjectType($argClassName)]);
return new GenericObjectType($returnType->getClassName(), $templateTypes);
}
}
Loading