Skip to content

Commit f973121

Browse files
add phpstan extensions
1 parent e52f52b commit f973121

20 files changed

+943
-0
lines changed

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,92 @@ The `analyze` method of your processor must return one of two values from the `R
268268
* `Result::SHOULD_BE_NULL`: Indicates that the embeddable object should be treated as null. Note that "should" is used because the parent entity might have the embeddable class defined as not nullable. There is no guarantee the parent class accepts `null` as a value; this depends on database consistency and the user's data model.
269269
* `Result::KEEP_INITIALIZED`: Indicates that the embeddable object should remain initialized.
270270

271+
## PHPStan Extension
272+
273+
This bundle includes a PHPStan extension that validates `#[NullableEmbeddable]` classes to ensure they follow best practices for working with Doctrine's nullable embeddable behavior.
274+
275+
### Why This Extension is Important
276+
277+
When Doctrine determines that an entire embeddable object should be null (which is what this bundle does), it sets **all** the embeddable's database columns to `NULL`. This has important implications for how you structure your embeddable classes:
278+
279+
1. **Property Initialization**: Properties with non-null default values should be initialized in the constructor, not outside it. This is because Doctrine hydrates entities by skipping the constructor. For example:
280+
```php
281+
// BAD - Don't do this:
282+
private bool $enabled = true; // Doctrine gets NULL from DB but property shows true
283+
284+
// GOOD - Do this instead:
285+
public function __construct(
286+
private bool $enabled = true,
287+
) {}
288+
```
289+
290+
2. **Nullable Columns**: All properties mapped to database columns must be nullable. This can be achieved either by using a PHP nullable type (`?string`) which Doctrine automatically infers as `nullable: true`, or by explicitly setting `nullable: true` in the `#[Column]` attribute. This is required because when the embeddable object is null, Doctrine will set all its database columns to `NULL`.
291+
292+
3. **Nested Embeddables with Defaults**: Embedded objects that have explicit non-null default values must be typed as nullable. Uninitialized embedded properties are fine since they remain uninitialized when the parent is null.
293+
294+
### Automatic Installation (Recommended)
295+
296+
If you have `phpstan/extension-installer` installed (which is included in `require-dev`), the extension will be automatically registered. No additional configuration needed!
297+
298+
```bash
299+
composer require --dev phpstan/phpstan phpstan/extension-installer
300+
```
301+
302+
### Manual Installation
303+
304+
If you don't have `phpstan/extension-installer`, you can manually include the extension in your `phpstan.neon` or `phpstan.neon.dist`:
305+
306+
```neon
307+
includes:
308+
- vendor/andanteproject/nullable-embeddable-bundle/extension.neon
309+
```
310+
311+
### What the Extension Checks
312+
313+
The PHPStan extension will report errors for:
314+
315+
1. **Properties with non-null default values outside the constructor** - These should be moved to the constructor to avoid hydration issues
316+
2. **Non-nullable column mappings** - Properties with `#[Column]` must be nullable, either via PHP nullable type (`?Type`) or explicit `nullable: true`
317+
3. **Embedded objects with non-null default values** - Embedded properties with explicit defaults must be nullable (uninitialized embedded properties are allowed)
318+
319+
### Example
320+
321+
```php
322+
#[ORM\Embeddable]
323+
#[NullableEmbeddable(processor: /* ... */)]
324+
class Address
325+
{
326+
// ERROR: Property has non-null default outside constructor
327+
// private bool $isPrimary = false;
328+
329+
// ERROR: Column is not nullable (neither PHP type nor explicit attribute)
330+
// #[ORM\Column(type: Types::STRING)]
331+
// private string $street;
332+
333+
// CORRECT: Column is nullable via PHP type (Doctrine infers nullable: true)
334+
#[ORM\Column(type: Types::STRING)]
335+
private ?string $street = null;
336+
337+
// ALSO CORRECT: Column explicitly nullable (even with non-nullable PHP type)
338+
#[ORM\Column(type: Types::STRING, nullable: true)]
339+
private string $city;
340+
341+
// CORRECT: Uninitialized embedded property (will remain uninitialized when parent is null)
342+
#[ORM\Embedded(class: Country::class)]
343+
private Country $country;
344+
345+
// ALSO CORRECT: Embedded property initialized to null
346+
#[ORM\Embedded(class: Region::class)]
347+
private ?Region $region = null;
348+
349+
// CORRECT: Default value in constructor
350+
public function __construct(
351+
#[ORM\Column(type: Types::BOOLEAN, nullable: true)]
352+
private bool $isPrimary = false,
353+
) {}
354+
}
355+
```
356+
271357
## Configuration
272358

273359
The bundle provides a configuration option to enable a cache warmer for improved performance in production environments.

composer.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,16 @@
5555
"composer/package-versions-deprecated": true,
5656
"phpstan/extension-installer": true
5757
}
58+
},
59+
"extra": {
60+
"phpstan": {
61+
"includes": [
62+
"extension.neon"
63+
]
64+
}
65+
},
66+
"suggest": {
67+
"phpstan/phpstan": "To validate NullableEmbeddable classes at static analysis time",
68+
"phpstan/extension-installer": "To automatically register the PHPStan extension (recommended)"
5869
}
5970
}

extension.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
services:
2+
-
3+
class: Andante\NullableEmbeddableBundle\PHPStan\Rules\NullableEmbeddablePropertyRule
4+
tags:
5+
- phpstan.rules.rule

phpstan-8.5-plus.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
includes:
2+
- extension.neon
3+
14
parameters:
25
level: 9
36
bootstrapFiles:
@@ -7,3 +10,6 @@ parameters:
710
- tests
811
- phpstan-stubs
912
- tests/Fixtures/ValidClosureProcessorEntity
13+
excludePaths:
14+
- tests/PHPStan
15+
- tests/Functional/PHPStanRulesIssuesTests/Fixtures

phpstan-lt-8.5.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
includes:
2+
- extension.neon
3+
14
parameters:
25
level: 9
36
bootstrapFiles:
@@ -8,3 +11,5 @@ parameters:
811
- phpstan-stubs
912
excludePaths:
1013
- tests/Fixtures/ValidClosureProcessorEntity
14+
- tests/PHPStan
15+
- tests/Functional/PHPStanRulesIssuesTests/Fixtures
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Andante\NullableEmbeddableBundle\PHPStan\Rules;
6+
7+
use Andante\NullableEmbeddableBundle\Attribute\NullableEmbeddable;
8+
use Doctrine\ORM\Mapping\Column;
9+
use Doctrine\ORM\Mapping\Embeddable;
10+
use Doctrine\ORM\Mapping\Embedded;
11+
use PhpParser\Node;
12+
use PHPStan\Analyser\Scope;
13+
use PHPStan\Node\InClassNode;
14+
use PHPStan\Rules\Rule;
15+
use PHPStan\Rules\RuleErrorBuilder;
16+
17+
/**
18+
* @implements Rule<InClassNode>
19+
*/
20+
class NullableEmbeddablePropertyRule implements Rule
21+
{
22+
public function getNodeType(): string
23+
{
24+
return InClassNode::class;
25+
}
26+
27+
public function processNode(Node $node, Scope $scope): array
28+
{
29+
$classReflection = $node->getClassReflection();
30+
31+
// Only check classes with both Embeddable and NullableEmbeddable attributes
32+
if (!$this->hasEmbeddableAttribute($classReflection->getName())) {
33+
return [];
34+
}
35+
36+
if (!$this->hasNullableEmbeddableAttribute($classReflection->getName())) {
37+
return [];
38+
}
39+
40+
$errors = [];
41+
$reflectionClass = new \ReflectionClass($classReflection->getName());
42+
43+
foreach ($reflectionClass->getProperties() as $property) {
44+
// Skip static properties
45+
if ($property->isStatic()) {
46+
continue;
47+
}
48+
49+
// Rule 1: Properties with non-null default values outside constructor
50+
if ($this->hasNonNullDefaultValue($property, $reflectionClass)) {
51+
$errors[] = RuleErrorBuilder::message(
52+
\sprintf(
53+
'Property %s::$%s in a NullableEmbeddable class has a non-null default value outside the constructor. '.
54+
'Initialize it in the constructor instead to avoid issues with Doctrine hydration (Doctrine skips constructors during hydration).',
55+
$classReflection->getName(),
56+
$property->getName()
57+
)
58+
)->identifier('nullableEmbeddable.propertyInitialization')->build();
59+
}
60+
61+
// Rule 2: Properties with Column mapping must be nullable
62+
// Either explicitly via nullable=true OR implicitly via PHP nullable type
63+
$columnAttribute = $this->getColumnAttribute($property);
64+
if (null !== $columnAttribute) {
65+
$isExplicitlyNullable = $this->isColumnNullable($columnAttribute);
66+
$propertyType = $property->getType();
67+
$isPhpNullable = $propertyType instanceof \ReflectionNamedType && $propertyType->allowsNull();
68+
69+
// Only flag if both explicit attribute AND PHP type are not nullable
70+
if (!$isExplicitlyNullable && !$isPhpNullable) {
71+
$errors[] = RuleErrorBuilder::message(
72+
\sprintf(
73+
'Property %s::$%s in a NullableEmbeddable class must be nullable (either via nullable=true in #[Column] or ?Type). '.
74+
'When the embeddable object is null, Doctrine will set all its database columns to NULL.',
75+
$classReflection->getName(),
76+
$property->getName()
77+
)
78+
)->identifier('nullableEmbeddable.columnNullable')->build();
79+
}
80+
}
81+
82+
// Rule 3: Embedded properties with explicit non-null defaults must be nullable
83+
// Note: Uninitialized embedded properties are fine - they remain uninitialized when parent is null
84+
$embeddedAttribute = $this->getEmbeddedAttribute($property);
85+
if (null !== $embeddedAttribute && $property->hasDefaultValue()) {
86+
$defaultValue = $property->getDefaultValue();
87+
$propertyType = $property->getType();
88+
89+
// Only flag if there's an explicit non-null default value at class level
90+
if (null !== $defaultValue && $propertyType instanceof \ReflectionNamedType && !$propertyType->allowsNull()) {
91+
$errors[] = RuleErrorBuilder::message(
92+
\sprintf(
93+
'Property %s::$%s in a NullableEmbeddable class is an embedded object with a non-null default value and must be nullable. '.
94+
'When the parent embeddable is null, all nested embeddables with default values must accept null.',
95+
$classReflection->getName(),
96+
$property->getName()
97+
)
98+
)->identifier('nullableEmbeddable.embeddedNullable')->build();
99+
}
100+
}
101+
}
102+
103+
return $errors;
104+
}
105+
106+
/**
107+
* @param class-string $className
108+
*/
109+
private function hasEmbeddableAttribute(string $className): bool
110+
{
111+
try {
112+
$reflectionClass = new \ReflectionClass($className);
113+
114+
return !empty($reflectionClass->getAttributes(Embeddable::class));
115+
} catch (\ReflectionException) {
116+
return false;
117+
}
118+
}
119+
120+
/**
121+
* @param class-string $className
122+
*/
123+
private function hasNullableEmbeddableAttribute(string $className): bool
124+
{
125+
try {
126+
$reflectionClass = new \ReflectionClass($className);
127+
128+
return !empty($reflectionClass->getAttributes(NullableEmbeddable::class));
129+
} catch (\ReflectionException) {
130+
return false;
131+
}
132+
}
133+
134+
/**
135+
* @param \ReflectionClass<object> $reflectionClass
136+
*/
137+
private function hasNonNullDefaultValue(\ReflectionProperty $property, \ReflectionClass $reflectionClass): bool
138+
{
139+
// Check if property has a default value
140+
if (!$property->hasDefaultValue()) {
141+
return false;
142+
}
143+
144+
$defaultValue = $property->getDefaultValue();
145+
146+
// If default value is null, that's fine
147+
if (null === $defaultValue) {
148+
return false;
149+
}
150+
151+
// Check if this property is initialized in the constructor
152+
if ($this->isInitializedInConstructor($property, $reflectionClass)) {
153+
return false;
154+
}
155+
156+
// Non-null default value outside constructor
157+
return true;
158+
}
159+
160+
/**
161+
* @param \ReflectionClass<object> $reflectionClass
162+
*/
163+
private function isInitializedInConstructor(\ReflectionProperty $property, \ReflectionClass $reflectionClass): bool
164+
{
165+
$constructor = $reflectionClass->getConstructor();
166+
167+
if (null === $constructor) {
168+
return false;
169+
}
170+
171+
// Check if property is a constructor parameter (promoted property)
172+
foreach ($constructor->getParameters() as $param) {
173+
if ($param->getName() === $property->getName() && $param->isPromoted()) {
174+
return true;
175+
}
176+
}
177+
178+
return false;
179+
}
180+
181+
/**
182+
* @return \ReflectionAttribute<Column>|null
183+
*/
184+
private function getColumnAttribute(\ReflectionProperty $property): ?\ReflectionAttribute
185+
{
186+
$attributes = $property->getAttributes(Column::class);
187+
188+
return $attributes[0] ?? null;
189+
}
190+
191+
/**
192+
* @param \ReflectionAttribute<Column> $columnAttribute
193+
*/
194+
private function isColumnNullable(\ReflectionAttribute $columnAttribute): bool
195+
{
196+
$arguments = $columnAttribute->getArguments();
197+
198+
// Check named argument
199+
if (isset($arguments['nullable'])) {
200+
return true === $arguments['nullable'];
201+
}
202+
203+
// Column mapping defaults to nullable: false
204+
return false;
205+
}
206+
207+
/**
208+
* @return \ReflectionAttribute<Embedded>|null
209+
*/
210+
private function getEmbeddedAttribute(\ReflectionProperty $property): ?\ReflectionAttribute
211+
{
212+
$attributes = $property->getAttributes(Embedded::class);
213+
214+
return $attributes[0] ?? null;
215+
}
216+
}

0 commit comments

Comments
 (0)