diff --git a/composer.json b/composer.json index e014ead..72c029b 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ ], "require": { "php": "^7.2 || ^8", + "composer-runtime-api": "^2.0", "composer/semver": "^1.4 || ^2.0 || ^3.0", "vimeo/psalm": "^4.9" }, diff --git a/src/MetadataProvider.php b/src/MetadataProvider.php new file mode 100644 index 0000000..6d9f457 --- /dev/null +++ b/src/MetadataProvider.php @@ -0,0 +1,63 @@ + */ + private static $loadedMetadata = []; + + /** + * @param class-string $class + */ + public static function get(string $class): ?ClassMetadataInterface + { + $mappingDriver = self::getMappingDriver(); + if ($mappingDriver === null) { + return null; + } + + if (!array_key_exists($class, self::$loadedMetadata)) { + try { + $metadata = new ClassMetadata($class); + $mappingDriver->loadMetadataForClass($class, $metadata); + self::$loadedMetadata[$class] = $metadata; + } catch (Throwable $_) { + self::$loadedMetadata[$class] = null; // Don't try loading again + } + } + + return self::$loadedMetadata[$class]; + } + + private static function getMappingDriver(): ?MappingDriver + { + if (!InstalledVersions::isInstalled("doctrine/orm")) { + return null; + } + + if (self::$mappingDriver === null) { + try { + self::$mappingDriver = new AnnotationDriver(new AnnotationReader()); + } catch (Throwable $_) { + // Don't keep trying after it fails the first time. + self::$mappingDriver = false; + } + } + + return self::$mappingDriver ?: null; + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 0c5cc06..f81aad5 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -8,6 +8,8 @@ use Psalm\Plugin\PluginEntryPointInterface; use Psalm\Plugin\RegistrationInterface; use SimpleXMLElement; +use Weirdan\DoctrinePsalmPlugin\Provider\ParamsProvider\RepositoryParamsProvider; +use Weirdan\DoctrinePsalmPlugin\Provider\ParamsProvider\RepositoryParamsProviderClassPopulator; use Weirdan\DoctrinePsalmPlugin\Provider\ReturnTypeProvider\CollectionFirstAndLast; use function array_merge; @@ -18,8 +20,13 @@ class Plugin implements PluginEntryPointInterface { + /** @var RegistrationInterface|null */ + public static $registrationInterface = null; + public function __invoke(RegistrationInterface $psalm, ?SimpleXMLElement $config = null): void { + self::$registrationInterface = $psalm; + foreach ($this->getStubFiles() as $file) { $psalm->addStubFile($file); } @@ -27,6 +34,9 @@ public function __invoke(RegistrationInterface $psalm, ?SimpleXMLElement $config if (class_exists(CollectionFirstAndLast::class)) { $psalm->registerHooksFromClass(CollectionFirstAndLast::class); } + if (class_exists(RepositoryParamsProviderClassPopulator::class)) { + $psalm->registerHooksFromClass(RepositoryParamsProviderClassPopulator::class); + } } /** @return string[] */ diff --git a/src/Provider/ParamsProvider/RepositoryParamsProvider.php b/src/Provider/ParamsProvider/RepositoryParamsProvider.php new file mode 100644 index 0000000..190d76f --- /dev/null +++ b/src/Provider/ParamsProvider/RepositoryParamsProvider.php @@ -0,0 +1,151 @@ + */ + public static $classes = []; + + /** + * @return array + */ + public static function getClassLikeNames(): array + { + return self::$classes; + } + + public static function getMethodParams(MethodParamsProviderEvent $event): ?array + { + $statements_source = $event->getStatementsSource(); + if ($statements_source === null) { + return null; + } + $node_type_provider = $statements_source->getNodeTypeProvider(); + $stmt = $event->getStmt(); // Passed this down locally, Psalm would need updated for it to work + if ($stmt === null) { + return null; + } + $object_type = $node_type_provider->getType($stmt->var); + if ($object_type === null || !$object_type->isSingle()) { + return null; + } + $object_type = $object_type->getSingleAtomic(); + if ($object_type instanceof TGenericObject) { + // TODO fix when using multiple parameters with different positions + $entity_type = $object_type->type_params[0]; + } elseif ($object_type instanceof TNamedObject) { + // I didn't see a way to do this without using internal methods + $class_storage = $statements_source->getCodebase()->classlike_storage_provider->get($object_type->value); + /** @psalm-suppress UndefinedClass */ + $entity_type = $class_storage->template_extended_params[ObjectRepository::class]["T"] + ?? $class_storage->template_extended_params[\Doctrine\Common\Persistence\ObjectRepository::class]["T"] + ?? null; + } else { + return null; + } + if ($entity_type === null || !$entity_type->isSingle()) { + return null; + } + $entity_type = $entity_type->getSingleAtomic(); + if (!$entity_type instanceof TNamedObject) { + return null; + } + /** @var class-string */ + $entity_class = $entity_type->value; + + $entity_metadata = MetadataProvider::get($entity_class); + if ($entity_metadata === null) { + return null; + } + $properties = $entity_metadata->getFieldNames(); + $properties = array_combine($properties, $properties); + if (count($properties) === 0) { + return null; + } + $property_types = array_map(function (string $property) use ($entity_metadata) { + return $entity_metadata->getTypeOfField($property); + }, $properties); + + switch ($event->getMethodNameLowercase()) { + case "findby": + return [ + new FunctionLikeParameter( + "criteria", + false, + new Union([new TKeyedArray( + array_map(function (?string $property_type) { + switch ($property_type) { + case "integer": + $type = new TInt(); + break; + case "string": + case "text": + $type = new TString(); + break; + case "boolean": + $type = new TBool(); + break; + // TODO add more cases + default: + $type = new TMixed(); + break; + } + $union = new Union([$type]); + $union = new Union([$type, new TList($union)]); + $union->possibly_undefined = true; + return $union; + }, $property_types), + )]) + ), + new FunctionLikeParameter( + "orderBy", + false, + new Union([new TKeyedArray( + array_map(function (string $_) { + $type = new Union([ + new TLiteralString("asc"), + new TLiteralString("ASC"), + new TLiteralString("desc"), + new TLiteralString("DESC"), + ]); + $type->possibly_undefined = true; + return $type; + }, $properties), + )]) + ), + new FunctionLikeParameter( + "limit", + false, + new Union([new TIntRange(0, null)]), + ), + new FunctionLikeParameter( + "offset", + false, + new Union([new TIntRange(0, null)]), + ), + ]; + } + + return null; + } +} diff --git a/src/Provider/ParamsProvider/RepositoryParamsProviderClassPopulator.php b/src/Provider/ParamsProvider/RepositoryParamsProviderClassPopulator.php new file mode 100644 index 0000000..e464812 --- /dev/null +++ b/src/Provider/ParamsProvider/RepositoryParamsProviderClassPopulator.php @@ -0,0 +1,26 @@ +getCodebase()->classlike_storage_provider->getAll() as $class) { + if (isset($class->class_implements[strtolower(ObjectRepository::class)])) { + RepositoryParamsProvider::$classes[] = $class->name; + } + } + assert(Plugin::$registrationInterface !== null); + if (class_exists(RepositoryParamsProvider::class)) { + Plugin::$registrationInterface->registerHooksFromClass(RepositoryParamsProvider::class); + } + } +}