Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype for repository findBy type checking. #109

Draft
wants to merge 1 commit into
base: 2.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
63 changes: 63 additions & 0 deletions src/MetadataProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Weirdan\DoctrinePsalmPlugin;

use Composer\InstalledVersions;
use Doctrine\Common\Annotations\AnnotationReader;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\Driver\AnnotationDriver;
use Doctrine\Persistence\Mapping\ClassMetadata as ClassMetadataInterface;
use Doctrine\Persistence\Mapping\Driver\MappingDriver;
use Throwable;

class MetadataProvider
{
/** @var MappingDriver|false|null */
private static $mappingDriver = null;

/** @var array<class-string, ClassMetadata|null> */
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;
}
}
10 changes: 10 additions & 0 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,15 +20,23 @@

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);
}

if (class_exists(CollectionFirstAndLast::class)) {
$psalm->registerHooksFromClass(CollectionFirstAndLast::class);
}
if (class_exists(RepositoryParamsProviderClassPopulator::class)) {
$psalm->registerHooksFromClass(RepositoryParamsProviderClassPopulator::class);
}
}

/** @return string[] */
Expand Down
151 changes: 151 additions & 0 deletions src/Provider/ParamsProvider/RepositoryParamsProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

declare(strict_types=1);

namespace Weirdan\DoctrinePsalmPlugin\Provider\ParamsProvider;

use Doctrine\Persistence\ObjectRepository;
use Psalm\Plugin\EventHandler\Event\MethodParamsProviderEvent;
use Psalm\Plugin\EventHandler\MethodParamsProviderInterface;
use Psalm\Storage\FunctionLikeParameter;
use Psalm\Type\Atomic\TBool;
use Psalm\Type\Atomic\TGenericObject;
use Psalm\Type\Atomic\TInt;
use Psalm\Type\Atomic\TIntRange;
use Psalm\Type\Atomic\TKeyedArray;
use Psalm\Type\Atomic\TList;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Atomic\TMixed;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TString;
use Psalm\Type\Union;
use Weirdan\DoctrinePsalmPlugin\MetadataProvider;

class RepositoryParamsProvider implements MethodParamsProviderInterface
{
/** @var array<class-string> */
public static $classes = [];

/**
* @return array<string>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace Weirdan\DoctrinePsalmPlugin\Provider\ParamsProvider;

use Doctrine\Persistence\ObjectRepository;
use Psalm\Plugin\EventHandler\AfterCodebasePopulatedInterface;
use Psalm\Plugin\EventHandler\Event\AfterCodebasePopulatedEvent;
use Weirdan\DoctrinePsalmPlugin\Plugin;

class RepositoryParamsProviderClassPopulator implements AfterCodebasePopulatedInterface
{
public static function afterCodebasePopulated(AfterCodebasePopulatedEvent $event)
{
foreach ($event->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);
}
}
}