Skip to content

Commit 2f46b52

Browse files
authored
Suppress undefined static property error when property_exists() guard is present (#5544)
1 parent f0931f5 commit 2f46b52

10 files changed

Lines changed: 197 additions & 8 deletions

phpstan-baseline.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1644,7 +1644,7 @@ parameters:
16441644
-
16451645
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
16461646
identifier: phpstanApi.instanceofType
1647-
count: 2
1647+
count: 1
16481648
path: src/Type/Php/PropertyExistsTypeSpecifyingExtension.php
16491649

16501650
-

src/Rules/Properties/AccessStaticPropertiesCheck.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,14 @@
33
namespace PHPStan\Rules\Properties;
44

55
use PhpParser\Node;
6+
use PhpParser\Node\Arg;
7+
use PhpParser\Node\Expr\ClassConstFetch;
8+
use PhpParser\Node\Expr\FuncCall;
69
use PhpParser\Node\Expr\StaticPropertyFetch;
10+
use PhpParser\Node\Identifier;
711
use PhpParser\Node\Name;
12+
use PhpParser\Node\Name\FullyQualified;
13+
use PhpParser\Node\Scalar\String_;
814
use PHPStan\Analyser\NullsafeOperatorHelper;
915
use PHPStan\Analyser\Scope;
1016
use PHPStan\DependencyInjection\AutowiredParameter;
@@ -256,6 +262,19 @@ private function processSingleProperty(Scope $scope, StaticPropertyFetch $node,
256262
]);
257263
}
258264

265+
if ($node->class instanceof Name) {
266+
$classExpr = new ClassConstFetch($node->class, new Identifier('class'));
267+
} else {
268+
$classExpr = $node->class;
269+
}
270+
$propertyExistsCall = new FuncCall(new FullyQualified('property_exists'), [
271+
new Arg($classExpr),
272+
new Arg(new String_($name)),
273+
]);
274+
if ($scope->getType($propertyExistsCall)->isTrue()->yes()) {
275+
return [];
276+
}
277+
259278
return array_merge($messages, [
260279
RuleErrorBuilder::message(sprintf(
261280
'Access to an undefined static property %s::$%s.',

src/Type/Php/PropertyExistsTypeSpecifyingExtension.php

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@
1515
use PHPStan\Reflection\FunctionReflection;
1616
use PHPStan\Rules\Properties\PropertyReflectionFinder;
1717
use PHPStan\Type\Accessory\HasPropertyType;
18+
use PHPStan\Type\ClassStringType;
1819
use PHPStan\Type\Constant\ConstantBooleanType;
1920
use PHPStan\Type\Constant\ConstantStringType;
2021
use PHPStan\Type\FunctionTypeSpecifyingExtension;
2122
use PHPStan\Type\IntersectionType;
2223
use PHPStan\Type\ObjectWithoutClassType;
24+
use PHPStan\Type\UnionType;
2325
use function count;
2426

2527
#[AutowiredService]
@@ -66,17 +68,35 @@ public function specifyTypes(
6668
}
6769

6870
$objectType = $scope->getType($args[0]->value);
69-
if ($objectType instanceof ConstantStringType) {
70-
return new SpecifiedTypes([], []);
71-
} elseif ($objectType->isObject()->yes()) {
72-
$propertyNode = new PropertyFetch(
71+
if ($objectType->isString()->yes()) {
72+
return $this->typeSpecifier->create(
73+
new FuncCall(new FullyQualified('property_exists'), $node->getRawArgs()),
74+
new ConstantBooleanType(true),
75+
$context,
76+
$scope,
77+
);
78+
}
79+
80+
if (!$objectType->isObject()->yes()) {
81+
return $this->typeSpecifier->create(
7382
$args[0]->value,
74-
new Identifier($propertyNameType->getValue()),
83+
new UnionType([
84+
new IntersectionType([
85+
new ObjectWithoutClassType(),
86+
new HasPropertyType($propertyNameType->getValue()),
87+
]),
88+
new ClassStringType(),
89+
]),
90+
$context,
91+
$scope,
7592
);
76-
} else {
77-
return new SpecifiedTypes([], []);
7893
}
7994

95+
$propertyNode = new PropertyFetch(
96+
$args[0]->value,
97+
new Identifier($propertyNameType->getValue()),
98+
);
99+
80100
$propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyNode, $scope);
81101
if ($propertyReflection !== null) {
82102
if (!$propertyReflection->isNative()) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug2861Nsrt;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @param object|string $objectOrClass
9+
*/
10+
function testObjectOrString($objectOrClass): void {
11+
if (property_exists($objectOrClass, 'foo')) {
12+
assertType('class-string|(object&hasProperty(foo))', $objectOrClass);
13+
}
14+
}
15+
16+
/**
17+
* @param object|class-string $objectOrClass
18+
*/
19+
function testObjectOrClassString($objectOrClass): void {
20+
if (property_exists($objectOrClass, 'bar')) {
21+
assertType('class-string|(object&hasProperty(bar))', $objectOrClass);
22+
}
23+
}

tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,9 @@ public function testCloneWith(): void
253253
]);
254254
}
255255

256+
public function testBug2861(): void
257+
{
258+
$this->analyse([__DIR__ . '/data/bug-2861-assign.php'], []);
259+
}
260+
256261
}

tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,4 +1294,12 @@ public function testBug13539(): void
12941294
]);
12951295
}
12961296

1297+
public function testBug2861(): void
1298+
{
1299+
$this->checkThisOnly = false;
1300+
$this->checkUnionTypes = true;
1301+
$this->checkDynamicProperties = true;
1302+
$this->analyse([__DIR__ . '/data/bug-2861.php'], []);
1303+
}
1304+
12971305
}

tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ public function testRuleExpressionNames(): void
8080
]);
8181
}
8282

83+
public function testBug2861(): void
84+
{
85+
$this->analyse([__DIR__ . '/data/bug-2861-assign.php'], []);
86+
}
87+
8388
#[RequiresPhp('>= 8.5.0')]
8489
public function testAsymmetricVisibility(): void
8590
{

tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,4 +353,9 @@ public function testBug8668Bis(): void
353353
]);
354354
}
355355

356+
public function testBug2861(): void
357+
{
358+
$this->analyse([__DIR__ . '/data/bug-2861.php'], []);
359+
}
360+
356361
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug2861Assign;
4+
5+
class Foo {
6+
public static function test(): void {
7+
if (property_exists(static::class, 'default')) {
8+
static::$default = 'value';
9+
}
10+
}
11+
12+
/**
13+
* @param class-string $className
14+
*/
15+
public static function testExpr(string $className): void {
16+
if (property_exists($className, 'default')) {
17+
$className::$default = 'value';
18+
}
19+
}
20+
21+
public function testInstance(): void {
22+
if (property_exists($this, 'default')) {
23+
$this->default = 'value';
24+
}
25+
}
26+
27+
/** @param self $obj */
28+
public function testInstanceObj(self $obj): void {
29+
if (property_exists($obj, 'default')) {
30+
$obj->default = 'value';
31+
}
32+
}
33+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug2861;
4+
5+
trait EnumTrait {
6+
/** @var mixed */
7+
protected $value;
8+
9+
/** @param mixed $value */
10+
final public function __construct($value) {
11+
$this->value = $value;
12+
}
13+
14+
/** @return static|null */
15+
public static function getDefault() {
16+
if (property_exists(static::class, 'default') && null !== static::$default) {
17+
$obj = static::$default;
18+
return new static($obj);
19+
}
20+
return null;
21+
}
22+
}
23+
24+
class Foo {
25+
use EnumTrait;
26+
public const BLA = 'bla';
27+
}
28+
29+
class Bar {
30+
use EnumTrait;
31+
public static $default = 'bla';
32+
public const BLA = 'bla';
33+
}
34+
35+
class Baz {
36+
use EnumTrait;
37+
38+
/** @return static|null */
39+
public static function getDefault2() {
40+
if (property_exists(self::class, 'default') && null !== self::$default) {
41+
return new static(self::$default);
42+
}
43+
return null;
44+
}
45+
}
46+
47+
class ExpressionBased {
48+
/**
49+
* @param class-string $className
50+
*/
51+
public static function test(string $className): void {
52+
if (property_exists($className, 'default')) {
53+
echo $className::$default;
54+
}
55+
}
56+
}
57+
58+
class InstancePropertyAccess {
59+
public function test(): void {
60+
if (property_exists($this, 'default')) {
61+
echo $this->default;
62+
}
63+
}
64+
65+
/** @param self $obj */
66+
public function testObj(self $obj): void {
67+
if (property_exists($obj, 'default')) {
68+
echo $obj->default;
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)