diff --git a/composer.json b/composer.json index d1f2eea..f31ab44 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,8 @@ "larastan/larastan": "^2.9", "orchestra/testbench": "^9.0", "phpstan/phpstan": "^1.10", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-phpunit": "^1.4", "phpunit/phpunit": "^11.0" }, "autoload": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index cd2b068..3c3b3e5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,7 +1,10 @@ includes: - ./vendor/larastan/larastan/extension.neon + - ./vendor/phpstan/phpstan-mockery/extension.neon + - ./vendor/phpstan/phpstan-phpunit/extension.neon + - ./vendor/phpstan/phpstan-phpunit/rules.neon parameters: - level: 1 + level: 9 paths: - src - tests diff --git a/src/IdeHelper/BelongsToThroughRelationsHook.php b/src/IdeHelper/BelongsToThroughRelationsHook.php index 07db140..16ba107 100644 --- a/src/IdeHelper/BelongsToThroughRelationsHook.php +++ b/src/IdeHelper/BelongsToThroughRelationsHook.php @@ -42,6 +42,9 @@ public function run(ModelsCommand $command, Model $model): void } } + /** + * @param Relation<\Illuminate\Database\Eloquent\Model> $relationship + */ protected function addRelationship(ModelsCommand $command, ReflectionMethod $method, Relation $relationship): void { $type = '\\' . $relationship->getRelated()::class; diff --git a/src/IdeHelperServiceProvider.php b/src/IdeHelperServiceProvider.php index 16e7643..22874c4 100644 --- a/src/IdeHelperServiceProvider.php +++ b/src/IdeHelperServiceProvider.php @@ -3,6 +3,7 @@ namespace Staudenmeir\BelongsToThrough; use Barryvdh\LaravelIdeHelper\Console\ModelsCommand; +use Illuminate\Console\Command; use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\ServiceProvider; use Staudenmeir\BelongsToThrough\IdeHelper\BelongsToThroughRelationsHook; @@ -18,11 +19,14 @@ public function register(): void 'ide-helper.model_hooks', array_merge( [BelongsToThroughRelationsHook::class], - $config->get('ide-helper.model_hooks', []) + $config->array('ide-helper.model_hooks', []) ) ); } + /** + * @return class-string[] + */ public function provides(): array { return [ diff --git a/src/Relations/BelongsToThrough.php b/src/Relations/BelongsToThrough.php index 0029cc5..4e6d4cf 100644 --- a/src/Relations/BelongsToThrough.php +++ b/src/Relations/BelongsToThrough.php @@ -8,8 +8,16 @@ use Illuminate\Database\Eloquent\Relations\Concerns\SupportsDefaultModels; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Database\Query\Expression; use Illuminate\Support\Str; +/** + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @extends \Illuminate\Database\Eloquent\Relations\Relation + */ class BelongsToThrough extends Relation { use SupportsDefaultModels; @@ -24,7 +32,7 @@ class BelongsToThrough extends Relation /** * The "through" parent model instances. * - * @var \Illuminate\Database\Eloquent\Model[] + * @var TIntermediateModel[] */ protected $throughParents; @@ -38,27 +46,27 @@ class BelongsToThrough extends Relation /** * The custom foreign keys on the relationship. * - * @var array + * @var array */ protected $foreignKeyLookup; /** * The custom local keys on the relationship. * - * @var array + * @var array */ protected $localKeyLookup; /** * Create a new belongs to through relationship instance. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent - * @param \Illuminate\Database\Eloquent\Model[] $throughParents + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @param TIntermediateModel[] $throughParents * @param string|null $localKey * @param string $prefix - * @param array $foreignKeyLookup - * @param array $localKeyLookup + * @param array $foreignKeyLookup + * @param array $localKeyLookup * @return void * * @phpstan-ignore constructor.unusedParameter($localKey) @@ -99,7 +107,7 @@ public function addConstraints() /** * Set the join clauses on the query. * - * @param \Illuminate\Database\Eloquent\Builder|null $query + * @param \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model>|null $query * @return void */ protected function performJoins(?Builder $query = null) @@ -116,6 +124,7 @@ protected function performJoins(?Builder $query = null) $query->join($model->getTable(), $first, '=', $second); if ($this->hasSoftDeletes($model)) { + /** @phpstan-ignore method.notFound */ $column = $model->getQualifiedDeletedAtColumn(); $query->withGlobalScope(__CLASS__ . ":{$column}", function (Builder $query) use ($column) { @@ -173,7 +182,7 @@ public function hasSoftDeletes(Model $model) /** * Set the constraints for an eager load of the relation. * - * @param array $models + * @param \Illuminate\Database\Eloquent\Model[] $models * @return void */ public function addEagerConstraints(array $models) @@ -188,7 +197,7 @@ public function addEagerConstraints(array $models) * * @param \Illuminate\Database\Eloquent\Model[] $models * @param string $relation - * @return array + * @return \Illuminate\Database\Eloquent\Model[] */ public function initRelation(array $models, $relation) { @@ -203,9 +212,9 @@ public function initRelation(array $models, $relation) * Match the eagerly loaded results to their parents. * * @param \Illuminate\Database\Eloquent\Model[] $models - * @param \Illuminate\Database\Eloquent\Collection $results + * @param \Illuminate\Database\Eloquent\Collection $results * @param string $relation - * @return array + * @return \Illuminate\Database\Eloquent\Model[] */ public function match(array $models, Collection $results, $relation) { @@ -225,8 +234,8 @@ public function match(array $models, Collection $results, $relation) /** * Build model dictionary keyed by the relation's foreign key. * - * @param \Illuminate\Database\Eloquent\Collection $results - * @return array + * @param \Illuminate\Database\Eloquent\Collection $results + * @return \Illuminate\Database\Eloquent\Model[] */ protected function buildDictionary(Collection $results) { @@ -244,7 +253,7 @@ protected function buildDictionary(Collection $results) /** * Get the results of the relationship. * - * @return \Illuminate\Database\Eloquent\Model + * @return TRelatedModel|object|static|null */ public function getResults() { @@ -254,8 +263,8 @@ public function getResults() /** * Execute the query and get the first result. * - * @param array $columns - * @return \Illuminate\Database\Eloquent\Model|object|static|null + * @param string[] $columns + * @return TRelatedModel|object|static|null */ public function first($columns = ['*']) { @@ -269,8 +278,8 @@ public function first($columns = ['*']) /** * Execute the query as a "select" statement. * - * @param array $columns - * @return \Illuminate\Database\Eloquent\Collection + * @param string[] $columns + * @return \Illuminate\Database\Eloquent\Collection */ public function get($columns = ['*']) { @@ -290,16 +299,22 @@ public function get($columns = ['*']) /** * Add the constraints for a relationship query. * - * @param \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder $query - * @param \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder $parent - * @param array|mixed $columns - * @return \Illuminate\Database\Eloquent\Builder + * @param \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> $query + * @param \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> $parent + * @param string[]|mixed $columns + * @return \Illuminate\Database\Eloquent\Builder<\Illuminate\Database\Eloquent\Model> */ public function getRelationExistenceQuery(Builder $query, Builder $parent, $columns = ['*']) { $this->performJoins($query); - $foreignKey = $parent->getQuery()->from . '.' . $this->getFirstForeignKeyName(); + $from = $parent->getQuery()->from; + + if ($from instanceof Expression) { + $from = $from->getValue($query->getGrammar()); + } + + $foreignKey = $from . '.' . $this->getFirstForeignKeyName(); return $query->select($columns)->whereColumn( $this->getQualifiedFirstLocalKeyName(), @@ -311,12 +326,13 @@ public function getRelationExistenceQuery(Builder $query, Builder $parent, $colu /** * Restore soft-deleted models. * - * @param array|string ...$columns + * @param string[]|string ...$columns * @return $this */ public function withTrashed(...$columns) { if (empty($columns)) { + /** @phpstan-ignore method.notFound */ $this->query->withTrashed(); return $this; @@ -326,6 +342,7 @@ public function withTrashed(...$columns) $columns = $columns[0]; } + /** @var string[] $columns */ foreach ($columns as $column) { $this->query->withoutGlobalScope(__CLASS__ . ":$column"); } @@ -336,7 +353,7 @@ public function withTrashed(...$columns) /** * Get the "through" parent model instances. * - * @return \Illuminate\Database\Eloquent\Model[] + * @return TIntermediateModel[] */ public function getThroughParents() { @@ -350,7 +367,11 @@ public function getThroughParents() */ public function getFirstForeignKeyName() { - return $this->prefix . $this->getForeignKeyName(end($this->throughParents)); + // TODO: the method docblock doesn't match with the usage of `end($this->throughParents)` + /** @var TIntermediateModel $lastThroughParent */ + $lastThroughParent = end($this->throughParents); + + return $this->prefix . $this->getForeignKeyName($lastThroughParent); } /** @@ -360,6 +381,7 @@ public function getFirstForeignKeyName() */ public function getQualifiedFirstLocalKeyName() { + /** @var TIntermediateModel $lastThroughParent */ $lastThroughParent = end($this->throughParents); return $lastThroughParent->qualifyColumn($this->getLocalKeyName($lastThroughParent)); diff --git a/src/Traits/BelongsToThrough.php b/src/Traits/BelongsToThrough.php index c2b7faf..d029294 100644 --- a/src/Traits/BelongsToThrough.php +++ b/src/Traits/BelongsToThrough.php @@ -11,13 +11,16 @@ trait BelongsToThrough /** * Define a belongs-to-through relationship. * - * @param string $related - * @param array|string $through + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $related + * @param class-string[]|array{0: class-string, 1: string}[]|class-string $through * @param string|null $localKey * @param string $prefix - * @param array $foreignKeyLookup - * @param array $localKeyLookup - * @return \Znck\Eloquent\Relations\BelongsToThrough + * @param array, string> $foreignKeyLookup + * @param array, string> $localKeyLookup + * @return \Znck\Eloquent\Relations\BelongsToThrough */ public function belongsToThrough( $related, @@ -27,7 +30,10 @@ public function belongsToThrough( $foreignKeyLookup = [], array $localKeyLookup = [] ) { + /** @var TRelatedModel $relatedInstance */ $relatedInstance = $this->newRelatedInstance($related); + + /** @var TIntermediateModel[] $throughParents */ $throughParents = []; $foreignKeys = []; @@ -35,11 +41,14 @@ public function belongsToThrough( $foreignKey = null; if (is_array($model)) { + /** @var string $foreignKey */ $foreignKey = $model[1]; + /** @var class-string $model */ $model = $model[0]; } + /** @var TIntermediateModel $instance */ $instance = $this->belongsToThroughParentInstance($model); if ($foreignKey) { @@ -67,8 +76,8 @@ public function belongsToThrough( /** * Map keys to an associative array where the key is the table name and the value is the key from the lookup. * - * @param array $keyLookup - * @return array + * @param array, string> $keyLookup + * @return array */ protected function mapKeys(array $keyLookup): array { @@ -89,14 +98,17 @@ protected function mapKeys(array $keyLookup): array /** * Create a through parent instance for a belongs-to-through relationship. * - * @param string $model - * @return \Illuminate\Database\Eloquent\Model + * @template TModel of \Illuminate\Database\Eloquent\Model + * + * @param class-string $model + * @return TModel */ protected function belongsToThroughParentInstance($model) { + /** @var array{0: class-string, 1?: string} $segments */ $segments = preg_split('/\s+as\s+/i', $model); - /** @var \Illuminate\Database\Eloquent\Model $instance */ + /** @var TModel $instance */ $instance = new $segments[0](); if (isset($segments[1])) { @@ -109,14 +121,18 @@ protected function belongsToThroughParentInstance($model) /** * Instantiate a new BelongsToThrough relationship. * - * @param \Illuminate\Database\Eloquent\Builder $query - * @param \Illuminate\Database\Eloquent\Model $parent - * @param \Illuminate\Database\Eloquent\Model[] $throughParents - * @param string $localKey + * @template TRelatedModel of \Illuminate\Database\Eloquent\Model + * @template TIntermediateModel of \Illuminate\Database\Eloquent\Model + * @template TDeclaringModel of \Illuminate\Database\Eloquent\Model + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param TDeclaringModel $parent + * @param TIntermediateModel[] $throughParents + * @param string|null $localKey * @param string $prefix - * @param array $foreignKeyLookup - * @param array $localKeyLookup - * @return \Znck\Eloquent\Relations\BelongsToThrough + * @param array $foreignKeyLookup + * @param array $localKeyLookup + * @return \Znck\Eloquent\Relations\BelongsToThrough */ protected function newBelongsToThrough( Builder $query, diff --git a/tests/BelongsToThroughTest.php b/tests/BelongsToThroughTest.php index bccf29d..9afe730 100644 --- a/tests/BelongsToThroughTest.php +++ b/tests/BelongsToThroughTest.php @@ -11,139 +11,141 @@ class BelongsToThroughTest extends TestCase { - public function testLazyLoading() + public function testLazyLoading(): void { - $country = Comment::first()->country; + $country = Comment::firstOrFail()->country; - $this->assertEquals(1, $country->id); + $this->assertEquals(1, $country?->id); } - public function testLazyLoadingWithSingleThroughModel() + public function testLazyLoadingWithSingleThroughModel(): void { - $user = Comment::first()->user; + $user = Comment::firstOrFail()->user; - $this->assertEquals(11, $user->id); + $this->assertEquals(11, $user?->id); } - public function testLazyLoadingWithPrefix() + public function testLazyLoadingWithPrefix(): void { - $country = Comment::find(34)->countryWithPrefix; + $country = Comment::findOrFail(34)->countryWithPrefix; - $this->assertEquals(1, $country->id); + $this->assertEquals(1, $country?->id); } - public function testLazyLoadingWithCustomForeignKeys() + public function testLazyLoadingWithCustomForeignKeys(): void { - $country = Comment::find(35)->countryWithCustomForeignKeys; + $country = Comment::findOrFail(35)->countryWithCustomForeignKeys; - $this->assertEquals(1, $country->id); + $this->assertEquals(1, $country?->id); } - public function testLazyLoadingWithSoftDeletes() + public function testLazyLoadingWithSoftDeletes(): void { - $country = Comment::find(33)->country; + $country = Comment::findOrFail(33)->country; - $this->assertFalse($country->exists); + $this->assertFalse($country?->exists); } - public function testLazyLoadingWithDefault() + public function testLazyLoadingWithDefault(): void { - $country = Comment::find(33)->country; + $country = Comment::findOrFail(33)->country; $this->assertInstanceOf(Country::class, $country); $this->assertFalse($country->exists); } - public function testLazyLoadingWithAlias() + public function testLazyLoadingWithAlias(): void { - $comment = Comment::find(35)->grandparent; + $comment = Comment::findOrFail(35)->grandparent; - $this->assertEquals(33, $comment->id); + $this->assertEquals(33, $comment?->id); } - public function testEagerLoading() + public function testEagerLoading(): void { $comments = Comment::with('country')->get(); - $this->assertEquals(1, $comments[0]->country->id); - $this->assertEquals(2, $comments[1]->country->id); - $this->assertInstanceOf(Country::class, $comments[2]->country); + $this->assertEquals(1, $comments[0]?->country?->id); + $this->assertEquals(2, $comments[1]?->country?->id); + $this->assertInstanceOf(Country::class, $comments[2]?->country); $this->assertFalse($comments[2]->country->exists); } - public function testEagerLoadingWithPrefix() + public function testEagerLoadingWithPrefix(): void { $comments = Comment::with('countryWithPrefix')->get(); - $this->assertNull($comments[0]->countryWithPrefix); - $this->assertEquals(1, $comments[3]->countryWithPrefix->id); + $this->assertNull($comments[0]?->countryWithPrefix); + $this->assertEquals(1, $comments[3]?->countryWithPrefix?->id); } - public function testLazyEagerLoading() + public function testLazyEagerLoading(): void { $comments = Comment::all()->load('country'); - $this->assertEquals(1, $comments[0]->country->id); - $this->assertEquals(2, $comments[1]->country->id); - $this->assertInstanceOf(Country::class, $comments[2]->country); + $this->assertEquals(1, $comments[0]?->country?->id); + $this->assertEquals(2, $comments[1]?->country?->id); + $this->assertInstanceOf(Country::class, $comments[2]?->country); $this->assertFalse($comments[2]->country->exists); } - public function testExistenceQuery() + public function testExistenceQuery(): void { $comments = Comment::has('country')->get(); $this->assertEquals([31, 32], $comments->pluck('id')->all()); } - public function testExistenceQueryWithPrefix() + public function testExistenceQueryWithPrefix(): void { $comments = Comment::has('countryWithPrefix')->get(); $this->assertEquals([34], $comments->pluck('id')->all()); } - public function testWithTrashed() + public function testWithTrashed(): void { - $user = Comment::find(33)->user() + /** @var User $user */ + $user = Comment::findOrFail(33)->user() ->withTrashed() ->first(); $this->assertEquals(13, $user->id); } - public function testWithTrashedIntermediate() + public function testWithTrashedIntermediate(): void { - $country = Comment::find(33)->country() + /** @var Country $country */ + $country = Comment::findOrFail(33)->country() ->withTrashed(['users.deleted_at']) ->first(); $this->assertEquals(3, $country->id); } - public function testWithTrashedIntermediateAndWhereHas() + public function testWithTrashedIntermediateAndWhereHas(): void { $comments = Comment::has('countryWithTrashedUser')->get(); $this->assertEquals([31, 32, 33], $comments->pluck('id')->all()); } - public function testGetThroughParents() + public function testGetThroughParents(): void { - $throughParents = Comment::first()->country()->getThroughParents(); + $throughParents = Comment::firstOrFail()->country()->getThroughParents(); $this->assertCount(2, $throughParents); $this->assertInstanceOf(User::class, $throughParents[0]); $this->assertInstanceOf(Post::class, $throughParents[1]); } - public function testGetThroughWithCustomizedLocalKeys() + public function testGetThroughWithCustomizedLocalKeys(): void { $addresses = CustomerAddress::with('vendorCustomer')->get(); - $this->assertEquals(41, $addresses[0]->vendorCustomer->id); - $this->assertEquals(42, $addresses[1]->vendorCustomer->id); - $this->assertInstanceOf(VendorCustomer::class, $addresses[1]->vendorCustomer); - $this->assertFalse($addresses[2]->vendorCustomer()->exists()); + $this->assertEquals(41, $addresses[0]?->vendorCustomer?->id); + $this->assertEquals(42, $addresses[1]?->vendorCustomer?->id); + $this->assertInstanceOf(VendorCustomer::class, $addresses[1]?->vendorCustomer); + $this->assertFalse($addresses[2]?->vendorCustomer()->exists()); } } diff --git a/tests/IdeHelper/BelongsToThroughRelationsHookTest.php b/tests/IdeHelper/BelongsToThroughRelationsHookTest.php index 31e6bce..2840e62 100644 --- a/tests/IdeHelper/BelongsToThroughRelationsHookTest.php +++ b/tests/IdeHelper/BelongsToThroughRelationsHookTest.php @@ -28,7 +28,7 @@ protected function setUp(): void $db->bootEloquent(); } - public function testRun() + public function testRun(): void { $command = Mockery::mock(ModelsCommand::class); $command->shouldReceive('setProperty')->once()->with( diff --git a/tests/IdeHelper/Models/Comment.php b/tests/IdeHelper/Models/Comment.php index 7189f30..57cae80 100644 --- a/tests/IdeHelper/Models/Comment.php +++ b/tests/IdeHelper/Models/Comment.php @@ -9,6 +9,9 @@ class Comment extends Model { use BelongsToThrough; + /** + * @return \Znck\Eloquent\Relations\BelongsToThrough + */ public function country() { return $this->belongsToThrough(Country::class, [User::class, Post::class]); diff --git a/tests/IdeHelperServiceProviderTest.php b/tests/IdeHelperServiceProviderTest.php index 87d162c..4fd7616 100644 --- a/tests/IdeHelperServiceProviderTest.php +++ b/tests/IdeHelperServiceProviderTest.php @@ -11,15 +11,17 @@ class IdeHelperServiceProviderTest extends TestCase { public function testAutoRegistrationOfModelHook(): void { - $this->app->loadDeferredProvider(BarryvdhIdeHelperServiceProvider::class); - $this->app->loadDeferredProvider(IdeHelperServiceProvider::class); + $this->app?->loadDeferredProvider(BarryvdhIdeHelperServiceProvider::class); + $this->app?->loadDeferredProvider(IdeHelperServiceProvider::class); - /** @var \Illuminate\Contracts\Config\Repository $config */ - $config = $this->app->get('config'); + /** @var \Illuminate\Contracts\Config\Repository|null $config */ + $config = $this->app?->get('config'); + + $this->assertNotNull($config); $this->assertContains( BelongsToThroughRelationsHook::class, - $config->get('ide-helper.model_hooks'), + $config->array('ide-helper.model_hooks'), ); } diff --git a/tests/Models/Comment.php b/tests/Models/Comment.php index 25f2e51..654fead 100644 --- a/tests/Models/Comment.php +++ b/tests/Models/Comment.php @@ -5,15 +5,26 @@ use Znck\Eloquent\Relations\BelongsToThrough; use Znck\Eloquent\Traits\HasTableAlias; +/** + * @property int $id + * + * @property-read \Tests\Models\Country|null $country + */ class Comment extends Model { use HasTableAlias; + /** + * @return BelongsToThrough + */ public function country(): BelongsToThrough { return $this->belongsToThrough(Country::class, [User::class, Post::class])->withDefault(); } + /** + * @return BelongsToThrough + */ public function countryWithCustomForeignKeys(): BelongsToThrough { return $this->belongsToThrough( @@ -25,21 +36,37 @@ public function countryWithCustomForeignKeys(): BelongsToThrough ); } + /** + * @return BelongsToThrough + */ public function countryWithTrashedUser(): BelongsToThrough { + /* @phpstan-ignore return.type */ return $this->country()->withTrashed(['users.deleted_at']); } + /** + * @return BelongsToThrough + */ public function countryWithPrefix(): BelongsToThrough { return $this->belongsToThrough(Country::class, [User::class, Post::class], null, 'custom_'); } + /** + * @return BelongsToThrough + */ public function grandparent(): BelongsToThrough { + /** + * @phpstan-ignore return.type, argument.type, argument.templateType + */ return $this->belongsToThrough(self::class, self::class.' as alias', null, '', [self::class => 'parent_id']); } + /** + * @return BelongsToThrough + */ public function user(): BelongsToThrough { return $this->belongsToThrough(User::class, Post::class); diff --git a/tests/Models/Country.php b/tests/Models/Country.php index b565429..3d25a32 100644 --- a/tests/Models/Country.php +++ b/tests/Models/Country.php @@ -2,6 +2,9 @@ namespace Tests\Models; +/** + * @property int $id + */ class Country extends Model { // diff --git a/tests/Models/CustomerAddress.php b/tests/Models/CustomerAddress.php index f1f4825..d863a6d 100644 --- a/tests/Models/CustomerAddress.php +++ b/tests/Models/CustomerAddress.php @@ -6,6 +6,9 @@ class CustomerAddress extends Model { + /** + * @return BelongsToThrough + */ public function vendorCustomer(): BelongsToThrough { return $this->belongsToThrough(