Skip to content

Instantly share code, notes, and snippets.

@fesor
Last active July 28, 2022 13:50
Show Gist options
  • Save fesor/a47413c0441367a032e94ea570af1e64 to your computer and use it in GitHub Desktop.
Save fesor/a47413c0441367a032e94ea570af1e64 to your computer and use it in GitHub Desktop.
Phan plugins for Symfony and Doctrine

Phan plugins for Symfony and Doctrine

If you are working with legacy app which still uses $container->get() or even $em->getRepotirory() methods, phan will be unable to resolve types for you.

Phan provides you ability to override types for methods during analisys depending on context and arguments provided. But for this cases we also need somehow to gather type information from both container and doctrine mappings. So here is example of how this could be acheaved.

I use it as temporal solution until code cleanup process is incomplete for my current project, so I didn't prepare any ready-to-go library. If you want to do that - cool. But I don't have enough time and just wanted to share at least something with community.

Usage

First of all we need a way to gather information about types. I run cache:warmup before running the analisys to generate xml dump of the container. It will be used to get mapping of types for service id's.

When you have source of this type map, you could prepare initialization script for this plugin. Something like that:

# .phan/plugins/ContainerReturnTypePlugin.php 
<?php

return ContainerReturnTypePlugin::createFromXMLDump(
  __DIR__ . '/../../var/cache/dev/appDevDebugProjectContainer.xml'
);

For doctrine I wrote small command (ExposeDoctrineRepositoriesCommand) which allows me to dump map entity => repository, which will then be used to provide type information. I create this dump by simply running console app:doctrine:expose-mapping > .phan/doctrine.json before starting phan. You could even start this in initialization script via exec, but this depends on how you want it to work. Anyway, here is example of my initialization script:

<?php
# .phan/plugins/DoctrineTypeResolutionPlugin.php 
$doctrineMapping = json_decode(file_get_contents(__DIR__ . '/doctrine.json'), true);

return new DoctrineTypeResolutionPlugin($doctrineMapping);

Then we need to enable this plugins in phan's configuration file (which is located by default at .phan/config.php):

    // ...
    'plugins' => [
       '.phan/plugins/ContainerReturnTypePlugin.php',
       '.phan/plugins/DoctrineTypeResolutionPlugin.php',
    ],

Limitations

Probably it's possible to encounter types for generic methods like findBy and find of entity repository, but since I don't have much of them in my current code base, I didn't had a chanse to explore this abilities. So this plugin only resolves type of repository.

<?php
use Phan\CodeBase;
use Phan\Language\Context;
use Phan\Language\UnionType;
use Phan\PluginV2\ReturnTypeOverrideCapability;
use Phan\Language\Element\Method;
use Phan\PluginV2;
use \Phan\Language\FQSEN\FullyQualifiedClassName;
use Phan\Language\Element\Clazz;
class ContainerReturnTypePlugin extends PluginV2 implements ReturnTypeOverrideCapability
{
private $containerDump;
public function __construct(array $containerDump)
{
$this->containerDump = $containerDump;
}
public static function createFromXMLDump(string $pathToContainerXml): self
{
if (!is_file($pathToContainerXml)) {
throw new \InvalidArgumentException('XML Dump of container does not exists at given path');
}
$serviceContainer = file_get_contents($pathToContainerXml);
$xml = simplexml_load_string($serviceContainer);
$xml->registerXPathNamespace('c','http://symfony.com/schema/dic/services');
$serviceMap = [];
foreach ($xml->xpath('//c:service[@id][@class]') as $service) {
$serviceMap[(string) $service->attributes()->id] = (string) $service->attributes()->class;
}
foreach ($xml->xpath('//c:service[@id][@alias]') as $service) {
$alias = (string) $service->attributes()->alias;
if (!isset($serviceMap[$alias])) {
continue;
}
$serviceMap[(string) $service->attributes()->id] = $serviceMap[$alias];
}
return new self($serviceMap);
}
public function getReturnTypeOverrides(CodeBase $code_base): array
{
$returnTypeProvider = Closure::fromCallable([$this, 'returnTypeProvider']);
$types = [
\Symfony\Bundle\FrameworkBundle\Controller\Controller::class,
\Psr\Container\ContainerInterface::class,
];
$classes = array_reduce($types, function ($classes, $type) use ($code_base) {
return array_merge(
$classes,
iterator_to_array($this->subtypesOf($type, $code_base))
);
}, []);
$typeResolvers = [];
foreach ($classes as $className) {
$typeResolvers[$className . '::get'] = $returnTypeProvider;
}
return $typeResolvers;
}
private function returnTypeProvider(CodeBase $code_base, Context $context, Method $method, array $args) {
$serviceId = $args[0];
if ($serviceId instanceof \ast\Node && $serviceId->kind === \ast\AST_CLASS_CONST) {
$name = $serviceId->children['class']->children['name'];
return FullyQualifiedClassName::fromStringInContext($name, $context)
->asUnionType();
}
if (!is_string($serviceId) || !isset($this->containerDump[$serviceId])) {
return new UnionType();
}
$type = $this->containerDump[$serviceId];
$type = '\\' . ltrim($type, '\\');
return UnionType::fromFullyQualifiedString($type);
}
/**
* will not be needed as soon as https://github.com/phan/phan/issues/1182 will land
*/
private function subtypesOf($type, CodeBase $codeBase)
{
$baseType = $codeBase->getClassByFQSEN(
FullyQualifiedClassName::fromFullyQualifiedString('\\' . $type)
);
foreach ($codeBase->getUserDefinedClassMap() as $item) {
assert($item instanceof \Phan\Language\Element\Clazz);
if (!$this->hasType($item, $baseType, $codeBase)) {
continue;
}
yield (string) $item->getFQSEN();
}
}
/**
* Phan's `isSubclassOf` method doesn't really works with interfaces
*/
private function hasType(Clazz $type, Clazz $other, CodeBase $codeBase)
{
if ($type === $other) {
return true;
}
if ($other->isInterface()) {
foreach ($type->getInterfaceFQSENList() as $interface) {
if (!$codeBase->hasClassWithFQSEN($interface)) {
continue;
}
if ($this->hasType($codeBase->getClassByFQSEN($interface), $other, $codeBase)) {
return true;
}
}
return false;
}
if (!$type->hasParentType()) {
return false;
}
try {
return $this->hasType($type->getParentClass($codeBase), $other, $codeBase);
} catch (UnexpectedValueException $exception) {
return false;
}
}
}
<?php
use Phan\CodeBase;
use Phan\Language\Context;
use Phan\Language\UnionType;
use Phan\PluginV2\ReturnTypeOverrideCapability;
use Phan\Language\Element\Method;
use Phan\PluginV2;
use Phan\Language\Element\Clazz;
use \Phan\Language\FQSEN\FullyQualifiedClassName;
class DoctrineTypeResolutionPlugin extends PluginV2 implements ReturnTypeOverrideCapability
{
private $doctrineMapping;
public function __construct(array $doctrineMapping)
{
$this->doctrineMapping = $doctrineMapping;
}
public function getReturnTypeOverrides(CodeBase $code_base): array
{
$returnTypeProvider = Closure::fromCallable([$this, 'returnTypeProvider']);
$types = array_merge(
iterator_to_array($this->subtypesOf(\Doctrine\Common\Persistence\ObjectManager::class, $code_base)),
iterator_to_array($this->subtypesOf(\Doctrine\Common\Persistence\ManagerRegistry::class, $code_base))
);
return array_reduce($types, function ($map, $type) use ($returnTypeProvider) {
$map[$type . '::getRepository'] = $returnTypeProvider;
return $map;
}, []);
}
private function returnTypeProvider(CodeBase $code_base, Context $context, Method $method, array $args) {
$entityType = $args[0];
if ($entityType instanceof \ast\Node && $entityType->kind === \ast\AST_CLASS_CONST) {
$name = $entityType->children['class']->children['name'];
$entityType = (string) FullyQualifiedClassName::fromStringInContext($name, $context);
}
if (!is_string($entityType)) {
$entityType = '';
}
$entityNormalizedType = ltrim($entityType, '\\');
if (!isset($this->doctrineMapping[$entityNormalizedType])) {
return new UnionType();
}
$repositoryType = $this->doctrineMapping[$entityNormalizedType];
return UnionType::fromFullyQualifiedString('\\' . $repositoryType);
}
/**
* will not be needed as soon as https://github.com/phan/phan/issues/1182 will land
*/
private function subtypesOf($type, CodeBase $codeBase)
{
$baseType = $codeBase->getClassByFQSEN(
FullyQualifiedClassName::fromFullyQualifiedString('\\' . $type)
);
foreach ($codeBase->getUserDefinedClassMap() as $item) {
assert($item instanceof \Phan\Language\Element\Clazz);
if (!$this->hasType($item, $baseType, $codeBase)) {
continue;
}
yield (string) $item->getFQSEN();
}
}
/**
* Phan's `isSubclassOf` method doesn't really works with interfaces
*/
private function hasType(Clazz $type, Clazz $other, CodeBase $codeBase)
{
if ($type === $other) {
return true;
}
if ($other->isInterface()) {
foreach ($type->getInterfaceFQSENList() as $interface) {
if (!$codeBase->hasClassWithFQSEN($interface)) {
continue;
}
if ($this->hasType($codeBase->getClassByFQSEN($interface), $other, $codeBase)) {
return true;
}
}
return false;
}
if (!$type->hasParentType()) {
return false;
}
return $this->hasType($type->getParentClass($codeBase), $other, $codeBase);
}
}
<?php
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class ExposeDoctrineRepositoriesCommand extends ContainerAwareCommand
{
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('app:doctrine:expose-mapping')
->setDescription('dumps mapping information on entities and used custom repositories as JSON');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$output->write(json_encode(
iterator_to_array($this->getRepositoryInfo()),
JSON_PRETTY_PRINT
));
}
private function getRepositoryInfo()
{
/** @var ClassMetadata[] $metadata */
$em = $this->getContainer()->get('doctrine.orm.default_entity_manager');
$replacePairs = [];
foreach ($em->getConfiguration()->getEntityNamespaces() as $alias => $namespace) {
$replacePairs[$namespace . '\\'] = $alias . ':';
}
$aliasedEntity = function (string $className) use ($replacePairs) {
return strtr($className, $replacePairs);
};
$metadata = $em->getMetadataFactory()->getAllMetadata();
foreach ($metadata as $entityMetadata) {
if ($entityMetadata->isEmbeddedClass) {
continue;
}
$entityClassName = $entityMetadata->fullyQualifiedClassName($entityMetadata->getName());
$repositoryClassName = EntityRepository::class;
if ($entityMetadata->customRepositoryClassName) {
$repositoryClassName = $entityMetadata->customRepositoryClassName;
}
yield $entityClassName => $repositoryClassName;
yield $aliasedEntity($entityClassName) => $repositoryClassName;
}
yield from [];
}
}
@bobvandevijver
Copy link

Since DoctrineBundle 1.8.0 you can also use the autowiring capabilitity of Symfony in order to have the correct type available. See doctrine/DoctrineBundle#727.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment