From 3ab8326ad843c9fcd0667d8e4a6ece0e0de42711 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Wed, 17 Apr 2024 09:17:37 -0500 Subject: [PATCH 1/2] test: creating failing test --- tests/Type/data/model-factories.php | 36 ++++++++++++++++--- tests/application/app/Post.php | 8 +++++ .../factories/{ => Post}/PostFactory.php | 5 ++- 3 files changed, 44 insertions(+), 5 deletions(-) rename tests/application/database/factories/{ => Post}/PostFactory.php (82%) diff --git a/tests/Type/data/model-factories.php b/tests/Type/data/model-factories.php index 59c60fc2e..ac3c86b34 100644 --- a/tests/Type/data/model-factories.php +++ b/tests/Type/data/model-factories.php @@ -5,6 +5,9 @@ use App\Post; use App\User; +use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use function PHPStan\Testing\assertType; function test(?int $foo): void { @@ -55,14 +58,14 @@ function test(?int $foo): void { assertType('Database\Factories\UserFactory', User::factory()->afterCreating(fn (User $user) => $user)); assertType('Database\Factories\Domain\Foo\UserFactory', \App\Domain\Foo\User::factory()); - assertType('Database\Factories\PostFactory', Post::factory()); - assertType('Database\Factories\PostFactory', Post::factory()->new()); + assertType('Database\Factories\Post\PostFactory', Post::factory()); + assertType('Database\Factories\Post\PostFactory', Post::factory()->new()); assertType('App\Post', Post::factory()->createOne()); assertType('App\Post', Post::factory()->createOneQuietly()); assertType('Illuminate\Database\Eloquent\Collection', Post::factory()->createMany([])); assertType('App\Post', Post::factory()->makeOne()); - assertType('Database\Factories\PostFactory', Post::factory()->afterMaking(fn (Post $post) => $post)); - assertType('Database\Factories\PostFactory', Post::factory()->afterCreating(fn (Post $post) => $post)); + assertType('Database\Factories\Post\PostFactory', Post::factory()->afterMaking(fn (Post $post) => $post)); + assertType('Database\Factories\Post\PostFactory', Post::factory()->afterCreating(fn (Post $post) => $post)); assertType('App\User|Illuminate\Database\Eloquent\Collection', User::factory()->count($foo)->create()); assertType('App\User|Illuminate\Database\Eloquent\Collection', User::factory()->count($foo)->createQuietly()); @@ -75,3 +78,28 @@ function test(?int $foo): void { assertType('Database\Factories\UserFactory', User::factory()->trashed()); assertType('*ERROR*', Post::factory()->trashed()); } + +class Comment extends Model +{ + use HasFactory; + + /** @return CommentFactory */ + protected static function newFactory(): Factory + { + return CommentFactory::new(); + } + + private function test(): void + { + assertType('ModelFactories\CommentFactory', static::factory()); + } +} + +/** @extends Factory */ +class CommentFactory extends Factory +{ + public function definition(): array + { + return []; + } +} diff --git a/tests/application/app/Post.php b/tests/application/app/Post.php index 2464c6d39..6284aa5b7 100644 --- a/tests/application/app/Post.php +++ b/tests/application/app/Post.php @@ -2,7 +2,9 @@ namespace App; +use Database\Factories\Post\PostFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -31,4 +33,10 @@ public function newEloquentBuilder($query): PostBuilder { return new PostBuilder($query); } + + /** @return PostFactory */ + protected static function newFactory(): Factory + { + return PostFactory::new(); + } } diff --git a/tests/application/database/factories/PostFactory.php b/tests/application/database/factories/Post/PostFactory.php similarity index 82% rename from tests/application/database/factories/PostFactory.php rename to tests/application/database/factories/Post/PostFactory.php index dc94ee281..c9083b28a 100644 --- a/tests/application/database/factories/PostFactory.php +++ b/tests/application/database/factories/Post/PostFactory.php @@ -2,13 +2,16 @@ declare(strict_types=1); -namespace Database\Factories; +namespace Database\Factories\Post; use App\Post; use App\User; use Illuminate\Database\Eloquent\Factories\Factory; /** + * This is specifically testing a factory + * that is not in the expected namespace. + * * @extends Factory */ class PostFactory extends Factory From 544f9633a0c99bc0b6c0b3266c46cd5fa3146950 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Wed, 17 Apr 2024 10:40:55 -0500 Subject: [PATCH 2/2] feat: support newFactory method when resolving factory --- ...DynamicStaticMethodReturnTypeExtension.php | 81 ++++++++++++++++--- ...micStaticMethodReturnTypeExtensionTest.php | 39 ++++++--- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php b/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php index 592df2c30..996e56ddd 100644 --- a/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php +++ b/src/ReturnTypes/ModelFactoryDynamicStaticMethodReturnTypeExtension.php @@ -10,7 +10,9 @@ use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; use PHPStan\Analyser\Scope; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; @@ -18,18 +20,23 @@ use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; -use function basename; +use function array_map; use function class_exists; use function count; -use function ltrim; -use function str_replace; final class ModelFactoryDynamicStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { + public function __construct( + private ReflectionProvider $reflectionProvider, + ) { + } + public function getClass(): string { return Model::class; @@ -47,9 +54,9 @@ public function getTypeFromStaticMethodCall( ): Type { $class = $methodCall->class; - if (! $class instanceof Name) { - return new ErrorType(); - } + $calledOnType = $class instanceof Name + ? new ObjectType($scope->resolveName($class)) + : $scope->getType($class); if (count($methodCall->getArgs()) === 0) { $isSingleModel = TrinaryLogic::createYes(); @@ -68,18 +75,66 @@ public function getTypeFromStaticMethodCall( $isSingleModel = (new UnionType($numericTypes))->isSuperTypeOf($argType)->negate(); } - $factoryName = Factory::resolveFactoryName(ltrim($class->toCodeString(), '\\')); // @phpstan-ignore-line + return TypeCombinator::union(...array_map( + function (ClassReflection $classReflection) use ($scope, $isSingleModel) { + $factoryReflection = $this->getFactoryReflection($classReflection, $scope); + + if ($factoryReflection === null) { + return new ErrorType(); + } + + return new ModelFactoryType($factoryReflection->getName(), null, $factoryReflection, $isSingleModel); + }, + $calledOnType->getObjectClassReflections(), + )); + } + + private function getFactoryReflection( + ClassReflection $modelReflection, + Scope $scope, + ): ClassReflection|null { + $factoryReflection = $this->getFactoryFromNewFactoryMethod($modelReflection, $scope); + + if ($factoryReflection !== null) { + return $factoryReflection; + } + + /** @phpstan-ignore argument.type (guaranteed to be model class-string) */ + $factoryClass = Factory::resolveFactoryName($modelReflection->getName()); - if (class_exists($factoryName)) { - return new ModelFactoryType($factoryName, null, null, $isSingleModel); + if (class_exists($factoryClass)) { + return $this->reflectionProvider->getClass($factoryClass); } - $modelName = basename(str_replace('\\', '/', $class->toCodeString())); + return null; + } + + private function getFactoryFromNewFactoryMethod( + ClassReflection $modelReflection, + Scope $scope, + ): ClassReflection|null { + if (! $modelReflection->hasMethod('newFactory')) { + return null; + } + + $factoryReflections = $modelReflection->getMethod('newFactory', $scope) + ->getVariants()[0] + ->getReturnType() + ->getObjectClassReflections(); + + if (count($factoryReflections) !== 1) { + return null; + } - if (! class_exists('Database\\Factories\\' . $modelName . 'Factory')) { - return new ErrorType(); + foreach ($factoryReflections as $factoryReflection) { + if ( + $factoryReflection->isSubclassOf(Factory::class) + && ! $factoryReflection->isAbstract() + ) { + return $factoryReflection; + } } - return new ModelFactoryType('Database\\Factories\\' . $modelName . 'Factory', null, null, $isSingleModel); + return null; } } diff --git a/tests/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php b/tests/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php index 180c48c95..5b5dc7349 100644 --- a/tests/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php +++ b/tests/Unit/ModelFactoryDynamicStaticMethodReturnTypeExtensionTest.php @@ -4,6 +4,7 @@ namespace Unit; +use App\User; use Generator; use Larastan\Larastan\ReturnTypes\ModelFactoryDynamicStaticMethodReturnTypeExtension; use Larastan\Larastan\Types\Factory\ModelFactoryType; @@ -12,7 +13,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Scalar\LNumber; use PHPStan\Analyser\Scope; -use PHPStan\Reflection\Dummy\DummyMethodReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryNumericStringType; @@ -31,15 +32,30 @@ class ModelFactoryDynamicStaticMethodReturnTypeExtensionTest extends PHPStanTestCase { + private ReflectionProvider $reflectionProvider; + + public function setUp(): void + { + parent::setUp(); + + $this->reflectionProvider = $this->createReflectionProvider(); + } + /** @test */ public function it_sets_the_is_single_model_flag_to_true_if_no_args_given(): void { - $scope = $this->createMock(Scope::class); - $extension = new ModelFactoryDynamicStaticMethodReturnTypeExtension(); + $class = new Name(User::class); + + $scope = $this->createMock(Scope::class); + $scope->method('resolveName')->with($class)->willReturn(User::class); + + $extension = new ModelFactoryDynamicStaticMethodReturnTypeExtension( + $this->createReflectionProvider(), + ); $type = $extension->getTypeFromStaticMethodCall( - new DummyMethodReflection('factory'), // @phpstan-ignore-line - new StaticCall(new Name('App\\User'), 'factory', []), + $this->reflectionProvider->getClass(User::class)->getNativeMethod('factory'), + new StaticCall($class, 'factory', []), $scope, ); @@ -53,14 +69,19 @@ public function it_sets_the_is_single_model_flag_to_true_if_no_args_given(): voi */ public function it_sets_the_is_single_model_flag_correctly(Type $phpstanType, TrinaryLogic $expected): void { - $scope = $this->createMock(Scope::class); - $extension = new ModelFactoryDynamicStaticMethodReturnTypeExtension(); + $class = new Name(User::class); + $scope = $this->createMock(Scope::class); + $scope->method('resolveName')->with($class)->willReturn(User::class); $scope->method('getType')->willReturn($phpstanType); + $extension = new ModelFactoryDynamicStaticMethodReturnTypeExtension( + $this->createReflectionProvider(), + ); + $type = $extension->getTypeFromStaticMethodCall( - new DummyMethodReflection('factory'), // @phpstan-ignore-line - new StaticCall(new Name('App\\User'), 'factory', [new Arg(new LNumber(1))]), // args doesn't matter + $this->reflectionProvider->getClass(User::class)->getNativeMethod('factory'), + new StaticCall($class, 'factory', [new Arg(new LNumber(1))]), // args doesn't matter $scope, );