Skip to content

Commit 0f025af

Browse files
authored
Merge pull request #58 from CakeDC/type-factory
2 parents c3e4fd7 + 27a680a commit 0f025af

File tree

5 files changed

+284
-0
lines changed

5 files changed

+284
-0
lines changed

extension.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,7 @@ services:
5454
factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable, %s\Model\Table\%sTable, defaultTable)
5555
tags:
5656
- phpstan.broker.expressionTypeResolverExtension
57+
-
58+
class: CakeDC\PHPStan\Type\TypeFactoryBuildDynamicReturnTypeExtension
59+
tags:
60+
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2020, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\PHPStan\Type;
15+
16+
use Cake\Database\TypeFactory;
17+
use PhpParser\Node\Expr\StaticCall;
18+
use PHPStan\Analyser\Scope;
19+
use PHPStan\Reflection\MethodReflection;
20+
use PHPStan\Reflection\MissingPropertyFromReflectionException;
21+
use PHPStan\Reflection\ReflectionProvider;
22+
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
23+
use PHPStan\Type\ObjectType;
24+
use PHPStan\Type\Type;
25+
use ReflectionException;
26+
27+
/**
28+
* Provides return type for TypeFactory::build() based on the type name argument.
29+
*
30+
* This allows PHPStan to understand that TypeFactory::build('datetime') returns
31+
* a DateTimeType instance with its specific methods like setUserTimezone().
32+
*/
33+
class TypeFactoryBuildDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
34+
{
35+
/**
36+
* @var array<string, class-string>|null
37+
*/
38+
private ?array $typeMap = null;
39+
40+
/**
41+
* @var \PHPStan\Reflection\ReflectionProvider
42+
*/
43+
protected ReflectionProvider $reflectionProvider;
44+
45+
/**
46+
* @param \PHPStan\Reflection\ReflectionProvider $reflectionProvider
47+
*/
48+
public function __construct(ReflectionProvider $reflectionProvider)
49+
{
50+
$this->reflectionProvider = $reflectionProvider;
51+
}
52+
53+
/**
54+
* @return class-string
55+
*/
56+
public function getClass(): string
57+
{
58+
return TypeFactory::class;
59+
}
60+
61+
/**
62+
* Checks if the method is supported.
63+
*
64+
* @param \PHPStan\Reflection\MethodReflection $methodReflection Method reflection
65+
* @return bool
66+
*/
67+
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
68+
{
69+
return $methodReflection->getName() === 'build';
70+
}
71+
72+
/**
73+
* Returns the type from the static method call.
74+
*
75+
* @param \PHPStan\Reflection\MethodReflection $methodReflection Method reflection
76+
* @param \PhpParser\Node\Expr\StaticCall $methodCall Static method call
77+
* @param \PHPStan\Analyser\Scope $scope Scope
78+
* @return \PHPStan\Type\Type|null
79+
*/
80+
public function getTypeFromStaticMethodCall(
81+
MethodReflection $methodReflection,
82+
StaticCall $methodCall,
83+
Scope $scope,
84+
): ?Type {
85+
$args = $methodCall->getArgs();
86+
if (count($args) === 0) {
87+
return null;
88+
}
89+
90+
$argType = $scope->getType($args[0]->value);
91+
$constantStrings = $argType->getConstantStrings();
92+
if (count($constantStrings) !== 1) {
93+
return null;
94+
}
95+
96+
$typeName = $constantStrings[0]->getValue();
97+
$typeMap = $this->getTypeMap();
98+
99+
if (!isset($typeMap[$typeName])) {
100+
return null;
101+
}
102+
103+
return new ObjectType($typeMap[$typeName]);
104+
}
105+
106+
/**
107+
* Get the type map by reading TypeFactory's static property via reflection.
108+
* This is cached after the first call.
109+
*
110+
* @return array<string, class-string>
111+
*/
112+
private function getTypeMap(): array
113+
{
114+
if ($this->typeMap !== null) {
115+
return $this->typeMap;
116+
}
117+
118+
try {
119+
$reflection = $this->reflectionProvider->getClass(TypeFactory::class);
120+
/**
121+
* @var \PHPStan\Reflection\Php\PhpPropertyReflection $property
122+
*/
123+
$property = $reflection->getStaticProperty('_types');
124+
/**
125+
* @var array<string, class-string> $defaultValue
126+
*/
127+
$defaultValue = $property->getNativeReflection()->getValue();
128+
$this->typeMap = $defaultValue;
129+
130+
return $this->typeMap;
131+
} catch (MissingPropertyFromReflectionException | ReflectionException) {
132+
return [];
133+
}
134+
}
135+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;
5+
6+
use Cake\Database\TypeFactory;
7+
8+
class TypeFactoryCorrectUsage
9+
{
10+
public function testDateTimeTypeWithSetUserTimezone(): void
11+
{
12+
$type = TypeFactory::build('datetime');
13+
$type->setUserTimezone('America/New_York');
14+
}
15+
16+
public function testTimestampTypeWithSetUserTimezone(): void
17+
{
18+
$type = TypeFactory::build('timestamp');
19+
$type->setUserTimezone('UTC');
20+
}
21+
22+
public function testDateTimeFractionalTypeWithSetUserTimezone(): void
23+
{
24+
$type = TypeFactory::build('datetimefractional');
25+
$type->setUserTimezone('UTC');
26+
}
27+
28+
public function testDateTimeTimezoneTypeWithSetUserTimezone(): void
29+
{
30+
$type = TypeFactory::build('timestamptimezone');
31+
$type->setUserTimezone('UTC');
32+
}
33+
34+
public function testDateTypeWithSetLocaleFormat(): void
35+
{
36+
$type = TypeFactory::build('date');
37+
$type->setLocaleFormat('yyyy-MM-dd');
38+
}
39+
40+
public function testTimeTypeWithSetLocaleFormat(): void
41+
{
42+
$type = TypeFactory::build('time');
43+
$type->setLocaleFormat('HH:mm:ss');
44+
}
45+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;
5+
6+
use Cake\Database\TypeFactory;
7+
8+
class TypeFactoryIncorrectUsage
9+
{
10+
public function testIntegerTypeWithSetUserTimezone(): void
11+
{
12+
$type = TypeFactory::build('integer');
13+
$type->setUserTimezone('UTC');
14+
}
15+
16+
public function testStringTypeWithSetUserTimezone(): void
17+
{
18+
$type = TypeFactory::build('string');
19+
$type->setUserTimezone('UTC');
20+
}
21+
22+
public function testBoolTypeWithSetUserTimezone(): void
23+
{
24+
$type = TypeFactory::build('boolean');
25+
$type->setUserTimezone('UTC');
26+
}
27+
28+
public function testJsonTypeWithNonExistentMethod(): void
29+
{
30+
$type = TypeFactory::build('json');
31+
$type->nonExistentMethod();
32+
}
33+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
6+
*
7+
* Licensed under The MIT License
8+
* Redistributions of files must retain the above copyright notice.
9+
*
10+
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
11+
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
12+
*/
13+
14+
namespace CakeDC\PHPStan\Test\TestCase\Type;
15+
16+
use PHPUnit\Framework\TestCase;
17+
18+
class TypeFactoryBuildDynamicReturnTypeExtensionTest extends TestCase
19+
{
20+
/**
21+
* Test that TypeFactory::build() returns correct types and allows valid method calls.
22+
*
23+
* @return void
24+
*/
25+
public function testTypeFactoryBuildReturnsCorrectTypes(): void
26+
{
27+
$output = $this->runPhpStan(__DIR__ . '/Fake/TypeFactoryCorrectUsage.php');
28+
static::assertStringContainsString('[OK] No errors', $output);
29+
}
30+
31+
/**
32+
* Test that TypeFactory::build() catches invalid method calls.
33+
*
34+
* @return void
35+
*/
36+
public function testTypeFactoryBuildCatchesInvalidMethodCalls(): void
37+
{
38+
$output = $this->runPhpStan(__DIR__ . '/Fake/TypeFactoryIncorrectUsage.php');
39+
40+
static::assertStringContainsString('IntegerType::setUserTimezone()', $output);
41+
static::assertStringContainsString('StringType::setUserTimezone()', $output);
42+
static::assertStringContainsString('BoolType::setUserTimezone()', $output);
43+
static::assertStringContainsString('JsonType::nonExistentMethod()', $output);
44+
static::assertStringContainsString('Found 4 errors', $output);
45+
}
46+
47+
/**
48+
* Run PHPStan on a file and return the output.
49+
*
50+
* @param string $file File to analyze
51+
* @return string
52+
*/
53+
private function runPhpStan(string $file): string
54+
{
55+
$configFile = dirname(__DIR__, 3) . '/extension.neon';
56+
$command = sprintf(
57+
'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1',
58+
escapeshellarg(dirname(__DIR__, 3)),
59+
escapeshellarg($file),
60+
escapeshellarg($configFile),
61+
);
62+
63+
exec($command, $output, $exitCode);
64+
65+
return implode("\n", $output);
66+
}
67+
}

0 commit comments

Comments
 (0)