From f98654a7aa5c8c8582aa3ea35b208bfa11874a2d Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sun, 15 Jan 2023 15:25:13 +0100 Subject: [PATCH] feat(phpstan): foundation for usage in extensions (#3666) * feat(phpstan): pick up extended model relations typings * feat(phpstan): pick up extended model date attributes * feat(core): introduce `castAttribute` extender Stops using `dates` as it's deprecated in laravel 8 * feat(phpstan): pick up extended model attributes through casts * fix: extenders not resolved when declared namespace * fix(phpstan): new model attributes are always nullable * chore(phpstan): add helpful cache clearing command * Apply fixes from StyleCI * chore: improve extend files provider logic * chore: rename `castAttribute` to just `cast` * chore: update phpstan package to detect `cast` method * Update framework/core/src/Extend/Model.php Signed-off-by: Sami Mazouz --- extension.neon | 24 +++ src/Attributes/AttributeProperty.php | 94 ++++++++++++ .../ModelCastAttributeExtension.php | 87 +++++++++++ .../ModelDateAttributesExtension.php | 76 ++++++++++ src/Extender/Extender.php | 73 +++++++++ src/Extender/FilesProvider.php | 45 ++++++ src/Extender/MethodCall.php | 24 +++ src/Extender/Resolver.php | 138 ++++++++++++++++++ src/Relations/ModelRelationsExtension.php | 86 +++++++++++ src/Relations/RelationMethod.php | 132 +++++++++++++++++ src/Relations/RelationProperty.php | 110 ++++++++++++++ 11 files changed, 889 insertions(+) create mode 100644 src/Attributes/AttributeProperty.php create mode 100644 src/Attributes/ModelCastAttributeExtension.php create mode 100644 src/Attributes/ModelDateAttributesExtension.php create mode 100644 src/Extender/Extender.php create mode 100644 src/Extender/FilesProvider.php create mode 100644 src/Extender/MethodCall.php create mode 100644 src/Extender/Resolver.php create mode 100644 src/Relations/ModelRelationsExtension.php create mode 100644 src/Relations/RelationMethod.php create mode 100644 src/Relations/RelationProperty.php diff --git a/extension.neon b/extension.neon index c8950db..7de0093 100644 --- a/extension.neon +++ b/extension.neon @@ -15,3 +15,27 @@ parameters: - stubs/Illuminate/Contracts/Filesystem/Factory.stub - stubs/Illuminate/Contracts/Filesystem/Cloud.stub - stubs/Illuminate/Contracts/Filesystem/Filesystem.stub + +services: + - + class: Flarum\PHPStan\Relations\ModelRelationsExtension + tags: + - phpstan.broker.methodsClassReflectionExtension + - phpstan.broker.propertiesClassReflectionExtension + - + class: Flarum\PHPStan\Attributes\ModelDateAttributesExtension + tags: + - phpstan.broker.propertiesClassReflectionExtension + - + class: Flarum\PHPStan\Attributes\ModelCastAttributeExtension + tags: + - phpstan.broker.propertiesClassReflectionExtension + - + class: Flarum\PHPStan\Extender\FilesProvider + arguments: + - %paths% + - + class: Flarum\PHPStan\Extender\Resolver + arguments: + - @Flarum\PHPStan\Extender\FilesProvider + - @defaultAnalysisParser diff --git a/src/Attributes/AttributeProperty.php b/src/Attributes/AttributeProperty.php new file mode 100644 index 0000000..d30f1d2 --- /dev/null +++ b/src/Attributes/AttributeProperty.php @@ -0,0 +1,94 @@ +classReflection = $classReflection; + $this->type = $type; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function getReadableType(): Type + { + return $this->type; + } + + public function getWritableType(): Type + { + return $this->getReadableType(); + } + + public function canChangeTypeAfterAssignment(): bool + { + return false; + } + + public function isReadable(): bool + { + return true; + } + + public function isWritable(): bool + { + return true; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } +} diff --git a/src/Attributes/ModelCastAttributeExtension.php b/src/Attributes/ModelCastAttributeExtension.php new file mode 100644 index 0000000..49c3c8b --- /dev/null +++ b/src/Attributes/ModelCastAttributeExtension.php @@ -0,0 +1,87 @@ +extendersResolver = $extendersResolver; + $this->typeStringResolver = $typeStringResolver; + } + + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + return $this->findCastAttributeMethod($classReflection, $propertyName) !== null; + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->resolveCastAttributeProperty($this->findCastAttributeMethod($classReflection, $propertyName), $classReflection); + } + + private function findCastAttributeMethod(ClassReflection $classReflection, string $propertyName): ?MethodCall + { + foreach ($this->extendersResolver->getExtenders() as $extender) { + if (! $extender->isExtender('Model')) { + continue; + } + + foreach (array_merge([$classReflection->getName()], $classReflection->getParentClassesNames()) as $className) { + if ($className === 'Flarum\Database\AbstractModel') { + break; + } + + if ($extender->extends($className)) { + if ($methodCalls = $extender->findMethodCalls('cast')) { + foreach ($methodCalls as $methodCall) { + if ($methodCall->arguments[0]->value === $propertyName) { + return $methodCall; + } + } + } + } + } + } + + return null; + } + + private function resolveCastAttributeProperty(MethodCall $methodCall, ClassReflection $classReflection): PropertyReflection + { + $typeName = $methodCall->arguments[1]->value; + $type = $this->typeStringResolver->resolve("$typeName|null"); + + if (str_contains($typeName, 'date') || $typeName === 'timestamp') { + $type = new UnionType([ + new ObjectType(Carbon::class), + new NullType(), + ]); + } + + return new AttributeProperty($classReflection, $type); + } +} diff --git a/src/Attributes/ModelDateAttributesExtension.php b/src/Attributes/ModelDateAttributesExtension.php new file mode 100644 index 0000000..4699366 --- /dev/null +++ b/src/Attributes/ModelDateAttributesExtension.php @@ -0,0 +1,76 @@ +extendersResolver = $extendersResolver; + } + + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + return $this->findDateAttributeMethod($classReflection, $propertyName) !== null; + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + return $this->resolveDateAttributeProperty($this->findDateAttributeMethod($classReflection, $propertyName), $classReflection); + } + + private function findDateAttributeMethod(ClassReflection $classReflection, string $propertyName): ?MethodCall + { + foreach ($this->extendersResolver->getExtenders() as $extender) { + if (! $extender->isExtender('Model')) { + continue; + } + + foreach (array_merge([$classReflection->getName()], $classReflection->getParentClassesNames()) as $className) { + if ($className === 'Flarum\Database\AbstractModel') { + break; + } + + if ($extender->extends($className)) { + if ($methodCalls = $extender->findMethodCalls('dateAttribute')) { + foreach ($methodCalls as $methodCall) { + if ($methodCall->arguments[0]->value === $propertyName) { + return $methodCall; + } + } + } + } + } + } + + return null; + } + + private function resolveDateAttributeProperty(MethodCall $methodCall, ClassReflection $classReflection): PropertyReflection + { + return new AttributeProperty($classReflection, new UnionType([ + new ObjectType(Carbon::class), + new NullType(), + ])); + } +} diff --git a/src/Extender/Extender.php b/src/Extender/Extender.php new file mode 100644 index 0000000..61e351d --- /dev/null +++ b/src/Extender/Extender.php @@ -0,0 +1,73 @@ +qualifiedClassName = $qualifiedClassName; + $this->constructorArguments = $constructorArguments; + $this->methodCalls = $methodCalls; + } + + public function isExtender(string $className): bool + { + return $this->qualifiedClassName === "Flarum\\Extend\\$className"; + } + + public function extends(...$args): bool + { + foreach ($this->constructorArguments as $index => $constructorArgument) { + $string = null; + + switch (get_class($constructorArgument)) { + case Expr\ClassConstFetch::class: + $string = $constructorArgument->class->toString(); + break; + case Scalar\String_::class: + $string = $constructorArgument->value; + break; + default: + $string = $constructorArgument; + } + + if ($string !== $args[$index]) { + return false; + } + } + + return true; + } + + /** @return MethodCall[] */ + public function findMethodCalls(string ...$methods): array + { + $methodCalls = []; + + foreach ($this->methodCalls as $methodCall) { + if (in_array($methodCall->methodName, $methods)) { + $methodCalls[] = $methodCall; + } + } + + return $methodCalls; + } +} diff --git a/src/Extender/FilesProvider.php b/src/Extender/FilesProvider.php new file mode 100644 index 0000000..4b7b4e5 --- /dev/null +++ b/src/Extender/FilesProvider.php @@ -0,0 +1,45 @@ +paths = $paths; + } + + public function getExtenderFiles(): array + { + if ($this->cachedExtenderFiles === null) { + $this->cachedExtenderFiles = $this->findExtenderFiles(); + } + + return $this->cachedExtenderFiles; + } + + private function findExtenderFiles(): array + { + $extenderFiles = []; + + foreach ($this->paths as $path) { + if (str_contains($path, 'extend.php') && file_exists($path)) { + $extenderFiles[] = $path; + } + } + + return $extenderFiles; + } +} diff --git a/src/Extender/MethodCall.php b/src/Extender/MethodCall.php new file mode 100644 index 0000000..0d2f196 --- /dev/null +++ b/src/Extender/MethodCall.php @@ -0,0 +1,24 @@ +methodName = $methodName; + $this->arguments = $arguments; + } +} diff --git a/src/Extender/Resolver.php b/src/Extender/Resolver.php new file mode 100644 index 0000000..e1e3dcf --- /dev/null +++ b/src/Extender/Resolver.php @@ -0,0 +1,138 @@ +extenderFilesProvider = $extenderFilesProvider; + $this->parser = $parser; + } + + public function getExtenders(): array + { + if ($this->cachedExtenders) { + return $this->cachedExtenders; + } + + return $this->cachedExtenders = $this->resolveExtenders(); + } + + public function getExtendersFor(string $extenderClass, ...$args): array + { + $extenders = []; + + foreach ($this->getExtenders() as $extender) { + if ($extender->isExtender($extenderClass)) { + $extenders[] = $extender; + } + } + + return $extenders; + } + + private function resolveExtenders(): array + { + $extenders = []; + + foreach ($this->extenderFilesProvider->getExtenderFiles() as $extenderFile) { + $extenders = array_merge($extenders, $this->resolveExtendersFromFile($extenderFile)); + } + + return $extenders; + } + + /** + * @return Extender[] + * @throws ParserErrorsException + * @throws \Exception + */ + private function resolveExtendersFromFile($extenderFile): array + { + /** @var Extender[] $extenders */ + $extenders = []; + + $statements = $this->parser->parseFile($extenderFile); + + if ($statements[0] instanceof Namespace_) { + $statements = $statements[0]->stmts; + } + + foreach ($statements as $statement) { + if ($statement instanceof Return_) { + $expression = $statement->expr; + + if ($expression instanceof Array_) { + foreach ($expression->items as $item) { + if ($item->value instanceof MethodCall) { + $extenders[] = $this->resolveExtender($item->value); + } + } + } + } + } + + return $extenders; + } + + private function resolveExtenderNew(New_ $var, array $methodCalls = []): Extender + { + return new Extender($var->class->toString(), array_map(function (Arg $arg) { + $arg->value->setAttributes([]); + + return $arg->value; + }, $var->args), $methodCalls); + } + + private function resolveMethod(MethodCall $var): ExtenderMethodCall + { + return new ExtenderMethodCall($var->name->toString(), array_map(function (Arg $arg) { + $arg->value->setAttributes([]); + + return $arg->value; + }, $var->args)); + } + + private function resolveExtender(MethodCall $value): Extender + { + $methodStack = [$this->resolveMethod($value)]; + + while ($value->var instanceof MethodCall) { + $methodStack[] = $this->resolveMethod($value->var); + $value = $value->var; + } + + $methodStack = array_reverse($methodStack); + + if (! $value->var instanceof New_) { + throw new \Exception('Unable to resolve extender for '.get_class($value->var)); + } + + return $this->resolveExtenderNew($value->var, $methodStack); + } +} diff --git a/src/Relations/ModelRelationsExtension.php b/src/Relations/ModelRelationsExtension.php new file mode 100644 index 0000000..737c383 --- /dev/null +++ b/src/Relations/ModelRelationsExtension.php @@ -0,0 +1,86 @@ +extendersResolver = $extendersResolver; + } + + public function hasMethod(ClassReflection $classReflection, string $methodName): bool + { + return $this->findRelationMethod($classReflection, $methodName) !== null; + } + + public function getMethod(ClassReflection $classReflection, string $methodName): MethodReflection + { + return $this->resolveRelationMethod($this->findRelationMethod($classReflection, $methodName), $classReflection); + } + + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + return $this->findRelationMethod($classReflection, $propertyName) !== null; + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): \PHPStan\Reflection\PropertyReflection + { + return $this->resolveRelationProperty($this->findRelationMethod($classReflection, $propertyName), $classReflection); + } + + private function findRelationMethod(ClassReflection $classReflection, string $methodName): ?MethodCall + { + foreach ($this->extendersResolver->getExtenders() as $extender) { + if (! $extender->isExtender('Model')) { + continue; + } + + foreach (array_merge([$classReflection->getName()], $classReflection->getParentClassesNames()) as $className) { + if ($className === 'Flarum\Database\AbstractModel') { + break; + } + + if ($extender->extends($className)) { + if ($methodCalls = $extender->findMethodCalls('belongsTo', 'belongsToMany', 'hasMany', 'hasOne')) { + foreach ($methodCalls as $methodCall) { + if ($methodCall->arguments[0]->value === $methodName) { + return $methodCall; + } + } + } + } + } + } + + return null; + } + + private function resolveRelationMethod(MethodCall $methodCall, ClassReflection $classReflection): MethodReflection + { + return new RelationMethod($methodCall, $classReflection); + } + + private function resolveRelationProperty(MethodCall $methodCall, ClassReflection $classReflection): PropertyReflection + { + return new RelationProperty($methodCall, $classReflection); + } +} diff --git a/src/Relations/RelationMethod.php b/src/Relations/RelationMethod.php new file mode 100644 index 0000000..cbe4884 --- /dev/null +++ b/src/Relations/RelationMethod.php @@ -0,0 +1,132 @@ +methodCall = $methodCall; + $this->classReflection = $classReflection; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function getName(): string + { + return $this->methodCall->arguments[0]->value; + } + + public function getPrototype(): ClassMemberReflection + { + return $this; + } + + public function getVariants(): array + { + $returnType = 'Illuminate\Database\Eloquent\Relations\Relation'; + + switch ($this->methodCall->methodName) { + case 'belongsTo': + $returnType = 'Illuminate\Database\Eloquent\Relations\BelongsTo'; + break; + case 'belongsToMany': + $returnType = 'Illuminate\Database\Eloquent\Relations\BelongsToMany'; + break; + case 'hasMany': + $returnType = 'Illuminate\Database\Eloquent\Relations\HasMany'; + break; + case 'hasOne': + $returnType = 'Illuminate\Database\Eloquent\Relations\HasOne'; + break; + } + + $relationTarget = $this->methodCall->arguments[1]->class->toString(); + + return [ + new FunctionVariant( + TemplateTypeMap::createEmpty(), + null, + [], + false, + new GenericObjectType($returnType, [new ObjectType($relationTarget)]) + ), + ]; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getThrowType(): ?Type + { + return null; + } + + public function hasSideEffects(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } +} diff --git a/src/Relations/RelationProperty.php b/src/Relations/RelationProperty.php new file mode 100644 index 0000000..108ceee --- /dev/null +++ b/src/Relations/RelationProperty.php @@ -0,0 +1,110 @@ +methodCall = $methodCall; + $this->classReflection = $classReflection; + } + + public function getDeclaringClass(): ClassReflection + { + return $this->classReflection; + } + + public function isStatic(): bool + { + return false; + } + + public function isPrivate(): bool + { + return false; + } + + public function isPublic(): bool + { + return true; + } + + public function getDocComment(): ?string + { + return null; + } + + public function getReadableType(): Type + { + switch ($this->methodCall->methodName) { + case 'hasMany': + case 'belongsToMany': + return new GenericObjectType(Collection::class, [new ObjectType($this->methodCall->arguments[1]->class->toString())]); + + case 'hasOne': + case 'belongsTo': + return new ObjectType($this->methodCall->arguments[1]->class->toString()); + + default: + throw new Exception('Unknown relationship type for relation: '.$this->methodCall->methodName); + } + } + + public function getWritableType(): Type + { + return $this->getReadableType(); + } + + public function canChangeTypeAfterAssignment(): bool + { + return false; + } + + public function isReadable(): bool + { + return true; + } + + public function isWritable(): bool + { + return true; + } + + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function getDeprecatedDescription(): ?string + { + return null; + } + + public function isInternal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } +}