Skip to content

Commit 12b1574

Browse files
authored
Additional Analysers for Models/Query Builder (#438)
* wip * working analyser & tests * Fix namespaces * Adds tests and fixes scope checking * Rector changes * Fixes/feedback * phpstan fixes * Remove object checks * Adds relation check on model * Adds the relation analyzer * fix typo * feedback fixes * test fix * feedback fixes
1 parent 7ef8335 commit 12b1574

22 files changed

+809
-90
lines changed

src/NodeAnalyzer/ModelAnalyzer.php

Lines changed: 138 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,44 @@
22

33
namespace RectorLaravel\NodeAnalyzer;
44

5-
use Exception;
65
use Illuminate\Database\Eloquent\Model;
6+
use InvalidArgumentException;
7+
use PHPStan\Analyser\Scope;
78
use PHPStan\Reflection\ClassReflection;
9+
use PHPStan\Reflection\ExtendedMethodReflection;
810
use PHPStan\Reflection\ReflectionProvider;
11+
use PHPStan\Type\ObjectType;
12+
use ReflectionException;
13+
use Throwable;
914

1015
class ModelAnalyzer
1116
{
1217
public function __construct(
13-
private readonly ReflectionProvider $reflectionProvider
18+
private readonly ReflectionProvider $reflectionProvider,
1419
) {}
1520

21+
protected static function relationType(): ObjectType
22+
{
23+
return new ObjectType('Illuminate\Database\Eloquent\Relations\Relation');
24+
}
25+
1626
/**
1727
* Returns the table name of a model
1828
*
19-
* @param class-string<Model> $class
29+
* @param class-string<Model>|ObjectType $model
2030
*
21-
* @throws Exception
31+
* @throws InvalidArgumentException|ReflectionException
2232
*/
23-
public function getTable(string $class): ?string
33+
public function getTable(string|ObjectType $model): ?string
2434
{
25-
$classReflection = $this->getClass($class);
35+
$model = $this->resolveModelClassToInstance($model);
36+
37+
if (! $model instanceof Model) {
38+
return null;
39+
}
40+
41+
$table = $model->getTable();
2642

27-
/** @var Model $instance */
28-
$instance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor();
29-
$table = $instance->getTable();
3043
if (! is_string($table)) {
3144
return null;
3245
}
@@ -35,15 +48,22 @@ public function getTable(string $class): ?string
3548
}
3649

3750
/**
38-
* @param class-string<Model> $class
51+
* Returns the primary key for a model
52+
*
53+
* @param class-string<Model>|ObjectType $model
54+
*
55+
* @throws ReflectionException
3956
*/
40-
public function getPrimaryKey(string $class): ?string
57+
public function getPrimaryKey(string|ObjectType $model): ?string
4158
{
42-
$classReflection = $this->getClass($class);
59+
$model = $this->resolveModelClassToInstance($model);
60+
61+
if (! $model instanceof Model) {
62+
return null;
63+
}
64+
65+
$keyName = $model->getKeyName();
4366

44-
/** @var Model $instance */
45-
$instance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor();
46-
$keyName = $instance->getKeyName();
4767
if (! is_string($keyName)) {
4868
return null;
4969
}
@@ -52,26 +72,125 @@ public function getPrimaryKey(string $class): ?string
5272
}
5373

5474
/**
75+
* @param class-string<Model>|ObjectType $model
76+
*/
77+
public function isQueryScopeOnModel(string|ObjectType $model, string $scopeName, Scope $scope): bool
78+
{
79+
if (! is_string($model)) {
80+
/** @var class-string<Model> $model */
81+
$model = $model->getClassName();
82+
}
83+
84+
$classReflection = $this->getClass($model);
85+
86+
if ($classReflection->hasMethod('scope' . ucfirst($scopeName))) {
87+
return true;
88+
}
89+
90+
if (! $classReflection->hasMethod($scopeName)) {
91+
return false;
92+
}
93+
94+
$extendedMethodReflection = $classReflection->getMethod($scopeName, $scope);
95+
96+
return $this->usesScopeAttribute($extendedMethodReflection);
97+
}
98+
99+
/**
100+
* @param class-string<Model>|ObjectType $model
101+
*/
102+
public function isRelationshipOnModel(string|ObjectType $model, string $relationName, Scope $scope): bool
103+
{
104+
if (! is_string($model)) {
105+
/** @var class-string<Model> $model */
106+
$model = $model->getClassName();
107+
}
108+
109+
$classReflection = $this->getClass($model);
110+
111+
if (! $classReflection->hasMethod($relationName)) {
112+
return false;
113+
}
114+
115+
$extendedMethodReflection = $classReflection->getMethod($relationName, $scope);
116+
117+
foreach ($extendedMethodReflection->getVariants() as $extendedParametersAcceptor) {
118+
$returnType = $extendedParametersAcceptor->getReturnType();
119+
120+
if ($returnType->isObject()->maybe()) {
121+
continue;
122+
}
123+
124+
if (self::relationType()->isSuperTypeOf($returnType)->yes()) {
125+
return true;
126+
}
127+
}
128+
129+
return false;
130+
}
131+
132+
private function usesScopeAttribute(ExtendedMethodReflection $extendedMethodReflection): bool
133+
{
134+
foreach ($extendedMethodReflection->getAttributes() as $attributeReflection) {
135+
if ($attributeReflection->getName() === 'Illuminate\Database\Eloquent\Attributes\Scope') {
136+
return true;
137+
}
138+
}
139+
140+
return false;
141+
}
142+
143+
/**
144+
* Get the ClassReflectionFor the Model
145+
*
55146
* @param class-string<Model> $class
56147
*
57-
* @throws Exception
148+
* @throws InvalidArgumentException
58149
*/
59150
private function getClass(string $class): ClassReflection
60151
{
61152
if (! $this->reflectionProvider->hasClass($class)) {
62-
throw new Exception('Class not found');
153+
throw new InvalidArgumentException('Class not found');
63154
}
64155

65156
$classReflection = $this->reflectionProvider->getClass($class);
66157

67158
if (! $classReflection->isClass()) {
68-
throw new Exception('Class is not class');
159+
throw new InvalidArgumentException('Class string does not resolve to class');
69160
}
70161

71162
if (! $classReflection->isSubclassOfClass($this->reflectionProvider->getClass(Model::class))) {
72-
throw new Exception('Class is not subclass of Model');
163+
throw new InvalidArgumentException('Class is not subclass of Model');
73164
}
74165

75166
return $classReflection;
76167
}
168+
169+
/**
170+
* Create an instance of the Model to interact with
171+
*
172+
* @param class-string<Model>|ObjectType $model
173+
*
174+
* @throws ReflectionException
175+
*/
176+
private function resolveModelClassToInstance(string|ObjectType $model): ?Model
177+
{
178+
$classReflection = is_string($model)
179+
? $this->getClass($model)
180+
: $model->getObjectClassReflections()[0];
181+
182+
if ($classReflection->isAbstract()) {
183+
return null;
184+
}
185+
186+
try {
187+
/** @var Model $instance */
188+
$instance = $classReflection->getNativeReflection()->newInstance();
189+
} catch (Throwable) {
190+
/** @var Model $instance */
191+
$instance = $classReflection->getNativeReflection()->newInstanceWithoutConstructor();
192+
}
193+
194+
return $instance;
195+
}
77196
}

src/NodeAnalyzer/QueryBuilderAnalyzer.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22

33
namespace RectorLaravel\NodeAnalyzer;
44

5+
use Illuminate\Database\Eloquent\Builder;
6+
use InvalidArgumentException;
7+
use PhpParser\Node;
58
use PhpParser\Node\Expr\MethodCall;
69
use PhpParser\Node\Expr\StaticCall;
10+
use PHPStan\Analyser\Scope;
711
use PHPStan\Reflection\ClassReflection;
812
use PHPStan\Type\ObjectType;
13+
use PHPStan\Type\Type;
14+
use Rector\Exception\ShouldNotHappenException;
915
use Rector\NodeNameResolver\NodeNameResolver;
1016
use Rector\NodeTypeResolver\NodeTypeResolver;
1117

@@ -26,6 +32,16 @@ protected static function queryBuilderType(): ObjectType
2632
return new ObjectType('Illuminate\Contracts\Database\Query\Builder');
2733
}
2834

35+
protected static function eloquentQueryBuilderType(): ObjectType
36+
{
37+
return new ObjectType('Illuminate\Database\Eloquent\Builder');
38+
}
39+
40+
/**
41+
* Determine if a Method or Static call is on a Query Builder instance.
42+
*
43+
* @throws ShouldNotHappenException
44+
*/
2945
public function isMatchingCall(MethodCall|StaticCall $node, string $method): bool
3046
{
3147
if (! $this->nodeNameResolver->isName($node->name, $method)) {
@@ -39,6 +55,11 @@ public function isMatchingCall(MethodCall|StaticCall $node, string $method): boo
3955
return $this->nodeTypeResolver->isObjectType($node->var, self::queryBuilderType());
4056
}
4157

58+
/**
59+
* Determine if a Static call is being forwarded to a Query Builder object from a Model
60+
*
61+
* @throws ShouldNotHappenException
62+
*/
4263
public function isProxyCall(StaticCall $staticCall): bool
4364
{
4465
if (! $this->nodeTypeResolver->isObjectType($staticCall->class, self::modelType())) {
@@ -66,4 +87,50 @@ public function isProxyCall(StaticCall $staticCall): bool
6687

6788
return ! $reflectionClass->hasNativeMethod($methodName);
6889
}
90+
91+
/**
92+
* Resolve the Model being used by an instance of an Eloquent Query Builder
93+
*
94+
* @return ObjectType|null
95+
*
96+
* @throws \PHPStan\ShouldNotHappenException
97+
*/
98+
public function resolveQueryBuilderModel(Type $objectType, Scope $scope): ?Type
99+
{
100+
if (self::queryBuilderType()->isSuperTypeOf($objectType)->no()) {
101+
throw new InvalidArgumentException('Object type must be an Eloquent query builder.');
102+
}
103+
104+
$extendedPropertyReflection = $objectType->getInstanceProperty('model', $scope);
105+
$modelType = $extendedPropertyReflection->getReadableType();
106+
107+
if (self::modelType()->isSuperTypeOf($modelType)->no()) {
108+
return null;
109+
}
110+
111+
/** @phpstan-ignore return.type */
112+
return $modelType;
113+
}
114+
115+
/**
116+
* Determine if a node is an Eloquent Query Builder for a particular Eloquent Model
117+
*
118+
* @throws \PHPStan\ShouldNotHappenException
119+
* @throws ShouldNotHappenException
120+
*/
121+
public function isQueryUsingModel(Node $node, ObjectType $objectType): bool
122+
{
123+
$classType = $this->nodeTypeResolver->getType($node);
124+
125+
if (self::eloquentQueryBuilderType()->isSuperTypeOf($classType)->no()) {
126+
return false;
127+
}
128+
$type = $classType->getTemplateType(Builder::class, 'TModel');
129+
if (self::modelType()->isSuperTypeOf($type)->no()) {
130+
return false;
131+
}
132+
133+
/** @phpstan-ignore method.notFound */
134+
return $type->getClassName() === $objectType->getClassName();
135+
}
69136
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace RectorLaravel\NodeAnalyzer;
4+
5+
use Illuminate\Database\Eloquent\Relations\Relation;
6+
use PHPStan\Type\ObjectType;
7+
use PHPStan\Type\Type;
8+
9+
class RelationshipAnalyzer
10+
{
11+
protected static function relationType(): ObjectType
12+
{
13+
return new ObjectType('Illuminate\Database\Eloquent\Relations\Relation');
14+
}
15+
16+
protected static function modelType(): ObjectType
17+
{
18+
return new ObjectType('Illuminate\Database\Eloquent\Model');
19+
}
20+
21+
/**
22+
* Resolve the Related Model of the Relationship.
23+
*
24+
* @return ObjectType|null
25+
*/
26+
public function resolveRelatedForRelation(Type $objectType): ?Type
27+
{
28+
$modelType = $objectType->getTemplateType(Relation::class, 'TRelatedModel');
29+
if (self::modelType()->isSuperTypeOf($modelType)->no()) {
30+
return null;
31+
}
32+
33+
/** @phpstan-ignore return.type */
34+
return $modelType;
35+
}
36+
37+
/**
38+
* Resolve the Parent Model of the Relationship.
39+
*
40+
* @return ObjectType|null
41+
*/
42+
public function resolveParentForRelation(Type $objectType): ?Type
43+
{
44+
$modelType = $objectType->getTemplateType(Relation::class, 'TDeclaringModel');
45+
46+
if (self::modelType()->isSuperTypeOf($modelType)->no()) {
47+
return null;
48+
}
49+
50+
/** @phpstan-ignore return.type */
51+
return $modelType;
52+
}
53+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
namespace Illuminate\Database\Eloquent\Attributes;
4+
5+
use Attribute;
6+
7+
if (class_exists('Illuminate\Database\Eloquent\Attributes\Scope')) {
8+
return;
9+
}
10+
11+
#[Attribute(Attribute::TARGET_METHOD)]
12+
class Scope
13+
{
14+
/**
15+
* Create a new attribute instance.
16+
*/
17+
public function __construct() {}
18+
}

0 commit comments

Comments
 (0)