diff --git a/extension.neon b/extension.neon index 0d47eed..e1bb695 100644 --- a/extension.neon +++ b/extension.neon @@ -54,3 +54,7 @@ services: factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable, %s\Model\Table\%sTable, defaultTable) tags: - phpstan.broker.expressionTypeResolverExtension + - + class: CakeDC\PHPStan\Type\TypeFactoryBuildDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicStaticMethodReturnTypeExtension diff --git a/src/Type/TypeFactoryBuildDynamicReturnTypeExtension.php b/src/Type/TypeFactoryBuildDynamicReturnTypeExtension.php new file mode 100644 index 0000000..3f42770 --- /dev/null +++ b/src/Type/TypeFactoryBuildDynamicReturnTypeExtension.php @@ -0,0 +1,135 @@ +|null + */ + private ?array $typeMap = null; + + /** + * @var \PHPStan\Reflection\ReflectionProvider + */ + protected ReflectionProvider $reflectionProvider; + + /** + * @param \PHPStan\Reflection\ReflectionProvider $reflectionProvider + */ + public function __construct(ReflectionProvider $reflectionProvider) + { + $this->reflectionProvider = $reflectionProvider; + } + + /** + * @return class-string + */ + public function getClass(): string + { + return TypeFactory::class; + } + + /** + * Checks if the method is supported. + * + * @param \PHPStan\Reflection\MethodReflection $methodReflection Method reflection + * @return bool + */ + public function isStaticMethodSupported(MethodReflection $methodReflection): bool + { + return $methodReflection->getName() === 'build'; + } + + /** + * Returns the type from the static method call. + * + * @param \PHPStan\Reflection\MethodReflection $methodReflection Method reflection + * @param \PhpParser\Node\Expr\StaticCall $methodCall Static method call + * @param \PHPStan\Analyser\Scope $scope Scope + * @return \PHPStan\Type\Type|null + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope, + ): ?Type { + $args = $methodCall->getArgs(); + if (count($args) === 0) { + return null; + } + + $argType = $scope->getType($args[0]->value); + $constantStrings = $argType->getConstantStrings(); + if (count($constantStrings) !== 1) { + return null; + } + + $typeName = $constantStrings[0]->getValue(); + $typeMap = $this->getTypeMap(); + + if (!isset($typeMap[$typeName])) { + return null; + } + + return new ObjectType($typeMap[$typeName]); + } + + /** + * Get the type map by reading TypeFactory's static property via reflection. + * This is cached after the first call. + * + * @return array + */ + private function getTypeMap(): array + { + if ($this->typeMap !== null) { + return $this->typeMap; + } + + try { + $reflection = $this->reflectionProvider->getClass(TypeFactory::class); + /** + * @var \PHPStan\Reflection\Php\PhpPropertyReflection $property + */ + $property = $reflection->getStaticProperty('_types'); + /** + * @var array $defaultValue + */ + $defaultValue = $property->getNativeReflection()->getValue(); + $this->typeMap = $defaultValue; + + return $this->typeMap; + } catch (MissingPropertyFromReflectionException | ReflectionException) { + return []; + } + } +} diff --git a/tests/TestCase/Type/Fake/TypeFactoryCorrectUsage.php b/tests/TestCase/Type/Fake/TypeFactoryCorrectUsage.php new file mode 100644 index 0000000..8483240 --- /dev/null +++ b/tests/TestCase/Type/Fake/TypeFactoryCorrectUsage.php @@ -0,0 +1,45 @@ +setUserTimezone('America/New_York'); + } + + public function testTimestampTypeWithSetUserTimezone(): void + { + $type = TypeFactory::build('timestamp'); + $type->setUserTimezone('UTC'); + } + + public function testDateTimeFractionalTypeWithSetUserTimezone(): void + { + $type = TypeFactory::build('datetimefractional'); + $type->setUserTimezone('UTC'); + } + + public function testDateTimeTimezoneTypeWithSetUserTimezone(): void + { + $type = TypeFactory::build('timestamptimezone'); + $type->setUserTimezone('UTC'); + } + + public function testDateTypeWithSetLocaleFormat(): void + { + $type = TypeFactory::build('date'); + $type->setLocaleFormat('yyyy-MM-dd'); + } + + public function testTimeTypeWithSetLocaleFormat(): void + { + $type = TypeFactory::build('time'); + $type->setLocaleFormat('HH:mm:ss'); + } +} diff --git a/tests/TestCase/Type/Fake/TypeFactoryIncorrectUsage.php b/tests/TestCase/Type/Fake/TypeFactoryIncorrectUsage.php new file mode 100644 index 0000000..174fc8a --- /dev/null +++ b/tests/TestCase/Type/Fake/TypeFactoryIncorrectUsage.php @@ -0,0 +1,33 @@ +setUserTimezone('UTC'); + } + + public function testStringTypeWithSetUserTimezone(): void + { + $type = TypeFactory::build('string'); + $type->setUserTimezone('UTC'); + } + + public function testBoolTypeWithSetUserTimezone(): void + { + $type = TypeFactory::build('boolean'); + $type->setUserTimezone('UTC'); + } + + public function testJsonTypeWithNonExistentMethod(): void + { + $type = TypeFactory::build('json'); + $type->nonExistentMethod(); + } +} diff --git a/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000..9ae3694 --- /dev/null +++ b/tests/TestCase/Type/TypeFactoryBuildDynamicReturnTypeExtensionTest.php @@ -0,0 +1,67 @@ +runPhpStan(__DIR__ . '/Fake/TypeFactoryCorrectUsage.php'); + static::assertStringContainsString('[OK] No errors', $output); + } + + /** + * Test that TypeFactory::build() catches invalid method calls. + * + * @return void + */ + public function testTypeFactoryBuildCatchesInvalidMethodCalls(): void + { + $output = $this->runPhpStan(__DIR__ . '/Fake/TypeFactoryIncorrectUsage.php'); + + static::assertStringContainsString('IntegerType::setUserTimezone()', $output); + static::assertStringContainsString('StringType::setUserTimezone()', $output); + static::assertStringContainsString('BoolType::setUserTimezone()', $output); + static::assertStringContainsString('JsonType::nonExistentMethod()', $output); + static::assertStringContainsString('Found 4 errors', $output); + } + + /** + * Run PHPStan on a file and return the output. + * + * @param string $file File to analyze + * @return string + */ + private function runPhpStan(string $file): string + { + $configFile = dirname(__DIR__, 3) . '/extension.neon'; + $command = sprintf( + 'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1', + escapeshellarg(dirname(__DIR__, 3)), + escapeshellarg($file), + escapeshellarg($configFile), + ); + + exec($command, $output, $exitCode); + + return implode("\n", $output); + } +}