Skip to content

Commit 27a16da

Browse files
authored
Merge pull request #191 from shochdoerfer/feature/extension_attributes
Add autoloader for extension classes
2 parents eed75f9 + 944039a commit 27a16da

File tree

6 files changed

+273
-1
lines changed

6 files changed

+273
-1
lines changed

composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
"type": "phpstan-extension",
55
"minimum-stability": "stable",
66
"config": {
7-
"sort-packages": true
7+
"sort-packages": true,
8+
"allow-plugins": {
9+
"phpstan/extension-installer": true,
10+
"captainhook/plugin-composer": true
11+
}
812
},
913
"license": "MIT",
1014
"authors": [

extension.neon

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,10 @@ services:
8888
classLoaderProvider: @classLoaderProvider
8989
tags:
9090
- phpstan.magento.autoloader
91+
extensionAutoloader:
92+
class: bitExpert\PHPStan\Magento\Autoload\ExtensionAutoloader
93+
arguments:
94+
cache: @autoloaderCache
95+
attributeDataProvider: @extensionAttributeDataProvider
96+
tags:
97+
- phpstan.magento.autoloader

phpstan.neon

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ parameters:
2222
-
2323
message: '~is not covered by backward compatibility promise.~'
2424
path: src/bitExpert/PHPStan/Magento/Autoload/ProxyAutoloader.php
25+
-
26+
message: '~is not covered by backward compatibility promise.~'
27+
path: src/bitExpert/PHPStan/Magento/Autoload/ExtensionAutoloader.php
2528
-
2629
message: '~is not covered by backward compatibility promise.~'
2730
path: src/bitExpert/PHPStan/Magento/Autoload/ExtensionInterfaceAutoloader.php
@@ -40,6 +43,12 @@ parameters:
4043
-
4144
message: '~is not covered by backward compatibility promise.~'
4245
path: tests/bitExpert/PHPStan/Magento/Autoload/RegistrationUnitTest.php
46+
-
47+
message: '~is not covered by backward compatibility promise.~'
48+
path: tests/bitExpert/PHPStan/Magento/Autoload/ExtensionAutoloaderUnitTest.php
49+
-
50+
message: '~Parameter #1 $argument of class ReflectionClass constructor expects class-string<MyUncachedExtension>|MyUncachedExtension, string given~'
51+
path: tests/bitExpert/PHPStan/Magento/Autoload/ExtensionAutoloaderUnitTest.php
4352
-
4453
message: '~is not covered by backward compatibility promise.~'
4554
path: tests/bitExpert/PHPStan/Magento/Autoload/ExtensionInterfaceAutoloaderUnitTest.php
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the phpstan-magento package.
5+
*
6+
* (c) bitExpert AG
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
declare(strict_types=1);
12+
13+
namespace bitExpert\PHPStan\Magento\Autoload;
14+
15+
use bitExpert\PHPStan\Magento\Autoload\DataProvider\ExtensionAttributeDataProvider;
16+
use Laminas\Code\Generator\ClassGenerator;
17+
use Laminas\Code\Generator\DocBlock\Tag\ParamTag;
18+
use Laminas\Code\Generator\DocBlock\Tag\ReturnTag;
19+
use Laminas\Code\Generator\DocBlockGenerator;
20+
use Laminas\Code\Generator\MethodGenerator;
21+
use PHPStan\Cache\Cache;
22+
23+
class ExtensionAutoloader implements Autoloader
24+
{
25+
/**
26+
* @var Cache
27+
*/
28+
private $cache;
29+
/**
30+
* @var ExtensionAttributeDataProvider
31+
*/
32+
private $attributeDataProvider;
33+
34+
/**
35+
* ExtensionAutoloader constructor.
36+
*
37+
* @param Cache $cache
38+
* @param ExtensionAttributeDataProvider $attributeDataProvider
39+
*/
40+
public function __construct(
41+
Cache $cache,
42+
ExtensionAttributeDataProvider $attributeDataProvider
43+
) {
44+
$this->cache = $cache;
45+
$this->attributeDataProvider = $attributeDataProvider;
46+
}
47+
48+
public function autoload(string $class): void
49+
{
50+
if (preg_match('#Extension$#', $class) !== 1) {
51+
return;
52+
}
53+
54+
$cachedFilename = $this->cache->load($class, '');
55+
if ($cachedFilename === null) {
56+
try {
57+
$this->cache->save($class, '', $this->getFileContents($class));
58+
$cachedFilename = $this->cache->load($class, '');
59+
} catch (\Exception $e) {
60+
return;
61+
}
62+
}
63+
64+
require_once($cachedFilename);
65+
}
66+
67+
/**
68+
* Given an extension attributes interface name, generate that interface (if possible)
69+
*/
70+
public function getFileContents(string $className): string
71+
{
72+
$sourceInterface = rtrim(substr($className, 0, -1 * strlen('Extension')), '\\') . 'ExtensionInterface';
73+
$attrInterface = rtrim(substr($sourceInterface, 0, -1 * strlen('ExtensionInterface')), '\\') . 'Interface';
74+
75+
$generator = new ClassGenerator();
76+
$generator
77+
->setName($className)
78+
->setExtendedClass('\Magento\Framework\Api\AbstractSimpleObject')
79+
->setImplementedInterfaces([$sourceInterface]);
80+
81+
$attrs = $this->attributeDataProvider->getAttributesForInterface($attrInterface);
82+
foreach ($attrs as $propertyName => $type) {
83+
/**
84+
* Generate getters and setters for each extension attribute
85+
*
86+
* @see \Magento\Framework\Api\Code\Generator\ExtensionAttributesGenerator::_getClassMethods
87+
*/
88+
89+
$generator->addMethodFromGenerator(
90+
MethodGenerator::fromArray([
91+
'name' => 'get' . ucfirst($propertyName),
92+
'docblock' => DocBlockGenerator::fromArray([
93+
'tags' => [
94+
new ReturnTag([$type, 'null']),
95+
],
96+
]),
97+
])
98+
);
99+
$generator->addMethodFromGenerator(
100+
MethodGenerator::fromArray([
101+
'name' => 'set' . ucfirst($propertyName),
102+
'parameters' => [$propertyName],
103+
'docblock' => DocBlockGenerator::fromArray([
104+
'tags' => [
105+
new ParamTag($propertyName, [$type]),
106+
new ReturnTag('$this')
107+
]
108+
])
109+
])
110+
);
111+
}
112+
113+
return "<?php\n\n" . $generator->generate();
114+
}
115+
116+
public function register(): void
117+
{
118+
\spl_autoload_register([$this, 'autoload'], true, false);
119+
}
120+
121+
public function unregister(): void
122+
{
123+
\spl_autoload_unregister([$this, 'autoload']);
124+
}
125+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace bitExpert\PHPStan\Magento\Autoload;
4+
5+
use bitExpert\PHPStan\Magento\Autoload\Cache\FileCacheStorage;
6+
use bitExpert\PHPStan\Magento\Autoload\DataProvider\ExtensionAttributeDataProvider;
7+
use org\bovigo\vfs\vfsStream;
8+
use PHPStan\Cache\Cache;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class ExtensionAutoloaderUnitTest extends TestCase
12+
{
13+
/**
14+
* @var Cache|\PHPUnit\Framework\MockObject\MockObject
15+
*/
16+
private $cache;
17+
/**
18+
* @var ExtensionAttributeDataProvider|\PHPUnit\Framework\MockObject\MockObject
19+
*/
20+
private $extAttrDataProvider;
21+
/**
22+
* @var ExtensionAutoloader
23+
*/
24+
private $autoloader;
25+
26+
protected function setUp(): void
27+
{
28+
$this->cache = $this->createMock(Cache::class);
29+
$this->extAttrDataProvider = $this->createMock(ExtensionAttributeDataProvider::class);
30+
$this->autoloader = new ExtensionAutoloader(
31+
$this->cache,
32+
$this->extAttrDataProvider
33+
);
34+
}
35+
36+
/**
37+
* @test
38+
*/
39+
public function autoloaderIgnoresClassesWithoutExtensionInterfacePostfix(): void
40+
{
41+
$this->cache->expects(self::never())
42+
->method('load');
43+
44+
$this->autoloader->autoload('SomeClass');
45+
}
46+
47+
/**
48+
* @test
49+
*/
50+
public function autoloaderUsesCachedFileWhenFound(): void
51+
{
52+
$this->cache->expects(self::once())
53+
->method('load')
54+
->willReturn(__DIR__ . '/HelperExtension.php');
55+
56+
$this->cache->expects(self::never())
57+
->method('save');
58+
59+
$this->autoloader->autoload(HelperExtension::class);
60+
61+
self::assertTrue(class_exists(HelperExtension::class, false));
62+
}
63+
64+
/**
65+
* @test
66+
*/
67+
public function autoloadGeneratesInterfaceWhenNotCached(): void
68+
{
69+
$className = 'MyUncachedExtension';
70+
// since the generated class implements an interface, we need to make it available here, otherwise
71+
// the autoloader will fail with an exception that the interface can't be found!
72+
class_alias(HelperExtensionInterface::class, 'MyUncachedExtensionInterface');
73+
74+
$root = vfsStream::setup('test');
75+
$cache = new Cache(new FileCacheStorage($root->url() . '/tmp/cache/PHPStan'));
76+
$autoloader = new ExtensionAutoloader($cache, $this->extAttrDataProvider);
77+
78+
$this->extAttrDataProvider->expects(self::once())
79+
->method('getAttributesForInterface')
80+
->willReturn(['attr' => 'string']);
81+
82+
$autoloader->autoload($className);
83+
static::assertTrue(class_exists($className));
84+
$classReflection = new \ReflectionClass($className);
85+
try {
86+
$getAttrReflection = $classReflection->getMethod('getAttr');
87+
$docComment = $getAttrReflection->getDocComment();
88+
if (!is_string($docComment)) {
89+
throw new \ReflectionException();
90+
}
91+
static::assertStringContainsString('@return string|null', $docComment);
92+
} catch (\ReflectionException $e) {
93+
static::fail('Could not find expected method getAttr on generated class');
94+
}
95+
96+
try {
97+
$setAttrReflection = $classReflection->getMethod('setAttr');
98+
$docComment = $setAttrReflection->getDocComment();
99+
if (!is_string($docComment)) {
100+
throw new \ReflectionException();
101+
}
102+
static::assertStringContainsString('@param string $attr', $docComment);
103+
} catch (\ReflectionException $e) {
104+
static::fail('Could not find expected generated method setAttr on generated class');
105+
}
106+
}
107+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the phpstan-magento package.
5+
*
6+
* (c) bitExpert AG
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
declare(strict_types=1);
12+
13+
namespace bitExpert\PHPStan\Magento\Autoload;
14+
15+
/**
16+
* Dummy attribute extension interface that can be loaded via the Autoloader in the test cases.
17+
*/
18+
class HelperExtension extends \Magento\Framework\Api\AbstractSimpleObject implements HelperExtensionInterface
19+
{
20+
}

0 commit comments

Comments
 (0)