Skip to content

Commit 88402dc

Browse files
committed
Readonly classes cannot be combined with #[AllowDynamicProperties]
1 parent 1dc44d1 commit 88402dc

10 files changed

+293
-1
lines changed

Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ lint:
8787
--exclude tests/PHPStan/Rules/Properties/data/non-abstract-hooked-properties-in-class.php \
8888
--exclude tests/PHPStan/Rules/Properties/data/hooked-properties-in-class.php \
8989
--exclude tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php \
90+
--exclude tests/PHPStan/Rules/Classes/data/bug-12281.php \
91+
--exclude tests/PHPStan/Rules/Traits/data/bug-12281.php \
9092
src tests
9193

9294
cs:

conf/config.level0.neon

+1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ rules:
103103
- PHPStan\Rules\Regexp\RegularExpressionPatternRule
104104
- PHPStan\Rules\Traits\ConflictingTraitConstantsRule
105105
- PHPStan\Rules\Traits\ConstantsInTraitsRule
106+
- PHPStan\Rules\Traits\TraitAttributesRule
106107
- PHPStan\Rules\Types\InvalidTypesInUnionRule
107108
- PHPStan\Rules\Variables\UnsetRule
108109
- PHPStan\Rules\Whitespace\FileWhitespaceRule

src/Rules/Classes/ClassAttributesRule.php

+31-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
use PHPStan\Node\InClassNode;
99
use PHPStan\Rules\AttributesCheck;
1010
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use function count;
13+
use function sprintf;
1114

1215
/**
1316
* @implements Rule<InClassNode>
@@ -28,12 +31,39 @@ public function processNode(Node $node, Scope $scope): array
2831
{
2932
$classLikeNode = $node->getOriginalNode();
3033

31-
return $this->attributesCheck->check(
34+
$errors = $this->attributesCheck->check(
3235
$scope,
3336
$classLikeNode->attrGroups,
3437
Attribute::TARGET_CLASS,
3538
'class',
3639
);
40+
41+
$classReflection = $node->getClassReflection();
42+
if (
43+
$classReflection->isReadOnly()
44+
|| $classReflection->isEnum()
45+
|| $classReflection->isInterface()
46+
) {
47+
$typeName = 'readonly class';
48+
$identifier = 'class.allowDynamicPropertiesReadonly';
49+
if ($classReflection->isEnum()) {
50+
$typeName = 'enum';
51+
$identifier = 'enum.allowDynamicProperties';
52+
}
53+
if ($classReflection->isInterface()) {
54+
$typeName = 'interface';
55+
$identifier = 'interface.allowDynamicProperties';
56+
}
57+
58+
if (count($classReflection->getNativeReflection()->getAttributes('AllowDynamicProperties')) > 0) {
59+
$errors[] = RuleErrorBuilder::message(sprintf('Attribute class AllowDynamicProperties cannot be used with %s.', $typeName))
60+
->identifier($identifier)
61+
->nonIgnorable()
62+
->build();
63+
}
64+
}
65+
66+
return $errors;
3767
}
3868

3969
}
+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Traits;
4+
5+
use Attribute;
6+
use PhpParser\Node;
7+
use PHPStan\Analyser\Scope;
8+
use PHPStan\Reflection\ReflectionProvider;
9+
use PHPStan\Rules\AttributesCheck;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use function count;
13+
14+
/**
15+
* @implements Rule<Node\Stmt\Trait_>
16+
*/
17+
final class TraitAttributesRule implements Rule
18+
{
19+
20+
public function __construct(
21+
private AttributesCheck $attributesCheck,
22+
private ReflectionProvider $reflectionProvider,
23+
)
24+
{
25+
}
26+
27+
public function getNodeType(): string
28+
{
29+
return Node\Stmt\Trait_::class;
30+
}
31+
32+
public function processNode(Node $node, Scope $scope): array
33+
{
34+
$traitName = $node->namespacedName;
35+
if ($traitName === null) {
36+
return [];
37+
}
38+
39+
if (!$this->reflectionProvider->hasClass($traitName->toString())) {
40+
return [];
41+
}
42+
43+
$errors = $this->attributesCheck->check(
44+
$scope,
45+
$node->attrGroups,
46+
Attribute::TARGET_CLASS,
47+
'class',
48+
);
49+
50+
$classReflection = $this->reflectionProvider->getClass($traitName->toString());
51+
if (count($classReflection->getNativeReflection()->getAttributes('AllowDynamicProperties')) > 0) {
52+
$errors[] = RuleErrorBuilder::message('Attribute class AllowDynamicProperties cannot be used with trait.')
53+
->identifier('class.allowDynamicPropertiesTrait')
54+
->nonIgnorable()
55+
->build();
56+
}
57+
58+
return $errors;
59+
}
60+
61+
}

tests/PHPStan/Rules/Classes/ClassAttributesRuleTest.php

+24
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,28 @@ public function testBug12011(): void
167167
]);
168168
}
169169

170+
public function testBug12281(): void
171+
{
172+
if (PHP_VERSION_ID < 80200) {
173+
$this->markTestSkipped('Test requires PHP 8.2.');
174+
}
175+
176+
$this->checkExplicitMixed = true;
177+
$this->checkImplicitMixed = true;
178+
$this->analyse([__DIR__ . '/data/bug-12281.php'], [
179+
[
180+
'Attribute class AllowDynamicProperties cannot be used with readonly class.',
181+
05,
182+
],
183+
[
184+
'Attribute class AllowDynamicProperties cannot be used with enum.',
185+
12,
186+
],
187+
[
188+
'Attribute class AllowDynamicProperties cannot be used with interface.',
189+
15,
190+
],
191+
]);
192+
}
193+
170194
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php // lint >= 8.2
2+
3+
namespace Bug12281;
4+
5+
#[\AllowDynamicProperties]
6+
readonly class BlogData { /* … */ }
7+
8+
/** @readonly */
9+
#[\AllowDynamicProperties]
10+
class BlogDataPhpdoc { /* … */ }
11+
12+
#[\AllowDynamicProperties]
13+
enum BlogDataEnum { /* … */ }
14+
15+
#[\AllowDynamicProperties]
16+
interface BlogDataInterface { /* … */ }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Traits;
4+
5+
use PHPStan\Php\PhpVersion;
6+
use PHPStan\Rules\AttributesCheck;
7+
use PHPStan\Rules\ClassCaseSensitivityCheck;
8+
use PHPStan\Rules\Classes\ClassAttributesRule;
9+
use PHPStan\Rules\ClassForbiddenNameCheck;
10+
use PHPStan\Rules\ClassNameCheck;
11+
use PHPStan\Rules\FunctionCallParametersCheck;
12+
use PHPStan\Rules\NullsafeCheck;
13+
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
14+
use PHPStan\Rules\Properties\PropertyReflectionFinder;
15+
use PHPStan\Rules\Rule;
16+
use PHPStan\Rules\RuleLevelHelper;
17+
use PHPStan\Rules\Traits\TraitAttributesRule;
18+
use PHPStan\Testing\RuleTestCase;
19+
use const PHP_VERSION_ID;
20+
21+
/**
22+
* @extends RuleTestCase<TraitAttributesRule>
23+
*/
24+
class TraitAttributesRuleTest extends RuleTestCase
25+
{
26+
27+
private bool $checkExplicitMixed = false;
28+
29+
private bool $checkImplicitMixed = false;
30+
31+
protected function getRule(): Rule
32+
{
33+
$reflectionProvider = $this->createReflectionProvider();
34+
return new TraitAttributesRule(
35+
new AttributesCheck(
36+
$reflectionProvider,
37+
new FunctionCallParametersCheck(
38+
new RuleLevelHelper($reflectionProvider, true, false, true, $this->checkExplicitMixed, $this->checkImplicitMixed, true, false),
39+
new NullsafeCheck(),
40+
new PhpVersion(80000),
41+
new UnresolvableTypeHelper(),
42+
new PropertyReflectionFinder(),
43+
true,
44+
true,
45+
true,
46+
true,
47+
),
48+
new ClassNameCheck(
49+
new ClassCaseSensitivityCheck($reflectionProvider, false),
50+
new ClassForbiddenNameCheck(self::getContainer()),
51+
),
52+
true,
53+
),
54+
$reflectionProvider,
55+
);
56+
}
57+
58+
public function testRule(): void
59+
{
60+
$this->analyse([__DIR__ . '/data/trait-attributes.php'], [
61+
[
62+
'Attribute class TraitAttributes\AbstractAttribute is abstract.',
63+
8,
64+
],
65+
[
66+
'Attribute class TraitAttributes\MyTargettedAttribute does not have the class target.',
67+
20,
68+
],
69+
]);
70+
}
71+
72+
public function testBug12011(): void
73+
{
74+
if (PHP_VERSION_ID < 80300) {
75+
$this->markTestSkipped('Test requires PHP 8.3.');
76+
}
77+
78+
$this->checkExplicitMixed = true;
79+
$this->checkImplicitMixed = true;
80+
81+
$this->analyse([__DIR__ . '/data/bug-12011.php'], [
82+
[
83+
'Parameter #1 $name of attribute class Bug12011Trait\Table constructor expects string|null, int given.',
84+
8,
85+
],
86+
]);
87+
}
88+
89+
public function testBug12281(): void
90+
{
91+
$this->analyse([__DIR__ . '/data/bug-12281.php'], [
92+
[
93+
'Attribute class AllowDynamicProperties cannot be used with trait.',
94+
11,
95+
],
96+
]);
97+
}
98+
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php // lint >= 8.3
2+
3+
namespace Bug12011Trait;
4+
5+
use Attribute;
6+
7+
8+
#[Table(self::TABLE_NAME)]
9+
trait MyTrait
10+
{
11+
private const int TABLE_NAME = 'table';
12+
}
13+
14+
class X {
15+
use MyTrait;
16+
}
17+
18+
#[Attribute(Attribute::TARGET_CLASS)]
19+
final class Table
20+
{
21+
public function __construct(
22+
public readonly string|null $name = null,
23+
public readonly string|null $schema = null,
24+
) {
25+
}
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php // lint >= 8.2
2+
3+
namespace Bug12281Traits;
4+
5+
#[\AllowDynamicProperties]
6+
enum BlogDataEnum { /* … */ } // reported by ClassAttributesRule
7+
8+
#[\AllowDynamicProperties]
9+
interface BlogDataInterface { /* … */ } // reported by ClassAttributesRule
10+
11+
#[\AllowDynamicProperties]
12+
trait BlogDataTrait { /* … */ }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace TraitAttributes;
4+
5+
#[\Attribute]
6+
abstract class AbstractAttribute {}
7+
8+
#[AbstractAttribute]
9+
trait MyTrait {}
10+
11+
#[\Attribute]
12+
class MyAttribute {}
13+
14+
#[MyAttribute]
15+
trait MyTrait2 {}
16+
17+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
18+
class MyTargettedAttribute {}
19+
20+
#[MyTargettedAttribute]
21+
trait MyTrait3 {}

0 commit comments

Comments
 (0)