Skip to content

fix: Enhance property reflection in UserPropertiesClassReflectionExtension to support identity class resolution and improve type inference for user component properties. #34

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Jun 8, 2025
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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@

- Enh #25: Add support for `PHPStan` Extension Installer (@samuelrajan747)
- Enh #26: Add `PHPStan` extension installer instructions and improve `ServiceMap` configuration handling (@terabytesoftw)
- Bug #27: Enhance error handling in `ServiceMap` for invalid configuration structures and add corresponding test cases (@terabytesoftw)
- Bug #27: Fix error handling in `ServiceMap` for invalid configuration structures and add corresponding test cases (@terabytesoftw)
- Bug #28: Refactor component handling in `ServiceMap` to improve variable naming and streamline logic (@terabytesoftw)
- Enh #29: Enable strict rules and bleeding edge analysis, and update `README.md` with strict configuration examples (@terabytesoftw)
- Bug #33: Enhance `ActiveRecordDynamicStaticMethodReturnTypeExtension` type inference for `ActiveQuery` support, and fix phpstan errors max lvl in tests (@terabytesoftw)
- Bug #33: Fix `ActiveRecordDynamicStaticMethodReturnTypeExtension` type inference for `ActiveQuery` support, and fix phpstan errors max lvl in tests (@terabytesoftw)
- Bug #34: Fix property reflection in `UserPropertiesClassReflectionExtension` to support `identityClass` resolution and improve type inference for `user` component properties (@terabytesoftw)

## 0.2.2 June 04, 2025

Expand Down
72 changes: 65 additions & 7 deletions src/ServiceMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,13 @@ final class ServiceMap
*/
private array $components = [];

/**
* Component definitions for Yii application analysis.
*
* @phpstan-var array<string, mixed>
*/
private array $componentsDefinitions = [];

/**
* Creates a new instance of the {@see ServiceMap} class.
*
Expand Down Expand Up @@ -123,6 +130,53 @@ public function getComponentClassById(string $id): string|null
return $this->components[$id] ?? null;
}

/**
* Retrieves the component definition array by its identifier.
*
* Looks up the component definition registered under the specified component ID in the internal component
* definitions map.
*
* This method provides access to the raw component configuration array, enabling static analysis tools and IDEs to
* inspect component properties, dependencies, and configuration options for accurate type inference and reflection
* analysis.
*
* @param string $id Component identifier to look up in the component definitions map.
*
* @return array|null Component definition array with configuration options, or `null` if not found.
*
* @phpstan-return array<array-key, mixed>|null
*/
public function getComponentDefinitionById(string $id): array|null
{
$definition = $this->componentsDefinitions[$id] ?? null;

return is_array($definition) ? $definition : null;
}

/**
* Retrieves the component definition for a given class.
*
* @param string $class Fully qualified class name to look up.
*
* @return array|null The component definition array, or null if not found.
*
* @phpstan-return array<array-key, mixed>|null
*/
public function getComponentDefinitionByClassName(string $class): array|null
{
foreach ($this->components as $id => $componentClass) {
if (
$componentClass === $class &&
isset($this->componentsDefinitions[$id])
&& is_array($this->componentsDefinitions[$id])
) {
return $this->componentsDefinitions[$id];
}
}

return null;
}

/**
* Resolves the fully qualified class name of a service from a PHP-Parser AST node.
*
Expand Down Expand Up @@ -154,12 +208,12 @@ public function getServiceClassFromNode(Node $node): string|null
*
* This method is responsible for parsing the Yii application configuration, providing a normalized array for
* further processing by the service and component mapping logic. It throws descriptive exceptions if the file is
* missing, does not return an array, or contains invalid section types, ensuring robust error handling and
* missing, doesn't return an array, or contains invalid section types, ensuring robust error handling and
* predictable static analysis.
*
* @param string $configPath Path to the Yii application configuration file. If empty, returns an empty array.
* @param string $configPath Path to the Yii application configuration file. If empty, return an empty array.
*
* @throws RuntimeException if the closure does not have a return type or the definition is unsupported.
* @throws RuntimeException if a runtime error prevents the operation from completing successfully.
*
* @phpstan import-type ServiceType from ServiceMap
* @phpstan-return array{}|ServiceType Normalized configuration array or empty array if no config is provided.
Expand Down Expand Up @@ -210,7 +264,7 @@ private function loadConfig(string $configPath): array
* @param array|int|object|string $definition Service definition to normalize.
*
* @throws ReflectionException if the service definition is invalid or can't be resolved.
* @throws RuntimeException if the closure does not have a return type or the definition is unsupported.
* @throws RuntimeException if a runtime error prevents the operation from completing successfully.
*
* @phpstan-import-type DefinitionType from ServiceMap
* @phpstan-param DefinitionType $definition
Expand Down Expand Up @@ -283,6 +337,10 @@ private function processComponents(array $config): void

if (isset($definition['class']) && is_string($definition['class']) && $definition['class'] !== '') {
$this->components[$id] = $definition['class'];

unset($definition['class']);

$this->componentsDefinitions[$id] = $definition;
}
}
}
Expand Down Expand Up @@ -356,7 +414,7 @@ private function processSingletons(array $config): void
* Throws a {@see RuntimeException} when a configuration file section is not an array.
*
* This method is invoked when a required section of the Yii application configuration file (such as components,
* container, container.definitions, or container.singletons) does not contain a valid array.
* container, container.definitions, or container.singletons) doesn't contain a valid array.
*
* It ensures that only valid array structures are processed during configuration parsing, providing a clear and
* descriptive error message for debugging and static analysis.
Expand Down Expand Up @@ -393,8 +451,8 @@ private function throwErrorWhenIdIsNotString(string ...$args): never
/**
* Throws a {@see RuntimeException} when a service or component definition is unsupported.
*
* This method is invoked when the provided definition for a service or component cannot be resolved to a valid
* class name or does not match any supported configuration pattern.
* This method is invoked when the provided definition for a service or component can't be resolved to a valid
* class name or doesn't match any supported configuration pattern.
*
* It ensures that only valid and supported definitions are processed during service and component resolution,
* providing a clear and descriptive error message for debugging and static analysis.
Expand Down
83 changes: 64 additions & 19 deletions src/reflection/UserPropertiesClassReflectionExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
MissingPropertyFromReflectionException,
PropertiesClassReflectionExtension,
PropertyReflection,
ReflectionProvider,
};
use PHPStan\Reflection\Annotations\AnnotationsPropertiesClassReflectionExtension;
use PHPStan\Reflection\Dummy\DummyPropertyReflection;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\{IntegerType, NullType, ObjectType, StringType, TypeCombinator};
use yii\web\User;
use yii2\extensions\phpstan\ServiceMap;

Expand Down Expand Up @@ -49,20 +51,22 @@ final class UserPropertiesClassReflectionExtension implements PropertiesClassRef
*
* @param AnnotationsPropertiesClassReflectionExtension $annotationsProperties Extension for handling
* annotation-based properties.
* @param ReflectionProvider $reflectionProvider Reflection provider for class and property lookups.
* @param ServiceMap $serviceMap Service map for resolving component classes by ID.
*/
public function __construct(
private readonly AnnotationsPropertiesClassReflectionExtension $annotationsProperties,
private readonly ReflectionProvider $reflectionProvider,
private readonly ServiceMap $serviceMap,
) {}

/**
* Retrieves the property reflection for a given property on the Yii user component.
*
* Resolves the property reflection for the specified property name by checking for the dynamic
* {@see User::identity] property, native properties, and annotation-based properties on the Yii user instance.
* {@see User::identity} property, native properties, and annotation-based properties on the Yii user instance.
*
* This method ensures that the {@see User::identity] property and properties defined native or via annotations are
* accessible for static analysis and IDE support.
* For the 'identity' property, it resolves the type based on the configured identityClass in the user component.
*
* @param ClassReflection $classReflection Class reflection instance for the Yii user component.
* @param string $propertyName Name of the property to retrieve.
Expand All @@ -73,10 +77,35 @@ public function __construct(
*/
public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection
{
if (
$propertyName === 'identity' &&
($componentClass = $this->serviceMap->getComponentClassById($propertyName)) !== null
) {
if (in_array($propertyName, ['id', 'identity', 'isGuest'], true) === true) {
$identityClass = $this->getIdentityClass();

if ($propertyName === 'identity' && $identityClass !== null) {
return new ComponentPropertyReflection(
new DummyPropertyReflection($propertyName),
new ObjectType($identityClass),
$classReflection,
);
}

if ($propertyName === 'id') {
return new ComponentPropertyReflection(
new DummyPropertyReflection($propertyName),
TypeCombinator::union(new IntegerType(), new StringType(), new NullType()),
$classReflection,
);
}

if ($propertyName === 'isGuest') {
return new ComponentPropertyReflection(
new DummyPropertyReflection($propertyName),
new ConstantBooleanType(true),
$classReflection,
);
}
}

if (($componentClass = $this->serviceMap->getComponentClassById($propertyName)) !== null) {
return new ComponentPropertyReflection(
new DummyPropertyReflection($propertyName),
new ObjectType($componentClass),
Expand All @@ -94,30 +123,46 @@ public function getProperty(ClassReflection $classReflection, string $propertyNa
/**
* Determines whether the specified property exists on the Yii user component.
*
* Checks for the existence of a property on the user instance by considering native properties and
* annotation-based properties.
*
* This method ensures compatibility with the user component context, enabling accurate property reflection for
* static analysis and IDE autocompletion.
* Checks for the existence of a property on the user instance by considering native properties,
* annotation-based properties, and the special 'identity' property.
*
* @param ClassReflection $classReflection Class reflection instance for the Yii user component.
* @param string $propertyName Name of the property to check for existence.
*
* @return bool `true` if the property exists as a native or annotated property; `false` otherwise.
* @return bool `true` if the property exists as a native, annotated, or identity property; `false` otherwise.
*/
public function hasProperty(ClassReflection $classReflection, string $propertyName): bool
{
if (
$classReflection->getName() !== User::class &&
$classReflection->isSubclassOf(User::class) === false) {
$classReflection->isSubclassOfClass($this->reflectionProvider->getClass(User::class)) === false
) {
return false;
}

if ($propertyName === 'identity' && $this->serviceMap->getComponentClassById($propertyName) !== null) {
return true;
return in_array($propertyName, ['id', 'identity', 'isGuest'], true)
? $this->getIdentityClass() !== null
: $this->serviceMap->getComponentClassById($propertyName) !== null;
}

/**
* Attempts to resolve the identity class from the user component configuration.
*
* This method tries to determine the identityClass configured for the user component
* by looking at the service map's user component configuration.
*
* @return string|null The fully qualified identity class name, or null if not found.
*/
private function getIdentityClass(): string|null
{
$identityClass = null;

$definition = $this->serviceMap->getComponentDefinitionByClassName(User::class);

if (isset($definition['identityClass']) && is_string($definition['identityClass'])) {
$identityClass = $definition['identityClass'];
}

return $classReflection->hasNativeProperty($propertyName)
|| $this->annotationsProperties->hasProperty($classReflection, $propertyName);
return $identityClass;
}
}
19 changes: 19 additions & 0 deletions tests/ServiceMapTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use SplObjectStorage;
use SplStack;
use yii\base\InvalidArgumentException;
use yii\web\User;
use yii2\extensions\phpstan\ServiceMap;
use yii2\extensions\phpstan\tests\stub\MyActiveRecord;

Expand Down Expand Up @@ -111,6 +112,24 @@ public function testItLoadsServicesAndComponents(): void
$serviceMap->getComponentClassById('customComponent'),
'ServiceMap should resolve component id \'customComponent\' to \'MyActiveRecord::class\'.',
);
self::assertSame(
[
'identityClass' => 'yii2\extensions\phpstan\tests\stub\User',
],
$serviceMap->getComponentDefinitionByClassName(User::class),
'ServiceMap should return the component definition for \'yii\web\User\'.',
);
self::assertNull(
$serviceMap->getComponentDefinitionByClassName('nonExistentComponent'),
'ServiceMap should return \'null\' for a \'non-existent\' component class.',
);
self::assertSame(
[
'identityClass' => 'yii2\extensions\phpstan\tests\stub\User',
],
$serviceMap->getComponentDefinitionById('user'),
'ServiceMap should return the component definition for \'user\'.',
);
self::assertNull(
$serviceMap->getComponentClassById('nonExistentComponent'),
'ServiceMap should return \'null\' for a \'non-existent\' component id.',
Expand Down
5 changes: 5 additions & 0 deletions tests/fixture/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use yii2\extensions\phpstan\tests\stub\MyActiveRecord;
use yii2\extensions\phpstan\tests\stub\User;

return [
'components' => [
Expand All @@ -13,6 +14,10 @@
'class' => MyActiveRecord::class,
],
'customInitializedComponent' => new MyActiveRecord(),
'user' => [
'class' => 'yii\web\User',
'identityClass' => User::class,
],
],
'container' => [
'singletons' => [
Expand Down
1 change: 1 addition & 0 deletions tests/stub/MyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public function actionMy(): void
Yii::$app->response->data = Yii::$app->request->rawBody;

$guest = Yii::$app->user->isGuest;
$id = Yii::$app->user->id;

Yii::$app->user->identity->getAuthKey();
Yii::$app->user->identity->doSomething();
Expand Down
36 changes: 36 additions & 0 deletions tests/stub/User.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace yii2\extensions\phpstan\tests\stub;

use yii\db\ActiveRecord;
use yii\web\IdentityInterface;

class User extends ActiveRecord implements IdentityInterface
{
public static function findIdentity($id)
{
return new static();
}

public static function findIdentityByAccessToken($token, $type = null)
{
return new static();
}

public function getId()
{
return 1;
}

public function getAuthKey()
{
return '';
}

public function validateAuthKey($authKey)
{
return true;
}
}