Skip to content

Commit

Permalink
Configuration loading: initial implementation
Browse files Browse the repository at this point in the history
Limitation: only XML mapping is supported. Annotations require more
work, and YAML mapping requires test infra support.

Refs #10
  • Loading branch information
weirdan committed Mar 23, 2019
1 parent f1f9251 commit a0d3a2c
Show file tree
Hide file tree
Showing 6 changed files with 294 additions and 9 deletions.
79 changes: 79 additions & 0 deletions src/DoctrineFacade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php
namespace Weirdan\DoctrinePsalmPlugin;

use Doctrine\Common\Persistence\Mapping\ClassMetadata;
use Doctrine\Common\Persistence\Mapping\StaticReflectionService;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Tools\Setup;
use InvalidArgumentException;
use SimpleXMLElement;

class DoctrineFacade
{
/** @var EntityManager */
private $em;

private function __construct(EntityManager $em)
{
$this->em = $em;
}

/**
* @return non-empty-array<int,string>
* @throws InvalidArgumentException when encountering unreadable config
*/
private static function loadPaths(SimpleXMLElement $container): array
{
if (!isset($container->path) || !$container->path instanceof SimpleXMLElement) {
throw new InvalidArgumentException('expecting at least one <path> element');
}

$paths = [];

/** @var SimpleXMLElement $path */
foreach ($container->path as $path) {
$paths[] = (string) $path;
}

if (empty($paths)) {
throw new InvalidArgumentException('expecting at least one <path> element');
}

return $paths;
}

/**
* @throws InvalidArgumentException when encountering unreadable config
*/
public static function load(SimpleXMLElement $config): self
{
if (!isset($config->doctrine) || !$config->doctrine instanceof SimpleXMLElement) {
throw new InvalidArgumentException('expecting <doctrine> subelement');
}

$doctrine = $config->doctrine;

if (isset($doctrine->annotations) && $doctrine->annotations instanceof SimpleXMLElement) {
$paths = self::loadPaths($doctrine->annotations);
$doctrineConfig = Setup::createAnnotationMetadataConfiguration($paths, true);
} elseif (isset($doctrine->yaml) && $doctrine->yaml instanceof SimpleXMLElement) {
$paths = self::loadPaths($doctrine->yaml);
$doctrineConfig = Setup::createYAMLMetadataConfiguration($paths, true);
} elseif (isset($doctrine->xml) && $doctrine->xml instanceof SimpleXMLElement) {
$paths = self::loadPaths($doctrine->xml);
$doctrineConfig = Setup::createXMLMetadataConfiguration($paths, true);
} else {
throw new InvalidArgumentException('expecting one of <annotations>, <yaml>, <xml> subelements');
}

$em = EntityManager::create(['driverClass' => DummyDriver::class], $doctrineConfig);
$em->getMetadataFactory()->setReflectionService(new StaticReflectionService());
return new self($em);
}

/** @param class-string $class */
public function getClassMetadata(string $class): ClassMetadata
{
return $this->em->getClassMetadata($class);
}
}
34 changes: 34 additions & 0 deletions src/DummyDriver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php
namespace Weirdan\DoctrinePsalmPlugin;

use BadMethodCallException;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Driver;

class DummyDriver implements Driver
{
public function connect(array $params, $username = null, $password = null, array $driverOptions = [])
{
throw new BadMethodCallException();
}

public function getDatabasePlatform()
{
throw new BadMethodCallException();
}

public function getSchemaManager(Connection $conn)
{
throw new BadMethodCallException();
}

public function getName()
{
return 'dummy';
}

public function getDatabase(Connection $conn)
{
throw new BadMethodCallException();
}
}
69 changes: 69 additions & 0 deletions src/Hooks/EntityManager/Find.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php
namespace Weirdan\DoctrinePsalmPlugin\Hooks\EntityManager;

use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\MappingException;
use Doctrine\Common\Persistence\Mapping\MappingException as CommonMappingException;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\IssueBuffer;
use Psalm\Issue\InvalidArgument;
use Psalm\Plugin\Hook\MethodReturnTypeProviderInterface;
use Psalm\StatementsSource;
use Psalm\Type;
use Psalm\Type\Atomic;
use Weirdan\DoctrinePsalmPlugin\Plugin;

class Find implements MethodReturnTypeProviderInterface
{
public static function getClassLikeNames(): array
{
return [
EntityManager::class
];
}

/**
* {@inheritDoc}
*/
public static function getMethodReturnType(
StatementsSource $source,
string $fqClasslikeName,
string $methodNameLc,
array $callArgs,
Context $context,
CodeLocation $codeLocation,
array $templateTypeParameters = null,
string $calledFqClasslikeName = null,
string $calledMethodNameLc = null
): ?Type\Union {
if ('find' !== $methodNameLc) {
return null;
}

if (!isset($callArgs[0])) {
return null;
}

/** @var Type\Union $potentialEntityType */
$potentialEntityType = $callArgs[0]->value->inferredType;
$methodId = ($calledFqClasslikeName ?? $fqClasslikeName) . '::' . ($calledMethodNameLc ?? $methodNameLc);
/** @var Atomic $type */
foreach ($potentialEntityType->getTypes() as $type) {
if ($type instanceof Atomic\TLiteralClassString) {
/** @var class-string */
$className = $type->value;
try {
Plugin::doctrine()->getClassMetadata($className);
} catch (CommonMappingException | MappingException $e) {
IssueBuffer::accepts(new InvalidArgument(
'Argument 1 of ' . $methodId . ' expects entity class, '
. 'non-entity ' . $type->getKey() . ' given',
new CodeLocation($source, $callArgs[0])
));
}
}
}
return null;
}
}
38 changes: 37 additions & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
@@ -1,24 +1,60 @@
<?php
namespace Weirdan\DoctrinePsalmPlugin;

use SimpleXMLElement;
use Psalm\Plugin\PluginEntryPointInterface;
use Psalm\Plugin\RegistrationInterface;
use RuntimeException;
use SimpleXMLElement;

class Plugin implements PluginEntryPointInterface
{
/** @var ?DoctrineFacade */
private static $doctrine = null;

/** @return void */
public function __invoke(RegistrationInterface $psalm, ?SimpleXMLElement $config = null)
{
if ($this->loadConfiguration($config)) {
$this->loadHooks($psalm);
}

$stubs = $this->getStubFiles();
foreach ($stubs as $file) {
$psalm->addStubFile($file);
}
}

private function loadHooks(RegistrationInterface $psalm): void
{
foreach ([
Hooks\EntityManager\Find::class
] as $class) {
class_exists($class, true);
$psalm->registerHooksFromClass($class);
}
}

/** @return string[] */
private function getStubFiles(): array
{
return glob(__DIR__ . '/' . '../stubs/*\\.php');
}

private function loadConfiguration(?SimpleXMLElement $config): bool
{
if (!$config) {
// TODO: add warning
return false;
}
self::$doctrine = DoctrineFacade::load($config);
return true;
}

public static function doctrine(): DoctrineFacade
{
if (null === self::$doctrine) {
throw new RuntimeException('Doctrine unavailable, this method is not expected to be called');
}
return self::$doctrine;
}
}
37 changes: 37 additions & 0 deletions tests/_support/Helper/Acceptance.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,46 @@
<?php
namespace Weirdan\DoctrinePsalmPlugin\Tests\Helper;

use Codeception\Exception\ModuleRequireException;
use Codeception\Module\Filesystem;
use Weirdan\Codeception\Psalm;

// here you can define custom actions
// all public methods declared in helper class will be available in $I

class Acceptance extends \Codeception\Module
{
/** @var ?Filesystem */
private $fs = null;
/**
* @Given I have the following mapping for :class :mapping
* @return void
*/
public function iHaveTheFollowingMappingForC(string $class, string $mapping)
{
/**
* @psalm-suppress MixedAssignment
* @psalm-suppress InvalidArgument
*/
$defaultFile = $this->getModule('\\' . Psalm\Module::class)->_getConfig('default_file');
assert(is_string($defaultFile));

$dir = dirname($defaultFile) . '/mapping';
@mkdir($dir, 0755, true);

$filename = $dir . '/' . str_replace('\\', '.', $class) . '.dcm.xml';
$this->fs()->writeToFile($filename, $mapping);
}

private function fs(): Filesystem
{
if (null === $this->fs) {
$fs = $this->getModule('Filesystem');
if (!$fs instanceof Filesystem) {
throw new ModuleRequireException($this, 'Needs Filesystem module');
}
$this->fs = $fs;
}
return $this->fs;
}
}
46 changes: 38 additions & 8 deletions tests/acceptance/EntityManager.feature
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,35 @@ Feature: EntityManager
<directory name="."/>
</projectFiles>
<plugins>
<pluginClass class="Weirdan\DoctrinePsalmPlugin\Plugin" />
<pluginClass class="Weirdan\DoctrinePsalmPlugin\Plugin">
<doctrine>
<xml>
<path>mapping</path>
</xml>
</doctrine>
</pluginClass>
</plugins>
</psalm>
"""
And I have the following mapping for "C"
"""
<?xml version="1.0"?>
<doctrine-mapping
xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="C" table="c">
</entity>
</doctrine-mapping>
"""
And I have the following code preamble
"""
<?php
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\EntityManager;
interface I {}
class C {}
/**
* @psalm-suppress InvalidReturnType
Expand All @@ -36,12 +54,12 @@ Feature: EntityManager
Scenario: EntityManager returns specialized EntityRepository
Given I have the following code
"""
atan(em()->getRepository(I::class));
atan(em()->getRepository(C::class));
"""
When I run Psalm
Then I see these errors
| Type | Message |
| InvalidArgument | Argument 1 of atan expects float, Doctrine\ORM\EntityRepository<I> provided |
| InvalidArgument | Argument 1 of atan expects float, Doctrine\ORM\EntityRepository<C> provided |
And I see no other errors

@EntityManager::getRepository
Expand All @@ -60,12 +78,24 @@ Feature: EntityManager
Scenario: Finding an entity
Given I have the following code
"""
atan(em()->find(I::class, 1));
atan(em()->find(C::class, 1));
"""
When I run Psalm
Then I see these errors
| Type | Message |
| InvalidArgument | Argument 1 of atan expects float, null\|I provided |
| InvalidArgument | Argument 1 of atan expects float, null\|C provided |
And I see no other errors

@EntityManager::find
Scenario: Finding a non-entity
Given I have the following code
"""
em()->find(InvalidArgumentException::class, 1);
"""
When I run Psalm
Then I see these errors
| Type | Message |
| InvalidArgument | Argument 1 of Doctrine\ORM\EntityManager::find expects entity class, non-entity class-string(InvalidArgumentException) given |
And I see no other errors

@EntityManager::find
Expand All @@ -84,12 +114,12 @@ Feature: EntityManager
Scenario: Getting a reference
Given I have the following code
"""
atan(em()->getReference(I::class, 1));
atan(em()->getReference(C::class, 1));
"""
When I run Psalm
Then I see these errors
| Type | Message |
| InvalidArgument | Argument 1 of atan expects float, I provided |
| InvalidArgument | Argument 1 of atan expects float, C provided |
And I see no other errors

@EntityManager::getReference
Expand Down

0 comments on commit a0d3a2c

Please sign in to comment.