Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,7 @@ services:
factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable, %s\Model\Table\%sTable, defaultTable)
tags:
- phpstan.broker.expressionTypeResolverExtension
-
class: CakeDC\PHPStan\Type\TypeFactoryBuildDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
4 changes: 4 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ parameters:
ignoreErrors:
-
identifier: missingType.generics
-
identifier: phpstanApi.runtimeReflection
message: '#Creating new ReflectionClass is a runtime reflection concept#'
path: src/Type/TypeFactoryBuildDynamicReturnTypeExtension.php
118 changes: 118 additions & 0 deletions src/Type/TypeFactoryBuildDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);

/**
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2020, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Type;

use Cake\Database\TypeFactory;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
use ReflectionClass;
use ReflectionException;

/**
* Provides return type for TypeFactory::build() based on the type name argument.
*
* This allows PHPStan to understand that TypeFactory::build('datetime') returns
* a DateTimeType instance with its specific methods like setUserTimezone().
*/
class TypeFactoryBuildDynamicReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
/**
* @var array<string, class-string>|null
*/
private ?array $typeMap = null;

/**
* @return class-string
*/
public function getClass(): string
{
return TypeFactory::class;
}

/**
* Checks if the method is supported.
*
* @param \PHPStan\Reflection\MethodReflection $methodReflection Method reflection
* @return bool
*/
public function isStaticMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'build';
}

/**
* Returns the type from the static method call.
*
* @param \PHPStan\Reflection\MethodReflection $methodReflection Method reflection
* @param \PhpParser\Node\Expr\StaticCall $methodCall Static method call
* @param \PHPStan\Analyser\Scope $scope Scope
* @return \PHPStan\Type\Type|null
*/
public function getTypeFromStaticMethodCall(
MethodReflection $methodReflection,
StaticCall $methodCall,
Scope $scope,
): ?Type {
$args = $methodCall->getArgs();
if (count($args) === 0) {
return null;
}

$argType = $scope->getType($args[0]->value);
$constantStrings = $argType->getConstantStrings();
if (count($constantStrings) !== 1) {
return null;
}

$typeName = $constantStrings[0]->getValue();
$typeMap = $this->getTypeMap();

if (!isset($typeMap[$typeName])) {
return null;
}

return new ObjectType($typeMap[$typeName]);
}

/**
* Get the type map by reading TypeFactory's static property via reflection.
* This is cached after the first call.
*
* @return array<string, class-string>
*/
private function getTypeMap(): array
{
if ($this->typeMap !== null) {
return $this->typeMap;
}

try {
$reflection = new ReflectionClass(TypeFactory::class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here could use ReflectionProvider

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am happy if someone wants to finish this up.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

okay

$property = $reflection->getProperty('_types');
$property->setAccessible(true);

/** @var array<string, class-string> $defaultValue */
$defaultValue = $property->getDefaultValue();
$this->typeMap = $defaultValue;

return $this->typeMap;
} catch (ReflectionException $e) {
return [];
}
}
}
45 changes: 45 additions & 0 deletions tests/TestCase/Type/Fake/TypeFactoryCorrectUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;

use Cake\Database\TypeFactory;

class TypeFactoryCorrectUsage
{
public function testDateTimeTypeWithSetUserTimezone(): void
{
$type = TypeFactory::build('datetime');
$type->setUserTimezone('America/New_York');
}

public function testTimestampTypeWithSetUserTimezone(): void
{
$type = TypeFactory::build('timestamp');
$type->setUserTimezone('UTC');
}

public function testDateTimeFractionalTypeWithSetUserTimezone(): void
{
$type = TypeFactory::build('datetimefractional');
$type->setUserTimezone('UTC');
}

public function testDateTimeTimezoneTypeWithSetUserTimezone(): void
{
$type = TypeFactory::build('timestamptimezone');
$type->setUserTimezone('UTC');
}

public function testDateTypeWithSetLocaleFormat(): void
{
$type = TypeFactory::build('date');
$type->setLocaleFormat('yyyy-MM-dd');
}

public function testTimeTypeWithSetLocaleFormat(): void
{
$type = TypeFactory::build('time');
$type->setLocaleFormat('HH:mm:ss');
}
}
33 changes: 33 additions & 0 deletions tests/TestCase/Type/Fake/TypeFactoryIncorrectUsage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);

namespace CakeDC\PHPStan\Test\TestCase\Type\Fake;

use Cake\Database\TypeFactory;

class TypeFactoryIncorrectUsage
{
public function testIntegerTypeWithSetUserTimezone(): void
{
$type = TypeFactory::build('integer');
$type->setUserTimezone('UTC');
}

public function testStringTypeWithSetUserTimezone(): void
{
$type = TypeFactory::build('string');
$type->setUserTimezone('UTC');
}

public function testBoolTypeWithSetUserTimezone(): void
{
$type = TypeFactory::build('boolean');
$type->setUserTimezone('UTC');
}

public function testJsonTypeWithNonExistentMethod(): void
{
$type = TypeFactory::build('json');
$type->nonExistentMethod();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);

/**
* Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
*
* Licensed under The MIT License
* Redistributions of files must retain the above copyright notice.
*
* @copyright Copyright 2025, Cake Development Corporation (https://www.cakedc.com)
* @license MIT License (http://www.opensource.org/licenses/mit-license.php)
*/

namespace CakeDC\PHPStan\Test\TestCase\Type;

use PHPUnit\Framework\TestCase;

class TypeFactoryBuildDynamicReturnTypeExtensionTest extends TestCase
{
/**
* Test that TypeFactory::build() returns correct types and allows valid method calls.
*
* @return void
*/
public function testTypeFactoryBuildReturnsCorrectTypes(): void
{
$output = $this->runPhpStan(__DIR__ . '/Fake/TypeFactoryCorrectUsage.php');
$this->assertStringContainsString('[OK] No errors', $output);
}

/**
* Test that TypeFactory::build() catches invalid method calls.
*
* @return void
*/
public function testTypeFactoryBuildCatchesInvalidMethodCalls(): void
{
$output = $this->runPhpStan(__DIR__ . '/Fake/TypeFactoryIncorrectUsage.php');

$this->assertStringContainsString('IntegerType::setUserTimezone()', $output);
$this->assertStringContainsString('StringType::setUserTimezone()', $output);
$this->assertStringContainsString('BoolType::setUserTimezone()', $output);
$this->assertStringContainsString('JsonType::nonExistentMethod()', $output);
$this->assertStringContainsString('Found 4 errors', $output);
}

/**
* Run PHPStan on a file and return the output.
*
* @param string $file File to analyze
* @return string
*/
private function runPhpStan(string $file): string
{
$configFile = dirname(__DIR__, 3) . '/extension.neon';
$command = sprintf(
'cd %s && vendor/bin/phpstan analyze %s --level=max --configuration=%s --no-progress 2>&1',
escapeshellarg(dirname(__DIR__, 3)),
escapeshellarg($file),
escapeshellarg($configFile),
);

exec($command, $output, $exitCode);

return implode("\n", $output);
}
}