Skip to content

Commit

Permalink
Merge pull request #191 from shochdoerfer/feature/extension_attributes
Browse files Browse the repository at this point in the history
Add autoloader for extension classes
  • Loading branch information
shochdoerfer committed Jan 15, 2022
2 parents eed75f9 + 944039a commit 27a16da
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 1 deletion.
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
"type": "phpstan-extension",
"minimum-stability": "stable",
"config": {
"sort-packages": true
"sort-packages": true,
"allow-plugins": {
"phpstan/extension-installer": true,
"captainhook/plugin-composer": true
}
},
"license": "MIT",
"authors": [
Expand Down
7 changes: 7 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,10 @@ services:
classLoaderProvider: @classLoaderProvider
tags:
- phpstan.magento.autoloader
extensionAutoloader:
class: bitExpert\PHPStan\Magento\Autoload\ExtensionAutoloader
arguments:
cache: @autoloaderCache
attributeDataProvider: @extensionAttributeDataProvider
tags:
- phpstan.magento.autoloader
9 changes: 9 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ parameters:
-
message: '~is not covered by backward compatibility promise.~'
path: src/bitExpert/PHPStan/Magento/Autoload/ProxyAutoloader.php
-
message: '~is not covered by backward compatibility promise.~'
path: src/bitExpert/PHPStan/Magento/Autoload/ExtensionAutoloader.php
-
message: '~is not covered by backward compatibility promise.~'
path: src/bitExpert/PHPStan/Magento/Autoload/ExtensionInterfaceAutoloader.php
Expand All @@ -40,6 +43,12 @@ parameters:
-
message: '~is not covered by backward compatibility promise.~'
path: tests/bitExpert/PHPStan/Magento/Autoload/RegistrationUnitTest.php
-
message: '~is not covered by backward compatibility promise.~'
path: tests/bitExpert/PHPStan/Magento/Autoload/ExtensionAutoloaderUnitTest.php
-
message: '~Parameter #1 $argument of class ReflectionClass constructor expects class-string<MyUncachedExtension>|MyUncachedExtension, string given~'
path: tests/bitExpert/PHPStan/Magento/Autoload/ExtensionAutoloaderUnitTest.php
-
message: '~is not covered by backward compatibility promise.~'
path: tests/bitExpert/PHPStan/Magento/Autoload/ExtensionInterfaceAutoloaderUnitTest.php
Expand Down
125 changes: 125 additions & 0 deletions src/bitExpert/PHPStan/Magento/Autoload/ExtensionAutoloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<?php

/*
* This file is part of the phpstan-magento package.
*
* (c) bitExpert AG
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);

namespace bitExpert\PHPStan\Magento\Autoload;

use bitExpert\PHPStan\Magento\Autoload\DataProvider\ExtensionAttributeDataProvider;
use Laminas\Code\Generator\ClassGenerator;
use Laminas\Code\Generator\DocBlock\Tag\ParamTag;
use Laminas\Code\Generator\DocBlock\Tag\ReturnTag;
use Laminas\Code\Generator\DocBlockGenerator;
use Laminas\Code\Generator\MethodGenerator;
use PHPStan\Cache\Cache;

class ExtensionAutoloader implements Autoloader
{
/**
* @var Cache
*/
private $cache;
/**
* @var ExtensionAttributeDataProvider
*/
private $attributeDataProvider;

/**
* ExtensionAutoloader constructor.
*
* @param Cache $cache
* @param ExtensionAttributeDataProvider $attributeDataProvider
*/
public function __construct(
Cache $cache,
ExtensionAttributeDataProvider $attributeDataProvider
) {
$this->cache = $cache;
$this->attributeDataProvider = $attributeDataProvider;
}

public function autoload(string $class): void
{
if (preg_match('#Extension$#', $class) !== 1) {
return;
}

$cachedFilename = $this->cache->load($class, '');
if ($cachedFilename === null) {
try {
$this->cache->save($class, '', $this->getFileContents($class));
$cachedFilename = $this->cache->load($class, '');
} catch (\Exception $e) {
return;
}
}

require_once($cachedFilename);
}

/**
* Given an extension attributes interface name, generate that interface (if possible)
*/
public function getFileContents(string $className): string
{
$sourceInterface = rtrim(substr($className, 0, -1 * strlen('Extension')), '\\') . 'ExtensionInterface';
$attrInterface = rtrim(substr($sourceInterface, 0, -1 * strlen('ExtensionInterface')), '\\') . 'Interface';

$generator = new ClassGenerator();
$generator
->setName($className)
->setExtendedClass('\Magento\Framework\Api\AbstractSimpleObject')
->setImplementedInterfaces([$sourceInterface]);

$attrs = $this->attributeDataProvider->getAttributesForInterface($attrInterface);
foreach ($attrs as $propertyName => $type) {
/**
* Generate getters and setters for each extension attribute
*
* @see \Magento\Framework\Api\Code\Generator\ExtensionAttributesGenerator::_getClassMethods
*/

$generator->addMethodFromGenerator(
MethodGenerator::fromArray([
'name' => 'get' . ucfirst($propertyName),
'docblock' => DocBlockGenerator::fromArray([
'tags' => [
new ReturnTag([$type, 'null']),
],
]),
])
);
$generator->addMethodFromGenerator(
MethodGenerator::fromArray([
'name' => 'set' . ucfirst($propertyName),
'parameters' => [$propertyName],
'docblock' => DocBlockGenerator::fromArray([
'tags' => [
new ParamTag($propertyName, [$type]),
new ReturnTag('$this')
]
])
])
);
}

return "<?php\n\n" . $generator->generate();
}

public function register(): void
{
\spl_autoload_register([$this, 'autoload'], true, false);
}

public function unregister(): void
{
\spl_autoload_unregister([$this, 'autoload']);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

namespace bitExpert\PHPStan\Magento\Autoload;

use bitExpert\PHPStan\Magento\Autoload\Cache\FileCacheStorage;
use bitExpert\PHPStan\Magento\Autoload\DataProvider\ExtensionAttributeDataProvider;
use org\bovigo\vfs\vfsStream;
use PHPStan\Cache\Cache;
use PHPUnit\Framework\TestCase;

class ExtensionAutoloaderUnitTest extends TestCase
{
/**
* @var Cache|\PHPUnit\Framework\MockObject\MockObject
*/
private $cache;
/**
* @var ExtensionAttributeDataProvider|\PHPUnit\Framework\MockObject\MockObject
*/
private $extAttrDataProvider;
/**
* @var ExtensionAutoloader
*/
private $autoloader;

protected function setUp(): void
{
$this->cache = $this->createMock(Cache::class);
$this->extAttrDataProvider = $this->createMock(ExtensionAttributeDataProvider::class);
$this->autoloader = new ExtensionAutoloader(
$this->cache,
$this->extAttrDataProvider
);
}

/**
* @test
*/
public function autoloaderIgnoresClassesWithoutExtensionInterfacePostfix(): void
{
$this->cache->expects(self::never())
->method('load');

$this->autoloader->autoload('SomeClass');
}

/**
* @test
*/
public function autoloaderUsesCachedFileWhenFound(): void
{
$this->cache->expects(self::once())
->method('load')
->willReturn(__DIR__ . '/HelperExtension.php');

$this->cache->expects(self::never())
->method('save');

$this->autoloader->autoload(HelperExtension::class);

self::assertTrue(class_exists(HelperExtension::class, false));
}

/**
* @test
*/
public function autoloadGeneratesInterfaceWhenNotCached(): void
{
$className = 'MyUncachedExtension';
// since the generated class implements an interface, we need to make it available here, otherwise
// the autoloader will fail with an exception that the interface can't be found!
class_alias(HelperExtensionInterface::class, 'MyUncachedExtensionInterface');

$root = vfsStream::setup('test');
$cache = new Cache(new FileCacheStorage($root->url() . '/tmp/cache/PHPStan'));
$autoloader = new ExtensionAutoloader($cache, $this->extAttrDataProvider);

$this->extAttrDataProvider->expects(self::once())
->method('getAttributesForInterface')
->willReturn(['attr' => 'string']);

$autoloader->autoload($className);
static::assertTrue(class_exists($className));
$classReflection = new \ReflectionClass($className);
try {
$getAttrReflection = $classReflection->getMethod('getAttr');
$docComment = $getAttrReflection->getDocComment();
if (!is_string($docComment)) {
throw new \ReflectionException();
}
static::assertStringContainsString('@return string|null', $docComment);
} catch (\ReflectionException $e) {
static::fail('Could not find expected method getAttr on generated class');
}

try {
$setAttrReflection = $classReflection->getMethod('setAttr');
$docComment = $setAttrReflection->getDocComment();
if (!is_string($docComment)) {
throw new \ReflectionException();
}
static::assertStringContainsString('@param string $attr', $docComment);
} catch (\ReflectionException $e) {
static::fail('Could not find expected generated method setAttr on generated class');
}
}
}
20 changes: 20 additions & 0 deletions tests/bitExpert/PHPStan/Magento/Autoload/HelperExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the phpstan-magento package.
*
* (c) bitExpert AG
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);

namespace bitExpert\PHPStan\Magento\Autoload;

/**
* Dummy attribute extension interface that can be loaded via the Autoloader in the test cases.
*/
class HelperExtension extends \Magento\Framework\Api\AbstractSimpleObject implements HelperExtensionInterface
{
}

0 comments on commit 27a16da

Please sign in to comment.