Skip to content
Merged
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
135 changes: 135 additions & 0 deletions src/Type/TypeFactoryBuildDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?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\Reflection\MissingPropertyFromReflectionException;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Type;
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;

/**
* @var \PHPStan\Reflection\ReflectionProvider
*/
protected ReflectionProvider $reflectionProvider;

/**
* @param \PHPStan\Reflection\ReflectionProvider $reflectionProvider
*/
public function __construct(ReflectionProvider $reflectionProvider)
{
$this->reflectionProvider = $reflectionProvider;
}

/**
* @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 = $this->reflectionProvider->getClass(TypeFactory::class);
/**
* @var \PHPStan\Reflection\Php\PhpPropertyReflection $property
*/
$property = $reflection->getStaticProperty('_types');
/**
* @var array<string, class-string> $defaultValue
*/
$defaultValue = $property->getNativeReflection()->getValue();
$this->typeMap = $defaultValue;

return $this->typeMap;
} catch (MissingPropertyFromReflectionException | ReflectionException) {
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');
static::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');

static::assertStringContainsString('IntegerType::setUserTimezone()', $output);
static::assertStringContainsString('StringType::setUserTimezone()', $output);
static::assertStringContainsString('BoolType::setUserTimezone()', $output);
static::assertStringContainsString('JsonType::nonExistentMethod()', $output);
static::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);
}
}